├── .docker ├── dgraph │ ├── .gitignore │ ├── docker-compose.multi.yml │ └── docker-compose.yml ├── elasticsearch │ ├── 5.6 │ │ ├── .dockerignore │ │ ├── Dockerfile │ │ ├── VERSION │ │ ├── config │ │ │ ├── elastic │ │ │ │ ├── elasticsearch.yml │ │ │ │ └── log4j2.properties │ │ │ └── logrotate │ │ ├── docker-healthcheck │ │ ├── elastic-entrypoint.sh │ │ └── hooks │ │ │ └── post_push │ └── 7.0.1 │ │ ├── Dockerfile │ │ ├── config │ │ ├── elasticsearch.yml │ │ ├── jvm.options │ │ └── log4j2.properties │ │ └── scripts │ │ └── docker-entrypoint.sh └── readeef │ ├── config │ └── readeef.toml │ └── scripts │ ├── build.sh │ └── entrypoint.sh ├── .dockerignore ├── .env ├── .gitignore ├── DOCKER.md ├── Dockerfile ├── Dockerfile.dev ├── LICENSE ├── Makefile ├── README.md ├── TODO ├── api ├── api.go ├── article.go ├── article_test.go ├── articlerepotype_string.go ├── articlestate_string.go ├── auth.go ├── auth_test.go ├── events.go ├── events_test.go ├── extract_generator_mock_test.go ├── features.go ├── features_test.go ├── feed.go ├── feed_manager_mock_test.go ├── feed_test.go ├── fever │ ├── favicon.go │ ├── feeds.go │ ├── groups.go │ ├── handler.go │ ├── ids.go │ ├── items.go │ ├── links.go │ ├── mark.go │ └── user.go ├── hubbub.go ├── hubbub_test.go ├── opml.go ├── opml_test.go ├── processor_article_mock_test.go ├── request.go ├── request_test.go ├── searcher_mock_test.go ├── tag.go ├── tag_test.go ├── token │ ├── bolt_storage.go │ └── token.go ├── token_storage_mock_test.go ├── ttrss │ ├── articles.go │ ├── auth.go │ ├── conversion.go │ ├── counters.go │ ├── feeds.go │ ├── generic.go │ ├── handler.go │ ├── session.go │ ├── settings.go │ └── tags.go ├── user.go ├── user_settings.go ├── user_settings_test.go └── user_test.go ├── cmd ├── readeef-client │ ├── config.go │ ├── main.go │ ├── open.go │ ├── platform_darwin.go │ ├── platform_linux.go │ └── platform_windows.go ├── readeef-static-locator │ └── main.go └── readeef │ ├── main.go │ ├── postgres.go │ ├── search-index.go │ ├── server.go │ ├── sqlite.go │ └── user-admin.go ├── config ├── config.go ├── default.go └── parts.go ├── content ├── article.go ├── article_test.go ├── err.go ├── extract.go ├── extract │ ├── extract.go │ ├── goose.go │ └── readability.go ├── extract_test.go ├── feed.go ├── feed_test.go ├── monitor │ ├── index.go │ ├── thumbnailer.go │ ├── unread.go │ └── user-filters.go ├── processor │ ├── absolutize_urls.go │ ├── cleanup.go │ ├── cleanup_test.go │ ├── common_test.go │ ├── insert_thumbnail_target.go │ ├── processor.go │ ├── proxy_http.go │ ├── relative_url.go │ ├── top_image_marker.go │ └── unescape.go ├── repo │ ├── article.go │ ├── article_test.go │ ├── common_test.go │ ├── eventable │ │ ├── article.go │ │ ├── bus.go │ │ ├── bus_test.go │ │ ├── feed.go │ │ └── service.go │ ├── extract.go │ ├── extract_test.go │ ├── feed.go │ ├── feed_test.go │ ├── logging │ │ ├── article.go │ │ ├── extract.go │ │ ├── feed.go │ │ ├── scores.go │ │ ├── service.go │ │ ├── subscription.go │ │ ├── tag.go │ │ ├── thumbnail.go │ │ └── user.go │ ├── mock_repo │ │ ├── article.go │ │ ├── extract.go │ │ ├── feed.go │ │ ├── scores.go │ │ ├── service.go │ │ ├── subscription.go │ │ ├── tag.go │ │ ├── thumbnail.go │ │ └── user.go │ ├── postgres_test.go │ ├── scores.go │ ├── scores_test.go │ ├── service.go │ ├── service_test.go │ ├── sql │ │ ├── article.go │ │ ├── db │ │ │ ├── base │ │ │ │ ├── article.go │ │ │ │ ├── extract.go │ │ │ │ ├── feed.go │ │ │ │ ├── helper.go │ │ │ │ ├── scores.go │ │ │ │ ├── subscription.go │ │ │ │ ├── tag.go │ │ │ │ ├── thumbnail.go │ │ │ │ └── user.go │ │ │ ├── db.go │ │ │ ├── helper.go │ │ │ ├── postgres │ │ │ │ ├── helper.go │ │ │ │ └── init.go │ │ │ └── sqlite3 │ │ │ │ ├── cgo.go │ │ │ │ ├── helper.go │ │ │ │ └── init.go │ │ ├── extract.go │ │ ├── feed.go │ │ ├── scores.go │ │ ├── service.go │ │ ├── subscription.go │ │ ├── tag.go │ │ ├── thumbnail.go │ │ └── user.go │ ├── sqlite3_test.go │ ├── subscription.go │ ├── subscription_test.go │ ├── tag.go │ ├── tag_test.go │ ├── thumbnail.go │ ├── thumbnail_test.go │ ├── user.go │ └── user_test.go ├── scores.go ├── scores_test.go ├── search │ ├── bleve.go │ ├── elastic.go │ └── search.go ├── subscription.go ├── subscription_test.go ├── tag.go ├── tag_test.go ├── thumbnail.go ├── thumbnail │ ├── description.go │ ├── extract.go │ └── thumbnail.go ├── thumbnail_test.go ├── user.go ├── user_test.go └── validation.go ├── docker-compose.yml ├── feed ├── favicon.go ├── favicon_test.go ├── scheduler.go ├── scheduler_test.go └── search.go ├── feed_manager.go ├── feed_manager_test.go ├── file.list ├── fs.go ├── fs_files.go ├── fs_files_nofs.go ├── go.mod ├── go.sum ├── hubbub.go ├── hubbub_test.go ├── internal └── legacy │ └── config.go ├── log ├── log.go ├── logrus.go └── std.go ├── parser ├── atom.go ├── atom_test.go ├── feed.go ├── opml.go ├── opml_test.go ├── parser.go ├── parser_test.go ├── pubsub.go ├── rss.go ├── rss1.go ├── rss1_test.go ├── rss2.go └── rss2_test.go ├── pool └── buffer.go ├── popularity ├── link.go ├── popularity.go ├── reddit.go └── twitter.go ├── rf-ng ├── .dockerignore ├── .editorconfig ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── angular.json ├── e2e │ ├── app.e2e-spec.ts │ ├── app.po.ts │ └── tsconfig.e2e.json ├── karma.conf.js ├── ngsw-config.json ├── package-lock.json ├── package.json ├── protractor.conf.js ├── src │ ├── app │ │ ├── app.module.ts │ │ ├── app.routing.ts │ │ ├── article-display │ │ │ ├── article-display.component.ts │ │ │ ├── article-display.css │ │ │ └── article-display.html │ │ ├── article-list │ │ │ ├── article-list.component.ts │ │ │ ├── article-list.css │ │ │ ├── article-list.html │ │ │ ├── list-item.component.ts │ │ │ ├── list-item.css │ │ │ └── list-item.html │ │ ├── components │ │ │ ├── app.css │ │ │ ├── app.html │ │ │ ├── app.spec.ts │ │ │ └── app.ts │ │ ├── guards │ │ │ └── auth.ts │ │ ├── login │ │ │ ├── login.component.ts │ │ │ ├── login.css │ │ │ └── login.html │ │ ├── main │ │ │ ├── main.component.ts │ │ │ ├── main.css │ │ │ ├── main.html │ │ │ └── routing-util.ts │ │ ├── services │ │ │ ├── api.ts │ │ │ ├── article.ts │ │ │ ├── auth.ts │ │ │ ├── events.ts │ │ │ ├── favicon.ts │ │ │ ├── features.ts │ │ │ ├── feed.ts │ │ │ ├── interaction.ts │ │ │ ├── preferences.ts │ │ │ ├── sharing.ts │ │ │ ├── tag.ts │ │ │ └── user.ts │ │ ├── settings │ │ │ ├── admin │ │ │ │ ├── admin.component.ts │ │ │ │ ├── admin.css │ │ │ │ ├── admin.html │ │ │ │ └── new-user.html │ │ │ ├── common.css │ │ │ ├── discovery │ │ │ │ ├── discovery.component.ts │ │ │ │ ├── discovery.css │ │ │ │ └── discovery.html │ │ │ ├── filters │ │ │ │ ├── filters.component.ts │ │ │ │ ├── filters.css │ │ │ │ ├── filters.html │ │ │ │ └── new-filter-dialog.html │ │ │ ├── general │ │ │ │ ├── general.component.ts │ │ │ │ ├── general.css │ │ │ │ ├── general.html │ │ │ │ └── password-form.html │ │ │ ├── management │ │ │ │ ├── error-dialog.html │ │ │ │ ├── management.component.ts │ │ │ │ ├── management.css │ │ │ │ └── management.html │ │ │ ├── settings.component.ts │ │ │ ├── settings.css │ │ │ ├── settings.html │ │ │ └── share-services │ │ │ │ ├── share-services.component.ts │ │ │ │ ├── share-services.css │ │ │ │ └── share-services.html │ │ ├── share-service │ │ │ └── share-service.component.ts │ │ ├── sidebar │ │ │ ├── side-bar-feed.html │ │ │ ├── side-bar-settings.html │ │ │ ├── side-bar.css │ │ │ ├── sidebar.feed.component.ts │ │ │ └── sidebar.settings.component.ts │ │ └── toolbar │ │ │ ├── toolbar-feed.html │ │ │ ├── toolbar-settings.html │ │ │ ├── toolbar.css │ │ │ ├── toolbar.feed.component.ts │ │ │ └── toolbar.settings.component.ts │ ├── assets │ │ ├── .gitkeep │ │ └── icons │ │ │ ├── readeef-114.png │ │ │ ├── readeef-144.png │ │ │ ├── readeef-72.png │ │ │ ├── readeef-small.png │ │ │ └── readeef.png │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── gesture-config.ts │ ├── index.html │ ├── locale │ │ ├── messages.bg.xlf │ │ ├── messages.en.xlf │ │ └── messages.xlf │ ├── main.ts │ ├── manifest.webmanifest │ ├── polyfills.ts │ ├── styles.css │ ├── test.ts │ ├── tsconfig.app.json │ ├── tsconfig.spec.json │ └── typings.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json ├── tslint.json ├── ui │ └── index.html ├── xliffmerge.json └── yarn.lock ├── systemd └── readeef.service ├── templates ├── goose-format-result.tmpl └── raw.tmpl ├── timeout_client.go └── web ├── proxy.go └── web.go /.docker/dgraph/.gitignore: -------------------------------------------------------------------------------- 1 | data0 2 | data1 3 | data2 4 | data3 5 | -------------------------------------------------------------------------------- /.docker/dgraph/docker-compose.multi.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.7" 3 | services: 4 | 5 | zero: 6 | image: dgraph/dgraph:latest 7 | volumes: 8 | - ./data0/data-volume:/dgraph 9 | ports: 10 | - 5080:5080 11 | - 6080:6080 12 | command: dgraph zero --my=zero:5080 --replicas 3 13 | 14 | server_1: 15 | image: dgraph/dgraph:latest 16 | hostname: "server_1" 17 | volumes: 18 | - ./data1/data-volume:/dgraph 19 | ports: 20 | - 8080:8080 21 | - 9080:9080 22 | command: dgraph server --my=server_1:7080 --lru_mb=4096 --zero=zero:5080 23 | 24 | server_2: 25 | image: dgraph/dgraph:latest 26 | hostname: "server_2" 27 | volumes: 28 | - ./data2/data-volume:/dgraph 29 | ports: 30 | - 8081:8081 31 | - 9081:9081 32 | command: dgraph server --my=server_2:7081 --lru_mb=4096 --zero=zero:5080 -o 1 33 | 34 | server_3: 35 | image: dgraph/dgraph:latest 36 | hostname: "server_3" 37 | volumes: 38 | - ./data3/data-volume:/dgraph 39 | ports: 40 | - 8082:8082 41 | - 9082:9082 42 | command: dgraph server --my=server_3:7082 --lru_mb=4096 --zero=zero:5080 -o 2 43 | 44 | ratel: 45 | image: dgraph/dgraph:latest 46 | hostname: "ratel" 47 | ports: 48 | - 8000:8000 49 | command: dgraph-ratel 50 | -------------------------------------------------------------------------------- /.docker/dgraph/docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: "3.7" 3 | services: 4 | 5 | zero: 6 | image: dgraph/dgraph:v${DGRAPH_VERSION} 7 | volumes: 8 | - type: volume 9 | source: dgraph 10 | target: /dgraph 11 | volume: 12 | nocopy: true 13 | ports: 14 | - 5080:5080 15 | - 6080:6080 16 | restart: on-failure 17 | command: dgraph zero --my=zero:5080 18 | 19 | server: 20 | image: dgraph/dgraph:v${DGRAPH_VERSION} 21 | volumes: 22 | - type: volume 23 | source: dgraph 24 | target: /dgraph 25 | volume: 26 | nocopy: true 27 | ports: 28 | - 8080:8080 29 | - 9080:9080 30 | restart: on-failure 31 | command: dgraph alpha --my=server:7080 --lru_mb=2048 --zero=zero:5080 32 | 33 | ratel: 34 | image: dgraph/dgraph:v1.1.0 35 | volumes: 36 | - type: volume 37 | source: dgraph 38 | target: /dgraph 39 | volume: 40 | nocopy: true 41 | ports: 42 | - 8000:8000 43 | command: dgraph-ratel 44 | 45 | volumes: 46 | dgraph: -------------------------------------------------------------------------------- /.docker/elasticsearch/5.6/.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore .git folder 2 | .git* 3 | .gitignore 4 | 5 | es-logo.png 6 | LICENSE 7 | README.md 8 | docker-compose.yml 9 | .DS_Store 10 | build 11 | release 12 | Makefile 13 | -------------------------------------------------------------------------------- /.docker/elasticsearch/5.6/VERSION: -------------------------------------------------------------------------------- 1 | 5.6 2 | -------------------------------------------------------------------------------- /.docker/elasticsearch/5.6/config/elastic/elasticsearch.yml: -------------------------------------------------------------------------------- 1 | network.host: 0.0.0.0 2 | 3 | # this value is required because we set "network.host" 4 | # be sure to modify it appropriately for a production cluster deployment 5 | discovery.zen.minimum_master_nodes: 1 6 | # bootstrap.memory_lock: true 7 | 8 | node.master: true 9 | node.ingest: true 10 | node.data: true 11 | -------------------------------------------------------------------------------- /.docker/elasticsearch/5.6/config/elastic/log4j2.properties: -------------------------------------------------------------------------------- 1 | status = error 2 | 3 | appender.console.type = Console 4 | appender.console.name = console 5 | appender.console.layout.type = PatternLayout 6 | appender.console.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%m%n 7 | 8 | rootLogger.level = info 9 | rootLogger.appenderRef.console.ref = console 10 | -------------------------------------------------------------------------------- /.docker/elasticsearch/5.6/config/logrotate: -------------------------------------------------------------------------------- 1 | /var/log/elasticsearch/*.log { 2 | daily 3 | rotate 50 4 | size 50M 5 | copytruncate 6 | compress 7 | delaycompress 8 | missingok 9 | notifempty 10 | create 644 elasticsearch elasticsearch 11 | } 12 | -------------------------------------------------------------------------------- /.docker/elasticsearch/5.6/docker-healthcheck: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | host="$(hostname --ip-address || echo '127.0.0.1')" 5 | 6 | if health="$(curl -fsSL "http://$host:9200/_cat/health?h=status")"; then 7 | health="$(echo "$health" | sed -r 's/^[[:space:]]+|[[:space:]]+$//g')" # trim whitespace (otherwise we'll have "green ") 8 | if [ "$health" = 'green' ]; then 9 | exit 0 10 | fi 11 | echo >&2 "unexpected health status: $health" 12 | fi 13 | 14 | # If the probe returns 2 ("starting") when the container has already moved out of the "starting" state then it is treated as "unhealthy" instead. 15 | # https://github.com/docker/docker/blob/dcc65376bac8e73bb5930fce4cddc2350bb7baa2/docs/reference/builder.md#healthcheck 16 | exit 2 17 | -------------------------------------------------------------------------------- /.docker/elasticsearch/5.6/hooks/post_push: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | VERSION=$(cat Dockerfile | grep '^ENV VERSION' | cut -d" " -f3) 6 | TAGS=($VERSION 5) 7 | 8 | for TAG in "${TAGS[@]}"; do 9 | echo "===> Tagging $IMAGE_NAME as $DOCKER_REPO:$TAG" 10 | docker tag $IMAGE_NAME $DOCKER_REPO:$TAG 11 | echo "===> Pushing $DOCKER_REPO:$TAG" 12 | docker push $DOCKER_REPO:$TAG 13 | done 14 | -------------------------------------------------------------------------------- /.docker/elasticsearch/7.0.1/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.elastic.co/elasticsearch/elasticsearch:7.0.1 2 | 3 | USER elasticsearch 4 | 5 | # Copy config files 6 | COPY ./config/jvm.options /usr/share/elasticsearch/config/ 7 | COPY ./config/log4j2.properties /usr/share/elasticsearch/config/ 8 | COPY ./config/elasticsearch.yml /usr/share/elasticsearch/config/ 9 | 10 | # Install Cerebro 11 | ARG CEREBRO_VERSION 12 | RUN cd /usr/share/elasticsearch/ \ 13 | && wget -O cerebro-${CEREBRO_VERSION}.tgz https://github.com/lmenezes/cerebro/releases/download/v${CEREBRO_VERSION}/cerebro-${CEREBRO_VERSION}.tgz \ 14 | && tar zxvf cerebro-${CEREBRO_VERSION}.tgz \ 15 | && rm cerebro-${CEREBRO_VERSION}.tgz \ 16 | && mkdir cerebro-${CEREBRO_VERSION}/logs \ 17 | && mv cerebro-${CEREBRO_VERSION} cerebro 18 | 19 | COPY ./scripts/docker-entrypoint.sh /usr/share/elasticsearch/start -------------------------------------------------------------------------------- /.docker/elasticsearch/7.0.1/config/elasticsearch.yml: -------------------------------------------------------------------------------- 1 | cluster.name: "docker-cluster" 2 | # cluster.initial_master_nodes 3 | network.host: 0.0.0.0 4 | 5 | node.name: elasticsearch 6 | cluster.initial_master_nodes: elasticsearch 7 | bootstrap.memory_lock: true 8 | #index.blocks.read_only_allow_delete: null 9 | # minimum_master_nodes need to be explicitly set when bound on a public IP 10 | # set to 1 to allow single node clusters 11 | # Details: https://github.com/elastic/elasticsearch/pull/17288 12 | discovery.zen.minimum_master_nodes: 1 13 | #discovery.seed_hosts 14 | #discovery.seed_providers 15 | xpack.license.self_generated.type: basic 16 | 17 | # ---------------------------------- Network ----------------------------------- 18 | 19 | # CORS Settings 20 | http.cors.enabled: true 21 | http.cors.allow-origin: "*" 22 | http.cors.allow-methods: OPTIONS, HEAD, GET, POST, PUT, DELETE 23 | http.cors.allow-headers: "X-Requested-With, Content-Type, Content-Length, X-User" 24 | 25 | # twint -u noneprivacy -es localhost:9200 -------------------------------------------------------------------------------- /.docker/elasticsearch/7.0.1/config/log4j2.properties: -------------------------------------------------------------------------------- 1 | status = error 2 | 3 | appender.console.type = Console 4 | appender.console.name = console 5 | appender.console.layout.type = PatternLayout 6 | appender.console.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%m%n 7 | 8 | rootLogger.level = info 9 | rootLogger.appenderRef.console.ref = console -------------------------------------------------------------------------------- /.docker/elasticsearch/7.0.1/scripts/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/bash 2 | 3 | # Start Cerebro 4 | /usr/share/elasticsearch/cerebro/bin/cerebro >> /usr/share/elasticsearch/cerebro/logs/cerebro.log 2>&1 & 5 | 6 | # Start ES 7 | /usr/share/elasticsearch/bin/elasticsearch -------------------------------------------------------------------------------- /.docker/readeef/config/readeef.toml: -------------------------------------------------------------------------------- 1 | [server] 2 | port = 8080 3 | 4 | [server.auto-cert] 5 | storage-path = "./storage/certs" 6 | 7 | [log] 8 | level = "info" # error, info, debug 9 | file = "-" # stderr, or a filename 10 | formatter = "text" # text, json 11 | access-file = "" # stdout or a filename 12 | 13 | [api] 14 | emulators = [] # ["tt-rss", "fever"] 15 | 16 | [api.limits] 17 | articles-per-query = 200 18 | 19 | [auth] 20 | session-storage-path = "./storage/session.db" 21 | token-storage-path = "./storage/token.db" 22 | 23 | [db] 24 | driver = "postgres" 25 | connect = "host=db user=readeef dbname=readeef password=readeef sslmode=disable" 26 | 27 | [feed-manager] 28 | update-interval = "30m" 29 | monitors = ["index", "thumbnailer"] 30 | 31 | [timeout] 32 | connect = "1s" 33 | read-write = "2s" 34 | 35 | [hubbub] 36 | from = "readeef" 37 | 38 | [popularity] 39 | delay = "5s" 40 | # providers = ["Reddit", "Twitter"] 41 | 42 | [feed-parser] 43 | processors = ["cleanup", "top-image-marker", "absolutize-urls"] 44 | proxy-http-url-template = "/proxy?url={{ . }}" 45 | 46 | [content] 47 | thumbnail-generator = "description" 48 | 49 | [content.extract] 50 | generator = "goose" # readability 51 | 52 | [content.search] 53 | provider = "bleve" 54 | batch-size = 100 55 | bleve-path = "./storage/search.bleve" 56 | elastic-url = "http://elasticsearch:9200" 57 | 58 | [content.article] 59 | processors = ["insert-thumbnail-target"] 60 | proxy-http-url-template = "/proxy?url={{ . }}" 61 | 62 | #[ui] 63 | # path = "/opt/readeef/ui" -------------------------------------------------------------------------------- /.docker/readeef/scripts/build.sh: -------------------------------------------------------------------------------- 1 | #/bin/sh 2 | 3 | set -x 4 | set -e 5 | 6 | # build binaries 7 | go mod tidy 8 | go get github.com/urandom/embed/cmd/embed 9 | go get github.com/urandom/readeef/cmd/readeef-static-locator 10 | 11 | # requirements for building ui 12 | cd rf-ng 13 | npm install --unsafe-perm -g node-gyp webpack-dev-server rimraf webpack typescript @angular/cli 14 | npm install 15 | cd .. 16 | 17 | make all 18 | ls -l ./rf-ng/ui -------------------------------------------------------------------------------- /.docker/readeef/scripts/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #/bin/sh 2 | 3 | set -x 4 | set -e 5 | 6 | case "$1" in 7 | 8 | 'server') 9 | exec readeef server $@ $ARGS 10 | ;; 11 | 12 | 'dev') 13 | apk add --no-cache nano bash 14 | exec /bin/bash $@ $ARGS 15 | ;; 16 | 17 | 'index') 18 | exec readeef search-index $@ $ARGS 19 | ;; 20 | 21 | 'readeef-static-locator') 22 | exec readeef-static-locator $@ $ARGS 23 | ;; 24 | 25 | *) 26 | exec readeef server $@ 27 | ;; 28 | esac -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .git/ 3 | .git/* 4 | .git/** 5 | Dockerfile 6 | docker-compose.yml 7 | docker-sync.yml 8 | vendor 9 | vendor/ 10 | vendor/* 11 | vendor/** 12 | Makfile 13 | Dockerfile.dev 14 | /node_modules/ 15 | node_modules 16 | node_modules/ 17 | node_modules/* 18 | node_modules/** -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ## Readeef 2 | PROJECT_NAME=readeef 3 | TINI_VERSION=0.18.0 4 | READEEF_PORT=8080 5 | READEEF_NG_PORT=4200 6 | 7 | ## Postgresql 8 | POSTGRES_VERSION=10.1 9 | POSTGRES_PORT=5432 10 | POSTGRES_USER=readeef 11 | POSTGRES_PASSWORD=readeef 12 | POSTGRES_DB=readeef 13 | 14 | ## ELK 15 | ELASTIC_VERSION=7.0.1 16 | ELASTIC_PORT=9200 17 | KIBANA_VERSION=7.0.1 18 | KIBANA_PORT=5601 19 | CEREBRO_VERSION=0.8.5 20 | CEREBRO_PORT=9400 21 | 22 | ## DGRAPH 23 | DGRAPH_VERSION=1.1.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | static/dist 2 | .bck 3 | vendor 4 | vendor/ 5 | vendor/* 6 | vendor/** 7 | node_modules 8 | node_modules/ 9 | node_modules/* 10 | node_modules/** 11 | storage 12 | storage/ 13 | storage/* 14 | storage/** 15 | # fs_files.go 16 | -------------------------------------------------------------------------------- /DOCKER.md: -------------------------------------------------------------------------------- 1 | readeef with docker 2 | =================== 3 | 4 | # Docker 5 | 6 | ## Requirements 7 | 8 | To use this docker compose file you should comply with this requirements: 9 | 10 | * Install [Docker Desktop](https://www.docker.com/products/docker-desktop) for Windows/Mac or [Docker Engine](https://docs.docker.com/install/linux/docker-ce/ubuntu/#install-docker-ce) for Linux 11 | * Install [docker-compose](https://docs.docker.com/compose/install/) (This is installed by default on Windows and Mac with Docker installation) 12 | 13 | ### Build the image 14 | ```bash 15 | make docker-build 16 | ``` 17 | 18 | ### Run the image 19 | ```bash 20 | make docker-run 21 | ``` 22 | 23 | ### Run with Docker-compose 24 | 25 | ```bash 26 | docker-compose up -d 27 | ``` 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine3.10 AS builder 2 | MAINTAINER x0rzkov 3 | 4 | RUN apk add --no-cache make gcc g++ git ca-certificates musl-dev nodejs npm sqlite-dev sqlite 5 | 6 | COPY . /go/src/github.com/urandom/readeef 7 | WORKDIR /go/src/github.com/urandom/readeef 8 | 9 | RUN ./.docker/readeef/scripts/build.sh 10 | 11 | FROM alpine:3.10 AS runtime 12 | MAINTAINER x0rzkov 13 | 14 | ARG TINI_VERSION=${TINI_VERSION:-"v0.18.0"} 15 | 16 | # Install tini to /usr/local/sbin 17 | ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-muslc-amd64 /usr/local/sbin/tini 18 | 19 | # Install runtime dependencies & create runtime user 20 | RUN apk --no-cache --no-progress add ca-certificates git libssh2 openssl sqlite \ 21 | && chmod +x /usr/local/sbin/tini && mkdir -p /opt \ 22 | && adduser -D readeef -h /opt/readeef -s /bin/sh \ 23 | && su readeef -c 'cd /opt/readeef; mkdir -p bin config data ui' 24 | 25 | # Switch to user context 26 | USER readeef 27 | WORKDIR /opt/readeef 28 | 29 | # Copy readeef binaries to /opt/readeef/bin 30 | # COPY --from=builder /go/src/github.com/urandom/readeef/readeef/rf-ng/ui /opt/readeef/ui 31 | COPY --from=builder /go/src/github.com/urandom/readeef/readeef /opt/readeef/bin/readeef 32 | COPY --from=builder /go/bin/readeef-static-locator /opt/readeef/bin/readeef-static-locator 33 | COPY .docker/readeef/config/readeef.toml /opt/readeef/config/readeef.toml 34 | ENV PATH $PATH:/opt/readeef/bin 35 | 36 | # Container configuration 37 | EXPOSE 8080 38 | VOLUME ["/opt/readeef/data"] 39 | ENTRYPOINT ["tini", "-g", "--"] 40 | CMD ["/opt/readeef/bin/readeef", "server"] 41 | # Optional: create entrypoint file for multi-scenario start 42 | # ENTRYPOINT ["./.docker/readeef/scripts/entrypoint.sh"] 43 | # CMD ["dev"] 44 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM golang:alpine3.10 2 | 3 | MAINTAINER x0rzkov 4 | 5 | RUN apk add --no-cache bash nano make gcc g++ git ca-certificates musl-dev nodejs npm sqlite-dev sqlite 6 | 7 | COPY . /go/src/github.com/urandom/readeef 8 | WORKDIR /go/src/github.com/urandom/readeef 9 | 10 | RUN ./.docker/readeef/scripts/build.sh 11 | 12 | CMD ["/bin/bash"] 13 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Create indexes for foreign key columns that are used in queries 2 | * Non-fatal API errors 3 | * TinyRSS API emulation 4 | -------------------------------------------------------------------------------- /api/articlerepotype_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type articleRepoType api"; DO NOT EDIT. 2 | 3 | package api 4 | 5 | import "fmt" 6 | 7 | const _articleRepoType_name = "userRepoTypefavoriteRepoTypepopularRepoTypetagRepoTypefeedRepoType" 8 | 9 | var _articleRepoType_index = [...]uint8{0, 12, 28, 43, 54, 66} 10 | 11 | func (i articleRepoType) String() string { 12 | if i < 0 || i >= articleRepoType(len(_articleRepoType_index)-1) { 13 | return fmt.Sprintf("articleRepoType(%d)", i) 14 | } 15 | return _articleRepoType_name[_articleRepoType_index[i]:_articleRepoType_index[i+1]] 16 | } 17 | -------------------------------------------------------------------------------- /api/articlestate_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type articleState"; DO NOT EDIT. 2 | 3 | package api 4 | 5 | import "fmt" 6 | 7 | const _articleState_name = "readfavorite" 8 | 9 | var _articleState_index = [...]uint8{0, 4, 12} 10 | 11 | func (i articleState) String() string { 12 | if i < 0 || i >= articleState(len(_articleState_index)-1) { 13 | return fmt.Sprintf("articleState(%d)", i) 14 | } 15 | return _articleState_name[_articleState_index[i]:_articleState_index[i+1]] 16 | } 17 | -------------------------------------------------------------------------------- /api/auth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | jwt "github.com/dgrijalva/jwt-go" 7 | "github.com/urandom/handler/auth" 8 | "github.com/urandom/readeef/api/token" 9 | "github.com/urandom/readeef/content" 10 | "github.com/urandom/readeef/content/repo" 11 | "github.com/urandom/readeef/log" 12 | ) 13 | 14 | func tokenCreate(repo repo.User, secret []byte, log log.Log) http.Handler { 15 | return auth.TokenGenerator(nil, auth.AuthenticatorFunc(func(user, pass string) bool { 16 | u, err := repo.Get(content.Login(user)) 17 | if err != nil { 18 | log.Infof("Error fetching user %s: %+v", user, err) 19 | return false 20 | } 21 | 22 | ok, err := u.Authenticate(pass, secret) 23 | if err != nil { 24 | log.Infof("Error authenticating user %s: %+v", user, err) 25 | return false 26 | } 27 | return ok 28 | }), secret, auth.Logger(log)) 29 | } 30 | 31 | func tokenDelete(storage token.Storage, secret []byte, log log.Log) http.Handler { 32 | return auth.TokenBlacklister(nil, storage, secret, auth.Logger(log)) 33 | } 34 | 35 | func tokenValidator( 36 | repo repo.User, 37 | storage token.Storage, 38 | log log.Log, 39 | ) auth.TokenValidator { 40 | return auth.TokenValidatorFunc(func(token string, claims jwt.Claims) bool { 41 | exists, err := storage.Exists(token) 42 | 43 | if err != nil { 44 | log.Printf("Error using token storage: %+v\n", err) 45 | return false 46 | } 47 | 48 | if exists { 49 | return false 50 | } 51 | 52 | if c, ok := claims.(*jwt.StandardClaims); ok { 53 | _, err := repo.Get(content.Login(c.Subject)) 54 | 55 | if err != nil { 56 | if !content.IsNoContent(err) { 57 | log.Printf("Error getting user %s from repo: %+v\n", c.Subject, err) 58 | } 59 | 60 | return false 61 | } 62 | 63 | return true 64 | } 65 | 66 | return false 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /api/extract_generator_mock_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/urandom/readeef/content/extract (interfaces: Generator) 3 | 4 | // Package api is a generated GoMock package. 5 | package api 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | content "github.com/urandom/readeef/content" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockGenerator is a mock of Generator interface 14 | type MockGenerator struct { 15 | ctrl *gomock.Controller 16 | recorder *MockGeneratorMockRecorder 17 | } 18 | 19 | // MockGeneratorMockRecorder is the mock recorder for MockGenerator 20 | type MockGeneratorMockRecorder struct { 21 | mock *MockGenerator 22 | } 23 | 24 | // NewMockGenerator creates a new mock instance 25 | func NewMockGenerator(ctrl *gomock.Controller) *MockGenerator { 26 | mock := &MockGenerator{ctrl: ctrl} 27 | mock.recorder = &MockGeneratorMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockGenerator) EXPECT() *MockGeneratorMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Generate mocks base method 37 | func (m *MockGenerator) Generate(arg0 string) (content.Extract, error) { 38 | ret := m.ctrl.Call(m, "Generate", arg0) 39 | ret0, _ := ret[0].(content.Extract) 40 | ret1, _ := ret[1].(error) 41 | return ret0, ret1 42 | } 43 | 44 | // Generate indicates an expected call of Generate 45 | func (mr *MockGeneratorMockRecorder) Generate(arg0 interface{}) *gomock.Call { 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockGenerator)(nil).Generate), arg0) 47 | } 48 | -------------------------------------------------------------------------------- /api/features.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "net/http" 4 | 5 | type features struct { 6 | Search bool `json:"search,omitempty"` 7 | Extractor bool `json:"extractor,omitempty"` 8 | ProxyHTTP bool `json:"proxyHTTP,omitempty"` 9 | Popularity bool `json:"popularity,omitempty"` 10 | } 11 | 12 | func featuresHandler(features features) http.HandlerFunc { 13 | return func(w http.ResponseWriter, r *http.Request) { 14 | args{"features": features}.WriteJSON(w) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /api/features_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http/httptest" 6 | "reflect" 7 | "testing" 8 | ) 9 | 10 | func Test_featuresHandler(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | features features 14 | wantErr bool 15 | }{ 16 | {"all", features{Search: true, Extractor: true, ProxyHTTP: true, Popularity: true}, false}, 17 | {"some", features{Extractor: true}, false}, 18 | } 19 | type data struct { 20 | Features features `json:"features"` 21 | } 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | req := httptest.NewRequest("GET", "/features", nil) 25 | w := httptest.NewRecorder() 26 | 27 | featuresHandler(tt.features).ServeHTTP(w, req) 28 | got := data{} 29 | 30 | if err := json.Unmarshal(w.Body.Bytes(), &got); (err != nil) != tt.wantErr { 31 | t.Errorf("featuresHandler() error = %v, wantErr %v", err, tt.wantErr) 32 | return 33 | } 34 | 35 | if !reflect.DeepEqual(got.Features, tt.features) { 36 | t.Errorf("featuresHandler() = %v, want %v", got.Features, tt.features) 37 | } 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /api/fever/favicon.go: -------------------------------------------------------------------------------- 1 | package fever 2 | 3 | import ( 4 | "encoding/base64" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/urandom/readeef/content" 10 | "github.com/urandom/readeef/content/repo" 11 | rffeed "github.com/urandom/readeef/feed" 12 | "github.com/urandom/readeef/log" 13 | ) 14 | 15 | type favicon struct { 16 | ID content.FeedID `json:"id"` 17 | Data string `json:"data"` 18 | } 19 | 20 | var ( 21 | faviconCache = map[content.FeedID]string{} 22 | faviconCacheMu sync.RWMutex 23 | ) 24 | 25 | func favicons( 26 | r *http.Request, 27 | resp resp, 28 | user content.User, 29 | service repo.Service, 30 | log log.Log, 31 | ) error { 32 | log.Infoln("Fetching fever feeds favicons") 33 | 34 | var favicons []favicon 35 | 36 | feeds, err := service.FeedRepo().ForUser(user) 37 | if err != nil { 38 | return errors.WithMessage(err, "getting user feeds") 39 | } 40 | 41 | for _, f := range feeds { 42 | faviconCacheMu.RLock() 43 | data := faviconCache[f.ID] 44 | faviconCacheMu.RUnlock() 45 | 46 | if data != "" { 47 | favicons = append(favicons, favicon{f.ID, data}) 48 | continue 49 | } 50 | 51 | log.Debugf("Getting favicon for %q", f.SiteLink) 52 | b, ct, err := rffeed.Favicon(f.SiteLink) 53 | if err != nil { 54 | log.Printf("Error getting favicon for %q: %v", f.SiteLink, err) 55 | continue 56 | } 57 | 58 | data = ct + ";base64," + base64.StdEncoding.EncodeToString(b) 59 | faviconCacheMu.Lock() 60 | faviconCache[f.ID] = data 61 | faviconCacheMu.Unlock() 62 | 63 | favicons = append(favicons, favicon{f.ID, data}) 64 | } 65 | 66 | resp["favicons"] = favicons 67 | 68 | return nil 69 | } 70 | 71 | func init() { 72 | actions["favicons"] = favicons 73 | } 74 | -------------------------------------------------------------------------------- /api/fever/feeds.go: -------------------------------------------------------------------------------- 1 | package fever 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/urandom/readeef/content" 9 | "github.com/urandom/readeef/content/repo" 10 | "github.com/urandom/readeef/log" 11 | ) 12 | 13 | type feed struct { 14 | Id content.FeedID `json:"id"` 15 | Title string `json:"title"` 16 | FaviconID content.FeedID `json:"favicon_id"` 17 | Url string `json:"url"` 18 | SiteUrl string `json:"site_url"` 19 | IsSpark int `json:"is_spark"` 20 | UpdateTime int64 `json:"last_updated_on_time"` 21 | } 22 | 23 | func feeds( 24 | r *http.Request, 25 | resp resp, 26 | user content.User, 27 | service repo.Service, 28 | log log.Log, 29 | ) error { 30 | log.Infoln("Fetching fever feeds") 31 | 32 | var feverFeeds []feed 33 | 34 | feeds, err := service.FeedRepo().ForUser(user) 35 | if err != nil { 36 | return errors.WithMessage(err, "getting user feeds") 37 | } 38 | 39 | now := time.Now().Unix() 40 | for _, f := range feeds { 41 | feed := feed{ 42 | Id: f.ID, FaviconID: f.ID, Title: f.Title, Url: f.Link, 43 | SiteUrl: f.SiteLink, UpdateTime: now, 44 | } 45 | 46 | feverFeeds = append(feverFeeds, feed) 47 | } 48 | 49 | resp["feeds"] = feverFeeds 50 | 51 | err = groups(r, resp, user, service, log) 52 | delete(resp, "groups") 53 | 54 | return err 55 | } 56 | 57 | func init() { 58 | actions["feeds"] = feeds 59 | } 60 | -------------------------------------------------------------------------------- /api/fever/groups.go: -------------------------------------------------------------------------------- 1 | package fever 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/urandom/readeef/content" 10 | "github.com/urandom/readeef/content/repo" 11 | "github.com/urandom/readeef/log" 12 | ) 13 | 14 | type group struct { 15 | Id int64 `json:"id"` 16 | Title string `json:"title"` 17 | } 18 | 19 | type feedsGroup struct { 20 | GroupId int64 `json:"group_id"` 21 | FeedIds string `json:"feed_ids"` 22 | } 23 | 24 | func groups( 25 | r *http.Request, 26 | resp resp, 27 | user content.User, 28 | service repo.Service, 29 | log log.Log, 30 | ) error { 31 | log.Infoln("Fetching fever groups") 32 | 33 | tags, err := service.TagRepo().ForUser(user) 34 | if err != nil { 35 | return errors.WithMessage(err, "getting user tags") 36 | } 37 | 38 | g := make([]group, len(tags)) 39 | fg := make([]feedsGroup, len(tags)) 40 | 41 | feedRepo := service.FeedRepo() 42 | for i, tag := range tags { 43 | g[i] = group{Id: int64(tag.ID), Title: string(tag.Value)} 44 | 45 | feeds, err := feedRepo.ForTag(tag, user) 46 | if err != nil { 47 | return errors.WithMessage(err, "getting tag feeds") 48 | } 49 | 50 | ids := make([]string, len(feeds)) 51 | for j := range feeds { 52 | ids[j] = strconv.FormatInt(int64(feeds[j].ID), 10) 53 | } 54 | 55 | fg[i] = feedsGroup{GroupId: int64(tag.ID), FeedIds: strings.Join(ids, ",")} 56 | } 57 | 58 | resp["groups"], resp["feeds_groups"] = g, fg 59 | 60 | return nil 61 | } 62 | 63 | func init() { 64 | actions["groups"] = groups 65 | } 66 | -------------------------------------------------------------------------------- /api/fever/ids.go: -------------------------------------------------------------------------------- 1 | package fever 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/urandom/readeef/content" 9 | "github.com/urandom/readeef/content/repo" 10 | "github.com/urandom/readeef/log" 11 | "github.com/urandom/readeef/pool" 12 | ) 13 | 14 | func unreadItemIDs( 15 | r *http.Request, 16 | resp resp, 17 | user content.User, 18 | service repo.Service, 19 | log log.Log, 20 | ) error { 21 | log.Infoln("Fetching unread fever item ids") 22 | 23 | ids, err := service.ArticleRepo().IDs(user, 24 | content.UnreadOnly, content.Filters(content.GetUserFilters(user))) 25 | if err != nil { 26 | return errors.WithMessage(err, "getting unread ids") 27 | } 28 | 29 | buf := pool.Buffer.Get() 30 | defer pool.Buffer.Put(buf) 31 | 32 | for i := range ids { 33 | if i != 0 { 34 | buf.WriteString(",") 35 | } 36 | 37 | buf.WriteString(strconv.FormatInt(int64(ids[i]), 10)) 38 | } 39 | 40 | resp["unread_item_ids"] = buf.String() 41 | 42 | return nil 43 | } 44 | 45 | func savedItemIDs( 46 | r *http.Request, 47 | resp resp, 48 | user content.User, 49 | service repo.Service, 50 | log log.Log, 51 | ) error { 52 | log.Infoln("Fetching saved fever item ids") 53 | 54 | ids, err := service.ArticleRepo().IDs(user, 55 | content.FavoriteOnly, content.Filters(content.GetUserFilters(user))) 56 | if err != nil { 57 | return errors.WithMessage(err, "getting unread ids") 58 | } 59 | 60 | buf := pool.Buffer.Get() 61 | defer pool.Buffer.Put(buf) 62 | 63 | for i := range ids { 64 | if i != 0 { 65 | buf.WriteString(",") 66 | } 67 | 68 | buf.WriteString(strconv.FormatInt(int64(ids[i]), 10)) 69 | } 70 | 71 | resp["saved_item_ids"] = buf.String() 72 | 73 | return nil 74 | } 75 | 76 | func init() { 77 | actions["unread_item_ids"] = unreadItemIDs 78 | actions["saved_item_ids"] = savedItemIDs 79 | } 80 | -------------------------------------------------------------------------------- /api/fever/user.go: -------------------------------------------------------------------------------- 1 | package fever 2 | 3 | import ( 4 | "encoding/hex" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/urandom/readeef/content" 8 | "github.com/urandom/readeef/content/repo" 9 | "github.com/urandom/readeef/log" 10 | ) 11 | 12 | func readeefUser(repo repo.User, md5hex string, log log.Log) (content.User, error) { 13 | md5, err := hex.DecodeString(md5hex) 14 | 15 | if err != nil { 16 | return content.User{}, errors.Wrap(err, "decoding hex api_key") 17 | } 18 | 19 | user, err := repo.FindByMD5(md5) 20 | if err != nil { 21 | return content.User{}, errors.WithMessage(err, "getting user by md5") 22 | } 23 | return user, nil 24 | } 25 | -------------------------------------------------------------------------------- /api/processor_article_mock_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/urandom/readeef/content/processor (interfaces: Article) 3 | 4 | // Package api is a generated GoMock package. 5 | package api 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | content "github.com/urandom/readeef/content" 12 | ) 13 | 14 | // MockArticleProcessor is a mock of Article interface 15 | type MockArticleProcessor struct { 16 | ctrl *gomock.Controller 17 | recorder *MockArticleProcessorMockRecorder 18 | } 19 | 20 | // MockArticleProcessorMockRecorder is the mock recorder for MockArticleProcessor 21 | type MockArticleProcessorMockRecorder struct { 22 | mock *MockArticleProcessor 23 | } 24 | 25 | // NewMockArticleProcessor creates a new mock instance 26 | func NewMockArticleProcessor(ctrl *gomock.Controller) *MockArticleProcessor { 27 | mock := &MockArticleProcessor{ctrl: ctrl} 28 | mock.recorder = &MockArticleProcessorMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use 33 | func (m *MockArticleProcessor) EXPECT() *MockArticleProcessorMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // ProcessArticles mocks base method 38 | func (m *MockArticleProcessor) ProcessArticles(arg0 []content.Article) []content.Article { 39 | ret := m.ctrl.Call(m, "ProcessArticles", arg0) 40 | ret0, _ := ret[0].([]content.Article) 41 | return ret0 42 | } 43 | 44 | // ProcessArticles indicates an expected call of ProcessArticles 45 | func (mr *MockArticleProcessorMockRecorder) ProcessArticles(arg0 interface{}) *gomock.Call { 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProcessArticles", reflect.TypeOf((*MockArticleProcessor)(nil).ProcessArticles), arg0) 47 | } 48 | -------------------------------------------------------------------------------- /api/request.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | jwt "github.com/dgrijalva/jwt-go" 9 | "github.com/urandom/handler/auth" 10 | "github.com/urandom/readeef/content" 11 | "github.com/urandom/readeef/content/repo" 12 | "github.com/urandom/readeef/log" 13 | ) 14 | 15 | type contextKey string 16 | 17 | var userKey = contextKey("user") 18 | 19 | func userContext(repo repo.User, next http.Handler, log log.Log) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | if c, ok := auth.Claims(r).(*jwt.StandardClaims); ok { 22 | user, err := repo.Get(content.Login(c.Subject)) 23 | 24 | if err != nil { 25 | if content.IsNoContent(err) { 26 | http.Error(w, "Not found", http.StatusNotFound) 27 | return 28 | } 29 | 30 | fatal(w, log, "Error loading user: %+v", err) 31 | return 32 | } 33 | 34 | ctx := context.WithValue(r.Context(), userKey, user) 35 | 36 | next.ServeHTTP(w, r.WithContext(ctx)) 37 | } else { 38 | http.Error(w, "Invalid claims", http.StatusBadRequest) 39 | } 40 | }) 41 | } 42 | 43 | func userValidator(next http.Handler) http.Handler { 44 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 | if _, stop := userFromRequest(w, r); stop { 46 | return 47 | } 48 | 49 | next.ServeHTTP(w, r) 50 | 51 | }) 52 | } 53 | 54 | func userFromRequest(w http.ResponseWriter, r *http.Request) (user content.User, stop bool) { 55 | var ok bool 56 | if user, ok = r.Context().Value(userKey).(content.User); ok { 57 | return user, false 58 | } 59 | 60 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) 61 | return content.User{}, true 62 | } 63 | 64 | type args map[string]interface{} 65 | 66 | func (a args) WriteJSON(w http.ResponseWriter) { 67 | b, err := json.Marshal(a) 68 | 69 | if err != nil { 70 | http.Error(w, err.Error(), http.StatusInternalServerError) 71 | } 72 | 73 | w.Write(b) 74 | } 75 | -------------------------------------------------------------------------------- /api/searcher_mock_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: ./api/article.go 3 | 4 | // Package api is a generated GoMock package. 5 | package api 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | content "github.com/urandom/readeef/content" 10 | reflect "reflect" 11 | ) 12 | 13 | // Mocksearcher is a mock of searcher interface 14 | type Mocksearcher struct { 15 | ctrl *gomock.Controller 16 | recorder *MocksearcherMockRecorder 17 | } 18 | 19 | // MocksearcherMockRecorder is the mock recorder for Mocksearcher 20 | type MocksearcherMockRecorder struct { 21 | mock *Mocksearcher 22 | } 23 | 24 | // NewMocksearcher creates a new mock instance 25 | func NewMocksearcher(ctrl *gomock.Controller) *Mocksearcher { 26 | mock := &Mocksearcher{ctrl: ctrl} 27 | mock.recorder = &MocksearcherMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *Mocksearcher) EXPECT() *MocksearcherMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Search mocks base method 37 | func (m *Mocksearcher) Search(arg0 string, arg1 content.User, arg2 ...content.QueryOpt) ([]content.Article, error) { 38 | varargs := []interface{}{arg0, arg1} 39 | for _, a := range arg2 { 40 | varargs = append(varargs, a) 41 | } 42 | ret := m.ctrl.Call(m, "Search", varargs...) 43 | ret0, _ := ret[0].([]content.Article) 44 | ret1, _ := ret[1].(error) 45 | return ret0, ret1 46 | } 47 | 48 | // Search indicates an expected call of Search 49 | func (mr *MocksearcherMockRecorder) Search(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { 50 | varargs := append([]interface{}{arg0, arg1}, arg2...) 51 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Search", reflect.TypeOf((*Mocksearcher)(nil).Search), varargs...) 52 | } 53 | -------------------------------------------------------------------------------- /api/token/bolt_storage.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import ( 4 | "encoding/binary" 5 | "time" 6 | 7 | "github.com/boltdb/bolt" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type BoltStorage struct { 12 | db *bolt.DB 13 | } 14 | 15 | var ( 16 | bucket = []byte("token-bucket") 17 | ) 18 | 19 | func NewBoltStorage(path string) (BoltStorage, error) { 20 | db, err := bolt.Open(path, 0660, nil) 21 | 22 | if err != nil { 23 | return BoltStorage{}, errors.Wrapf(err, "opening token bolt storage %s", path) 24 | } 25 | 26 | err = db.Update(func(tx *bolt.Tx) error { 27 | _, err := tx.CreateBucketIfNotExists(bucket) 28 | 29 | return err 30 | }) 31 | if err != nil { 32 | return BoltStorage{}, errors.Wrap(err, "creating token bolt bucket") 33 | } 34 | 35 | return BoltStorage{db}, nil 36 | 37 | } 38 | 39 | func (b BoltStorage) Store(token string, expiration time.Time) error { 40 | err := b.db.Update(func(tx *bolt.Tx) error { 41 | b := tx.Bucket(bucket) 42 | 43 | buf := make([]byte, 8) 44 | binary.LittleEndian.PutUint64(buf, uint64(expiration.Unix())) 45 | 46 | return b.Put([]byte(token), buf) 47 | }) 48 | 49 | if err != nil { 50 | err = errors.Wrap(err, "writing token to storage") 51 | } 52 | 53 | return err 54 | } 55 | 56 | func (b BoltStorage) Exists(token string) (bool, error) { 57 | exists := false 58 | 59 | err := b.db.View(func(tx *bolt.Tx) error { 60 | if v := tx.Bucket(bucket).Get([]byte(token)); v != nil { 61 | exists = true 62 | } 63 | 64 | return nil 65 | }) 66 | 67 | if err != nil { 68 | err = errors.Wrap(err, "looking up token") 69 | } 70 | 71 | return exists, err 72 | } 73 | 74 | func (b BoltStorage) RemoveExpired() error { 75 | now := time.Now() 76 | 77 | err := b.db.Update(func(tx *bolt.Tx) error { 78 | 79 | c := tx.Bucket(bucket).Cursor() 80 | 81 | for k, v := c.First(); k != nil; k, v = c.Next() { 82 | t := time.Unix(int64(binary.LittleEndian.Uint64(v)), 0) 83 | 84 | if now.Before(t) { 85 | continue 86 | } 87 | 88 | if err := c.Delete(); err != nil { 89 | return err 90 | } 91 | } 92 | 93 | return nil 94 | }) 95 | 96 | if err != nil { 97 | err = errors.Wrap(err, "cleaning expired tokens") 98 | } 99 | 100 | return err 101 | } 102 | -------------------------------------------------------------------------------- /api/token/token.go: -------------------------------------------------------------------------------- 1 | package token 2 | 3 | import "time" 4 | 5 | type Storage interface { 6 | Store(token string, expiration time.Time) error 7 | Exists(token string) (bool, error) 8 | RemoveExpired() error 9 | } 10 | -------------------------------------------------------------------------------- /api/ttrss/auth.go: -------------------------------------------------------------------------------- 1 | package ttrss 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/urandom/readeef/content" 9 | "github.com/urandom/readeef/content/repo" 10 | ) 11 | 12 | func registerAuthActions(sessionManager sessionManager, secret []byte) { 13 | actions["login"] = func(req request, u content.User, service repo.Service) (interface{}, error) { 14 | return login(req, u, sessionManager, secret) 15 | } 16 | actions["logout"] = func(req request, u content.User, service repo.Service) (interface{}, error) { 17 | return logout(req, u, sessionManager) 18 | } 19 | actions["isLoggedIn"] = func(req request, u content.User, service repo.Service) (interface{}, error) { 20 | return isLoggedIn(req, u, sessionManager) 21 | } 22 | } 23 | 24 | func login( 25 | req request, user content.User, sessionManager sessionManager, secret []byte, 26 | ) (interface{}, error) { 27 | if ok, err := user.Authenticate(req.Password, []byte(secret)); !ok { 28 | return nil, errors.WithStack(newErr(fmt.Sprintf( 29 | "authentication for TT-RSS user '%s'", user.Login, 30 | ), "LOGIN_ERROR")) 31 | } else if err != nil { 32 | return nil, errors.WithStack(newErr(fmt.Sprintf( 33 | "authentication for TT-RSS user '%s': %v", user.Login, err, 34 | ), "LOGIN_ERROR")) 35 | } 36 | 37 | sessId := sessionManager.update(session{login: user.Login, lastVisit: time.Now()}) 38 | 39 | return genericContent{ 40 | ApiLevel: API_LEVEL, 41 | SessionId: sessId, 42 | }, nil 43 | } 44 | 45 | func logout( 46 | req request, user content.User, sessionManager sessionManager, 47 | ) (interface{}, error) { 48 | sessionManager.remove(req.Sid) 49 | return genericContent{Status: "OK"}, nil 50 | } 51 | 52 | func isLoggedIn( 53 | req request, user content.User, sessionManager sessionManager, 54 | ) (interface{}, error) { 55 | s := sessionManager.get(req.Sid) 56 | return genericContent{Status: s.login != ""}, nil 57 | } 58 | -------------------------------------------------------------------------------- /api/ttrss/generic.go: -------------------------------------------------------------------------------- 1 | package ttrss 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/urandom/readeef/content" 6 | "github.com/urandom/readeef/content/repo" 7 | ) 8 | 9 | type configContent struct { 10 | IconsDir string `json:"icons_dir"` 11 | IconsUrl string `json:"icons_url"` 12 | DaemonIsRunning bool `json:"daemon_is_running"` 13 | NumFeeds int `json:"num_feeds"` 14 | } 15 | 16 | type genericContent struct { 17 | Level int `json:"level,omitempty"` 18 | ApiLevel int `json:"api_level,omitempty"` 19 | Version string `json:"version,omitempty"` 20 | SessionId string `json:"session_id,omitempty"` 21 | Status interface{} `json:"status,omitempty"` 22 | Unread string `json:"unread,omitempty"` 23 | Updated int64 `json:"updated,omitempty"` 24 | Value interface{} `json:"value,omitempty"` 25 | Method string `json:"method,omitempty"` 26 | } 27 | 28 | func getApiLevel(req request, user content.User, service repo.Service) (interface{}, error) { 29 | return genericContent{Level: API_LEVEL}, nil 30 | } 31 | 32 | func getVersion(req request, user content.User, service repo.Service) (interface{}, error) { 33 | return genericContent{Version: API_VERSION}, nil 34 | } 35 | 36 | func getConfig(req request, user content.User, service repo.Service) (interface{}, error) { 37 | feeds, err := service.FeedRepo().ForUser(user) 38 | if err != nil { 39 | return nil, errors.WithMessage(err, "getting user feeds") 40 | } 41 | feedCount := len(feeds) 42 | 43 | return configContent{DaemonIsRunning: true, NumFeeds: feedCount}, nil 44 | } 45 | 46 | func unknown(req request, user content.User, service repo.Service) (interface{}, error) { 47 | return genericContent{Method: req.Op}, errors.WithStack(newErr("unknown method "+req.Op, "UNKNOWN_METHOD")) 48 | } 49 | 50 | func init() { 51 | actions["getApiLevel"] = getApiLevel 52 | actions["getVersion"] = getVersion 53 | actions["getConfig"] = getConfig 54 | } 55 | -------------------------------------------------------------------------------- /cmd/readeef-client/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/BurntSushi/toml" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | type config struct { 12 | URL string 13 | } 14 | 15 | var ( 16 | configPath = filepath.Join(CONFIG_DIR, "config.toml") 17 | ) 18 | 19 | func readConfig() (config, error) { 20 | var c config 21 | 22 | if _, err := toml.DecodeFile(configPath, &c); err != nil { 23 | return c, errors.Wrap(err, "reading config file") 24 | } 25 | 26 | return c, nil 27 | } 28 | 29 | func writeConfig(c config) error { 30 | if err := os.MkdirAll(configPath, 0755); err != nil { 31 | return errors.Wrap(err, "creating config directory") 32 | } 33 | 34 | if err := os.Rename(configPath, configPath+".bkp"); err != nil && !os.IsNotExist(err) { 35 | return errors.Wrap(err, "backing up config file") 36 | } 37 | 38 | f, err := os.Create(configPath) 39 | if err != nil { 40 | return errors.Wrap(err, "creating config file") 41 | } 42 | defer f.Close() 43 | 44 | encoder := toml.NewEncoder(f) 45 | if err := encoder.Encode(c); err != nil { 46 | return errors.Wrap(err, "encoding config") 47 | } 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /cmd/readeef-client/open.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | ) 7 | 8 | func open(u string) error { 9 | args := []string{} 10 | if len(OPEN) > 1 { 11 | args = append(args, OPEN...) 12 | } 13 | args = append(args, u) 14 | if err := exec.Command(OPEN[0], args...).Run(); err != nil { 15 | return fmt.Errorf("Error opening %s: %v", u, err) 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /cmd/readeef-client/platform_darwin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | var ( 9 | OPEN = []string{"open"} 10 | CONFIG_DIR = filepath.Join(os.Getenv("HOME"), "Library/Preferences/readeef-client") 11 | ) 12 | -------------------------------------------------------------------------------- /cmd/readeef-client/platform_linux.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | var ( 9 | OPEN = []string{"xdg-open"} 10 | CONFIG_DIR = filepath.Join(os.Getenv("HOME"), ".config/readeef-client") 11 | ) 12 | -------------------------------------------------------------------------------- /cmd/readeef-client/platform_windows.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | var ( 9 | OPEN = []string{"cmd", "/c", "start"} 10 | CONFIG_DIR = filepath.Join(os.Getenv("%APPDATA%"), "readeef-client") 11 | ) 12 | -------------------------------------------------------------------------------- /cmd/readeef/postgres.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import _ "github.com/urandom/readeef/content/repo/sql/db/postgres" 4 | -------------------------------------------------------------------------------- /cmd/readeef/search-index.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/urandom/readeef/config" 8 | "github.com/urandom/readeef/content/repo/sql" 9 | "github.com/urandom/readeef/content/search" 10 | ) 11 | 12 | var ( 13 | searchIndexVerbose bool 14 | ) 15 | 16 | func runSearchIndex(config config.Config, args []string) error { 17 | if searchIndexVerbose { 18 | config.Log.Level = "debug" 19 | } 20 | 21 | log := initLog(config.Log) 22 | service, err := sql.NewService(config.DB.Driver, config.DB.Connect, log) 23 | if err != nil { 24 | return errors.WithMessage(err, "creating content service") 25 | } 26 | 27 | searchProvider := initSearchProvider(config.Content, service, log) 28 | if searchProvider == nil { 29 | return errors.Errorf("unknown search provider %s", config.Content.Search.Provider) 30 | } 31 | 32 | log.Info("Starting feed indexing") 33 | 34 | if err := search.Reindex(searchProvider, service.ArticleRepo()); err != nil { 35 | return errors.WithMessage(err, "indexing all feeds") 36 | } 37 | 38 | return nil 39 | } 40 | 41 | func init() { 42 | flags := flag.NewFlagSet("search-index", flag.ExitOnError) 43 | flags.BoolVar(&searchIndexVerbose, "verbose", false, "verbose output") 44 | 45 | commands = append(commands, Command{ 46 | Name: "search-index", 47 | Desc: "re-index all feeds", 48 | Flags: flags, 49 | Run: runSearchIndex, 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/readeef/sqlite.go: -------------------------------------------------------------------------------- 1 | // +build cgo 2 | 3 | package main 4 | 5 | import _ "github.com/urandom/readeef/content/repo/sql/db/sqlite3" 6 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | 7 | "github.com/BurntSushi/toml" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // Config is the readeef configuration 12 | type Config struct { 13 | Server Server `toml:"server"` 14 | Log Log `toml:"log"` 15 | API API `toml:"api"` 16 | Timeout Timeout `toml:"timeout"` 17 | DB DB `toml:"db"` 18 | Auth Auth `toml:"auth"` 19 | Hubbub Hubbub `toml:"hubbub"` 20 | Popularity Popularity `toml:"popularity"` 21 | FeedParser FeedParser `toml:"feed-parser"` 22 | FeedManager FeedManager `toml:"feed-manager"` 23 | Content Content `toml:"content"` 24 | UI UI `toml:"ui"` 25 | } 26 | 27 | // Read loads the config data from the given path 28 | func Read(path string) (Config, error) { 29 | c, err := defaultConfig() 30 | 31 | if err != nil { 32 | return Config{}, errors.WithMessage(err, "initializing default config") 33 | } 34 | 35 | c, err = readPath(c, path) 36 | if err != nil { 37 | return Config{}, err 38 | } 39 | 40 | for _, c := range []converter{&c.API, &c.Log, &c.Timeout, &c.FeedManager, &c.Popularity, &c.Content} { 41 | c.Convert() 42 | } 43 | 44 | return c, nil 45 | 46 | } 47 | 48 | func readPath(c Config, path string) (Config, error) { 49 | if path != "" { 50 | b, err := ioutil.ReadFile(path) 51 | if err != nil { 52 | if os.IsNotExist(err) { 53 | return c, nil 54 | } 55 | return Config{}, errors.Wrapf(err, "reading config from %s", path) 56 | } 57 | 58 | if err = toml.Unmarshal(b, &c); err != nil { 59 | return Config{}, errors.Wrapf(err, "unmarshaling toml config from %s", path) 60 | } 61 | } 62 | 63 | return c, nil 64 | } 65 | -------------------------------------------------------------------------------- /config/default.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/BurntSushi/toml" 5 | "github.com/pkg/errors" 6 | ) 7 | 8 | func defaultConfig() (Config, error) { 9 | var def Config 10 | 11 | err := toml.Unmarshal([]byte(DefaultCfg), &def) 12 | 13 | if err != nil { 14 | return Config{}, errors.Wrap(err, "parsing default config") 15 | } 16 | 17 | def.API.Version = apiversion 18 | return def, nil 19 | } 20 | 21 | // DefaultCfg shows the default configuration of the readeef server 22 | var DefaultCfg = ` 23 | [server] 24 | port = 8080 25 | [server.auto-cert] 26 | storage-path = "./storage/certs" 27 | [log] 28 | level = "info" # error, info, debug 29 | file = "-" # stderr, or a filename 30 | formatter = "text" # text, json 31 | access-file = "" # stdout or a filename 32 | [api] 33 | emulators = [] # ["tt-rss", "fever"] 34 | [api.limits] 35 | articles-per-query = 200 36 | [db] 37 | driver = "sqlite3" 38 | connect = "file:./storage/content.sqlite3?cache=shared&mode=rwc&_busy_timeout=50000000&_foreign_keys=1&_journal=wal" 39 | [auth] 40 | session-storage-path = "./storage/session.db" 41 | token-storage-path = "./storage/token.db" 42 | [feed-manager] 43 | update-interval = "30m" 44 | monitors = ["index", "thumbnailer"] 45 | [timeout] 46 | connect = "1s" 47 | read-write = "2s" 48 | [hubbub] 49 | from = "readeef" 50 | [popularity] 51 | delay = "5s" 52 | # providers = ["Reddit", "Twitter"] 53 | [feed-parser] 54 | processors = ["cleanup", "top-image-marker", "absolutize-urls"] 55 | proxy-http-url-template = "/proxy?url={{ . }}" 56 | [content.extract] 57 | generator = "goose" # readability 58 | [content.search] 59 | provider = "bleve" 60 | batch-size = 100 61 | bleve-path = "./storage/search.bleve" 62 | elastic-url = "http://localhost:9200" 63 | [content.article] 64 | processors = ["insert-thumbnail-target"] 65 | proxy-http-url-template = "/proxy?url={{ . }}" 66 | [content.thumbnail] 67 | store = true 68 | [ui] 69 | path = "./rf-ng/ui" 70 | ` 71 | -------------------------------------------------------------------------------- /content/article_test.go: -------------------------------------------------------------------------------- 1 | package content_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/urandom/readeef/content" 7 | ) 8 | 9 | func TestArticle_Validate(t *testing.T) { 10 | type fields struct { 11 | ID content.ArticleID 12 | FeedID content.FeedID 13 | Link string 14 | } 15 | tests := []struct { 16 | name string 17 | fields fields 18 | wantErr bool 19 | }{ 20 | {"valid", fields{ID: 1, FeedID: 1, Link: "http://sugr.org"}, false}, 21 | {"link not absolute", fields{ID: 1, FeedID: 1, Link: "sugr.org"}, true}, 22 | {"no link", fields{ID: 1, FeedID: 1}, true}, 23 | {"no feed id", fields{ID: 1, Link: "http://sugr.org"}, true}, 24 | {"no id", fields{FeedID: 1, Link: "http://sugr.org"}, true}, 25 | {"nothing", fields{}, true}, 26 | } 27 | for _, tt := range tests { 28 | t.Run(tt.name, func(t *testing.T) { 29 | a := content.Article{ 30 | ID: tt.fields.ID, 31 | FeedID: tt.fields.FeedID, 32 | Link: tt.fields.Link, 33 | } 34 | if err := a.Validate(); (err != nil) != tt.wantErr { 35 | t.Errorf("Article.Validate() error = %v, wantErr %v", err, tt.wantErr) 36 | } 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /content/err.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var ( 6 | ErrNoContent = errors.New("No content") 7 | ) 8 | 9 | func IsNoContent(err error) bool { 10 | cause := errors.Cause(err) 11 | 12 | return cause == ErrNoContent 13 | } 14 | -------------------------------------------------------------------------------- /content/extract.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | type Extract struct { 9 | ArticleID ArticleID `db:"article_id"` 10 | Title string 11 | Content string 12 | TopImage string `db:"top_image"` 13 | Language string 14 | } 15 | 16 | func (e Extract) Validate() error { 17 | if e.ArticleID == 0 { 18 | return NewValidationError(errors.New("Article extract has no article id")) 19 | } 20 | 21 | return nil 22 | } 23 | 24 | func (e Extract) String() string { 25 | return fmt.Sprintf("%d: %s", e.ArticleID, e.Title) 26 | } 27 | -------------------------------------------------------------------------------- /content/extract/extract.go: -------------------------------------------------------------------------------- 1 | package extract 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/urandom/readeef/content" 9 | "github.com/urandom/readeef/content/processor" 10 | "github.com/urandom/readeef/content/repo" 11 | ) 12 | 13 | type Generator interface { 14 | Generate(link string) (content.Extract, error) 15 | } 16 | 17 | // Get retrieves the extract from the repository. If it doesn't exist, a new 18 | // one is created and stored before returning. 19 | func Get( 20 | article content.Article, 21 | repo repo.Extract, 22 | generator Generator, 23 | processors []processor.Article, 24 | ) (content.Extract, error) { 25 | extract, err := repo.Get(article) 26 | 27 | if err != nil { 28 | if !content.IsNoContent(err) { 29 | return extract, errors.WithMessage(err, fmt.Sprintf("getting extract for %s from repo", article)) 30 | } 31 | 32 | if extract, err = generator.Generate(article.Link); err != nil { 33 | return extract, errors.WithMessage(err, fmt.Sprintf("generating extract from %s", article.Link)) 34 | } 35 | 36 | extract.ArticleID = article.ID 37 | 38 | if err = repo.Update(extract); err != nil { 39 | return content.Extract{}, errors.WithMessage(err, fmt.Sprintf("updating extract %s", extract)) 40 | } 41 | } 42 | 43 | if len(processors) > 0 { 44 | a := content.Article{Description: extract.Content} 45 | 46 | articles := []content.Article{a} 47 | 48 | if extract.TopImage != "" { 49 | articles = append(articles, content.Article{ 50 | Description: fmt.Sprintf(``, extract.TopImage), 51 | }) 52 | } 53 | 54 | articles = processor.Articles(processors).Process(articles) 55 | 56 | extract.Content = articles[0].Description 57 | 58 | if extract.TopImage != "" { 59 | content := articles[1].Description 60 | 61 | content = strings.Replace(content, ` 0 { 26 | if _, ok := tagIDs[original[i].TagID]; ok { 27 | // Add the feed id to the list of feeds 28 | var found bool 29 | for _, id := range original[i].FeedIDs { 30 | if id == data.Feed.ID { 31 | found = true 32 | break 33 | } 34 | } 35 | 36 | if !found { 37 | original[i].FeedIDs = append( 38 | original[i].FeedIDs, 39 | data.Feed.ID, 40 | ) 41 | changed = true 42 | } 43 | } else { 44 | // Remove the feed id, if it was in the list 45 | for j := range original[i].FeedIDs { 46 | if original[i].FeedIDs[j] == data.Feed.ID { 47 | original[i].FeedIDs = append( 48 | original[i].FeedIDs[:j], 49 | original[i].FeedIDs[j+1:]..., 50 | ) 51 | 52 | changed = true 53 | break 54 | } 55 | } 56 | } 57 | } 58 | 59 | if original[i].Valid() { 60 | filters = append(filters, original[i]) 61 | } 62 | } 63 | 64 | if len(filters) != len(original) || changed { 65 | data.User.ProfileData["filters"] = filters 66 | if err := userRepo.Update(data.User); err != nil { 67 | log.Printf("Error updating user %s: %+v", data.User, err) 68 | } 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /content/processor/absolutize_urls.go: -------------------------------------------------------------------------------- 1 | package processor 2 | 3 | import ( 4 | "net/url" 5 | "path" 6 | "strings" 7 | 8 | "github.com/PuerkitoBio/goquery" 9 | "github.com/urandom/readeef/log" 10 | "github.com/urandom/readeef/parser" 11 | ) 12 | 13 | type AbsolutizeURLs struct { 14 | log log.Log 15 | } 16 | 17 | func NewAbsolutizeURLs(l log.Log) AbsolutizeURLs { 18 | return AbsolutizeURLs{log: l} 19 | } 20 | 21 | func (p AbsolutizeURLs) ProcessFeed(f parser.Feed) parser.Feed { 22 | p.log.Infof("Converting relative urls of feed '%s' to absolute\n", f.Title) 23 | 24 | for i := range f.Articles { 25 | if d, err := goquery.NewDocumentFromReader(strings.NewReader(f.Articles[i].Description)); err == nil { 26 | articleLink, err := url.Parse(f.Articles[i].Link) 27 | if err != nil { 28 | continue 29 | } 30 | 31 | if convertRelativeLinksToAbsolute(d, articleLink) { 32 | if content, err := d.Html(); err == nil { 33 | // net/http tries to provide valid html, adding html, head and body tags 34 | content = content[strings.Index(content, "")+6 : strings.LastIndex(content, "")] 35 | 36 | f.Articles[i].Description = content 37 | } 38 | } 39 | } 40 | } 41 | 42 | return f 43 | } 44 | 45 | func convertRelativeLinksToAbsolute(d *goquery.Document, articleLink *url.URL) bool { 46 | changed := false 47 | d.Find("[src]").Each(func(i int, s *goquery.Selection) { 48 | val, ok := s.Attr("src") 49 | if !ok { 50 | return 51 | } 52 | 53 | u, err := url.Parse(val) 54 | if err != nil { 55 | return 56 | } 57 | 58 | if !u.IsAbs() { 59 | u.Scheme = articleLink.Scheme 60 | 61 | if u.Host == "" { 62 | u.Host = articleLink.Host 63 | 64 | if len(u.Path) > 0 && u.Path[0] != '/' { 65 | u.Path = path.Join(path.Dir(articleLink.Path), u.Path) 66 | } 67 | } 68 | 69 | } 70 | 71 | s.SetAttr("src", u.String()) 72 | 73 | changed = true 74 | return 75 | }) 76 | 77 | return changed 78 | } 79 | -------------------------------------------------------------------------------- /content/processor/common_test.go: -------------------------------------------------------------------------------- 1 | package processor 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/urandom/readeef/config" 7 | "github.com/urandom/readeef/log" 8 | ) 9 | 10 | var ( 11 | logger log.Log 12 | ) 13 | 14 | func init() { 15 | cfg := config.Log{} 16 | cfg.Converted.Writer = os.Stderr 17 | cfg.Converted.Prefix = "[testing] " 18 | logger = log.WithStd(cfg) 19 | } 20 | -------------------------------------------------------------------------------- /content/processor/insert_thumbnail_target.go: -------------------------------------------------------------------------------- 1 | package processor 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | "strings" 7 | 8 | "github.com/PuerkitoBio/goquery" 9 | "github.com/urandom/readeef/content" 10 | "github.com/urandom/readeef/log" 11 | ) 12 | 13 | type InsertThumbnailTarget struct { 14 | log log.Log 15 | urlTemplate *template.Template 16 | } 17 | 18 | func NewInsertThumbnailTarget(l log.Log) InsertThumbnailTarget { 19 | return InsertThumbnailTarget{log: l} 20 | } 21 | 22 | func (p InsertThumbnailTarget) ProcessArticles(articles []content.Article) []content.Article { 23 | if len(articles) == 0 { 24 | return articles 25 | } 26 | 27 | p.log.Infof("Proxying urls of feed '%d'\n", articles[0].FeedID) 28 | 29 | for i := range articles { 30 | if articles[i].ThumbnailLink == "" { 31 | continue 32 | } 33 | 34 | if d, err := goquery.NewDocumentFromReader(strings.NewReader(articles[i].Description)); err == nil { 35 | if insertThumbnailTarget(d, articles[i].ThumbnailLink, p.log) { 36 | if content, err := d.Html(); err == nil { 37 | // net/http tries to provide valid html, adding html, head and body tags 38 | content = content[strings.Index(content, "")+6 : strings.LastIndex(content, "")] 39 | 40 | articles[i].Description = content 41 | } 42 | } 43 | } 44 | } 45 | 46 | return articles 47 | } 48 | 49 | func insertThumbnailTarget(d *goquery.Document, thumbnailLink string, log log.Log) bool { 50 | changed := false 51 | 52 | if d.Find(".top-image").Length() > 0 { 53 | return changed 54 | } 55 | 56 | thumbDoc, err := goquery.NewDocumentFromReader(strings.NewReader(fmt.Sprintf(``, thumbnailLink))) 57 | if err != nil { 58 | log.Infof("Error generating thumbnail image node: %v\n", err) 59 | return changed 60 | } 61 | 62 | d.Find("body").PrependSelection(thumbDoc.Find("img")) 63 | changed = true 64 | 65 | return changed 66 | } 67 | -------------------------------------------------------------------------------- /content/processor/processor.go: -------------------------------------------------------------------------------- 1 | package processor 2 | 3 | import ( 4 | "github.com/urandom/readeef/content" 5 | "github.com/urandom/readeef/parser" 6 | ) 7 | 8 | type Article interface { 9 | ProcessArticles([]content.Article) []content.Article 10 | } 11 | 12 | type Feed interface { 13 | ProcessFeed(parser.Feed) parser.Feed 14 | } 15 | 16 | type Articles []Article 17 | 18 | func (processors Articles) Process(articles []content.Article) []content.Article { 19 | for _, p := range processors { 20 | articles = p.ProcessArticles(articles) 21 | } 22 | 23 | return articles 24 | } 25 | -------------------------------------------------------------------------------- /content/processor/top_image_marker.go: -------------------------------------------------------------------------------- 1 | package processor 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/PuerkitoBio/goquery" 7 | "github.com/urandom/readeef/log" 8 | "github.com/urandom/readeef/parser" 9 | "golang.org/x/net/html" 10 | ) 11 | 12 | type TopImageMarker struct { 13 | log log.Log 14 | } 15 | 16 | func NewTopImageMarker(l log.Log) TopImageMarker { 17 | return TopImageMarker{log: l} 18 | } 19 | 20 | func (p TopImageMarker) ProcessFeed(f parser.Feed) parser.Feed { 21 | p.log.Infof("Locating suitable top images in articles of '%s'\n", f.Title) 22 | 23 | for i := range f.Articles { 24 | if d, err := goquery.NewDocumentFromReader(strings.NewReader(f.Articles[i].Description)); err == nil { 25 | if markTopImage(d) { 26 | if content, err := d.Html(); err == nil { 27 | // net/http tries to provide valid html, adding html, head and body tags 28 | content = content[strings.Index(content, "")+6 : strings.LastIndex(content, "")] 29 | f.Articles[i].Description = content 30 | } 31 | } 32 | } 33 | } 34 | 35 | return f 36 | } 37 | 38 | func markTopImage(d *goquery.Document) bool { 39 | changed := false 40 | 41 | totalTextCount := len(d.Text()) 42 | img := d.Find("img") 43 | 44 | if img.Length() == 0 { 45 | return changed 46 | } 47 | 48 | if totalTextCount == 0 { 49 | img.AddClass("top-image") 50 | changed = true 51 | } else { 52 | afterTextCount := len(img.NextAll().Text()) 53 | // Add any text-node siblings of the image 54 | for n := img.Get(0).NextSibling; n != nil; n = n.NextSibling { 55 | if n.Type == html.TextNode { 56 | afterTextCount += len(n.Data) 57 | } 58 | } 59 | 60 | img.Parents().Each(func(i int, s *goquery.Selection) { 61 | afterTextCount += len(s.NextAll().Text()) 62 | }) 63 | 64 | if totalTextCount-afterTextCount <= afterTextCount { 65 | img.AddClass("top-image") 66 | changed = true 67 | } 68 | } 69 | 70 | return changed 71 | } 72 | -------------------------------------------------------------------------------- /content/processor/unescape.go: -------------------------------------------------------------------------------- 1 | package processor 2 | 3 | import ( 4 | "html" 5 | 6 | "github.com/urandom/readeef/content" 7 | "github.com/urandom/readeef/log" 8 | "github.com/urandom/readeef/parser" 9 | ) 10 | 11 | type Unescape struct { 12 | log log.Log 13 | } 14 | 15 | func NewUnescape(l log.Log) Unescape { 16 | return Unescape{log: l} 17 | } 18 | 19 | func (p Unescape) ProcessArticles(articles []content.Article) []content.Article { 20 | if len(articles) == 0 { 21 | return articles 22 | } 23 | 24 | p.log.Infof("Unescaping articles of feed %d", articles[0].FeedID) 25 | 26 | for i := range articles { 27 | articles[i].Description = p.processDescription(articles[i].Description) 28 | } 29 | 30 | return articles 31 | } 32 | 33 | func (p Unescape) ProcessFeed(f parser.Feed) parser.Feed { 34 | for i := range f.Articles { 35 | f.Articles[i].Description = p.processDescription(f.Articles[i].Description) 36 | } 37 | 38 | return f 39 | } 40 | 41 | func (p Unescape) processDescription(description string) string { 42 | return html.UnescapeString(description) 43 | } 44 | -------------------------------------------------------------------------------- /content/repo/article.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/urandom/readeef/content" 4 | 5 | // Article allows fetching and manipulating content.Article objects 6 | type Article interface { 7 | ForUser(content.User, ...content.QueryOpt) ([]content.Article, error) 8 | 9 | All(...content.QueryOpt) ([]content.Article, error) 10 | 11 | Count(content.User, ...content.QueryOpt) (int64, error) 12 | IDs(content.User, ...content.QueryOpt) ([]content.ArticleID, error) 13 | 14 | Read(bool, content.User, ...content.QueryOpt) error 15 | Favor(bool, content.User, ...content.QueryOpt) error 16 | 17 | RemoveStaleUnreadRecords() error 18 | } 19 | -------------------------------------------------------------------------------- /content/repo/common_test.go: -------------------------------------------------------------------------------- 1 | package repo_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/urandom/readeef/config" 8 | "github.com/urandom/readeef/content/repo" 9 | "github.com/urandom/readeef/log" 10 | ) 11 | 12 | var ( 13 | skip = true 14 | 15 | cfg config.Log 16 | logger log.Log 17 | service repo.Service 18 | ) 19 | 20 | func skipTest(t *testing.T) { 21 | if skip { 22 | t.Skip("No database tag selected") 23 | } 24 | } 25 | 26 | func init() { 27 | cfg.Converted.Writer = os.Stderr 28 | cfg.Converted.Prefix = "[testing] " 29 | 30 | logger = log.WithStd(cfg) 31 | } 32 | -------------------------------------------------------------------------------- /content/repo/eventable/bus.go: -------------------------------------------------------------------------------- 1 | package eventable 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/urandom/readeef/content" 7 | ) 8 | 9 | type Event struct { 10 | Name string 11 | Data interface{} 12 | } 13 | 14 | type UserData interface { 15 | UserLogin() content.Login 16 | } 17 | 18 | type FeedData interface { 19 | FeedID() content.FeedID 20 | } 21 | 22 | type Stream chan Event 23 | 24 | type busCall func(*busPayload) 25 | 26 | type busPayload struct { 27 | listeners []Stream 28 | } 29 | 30 | type bus struct { 31 | ops chan busCall 32 | } 33 | 34 | func newBus(ctx context.Context) bus { 35 | b := bus{ 36 | ops: make(chan busCall), 37 | } 38 | 39 | go b.loop(ctx) 40 | 41 | return b 42 | } 43 | 44 | func (b bus) Dispatch(name string, data interface{}) { 45 | b.ops <- func(p *busPayload) { 46 | event := Event{name, data} 47 | for i := range p.listeners { 48 | p.listeners[i] <- event 49 | } 50 | } 51 | } 52 | 53 | func (b bus) Listener() Stream { 54 | ret := make(chan Event, 10) 55 | 56 | b.ops <- func(p *busPayload) { 57 | p.listeners = append(p.listeners, ret) 58 | } 59 | 60 | return ret 61 | } 62 | 63 | func (b bus) loop(ctx context.Context) { 64 | payload := busPayload{ 65 | []Stream{}, 66 | } 67 | 68 | for { 69 | select { 70 | case op := <-b.ops: 71 | op(&payload) 72 | case <-ctx.Done(): 73 | return 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /content/repo/eventable/bus_test.go: -------------------------------------------------------------------------------- 1 | package eventable 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/urandom/readeef/content" 9 | ) 10 | 11 | type event1data struct { 12 | data int 13 | } 14 | 15 | func (e event1data) UserLogin() content.Login { 16 | return "user1" 17 | } 18 | 19 | type event2data struct { 20 | data string 21 | } 22 | 23 | func (e event2data) UserLogin() content.Login { 24 | return "user2" 25 | } 26 | 27 | func Test_bus_Dispatch(t *testing.T) { 28 | type args struct { 29 | name string 30 | data UserData 31 | } 32 | tests := []struct { 33 | name string 34 | events []args 35 | listeners int 36 | }{ 37 | {"single event", []args{args{"event1", event1data{42}}}, 1}, 38 | {"multiple event", []args{ 39 | args{"event1", event1data{42}}, 40 | args{"event2", event2data{"event2"}}, 41 | }, 1}, 42 | {"single event, multi listeners", []args{args{"event1", event1data{42}}}, 3}, 43 | {"multiple event, multi listeners", []args{ 44 | args{"event1", event1data{42}}, 45 | args{"event2", event2data{"event2"}}, 46 | }, 5}, 47 | } 48 | for _, tt := range tests { 49 | t.Run(tt.name, func(t *testing.T) { 50 | ctx, cancel := context.WithCancel(context.Background()) 51 | defer cancel() 52 | b := newBus(ctx) 53 | 54 | done := make(chan struct{}) 55 | 56 | for i := 0; i < tt.listeners; i++ { 57 | go func() { 58 | l := b.Listener() 59 | i := 0 60 | 61 | for { 62 | select { 63 | case e := <-l: 64 | if e.Name != tt.events[i].name || e.Data != tt.events[i].data { 65 | t.Errorf("Test_bus_Dispatch(), expected %#v, got %#v", e, tt.events[i]) 66 | return 67 | } 68 | i++ 69 | case <-done: 70 | if i != len(tt.events) { 71 | t.Errorf("Received events = %d do not match expected = %d", i, len(tt.events)) 72 | } 73 | return 74 | case <-ctx.Done(): 75 | return 76 | } 77 | } 78 | }() 79 | } 80 | 81 | time.Sleep(500 * time.Millisecond) 82 | 83 | for _, e := range tt.events { 84 | b.Dispatch(e.name, e.data) 85 | } 86 | 87 | time.Sleep(500 * time.Millisecond) 88 | 89 | close(done) 90 | 91 | time.Sleep(500 * time.Millisecond) 92 | }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /content/repo/eventable/service.go: -------------------------------------------------------------------------------- 1 | package eventable 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/urandom/readeef/content/repo" 7 | "github.com/urandom/readeef/log" 8 | ) 9 | 10 | type Service struct { 11 | repo.Service 12 | eventBus bus 13 | 14 | article articleRepo 15 | feed feedRepo 16 | } 17 | 18 | func NewService(ctx context.Context, s repo.Service, log log.Log) Service { 19 | bus := newBus(ctx) 20 | 21 | return Service{ 22 | s, bus, 23 | articleRepo{s.ArticleRepo(), bus, log}, 24 | feedRepo{s.FeedRepo(), bus, log}, 25 | } 26 | } 27 | 28 | func (s Service) Listener() Stream { 29 | return s.eventBus.Listener() 30 | } 31 | 32 | func (s Service) ArticleRepo() repo.Article { 33 | return s.article 34 | } 35 | 36 | func (s Service) FeedRepo() repo.Feed { 37 | return s.feed 38 | } 39 | -------------------------------------------------------------------------------- /content/repo/extract.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/urandom/readeef/content" 4 | 5 | // Extract allows fetching and manipulating content.Extract objects 6 | type Extract interface { 7 | Get(content.Article) (content.Extract, error) 8 | Update(content.Extract) error 9 | } 10 | -------------------------------------------------------------------------------- /content/repo/feed.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/urandom/readeef/content" 4 | 5 | // Feed allows fetching and manipulating content.Feed objects 6 | type Feed interface { 7 | Get(content.FeedID, content.User) (content.Feed, error) 8 | FindByLink(link string) (content.Feed, error) 9 | 10 | ForUser(content.User) ([]content.Feed, error) 11 | ForTag(content.Tag, content.User) ([]content.Feed, error) 12 | All() ([]content.Feed, error) 13 | 14 | IDs() ([]content.FeedID, error) 15 | Unsubscribed() ([]content.Feed, error) 16 | 17 | Update(*content.Feed) ([]content.Article, error) 18 | Delete(content.Feed) error 19 | 20 | Users(content.Feed) ([]content.User, error) 21 | AttachTo(content.Feed, content.User) error 22 | DetachFrom(content.Feed, content.User) error 23 | 24 | SetUserTags(content.Feed, content.User, []*content.Tag) error 25 | } 26 | -------------------------------------------------------------------------------- /content/repo/logging/article.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urandom/readeef/content" 7 | "github.com/urandom/readeef/content/repo" 8 | "github.com/urandom/readeef/log" 9 | ) 10 | 11 | type articleRepo struct { 12 | repo.Article 13 | 14 | log log.Log 15 | } 16 | 17 | func (r articleRepo) ForUser(user content.User, opts ...content.QueryOpt) ([]content.Article, error) { 18 | start := time.Now() 19 | 20 | articles, err := r.Article.ForUser(user, opts...) 21 | 22 | r.log.Infof("repo.Article.ForUser took %s", time.Now().Sub(start)) 23 | 24 | return articles, err 25 | } 26 | 27 | func (r articleRepo) All(opts ...content.QueryOpt) ([]content.Article, error) { 28 | start := time.Now() 29 | 30 | articles, err := r.Article.All(opts...) 31 | 32 | r.log.Infof("repo.Article.All took %s", time.Now().Sub(start)) 33 | 34 | return articles, err 35 | } 36 | 37 | func (r articleRepo) Count(user content.User, opts ...content.QueryOpt) (int64, error) { 38 | start := time.Now() 39 | 40 | count, err := r.Article.Count(user, opts...) 41 | 42 | r.log.Infof("repo.Article.Count took %s", time.Now().Sub(start)) 43 | 44 | return count, err 45 | } 46 | 47 | func (r articleRepo) IDs(user content.User, opts ...content.QueryOpt) ([]content.ArticleID, error) { 48 | start := time.Now() 49 | 50 | ids, err := r.Article.IDs(user, opts...) 51 | 52 | r.log.Infof("repo.Article.IDs took %s", time.Now().Sub(start)) 53 | 54 | return ids, err 55 | } 56 | 57 | func (r articleRepo) Read(state bool, user content.User, opts ...content.QueryOpt) error { 58 | start := time.Now() 59 | 60 | err := r.Article.Read(state, user, opts...) 61 | 62 | r.log.Infof("repo.Article.Read took %s", time.Now().Sub(start)) 63 | 64 | return err 65 | } 66 | 67 | func (r articleRepo) Favor(state bool, user content.User, opts ...content.QueryOpt) error { 68 | start := time.Now() 69 | 70 | err := r.Article.Favor(state, user, opts...) 71 | 72 | r.log.Infof("repo.Article.Favor took %s", time.Now().Sub(start)) 73 | 74 | return err 75 | } 76 | 77 | func (r articleRepo) RemoveStaleUnreadRecords() error { 78 | start := time.Now() 79 | 80 | err := r.Article.RemoveStaleUnreadRecords() 81 | 82 | r.log.Infof("repo.Article.RemoveStaleUnreadRecords took %s", time.Now().Sub(start)) 83 | 84 | return err 85 | } 86 | -------------------------------------------------------------------------------- /content/repo/logging/extract.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urandom/readeef/content" 7 | "github.com/urandom/readeef/content/repo" 8 | "github.com/urandom/readeef/log" 9 | ) 10 | 11 | type extractRepo struct { 12 | repo.Extract 13 | 14 | log log.Log 15 | } 16 | 17 | func (r extractRepo) Get(article content.Article) (content.Extract, error) { 18 | start := time.Now() 19 | 20 | extract, err := r.Extract.Get(article) 21 | 22 | r.log.Infof("repo.Extract.Get took %s", time.Now().Sub(start)) 23 | 24 | return extract, err 25 | } 26 | 27 | func (r extractRepo) Update(extract content.Extract) error { 28 | start := time.Now() 29 | 30 | err := r.Extract.Update(extract) 31 | 32 | r.log.Infof("repo.Extract.Update took %s", time.Now().Sub(start)) 33 | 34 | return err 35 | } 36 | -------------------------------------------------------------------------------- /content/repo/logging/scores.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urandom/readeef/content" 7 | "github.com/urandom/readeef/content/repo" 8 | "github.com/urandom/readeef/log" 9 | ) 10 | 11 | type scoresRepo struct { 12 | repo.Scores 13 | 14 | log log.Log 15 | } 16 | 17 | func (r scoresRepo) Get(article content.Article) (content.Scores, error) { 18 | start := time.Now() 19 | 20 | scores, err := r.Scores.Get(article) 21 | 22 | r.log.Infof("repo.Scores.Get took %s", time.Now().Sub(start)) 23 | 24 | return scores, err 25 | } 26 | 27 | func (r scoresRepo) Update(scores content.Scores) error { 28 | start := time.Now() 29 | 30 | err := r.Scores.Update(scores) 31 | 32 | r.log.Infof("repo.Scores.Update took %s", time.Now().Sub(start)) 33 | 34 | return err 35 | } 36 | -------------------------------------------------------------------------------- /content/repo/logging/service.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "github.com/urandom/readeef/content/repo" 5 | "github.com/urandom/readeef/log" 6 | ) 7 | 8 | type Service struct { 9 | repo.Service 10 | 11 | article articleRepo 12 | extract extractRepo 13 | feed feedRepo 14 | scores scoresRepo 15 | subscription subscriptionRepo 16 | tag tagRepo 17 | thumbnail thumbnailRepo 18 | user userRepo 19 | } 20 | 21 | func NewService(s repo.Service, log log.Log) Service { 22 | return Service{ 23 | s, 24 | articleRepo{s.ArticleRepo(), log}, 25 | extractRepo{s.ExtractRepo(), log}, 26 | feedRepo{s.FeedRepo(), log}, 27 | scoresRepo{s.ScoresRepo(), log}, 28 | subscriptionRepo{s.SubscriptionRepo(), log}, 29 | tagRepo{s.TagRepo(), log}, 30 | thumbnailRepo{s.ThumbnailRepo(), log}, 31 | userRepo{s.UserRepo(), log}, 32 | } 33 | } 34 | 35 | func (s Service) ArticleRepo() repo.Article { 36 | return s.article 37 | } 38 | 39 | func (s Service) ExtractRepo() repo.Extract { 40 | return s.extract 41 | } 42 | 43 | func (s Service) FeedRepo() repo.Feed { 44 | return s.feed 45 | } 46 | 47 | func (s Service) ScoresRepo() repo.Scores { 48 | return s.scores 49 | } 50 | 51 | func (s Service) SubscriptionRepo() repo.Subscription { 52 | return s.subscription 53 | } 54 | 55 | func (s Service) TagRepo() repo.Tag { 56 | return s.tag 57 | } 58 | 59 | func (s Service) ThumbnailRepo() repo.Thumbnail { 60 | return s.thumbnail 61 | } 62 | 63 | func (s Service) UserRepo() repo.User { 64 | return s.user 65 | } 66 | -------------------------------------------------------------------------------- /content/repo/logging/subscription.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urandom/readeef/content" 7 | "github.com/urandom/readeef/content/repo" 8 | "github.com/urandom/readeef/log" 9 | ) 10 | 11 | type subscriptionRepo struct { 12 | repo.Subscription 13 | 14 | log log.Log 15 | } 16 | 17 | func (r subscriptionRepo) Get(feed content.Feed) (content.Subscription, error) { 18 | start := time.Now() 19 | 20 | subscription, err := r.Subscription.Get(feed) 21 | 22 | r.log.Infof("repo.Subscription.Get took %s", time.Now().Sub(start)) 23 | 24 | return subscription, err 25 | } 26 | 27 | func (r subscriptionRepo) All() ([]content.Subscription, error) { 28 | start := time.Now() 29 | 30 | subscriptions, err := r.Subscription.All() 31 | 32 | r.log.Infof("repo.Subscription.All took %s", time.Now().Sub(start)) 33 | 34 | return subscriptions, err 35 | } 36 | 37 | func (r subscriptionRepo) Update(subscription content.Subscription) error { 38 | start := time.Now() 39 | 40 | err := r.Subscription.Update(subscription) 41 | 42 | r.log.Infof("repo.Subscription.Update took %s", time.Now().Sub(start)) 43 | 44 | return err 45 | } 46 | -------------------------------------------------------------------------------- /content/repo/logging/tag.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urandom/readeef/content" 7 | "github.com/urandom/readeef/content/repo" 8 | "github.com/urandom/readeef/log" 9 | ) 10 | 11 | type tagRepo struct { 12 | repo.Tag 13 | 14 | log log.Log 15 | } 16 | 17 | func (r tagRepo) Get(id content.TagID, user content.User) (content.Tag, error) { 18 | start := time.Now() 19 | 20 | tag, err := r.Tag.Get(id, user) 21 | 22 | r.log.Infof("repo.Tag.Get took %s", time.Now().Sub(start)) 23 | 24 | return tag, err 25 | } 26 | 27 | func (r tagRepo) ForUser(user content.User) ([]content.Tag, error) { 28 | start := time.Now() 29 | 30 | tags, err := r.Tag.ForUser(user) 31 | 32 | r.log.Infof("repo.Tag.ForUser took %s", time.Now().Sub(start)) 33 | 34 | return tags, err 35 | } 36 | 37 | func (r tagRepo) ForFeed(feed content.Feed, user content.User) ([]content.Tag, error) { 38 | start := time.Now() 39 | 40 | tags, err := r.Tag.ForFeed(feed, user) 41 | 42 | r.log.Infof("repo.Tag.ForFeed took %s", time.Now().Sub(start)) 43 | 44 | return tags, err 45 | } 46 | 47 | func (r tagRepo) FeedIDs(tag content.Tag, user content.User) ([]content.FeedID, error) { 48 | start := time.Now() 49 | 50 | ids, err := r.Tag.FeedIDs(tag, user) 51 | 52 | r.log.Infof("repo.Tag.FeedIDs took %s", time.Now().Sub(start)) 53 | 54 | return ids, err 55 | } 56 | -------------------------------------------------------------------------------- /content/repo/logging/thumbnail.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urandom/readeef/content" 7 | "github.com/urandom/readeef/content/repo" 8 | "github.com/urandom/readeef/log" 9 | ) 10 | 11 | type thumbnailRepo struct { 12 | repo.Thumbnail 13 | 14 | log log.Log 15 | } 16 | 17 | func (r thumbnailRepo) Get(article content.Article) (content.Thumbnail, error) { 18 | start := time.Now() 19 | 20 | thumbnail, err := r.Thumbnail.Get(article) 21 | 22 | r.log.Infof("repo.Thumbnail.Get took %s", time.Now().Sub(start)) 23 | 24 | return thumbnail, err 25 | } 26 | 27 | func (r thumbnailRepo) Update(thumbnail content.Thumbnail) error { 28 | start := time.Now() 29 | 30 | err := r.Thumbnail.Update(thumbnail) 31 | 32 | r.log.Infof("repo.Thumbnail.Update took %s", time.Now().Sub(start)) 33 | 34 | return err 35 | } 36 | -------------------------------------------------------------------------------- /content/repo/logging/user.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/urandom/readeef/content" 7 | "github.com/urandom/readeef/content/repo" 8 | "github.com/urandom/readeef/log" 9 | ) 10 | 11 | type userRepo struct { 12 | repo.User 13 | 14 | log log.Log 15 | } 16 | 17 | func (r userRepo) Get(login content.Login) (content.User, error) { 18 | start := time.Now() 19 | 20 | user, err := r.User.Get(login) 21 | 22 | r.log.Infof("repo.User.Get took %s", time.Now().Sub(start)) 23 | 24 | return user, err 25 | } 26 | 27 | func (r userRepo) All() ([]content.User, error) { 28 | start := time.Now() 29 | 30 | users, err := r.User.All() 31 | 32 | r.log.Infof("repo.User.All took %s", time.Now().Sub(start)) 33 | 34 | return users, err 35 | } 36 | 37 | func (r userRepo) Update(user content.User) error { 38 | start := time.Now() 39 | 40 | err := r.User.Update(user) 41 | 42 | r.log.Infof("repo.User.Update took %s", time.Now().Sub(start)) 43 | 44 | return err 45 | } 46 | 47 | func (r userRepo) Delete(user content.User) error { 48 | start := time.Now() 49 | 50 | err := r.User.Delete(user) 51 | 52 | r.log.Infof("repo.User.Delete took %s", time.Now().Sub(start)) 53 | 54 | return err 55 | } 56 | 57 | func (r userRepo) FindByMD5(data []byte) (content.User, error) { 58 | start := time.Now() 59 | 60 | user, err := r.User.FindByMD5(data) 61 | 62 | r.log.Infof("repo.User.FindByMD5 took %s", time.Now().Sub(start)) 63 | 64 | return user, err 65 | } 66 | -------------------------------------------------------------------------------- /content/repo/mock_repo/extract.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/urandom/readeef/content/repo (interfaces: Extract) 3 | 4 | // Package mock_repo is a generated GoMock package. 5 | package mock_repo 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | content "github.com/urandom/readeef/content" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockExtract is a mock of Extract interface 14 | type MockExtract struct { 15 | ctrl *gomock.Controller 16 | recorder *MockExtractMockRecorder 17 | } 18 | 19 | // MockExtractMockRecorder is the mock recorder for MockExtract 20 | type MockExtractMockRecorder struct { 21 | mock *MockExtract 22 | } 23 | 24 | // NewMockExtract creates a new mock instance 25 | func NewMockExtract(ctrl *gomock.Controller) *MockExtract { 26 | mock := &MockExtract{ctrl: ctrl} 27 | mock.recorder = &MockExtractMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockExtract) EXPECT() *MockExtractMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Get mocks base method 37 | func (m *MockExtract) Get(arg0 content.Article) (content.Extract, error) { 38 | ret := m.ctrl.Call(m, "Get", arg0) 39 | ret0, _ := ret[0].(content.Extract) 40 | ret1, _ := ret[1].(error) 41 | return ret0, ret1 42 | } 43 | 44 | // Get indicates an expected call of Get 45 | func (mr *MockExtractMockRecorder) Get(arg0 interface{}) *gomock.Call { 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockExtract)(nil).Get), arg0) 47 | } 48 | 49 | // Update mocks base method 50 | func (m *MockExtract) Update(arg0 content.Extract) error { 51 | ret := m.ctrl.Call(m, "Update", arg0) 52 | ret0, _ := ret[0].(error) 53 | return ret0 54 | } 55 | 56 | // Update indicates an expected call of Update 57 | func (mr *MockExtractMockRecorder) Update(arg0 interface{}) *gomock.Call { 58 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockExtract)(nil).Update), arg0) 59 | } 60 | -------------------------------------------------------------------------------- /content/repo/mock_repo/scores.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/urandom/readeef/content/repo (interfaces: Scores) 3 | 4 | // Package mock_repo is a generated GoMock package. 5 | package mock_repo 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | content "github.com/urandom/readeef/content" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockScores is a mock of Scores interface 14 | type MockScores struct { 15 | ctrl *gomock.Controller 16 | recorder *MockScoresMockRecorder 17 | } 18 | 19 | // MockScoresMockRecorder is the mock recorder for MockScores 20 | type MockScoresMockRecorder struct { 21 | mock *MockScores 22 | } 23 | 24 | // NewMockScores creates a new mock instance 25 | func NewMockScores(ctrl *gomock.Controller) *MockScores { 26 | mock := &MockScores{ctrl: ctrl} 27 | mock.recorder = &MockScoresMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockScores) EXPECT() *MockScoresMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Get mocks base method 37 | func (m *MockScores) Get(arg0 content.Article) (content.Scores, error) { 38 | ret := m.ctrl.Call(m, "Get", arg0) 39 | ret0, _ := ret[0].(content.Scores) 40 | ret1, _ := ret[1].(error) 41 | return ret0, ret1 42 | } 43 | 44 | // Get indicates an expected call of Get 45 | func (mr *MockScoresMockRecorder) Get(arg0 interface{}) *gomock.Call { 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockScores)(nil).Get), arg0) 47 | } 48 | 49 | // Update mocks base method 50 | func (m *MockScores) Update(arg0 content.Scores) error { 51 | ret := m.ctrl.Call(m, "Update", arg0) 52 | ret0, _ := ret[0].(error) 53 | return ret0 54 | } 55 | 56 | // Update indicates an expected call of Update 57 | func (mr *MockScoresMockRecorder) Update(arg0 interface{}) *gomock.Call { 58 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockScores)(nil).Update), arg0) 59 | } 60 | -------------------------------------------------------------------------------- /content/repo/mock_repo/thumbnail.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: github.com/urandom/readeef/content/repo (interfaces: Thumbnail) 3 | 4 | // Package mock_repo is a generated GoMock package. 5 | package mock_repo 6 | 7 | import ( 8 | gomock "github.com/golang/mock/gomock" 9 | content "github.com/urandom/readeef/content" 10 | reflect "reflect" 11 | ) 12 | 13 | // MockThumbnail is a mock of Thumbnail interface 14 | type MockThumbnail struct { 15 | ctrl *gomock.Controller 16 | recorder *MockThumbnailMockRecorder 17 | } 18 | 19 | // MockThumbnailMockRecorder is the mock recorder for MockThumbnail 20 | type MockThumbnailMockRecorder struct { 21 | mock *MockThumbnail 22 | } 23 | 24 | // NewMockThumbnail creates a new mock instance 25 | func NewMockThumbnail(ctrl *gomock.Controller) *MockThumbnail { 26 | mock := &MockThumbnail{ctrl: ctrl} 27 | mock.recorder = &MockThumbnailMockRecorder{mock} 28 | return mock 29 | } 30 | 31 | // EXPECT returns an object that allows the caller to indicate expected use 32 | func (m *MockThumbnail) EXPECT() *MockThumbnailMockRecorder { 33 | return m.recorder 34 | } 35 | 36 | // Get mocks base method 37 | func (m *MockThumbnail) Get(arg0 content.Article) (content.Thumbnail, error) { 38 | ret := m.ctrl.Call(m, "Get", arg0) 39 | ret0, _ := ret[0].(content.Thumbnail) 40 | ret1, _ := ret[1].(error) 41 | return ret0, ret1 42 | } 43 | 44 | // Get indicates an expected call of Get 45 | func (mr *MockThumbnailMockRecorder) Get(arg0 interface{}) *gomock.Call { 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockThumbnail)(nil).Get), arg0) 47 | } 48 | 49 | // Update mocks base method 50 | func (m *MockThumbnail) Update(arg0 content.Thumbnail) error { 51 | ret := m.ctrl.Call(m, "Update", arg0) 52 | ret0, _ := ret[0].(error) 53 | return ret0 54 | } 55 | 56 | // Update indicates an expected call of Update 57 | func (mr *MockThumbnailMockRecorder) Update(arg0 interface{}) *gomock.Call { 58 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockThumbnail)(nil).Update), arg0) 59 | } 60 | -------------------------------------------------------------------------------- /content/repo/postgres_test.go: -------------------------------------------------------------------------------- 1 | // +build postgres 2 | 3 | package repo_test 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/urandom/readeef/content/repo/sql" 10 | "github.com/urandom/readeef/content/repo/sql/db" 11 | _ "github.com/urandom/readeef/content/repo/sql/db/postgres" 12 | ) 13 | 14 | func TestMain(m *testing.M) { 15 | db := db.New(logger) 16 | if err := db.Open("postgres", "host=/var/run/postgresql user=urandom dbname=readeef-test sslmode=disable"); err != nil { 17 | panic(err) 18 | } 19 | 20 | db.Exec("TRUNCATE articles CASCADE") 21 | db.Exec("TRUNCATE articles_scores CASCADE") 22 | db.Exec("TRUNCATE feed_images CASCADE") 23 | db.Exec("TRUNCATE feeds CASCADE") 24 | db.Exec("TRUNCATE hubbub_subscriptions CASCADE") 25 | db.Exec("TRUNCATE users CASCADE") 26 | db.Exec("TRUNCATE users_articles_states CASCADE") 27 | db.Exec("TRUNCATE users_feeds CASCADE") 28 | db.Exec("TRUNCATE users_feeds_tags CASCADE") 29 | 30 | db.Close() 31 | 32 | var err error 33 | service, err = sql.NewService("postgres", "host=/var/run/postgresql user=urandom dbname=readeef-test sslmode=disable", logger) 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | skip = false 39 | ret := m.Run() 40 | 41 | os.Exit(ret) 42 | } 43 | -------------------------------------------------------------------------------- /content/repo/scores.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/urandom/readeef/content" 4 | 5 | // Scores allows fetching and manipulating content.Scores objects 6 | type Scores interface { 7 | Get(content.Article) (content.Scores, error) 8 | Update(content.Scores) error 9 | } 10 | -------------------------------------------------------------------------------- /content/repo/service.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | // Service provices access to the different content repositories. 4 | type Service interface { 5 | UserRepo() User 6 | TagRepo() Tag 7 | FeedRepo() Feed 8 | SubscriptionRepo() Subscription 9 | ArticleRepo() Article 10 | ExtractRepo() Extract 11 | ThumbnailRepo() Thumbnail 12 | ScoresRepo() Scores 13 | } 14 | -------------------------------------------------------------------------------- /content/repo/service_test.go: -------------------------------------------------------------------------------- 1 | package repo_test 2 | 3 | import "testing" 4 | 5 | func TestService(t *testing.T) { 6 | skipTest(t) 7 | 8 | if service.UserRepo() == nil { 9 | t.Fatal("service.UserRepo() = nil") 10 | } 11 | 12 | if service.TagRepo() == nil { 13 | t.Fatal("service.TagRepo() = nil") 14 | } 15 | 16 | if service.FeedRepo() == nil { 17 | t.Fatal("service.FeedRepo() = nil") 18 | } 19 | 20 | if service.SubscriptionRepo() == nil { 21 | t.Fatal("service.SubscriptionRepo() = nil") 22 | } 23 | 24 | if service.ArticleRepo() == nil { 25 | t.Fatal("service.ArticleRepo() = nil") 26 | } 27 | 28 | if service.ExtractRepo() == nil { 29 | t.Fatal("service.ExtractRepo() = nil") 30 | } 31 | 32 | if service.ThumbnailRepo() == nil { 33 | t.Fatal("service.ThumbnailRepo() = nil") 34 | } 35 | 36 | if service.ScoresRepo() == nil { 37 | t.Fatal("service.ScoresRepo() = nil") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /content/repo/sql/db/base/extract.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | func init() { 4 | sqlStmts.Extract.Get = getArticleExtract 5 | sqlStmts.Extract.Create = createArticleExtract 6 | sqlStmts.Extract.Update = updateArticleExtract 7 | } 8 | 9 | const ( 10 | getArticleExtract = ` 11 | SELECT ae.title, ae.content, ae.top_image, ae.language 12 | FROM articles_extracts ae 13 | WHERE ae.article_id = :article_id 14 | ` 15 | createArticleExtract = ` 16 | INSERT INTO articles_extracts(article_id, title, content, top_image, language) 17 | SELECT :article_id, :title, :content, :top_image, :language EXCEPT SELECT article_id, title, content, top_image, language FROM articles_extracts WHERE article_id = :article_id 18 | ` 19 | updateArticleExtract = ` 20 | UPDATE articles_extracts SET title = :title, content = :content, top_image = :top_image, language = :language WHERE article_id = :article_id` 21 | ) 22 | -------------------------------------------------------------------------------- /content/repo/sql/db/base/helper.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | 8 | "github.com/jmoiron/sqlx" 9 | "github.com/urandom/readeef/content/repo/sql/db" 10 | ) 11 | 12 | type Helper struct { 13 | sql db.SqlStmts 14 | } 15 | 16 | func NewHelper() *Helper { 17 | return &Helper{sql: sqlStmts} 18 | } 19 | 20 | func (h Helper) SQL() db.SqlStmts { 21 | return h.sql 22 | } 23 | 24 | func (h *Helper) Set(override db.SqlStmts) { 25 | oursPtr := reflect.ValueOf(&h.sql) 26 | ours := oursPtr.Elem() 27 | theirs := reflect.ValueOf(override) 28 | 29 | for i := 0; i < ours.NumField(); i++ { 30 | ourInner := ours.Field(i) 31 | theirInner := theirs.Field(i) 32 | 33 | if theirInner.IsValid() { 34 | for j := 0; j < theirInner.NumField(); j++ { 35 | ourField := ourInner.Field(j) 36 | theirField := theirInner.Field(j) 37 | 38 | if theirField.IsValid() && ourField.CanSet() && ourField.Kind() == reflect.String { 39 | s := theirField.String() 40 | if s != "" { 41 | ourField.SetString(s) 42 | } 43 | } 44 | } 45 | } 46 | } 47 | } 48 | 49 | func (h Helper) Upgrade(db *db.DB, old, new int) error { 50 | return nil 51 | } 52 | 53 | func (h Helper) CreateWithID(tx *sqlx.Tx, sql string, arg interface{}) (int64, error) { 54 | var id int64 55 | 56 | stmt, err := tx.PrepareNamed(sql) 57 | if err != nil { 58 | return 0, err 59 | } 60 | defer stmt.Close() 61 | 62 | res, err := stmt.Exec(arg) 63 | if err != nil { 64 | return 0, err 65 | } 66 | 67 | id, err = res.LastInsertId() 68 | if err != nil { 69 | return 0, err 70 | } 71 | 72 | return id, nil 73 | } 74 | 75 | func (h Helper) WhereMultipleORs(column, prefix string, length int, equal bool) string { 76 | orSlice := make([]string, length) 77 | for i := 0; i < length; i++ { 78 | orSlice[i] = fmt.Sprintf(":%s%d", prefix, i) 79 | } 80 | 81 | sign := "IN" 82 | if !equal { 83 | sign = "NOT IN" 84 | } 85 | 86 | return fmt.Sprintf("%s %s (%s)", column, sign, strings.Join(orSlice, ", ")) 87 | } 88 | 89 | func (h Helper) RetryableErr(err error) bool { 90 | return false 91 | } 92 | 93 | var ( 94 | sqlStmts = db.SqlStmts{} 95 | ) 96 | -------------------------------------------------------------------------------- /content/repo/sql/db/base/scores.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | func init() { 4 | sqlStmts.Scores.Get = getArticleScores 5 | sqlStmts.Scores.Create = createArticleScores 6 | sqlStmts.Scores.Update = updateArticleScores 7 | } 8 | 9 | const ( 10 | getArticleScores = ` 11 | SELECT asco.score, asco.score1, asco.score2, asco.score3, asco.score4, asco.score5 12 | FROM articles_scores asco 13 | WHERE asco.article_id = :article_id 14 | ` 15 | createArticleScores = ` 16 | INSERT INTO articles_scores(article_id, score, score1, score2, score3, score4, score5) 17 | SELECT :article_id, :score, :score1, :score2, :score3, :score4, :score5 EXCEPT SELECT article_id, score, score1, score2, score3, score4, score5 FROM articles_scores WHERE article_id = :article_id` 18 | updateArticleScores = `UPDATE articles_scores SET score = :score, score1 = :score1, score2 = :score2, score3 = :score3, score4 = :score4, score5 = :score5 WHERE article_id = :article_id` 19 | ) 20 | -------------------------------------------------------------------------------- /content/repo/sql/db/base/subscription.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | func init() { 4 | sqlStmts.Subscription.GetForFeed = getFeedHubbubSubscription 5 | sqlStmts.Subscription.All = getHubbubSubscriptions 6 | sqlStmts.Subscription.Create = createHubbubSubscription 7 | sqlStmts.Subscription.Update = updateHubbubSubscription 8 | } 9 | 10 | const ( 11 | getFeedHubbubSubscription = ` 12 | SELECT link, lease_duration, verification_time, subscription_failure 13 | FROM hubbub_subscriptions WHERE feed_id = :feed_id` 14 | getHubbubSubscriptions = ` 15 | SELECT link, feed_id, lease_duration, verification_time, subscription_failure 16 | FROM hubbub_subscriptions` 17 | 18 | createHubbubSubscription = ` 19 | INSERT INTO hubbub_subscriptions(feed_id, link, lease_duration, verification_time, subscription_failure) 20 | SELECT :feed_id, :link, :lease_duration, :verification_time, :subscription_failure EXCEPT 21 | SELECT feed_id, link, lease_duration, verification_time, subscription_failure 22 | FROM hubbub_subscriptions WHERE feed_id = :feed_id 23 | ` 24 | updateHubbubSubscription = ` 25 | UPDATE hubbub_subscriptions SET link = :link, lease_duration = :lease_duration, 26 | verification_time = :verification_time, subscription_failure = :subscription_failure WHERE feed_id = :feed_id 27 | ` 28 | ) 29 | -------------------------------------------------------------------------------- /content/repo/sql/db/base/tag.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | func init() { 4 | sqlStmts.Tag.Get = getUserTag 5 | sqlStmts.Tag.GetByValue = getTagByValue 6 | sqlStmts.Tag.GetUserFeedIDs = getUserTagFeedIDs 7 | sqlStmts.Tag.AllForUser = getUserTags 8 | sqlStmts.Tag.AllForFeed = getUserFeedTags 9 | sqlStmts.Tag.Create = createTag 10 | sqlStmts.Tag.DeleteStale = deleteStaleTags 11 | } 12 | 13 | const ( 14 | getUserTag = ` 15 | SELECT t.value 16 | FROM tags t LEFT OUTER JOIN users_feeds_tags uft 17 | ON t.id = uft.tag_id 18 | WHERE id = :id AND uft.user_login = :user_login 19 | ` 20 | getTagByValue = `SELECT id FROM tags WHERE value = :value` 21 | getUserFeedTags = ` 22 | SELECT t.id, t.value 23 | FROM users_feeds_tags uft INNER JOIN tags t 24 | ON uft.tag_id = t.id 25 | WHERE uft.user_login = :user_login AND uft.feed_id = :feed_id` 26 | getUserTags = ` 27 | SELECT DISTINCT t.id, t.value 28 | FROM tags t LEFT OUTER JOIN users_feeds_tags uft 29 | ON t.id = uft.tag_id 30 | WHERE uft.user_login = :user_login 31 | ` 32 | getUserTagFeedIDs = ` 33 | SELECT uft.feed_id 34 | FROM users_feeds_tags uft 35 | WHERE uft.user_login = :user_login AND uft.tag_id = :id 36 | ` 37 | 38 | createTag = ` 39 | INSERT INTO tags (value) 40 | SELECT :value EXCEPT SELECT value FROM tags WHERE value = :value 41 | ` 42 | 43 | deleteStaleTags = `DELETE FROM tags WHERE id NOT IN (SELECT tag_id FROM users_feeds_tags)` 44 | ) 45 | -------------------------------------------------------------------------------- /content/repo/sql/db/base/thumbnail.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | func init() { 4 | sqlStmts.Thumbnail.Get = getArticleThumbnail 5 | sqlStmts.Thumbnail.Create = createArticleThumbnail 6 | sqlStmts.Thumbnail.Update = updateArticleThumbnail 7 | } 8 | 9 | const ( 10 | getArticleThumbnail = ` 11 | SELECT at.thumbnail, at.link, at.processed 12 | FROM articles_thumbnails at 13 | WHERE at.article_id = :article_id 14 | ` 15 | createArticleThumbnail = ` 16 | INSERT INTO articles_thumbnails(article_id, thumbnail, link, processed) 17 | SELECT :article_id, :thumbnail, :link, :processed EXCEPT SELECT article_id, thumbnail, link, processed FROM articles_thumbnails WHERE article_id = :article_id 18 | ` 19 | updateArticleThumbnail = ` 20 | UPDATE articles_thumbnails SET thumbnail = :thumbnail, link = :link, processed = :processed WHERE article_id = :article_id` 21 | ) 22 | -------------------------------------------------------------------------------- /content/repo/sql/db/base/user.go: -------------------------------------------------------------------------------- 1 | package base 2 | 3 | func init() { 4 | sqlStmts.User.Get = getUser 5 | sqlStmts.User.GetByMD5API = getUserByMD5Api 6 | sqlStmts.User.All = getUsers 7 | sqlStmts.User.Create = createUser 8 | sqlStmts.User.Update = updateUser 9 | sqlStmts.User.Delete = deleteUser 10 | } 11 | 12 | const ( 13 | getUser = `SELECT first_name, last_name, email, admin, active, profile_data, hash_type, salt, hash, md5_api FROM users WHERE login = :login` 14 | getUserByMD5Api = `SELECT login, first_name, last_name, email, admin, active, profile_data, hash_type, salt, hash FROM users WHERE md5_api = :md5_api` 15 | getUsers = `SELECT login, first_name, last_name, email, admin, active, profile_data, hash_type, salt, hash, md5_api FROM users` 16 | 17 | createUser = ` 18 | INSERT INTO users(login, first_name, last_name, email, admin, active, profile_data, hash_type, salt, hash, md5_api) 19 | SELECT :login, :first_name, :last_name, :email, :admin, :active, :profile_data, :hash_type, :salt, :hash, :md5_api EXCEPT 20 | SELECT login, first_name, last_name, email, admin, active, profile_data, hash_type, salt, hash, md5_api FROM users WHERE login = :login` 21 | updateUser = ` 22 | UPDATE users SET first_name = :first_name, last_name = :last_name, email = :email, admin = :admin, active = :active, profile_data = :profile_data, hash_type = :hash_type, salt = :salt, hash = :hash, md5_api = :md5_api 23 | WHERE login = :login` 24 | deleteUser = `DELETE FROM users WHERE login = :login` 25 | ) 26 | -------------------------------------------------------------------------------- /content/repo/sql/db/sqlite3/cgo.go: -------------------------------------------------------------------------------- 1 | // +build cgo 2 | 3 | package sqlite3 4 | 5 | import _ "github.com/mattn/go-sqlite3" 6 | -------------------------------------------------------------------------------- /content/repo/sql/extract.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/pkg/errors" 8 | "github.com/urandom/readeef/content" 9 | "github.com/urandom/readeef/content/repo/sql/db" 10 | "github.com/urandom/readeef/log" 11 | ) 12 | 13 | type extractRepo struct { 14 | db *db.DB 15 | 16 | log log.Log 17 | } 18 | 19 | func (r extractRepo) Get(article content.Article) (content.Extract, error) { 20 | if err := article.Validate(); err != nil { 21 | return content.Extract{}, errors.WithMessage(err, "validating article") 22 | } 23 | 24 | r.log.Infof("Getting extract for article %s", article) 25 | 26 | extract := content.Extract{ArticleID: article.ID} 27 | if err := r.db.WithNamedStmt(r.db.SQL().Extract.Get, nil, func(stmt *sqlx.NamedStmt) error { 28 | return stmt.Get(&extract, extract) 29 | }); err != nil { 30 | if err == sql.ErrNoRows { 31 | err = content.ErrNoContent 32 | } 33 | 34 | return content.Extract{}, errors.Wrapf(err, "getting extract for article %s", article) 35 | } 36 | 37 | return extract, nil 38 | } 39 | 40 | func (r extractRepo) Update(extract content.Extract) error { 41 | if err := extract.Validate(); err != nil { 42 | return errors.WithMessage(err, "validating extract") 43 | } 44 | 45 | r.log.Infof("Updating extract %s", extract) 46 | 47 | return r.db.WithTx(func(tx *sqlx.Tx) error { 48 | s := r.db.SQL() 49 | return r.db.WithNamedStmt(s.Extract.Update, tx, func(stmt *sqlx.NamedStmt) error { 50 | res, err := stmt.Exec(extract) 51 | if err != nil { 52 | return errors.Wrap(err, "executing extract update stmt") 53 | } 54 | 55 | if num, err := res.RowsAffected(); err == nil && num > 0 { 56 | return nil 57 | } 58 | 59 | return r.db.WithNamedStmt(s.Extract.Create, tx, func(stmt *sqlx.NamedStmt) error { 60 | if _, err = stmt.Exec(extract); err != nil { 61 | return errors.Wrap(err, "executing extract create stmt") 62 | } 63 | return nil 64 | }) 65 | }) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /content/repo/sql/scores.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/pkg/errors" 8 | "github.com/urandom/readeef/content" 9 | "github.com/urandom/readeef/content/repo/sql/db" 10 | "github.com/urandom/readeef/log" 11 | ) 12 | 13 | type scoresRepo struct { 14 | db *db.DB 15 | 16 | log log.Log 17 | } 18 | 19 | func (r scoresRepo) Get(article content.Article) (content.Scores, error) { 20 | if err := article.Validate(); err != nil { 21 | return content.Scores{}, errors.WithMessage(err, "validating article") 22 | } 23 | 24 | r.log.Debugf("Getting scores for article %s", article) 25 | 26 | scores := content.Scores{ArticleID: article.ID} 27 | if err := r.db.WithNamedStmt(r.db.SQL().Scores.Get, nil, func(stmt *sqlx.NamedStmt) error { 28 | return stmt.Get(&scores, scores) 29 | }); err != nil { 30 | if err == sql.ErrNoRows { 31 | err = content.ErrNoContent 32 | } 33 | 34 | return content.Scores{}, errors.Wrapf(err, "getting scores for article %s", article) 35 | } 36 | 37 | return scores, nil 38 | } 39 | 40 | func (r scoresRepo) Update(scores content.Scores) error { 41 | if err := scores.Validate(); err != nil { 42 | return errors.WithMessage(err, "validating scores") 43 | } 44 | 45 | r.log.Infof("Updating scores %s", scores) 46 | 47 | s := r.db.SQL() 48 | return r.db.WithTx(func(tx *sqlx.Tx) error { 49 | return r.db.WithNamedStmt(s.Scores.Update, tx, func(stmt *sqlx.NamedStmt) error { 50 | res, err := stmt.Exec(scores) 51 | if err != nil { 52 | return errors.Wrap(err, "executing scores update stmt") 53 | } 54 | 55 | if num, err := res.RowsAffected(); err == nil && num > 0 { 56 | return nil 57 | } 58 | 59 | return r.db.WithNamedStmt(s.Scores.Create, tx, func(stmt *sqlx.NamedStmt) error { 60 | if _, err = stmt.Exec(scores); err != nil { 61 | return errors.Wrap(err, "executing scores create stmt") 62 | } 63 | 64 | return nil 65 | }) 66 | }) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /content/repo/sql/service.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/urandom/readeef/content/repo" 8 | "github.com/urandom/readeef/content/repo/sql/db" 9 | "github.com/urandom/readeef/log" 10 | ) 11 | 12 | type Service struct { 13 | user repo.User 14 | tag repo.Tag 15 | feed repo.Feed 16 | subscription repo.Subscription 17 | article repo.Article 18 | extract repo.Extract 19 | scores repo.Scores 20 | thumbnail repo.Thumbnail 21 | } 22 | 23 | func NewService(driver, source string, log log.Log) (Service, error) { 24 | switch driver { 25 | case "sqlite3", "postgres": 26 | db := db.New(log) 27 | if err := db.Open(driver, source); err != nil { 28 | return Service{}, errors.Wrap(err, "connecting to database") 29 | } 30 | 31 | log.Infof("Initializing sql repo with driver %s", driver) 32 | return Service{ 33 | user: userRepo{db, log}, 34 | tag: tagRepo{db, log}, 35 | feed: feedRepo{db, log}, 36 | subscription: subscriptionRepo{db, log}, 37 | article: articleRepo{db, log}, 38 | extract: extractRepo{db, log}, 39 | scores: scoresRepo{db, log}, 40 | thumbnail: thumbnailRepo{db, log}, 41 | }, nil 42 | default: 43 | panic(fmt.Sprintf("Cannot provide a repo for driver '%s'\n", driver)) 44 | } 45 | } 46 | 47 | func (s Service) UserRepo() repo.User { 48 | return s.user 49 | } 50 | 51 | func (s Service) TagRepo() repo.Tag { 52 | return s.tag 53 | } 54 | 55 | func (s Service) FeedRepo() repo.Feed { 56 | return s.feed 57 | } 58 | 59 | func (s Service) SubscriptionRepo() repo.Subscription { 60 | return s.subscription 61 | } 62 | 63 | func (s Service) ArticleRepo() repo.Article { 64 | return s.article 65 | } 66 | 67 | func (s Service) ExtractRepo() repo.Extract { 68 | return s.extract 69 | } 70 | 71 | func (s Service) ScoresRepo() repo.Scores { 72 | return s.scores 73 | } 74 | 75 | func (s Service) ThumbnailRepo() repo.Thumbnail { 76 | return s.thumbnail 77 | } 78 | -------------------------------------------------------------------------------- /content/repo/sql/thumbnail.go: -------------------------------------------------------------------------------- 1 | package sql 2 | 3 | import ( 4 | "database/sql" 5 | 6 | "github.com/jmoiron/sqlx" 7 | "github.com/pkg/errors" 8 | "github.com/urandom/readeef/content" 9 | "github.com/urandom/readeef/content/repo/sql/db" 10 | "github.com/urandom/readeef/log" 11 | ) 12 | 13 | type thumbnailRepo struct { 14 | db *db.DB 15 | 16 | log log.Log 17 | } 18 | 19 | func (r thumbnailRepo) Get(article content.Article) (content.Thumbnail, error) { 20 | if err := article.Validate(); err != nil { 21 | return content.Thumbnail{}, errors.WithMessage(err, "validating article") 22 | } 23 | 24 | r.log.Infof("Getting thumbnail for article %s", article) 25 | 26 | thumbnail := content.Thumbnail{ArticleID: article.ID} 27 | if err := r.db.WithNamedStmt(r.db.SQL().Thumbnail.Get, nil, func(stmt *sqlx.NamedStmt) error { 28 | return stmt.Get(&thumbnail, thumbnail) 29 | }); err != nil { 30 | if err == sql.ErrNoRows { 31 | err = content.ErrNoContent 32 | } 33 | 34 | return content.Thumbnail{}, errors.Wrapf(err, "getting thumbnail for article %s", article) 35 | } 36 | 37 | return thumbnail, nil 38 | } 39 | 40 | func (r thumbnailRepo) Update(thumbnail content.Thumbnail) error { 41 | if err := thumbnail.Validate(); err != nil { 42 | return errors.WithMessage(err, "validating thumbnail") 43 | } 44 | 45 | r.log.Infof("Updating thumbnail %s", thumbnail) 46 | 47 | return r.db.WithTx(func(tx *sqlx.Tx) error { 48 | s := r.db.SQL() 49 | return r.db.WithNamedStmt(s.Thumbnail.Update, tx, func(stmt *sqlx.NamedStmt) error { 50 | res, err := stmt.Exec(thumbnail) 51 | if err != nil { 52 | return errors.Wrap(err, "executing thumbnail update stmt") 53 | } 54 | 55 | if num, err := res.RowsAffected(); err == nil && num > 0 { 56 | return nil 57 | } 58 | 59 | return r.db.WithNamedStmt(s.Thumbnail.Create, tx, func(stmt *sqlx.NamedStmt) error { 60 | if _, err := stmt.Exec(thumbnail); err != nil { 61 | return errors.Wrap(err, "executing thumbnail create stmt") 62 | } 63 | 64 | return nil 65 | }) 66 | }) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /content/repo/sqlite3_test.go: -------------------------------------------------------------------------------- 1 | // +build sqlite3 2 | 3 | package repo_test 4 | 5 | import ( 6 | "os" 7 | "testing" 8 | 9 | "github.com/urandom/readeef/content/repo/sql" 10 | "github.com/urandom/readeef/content/repo/sql/db" 11 | _ "github.com/urandom/readeef/content/repo/sql/db/sqlite3" 12 | ) 13 | 14 | func TestMain(m *testing.M) { 15 | db := db.New(logger) 16 | if err := db.Open("sqlite3", "file:/tmp/readeef-test.sqlite3?cache=shared&_foreign_keys=1&_journal=wal"); err != nil { 17 | // if err := db.Open("sqlite3", "file::memory:?cache=shared"); err != nil { 18 | panic(err) 19 | } 20 | 21 | db.Exec("DELETE FROM articles") 22 | db.Exec("DELETE FROM articles_scores") 23 | db.Exec("DELETE FROM feed_images") 24 | db.Exec("DELETE FROM feeds") 25 | db.Exec("DELETE FROM hubbub_subscriptions") 26 | db.Exec("DELETE FROM users") 27 | db.Exec("DELETE FROM users_articles_states") 28 | db.Exec("DELETE FROM users_feeds") 29 | db.Exec("DELETE FROM users_feeds_tags") 30 | 31 | var err error 32 | service, err = sql.NewService("sqlite3", "file:/tmp/readeef-test.sqlite3?cache=shared&_foreign_keys=1&_journal=wal", logger) 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | skip = false 38 | ret := m.Run() 39 | 40 | os.Exit(ret) 41 | } 42 | -------------------------------------------------------------------------------- /content/repo/subscription.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/urandom/readeef/content" 4 | 5 | // Subscription allows fetching and manipulating content.Subscription objects 6 | type Subscription interface { 7 | Get(content.Feed) (content.Subscription, error) 8 | All() ([]content.Subscription, error) 9 | 10 | Update(content.Subscription) error 11 | } 12 | -------------------------------------------------------------------------------- /content/repo/tag.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/urandom/readeef/content" 4 | 5 | // Tag allows fetching and manipulating content.Tag objects 6 | type Tag interface { 7 | Get(content.TagID, content.User) (content.Tag, error) 8 | 9 | ForUser(content.User) ([]content.Tag, error) 10 | ForFeed(content.Feed, content.User) ([]content.Tag, error) 11 | 12 | FeedIDs(content.Tag, content.User) ([]content.FeedID, error) 13 | } 14 | -------------------------------------------------------------------------------- /content/repo/thumbnail.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/urandom/readeef/content" 4 | 5 | // Thumbnail allows fetching and manipulating content.Thumbnail objects 6 | type Thumbnail interface { 7 | Get(content.Article) (content.Thumbnail, error) 8 | Update(content.Thumbnail) error 9 | } 10 | -------------------------------------------------------------------------------- /content/repo/user.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import "github.com/urandom/readeef/content" 4 | 5 | // User allows manipulating content.User objects 6 | type User interface { 7 | Get(content.Login) (content.User, error) 8 | All() ([]content.User, error) 9 | Update(content.User) error 10 | Delete(content.User) error 11 | 12 | FindByMD5([]byte) (content.User, error) 13 | } 14 | -------------------------------------------------------------------------------- /content/scores.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // Scores contains the calculated popularity score of an article. 10 | type Scores struct { 11 | ArticleID ArticleID `db:"article_id"` 12 | Score int64 13 | Score1 int64 14 | Score2 int64 15 | Score3 int64 16 | Score4 int64 17 | Score5 int64 18 | } 19 | 20 | // Calculate returns the overall score of the article. 21 | func (s Scores) Calculate() int64 { 22 | return s.Score1 + int64(0.1*float64(s.Score2)) + int64(0.01*float64(s.Score3)) + int64(0.001*float64(s.Score4)) + int64(0.0001*float64(s.Score5)) 23 | } 24 | 25 | // Validate validates the score data. 26 | func (s Scores) Validate() error { 27 | if s.ArticleID == 0 { 28 | return NewValidationError(errors.New("Articls sxtract has no articls id")) 29 | } 30 | 31 | return nil 32 | } 33 | 34 | func (s Scores) String() string { 35 | return fmt.Sprintf("%d: %d", s.ArticleID, s.Score) 36 | } 37 | -------------------------------------------------------------------------------- /content/scores_test.go: -------------------------------------------------------------------------------- 1 | package content_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/urandom/readeef/content" 7 | ) 8 | 9 | func TestScores_Validate(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | ArticleID content.ArticleID 13 | wantErr bool 14 | }{ 15 | {"valid", 1, false}, 16 | {"invalid", 0, true}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | s := content.Scores{ 21 | ArticleID: tt.ArticleID, 22 | } 23 | if err := s.Validate(); (err != nil) != tt.wantErr { 24 | t.Errorf("Scores.Validate() error = %v, wantErr %v", err, tt.wantErr) 25 | } 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /content/search/search.go: -------------------------------------------------------------------------------- 1 | package search 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/urandom/readeef/content" 8 | "github.com/urandom/readeef/content/repo" 9 | ) 10 | 11 | type indexOperation int 12 | 13 | const ( 14 | BatchAdd indexOperation = iota + 1 15 | BatchDelete 16 | ) 17 | 18 | type Provider interface { 19 | IsNewIndex() bool 20 | Search(string, content.User, ...content.QueryOpt) ([]content.Article, error) 21 | BatchIndex(articles []content.Article, op indexOperation) error 22 | RemoveFeed(content.FeedID) error 23 | } 24 | 25 | func Reindex(p Provider, repo repo.Article) error { 26 | limit := 2000 27 | offset := 0 28 | 29 | for { 30 | articles, err := repo.All(content.Paging(limit, offset)) 31 | if err != nil { 32 | return errors.WithMessage(err, fmt.Sprintf( 33 | "getting articles in window %d-%d", offset, offset+limit, 34 | )) 35 | } 36 | 37 | if err = p.BatchIndex(articles, BatchAdd); err != nil { 38 | return errors.WithMessage(err, "adding batch to index") 39 | } 40 | 41 | if len(articles) < limit { 42 | return nil 43 | } 44 | 45 | offset += limit 46 | 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /content/subscription.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "time" 8 | ) 9 | 10 | type Subscription struct { 11 | FeedID FeedID `db:"feed_id"` 12 | Link string `db:"link"` 13 | LeaseDuration int64 `db:"lease_duration"` 14 | VerificationTime time.Time `db:"verification_time"` 15 | SubscriptionFailure bool `db:"subscription_failure"` 16 | } 17 | 18 | func (s Subscription) Validate() error { 19 | if s.FeedID == 0 { 20 | return NewValidationError(errors.New("Invalid feed id")) 21 | } 22 | 23 | if s.Link == "" { 24 | return NewValidationError(errors.New("No subscription link")) 25 | } 26 | 27 | if u, err := url.Parse(s.Link); err != nil || !u.IsAbs() { 28 | return NewValidationError(errors.New("Invalid subscription link")) 29 | } 30 | 31 | return nil 32 | } 33 | 34 | func (s Subscription) String() string { 35 | return fmt.Sprintf("%s: %d", s.Link, s.FeedID) 36 | } 37 | -------------------------------------------------------------------------------- /content/subscription_test.go: -------------------------------------------------------------------------------- 1 | package content_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/urandom/readeef/content" 7 | ) 8 | 9 | func TestSubscription_Validate(t *testing.T) { 10 | type fields struct { 11 | Link string 12 | FeedID content.FeedID 13 | } 14 | tests := []struct { 15 | name string 16 | fields fields 17 | wantErr bool 18 | }{ 19 | {"valid", fields{FeedID: 1, Link: "http://sugr.org"}, false}, 20 | {"link not absolute", fields{FeedID: 1, Link: "sugr.org"}, true}, 21 | {"no link", fields{FeedID: 1}, true}, 22 | {"no feed id", fields{Link: "http://sugr.org"}, true}, 23 | {"nothing", fields{}, true}, 24 | } 25 | for _, tt := range tests { 26 | t.Run(tt.name, func(t *testing.T) { 27 | s := content.Subscription{ 28 | Link: tt.fields.Link, 29 | FeedID: tt.fields.FeedID, 30 | } 31 | if err := s.Validate(); (err != nil) != tt.wantErr { 32 | t.Errorf("Subscription.Validate() error = %v, wantErr %v", err, tt.wantErr) 33 | } 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /content/tag.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import ( 4 | "database/sql/driver" 5 | "errors" 6 | "fmt" 7 | ) 8 | 9 | type TagID int64 10 | type TagValue string 11 | 12 | type Tag struct { 13 | ID TagID `json:"id"` 14 | Value TagValue `json:"value"` 15 | } 16 | 17 | func (t Tag) Validate() error { 18 | if t.Value == "" { 19 | return NewValidationError(errors.New("Tag has no value")) 20 | } 21 | 22 | return nil 23 | } 24 | 25 | func (t Tag) String() string { 26 | return string(t.Value) 27 | } 28 | 29 | func (id *TagID) Scan(src interface{}) error { 30 | asInt, ok := src.(int64) 31 | if !ok { 32 | return fmt.Errorf("Scan source '%#v' (%T) was not of type int64 (TagId)", src, src) 33 | } 34 | 35 | *id = TagID(asInt) 36 | 37 | return nil 38 | } 39 | 40 | func (id TagID) Value() (driver.Value, error) { 41 | return int64(id), nil 42 | } 43 | 44 | func (val *TagValue) Scan(src interface{}) error { 45 | switch t := src.(type) { 46 | case string: 47 | *val = TagValue(t) 48 | case []byte: 49 | *val = TagValue(t) 50 | default: 51 | return fmt.Errorf("Scan source '%#v' (%T) was not of type string (TagValue)", src, src) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | func (val TagValue) Value() (driver.Value, error) { 58 | return string(val), nil 59 | } 60 | -------------------------------------------------------------------------------- /content/tag_test.go: -------------------------------------------------------------------------------- 1 | package content_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/urandom/readeef/content" 7 | ) 8 | 9 | func TestTag_Validate(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | Value content.TagValue 13 | wantErr bool 14 | }{ 15 | {"valid", "tag", false}, 16 | {"invalid", "", true}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | tag := content.Tag{ 21 | Value: tt.Value, 22 | } 23 | if err := tag.Validate(); (err != nil) != tt.wantErr { 24 | t.Errorf("Tag.Validate() error = %v, wantErr %v", err, tt.wantErr) 25 | } 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /content/thumbnail.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | type Thumbnail struct { 9 | ArticleID ArticleID `db:"article_id"` 10 | Thumbnail string 11 | Link string 12 | Processed bool 13 | } 14 | 15 | func (t Thumbnail) Validate() error { 16 | if t.ArticleID == 0 { 17 | return NewValidationError(errors.New("Article thumbnail has no article id")) 18 | } 19 | 20 | return nil 21 | } 22 | 23 | func (t Thumbnail) String() string { 24 | return fmt.Sprintf("%d: %s", t.ArticleID, t.Link) 25 | } 26 | -------------------------------------------------------------------------------- /content/thumbnail/description.go: -------------------------------------------------------------------------------- 1 | package thumbnail 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/pkg/errors" 8 | "github.com/urandom/readeef/content" 9 | "github.com/urandom/readeef/content/repo" 10 | "github.com/urandom/readeef/log" 11 | ) 12 | 13 | type description struct { 14 | repo repo.Thumbnail 15 | store bool 16 | log log.Log 17 | } 18 | 19 | func FromDescription(repo repo.Thumbnail, store bool, log log.Log) Generator { 20 | return description{repo: repo, store: store, log: log} 21 | } 22 | 23 | func (t description) Generate(a content.Article) error { 24 | thumbnail := content.Thumbnail{ArticleID: a.ID, Processed: true} 25 | 26 | t.log.Debugf("Generating thumbnail for article %s from description", a) 27 | 28 | thumbnail.Thumbnail, thumbnail.Link = 29 | generateThumbnailFromDescription(strings.NewReader(a.Description)) 30 | 31 | if !t.store { 32 | thumbnail.Thumbnail = "" 33 | } 34 | 35 | if err := t.repo.Update(thumbnail); err != nil { 36 | return errors.WithMessage(err, fmt.Sprintf("saving thumbnail of %s", a)) 37 | } 38 | 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /content/thumbnail_test.go: -------------------------------------------------------------------------------- 1 | package content_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/urandom/readeef/content" 7 | ) 8 | 9 | func TestThumbnail_Validate(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | ArticleID content.ArticleID 13 | wantErr bool 14 | }{ 15 | {"valid", 1, false}, 16 | {"invalid", 0, true}, 17 | } 18 | for _, tt := range tests { 19 | t.Run(tt.name, func(t *testing.T) { 20 | thumb := content.Thumbnail{ 21 | ArticleID: tt.ArticleID, 22 | } 23 | if err := thumb.Validate(); (err != nil) != tt.wantErr { 24 | t.Errorf("Thumbnail.Validate() error = %v, wantErr %v", err, tt.wantErr) 25 | } 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /content/validation.go: -------------------------------------------------------------------------------- 1 | package content 2 | 3 | import "github.com/pkg/errors" 4 | 5 | type ValidationError struct { 6 | error 7 | } 8 | 9 | func NewValidationError(err error) error { 10 | return errors.WithStack(ValidationError{err}) 11 | } 12 | 13 | func IsValidationError(err error) bool { 14 | _, ok := errors.Cause(err).(ValidationError) 15 | return ok 16 | } 17 | -------------------------------------------------------------------------------- /feed/favicon.go: -------------------------------------------------------------------------------- 1 | package feed 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/PuerkitoBio/goquery" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | func Favicon(site string) ([]byte, string, error) { 13 | doc, err := goquery.NewDocument(site) 14 | if err != nil { 15 | return nil, "", errors.Wrapf(err, "querying site: %q", site) 16 | } 17 | 18 | icons := doc.Find(`link[rel="shortcut icon"], link[rel="icon"], link[rel="icon shortcut"]`). 19 | Filter(`[href]`). 20 | Map(func(_ int, s *goquery.Selection) string { 21 | return s.AttrOr("href", "") 22 | }) 23 | 24 | var siteURL *url.URL 25 | for _, icon := range icons { 26 | if icon == "" { 27 | continue 28 | } 29 | 30 | iconURL, err := url.Parse(icon) 31 | if err != nil { 32 | continue 33 | } 34 | 35 | if !iconURL.IsAbs() { 36 | if siteURL == nil { 37 | siteURL, err = url.Parse(site) 38 | if err != nil { 39 | continue 40 | } 41 | } 42 | 43 | iconURL.Scheme = siteURL.Scheme 44 | if iconURL.Host == "" { 45 | iconURL.Host = siteURL.Host 46 | } 47 | } 48 | 49 | resp, err := http.Get(iconURL.String()) 50 | if err != nil { 51 | return nil, "", errors.Wrapf(err, "getting favicon %q", iconURL) 52 | } 53 | defer resp.Body.Close() 54 | 55 | b, err := ioutil.ReadAll(resp.Body) 56 | 57 | return b, resp.Header.Get("content-type"), err 58 | } 59 | 60 | return nil, "", nil 61 | } 62 | -------------------------------------------------------------------------------- /feed/favicon_test.go: -------------------------------------------------------------------------------- 1 | package feed 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestFavicon(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | site string 11 | want bool 12 | wantCT string 13 | wantErr bool 14 | }{ 15 | {name: "no url", wantErr: true}, 16 | {name: "broken url", site: "https://example-nonexistent.com", wantErr: true}, 17 | {name: "wikipedia", site: "https://en.wikipedia.org", want: true, wantCT: "image/vnd.microsoft.icon"}, 18 | } 19 | for _, tt := range tests { 20 | t.Run(tt.name, func(t *testing.T) { 21 | got, ct, err := Favicon(tt.site) 22 | if (err != nil) != tt.wantErr { 23 | t.Errorf("Favicon() error = %v, wantErr %v", err, tt.wantErr) 24 | return 25 | } 26 | 27 | if (len(got) > 0) != tt.want { 28 | t.Errorf("Favicon() = %v, want %v", got, tt.want) 29 | } 30 | 31 | if ct != tt.wantCT { 32 | t.Errorf("Favicon() content-type = %q, want %q", ct, tt.wantCT) 33 | } 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /file.list: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urandom/readeef/95c851e5ee6c3a12b0a8888985802698a56bf47b/file.list -------------------------------------------------------------------------------- /fs.go: -------------------------------------------------------------------------------- 1 | // +build !nofs 2 | 3 | package readeef 4 | 5 | //go:generate embed -output fs_files.go -package-name readeef -build-tags !nofs -fallback rf-ng/ui/... templates/raw.tmpl templates/goose-format-result.tmpl 6 | -------------------------------------------------------------------------------- /fs_files_nofs.go: -------------------------------------------------------------------------------- 1 | // +build nofs 2 | 3 | package readeef 4 | 5 | import "net/http" 6 | 7 | // NewFileSystem creates a new http.Dir filesystem 8 | func NewFileSystem() (http.FileSystem, error) { 9 | return http.Dir("."), nil 10 | } 11 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | // Log provides some common methods for outputting messages. 4 | // It may be used to exchange the default log.Logger error logger with another 5 | // provider. 6 | type Log interface { 7 | Print(v ...interface{}) 8 | Printf(format string, v ...interface{}) 9 | Println(v ...interface{}) 10 | 11 | Info(v ...interface{}) 12 | Infof(format string, v ...interface{}) 13 | Infoln(v ...interface{}) 14 | 15 | Debug(v ...interface{}) 16 | Debugf(format string, v ...interface{}) 17 | Debugln(v ...interface{}) 18 | } 19 | -------------------------------------------------------------------------------- /log/logrus.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | lg "github.com/Sirupsen/logrus" 5 | "github.com/urandom/readeef/config" 6 | ) 7 | 8 | type logrus struct { 9 | *lg.Logger 10 | } 11 | 12 | func WithLogrus(cfg config.Log) Log { 13 | logger := logrus{Logger: lg.New()} 14 | 15 | logger.Out = cfg.Converted.Writer 16 | 17 | switch cfg.Formatter { 18 | case "text", "": 19 | logger.Formatter = &lg.TextFormatter{DisableTimestamp: true} 20 | case "json": 21 | logger.Formatter = &lg.JSONFormatter{} 22 | } 23 | 24 | switch cfg.Level { 25 | case "info": 26 | logger.Level = lg.InfoLevel 27 | case "debug": 28 | logger.Level = lg.DebugLevel 29 | case "error", "": 30 | logger.Level = lg.ErrorLevel 31 | } 32 | 33 | return logger 34 | } 35 | 36 | func (l logrus) Print(args ...interface{}) { 37 | l.Logger.Error(args...) 38 | } 39 | 40 | func (l logrus) Printf(format string, args ...interface{}) { 41 | l.Logger.Errorf(format, args...) 42 | } 43 | 44 | func (l logrus) Errorln(args ...interface{}) { 45 | l.Logger.Errorln(args...) 46 | } 47 | -------------------------------------------------------------------------------- /log/std.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/urandom/readeef/config" 7 | ) 8 | 9 | type stdLogger struct { 10 | *log.Logger 11 | } 12 | 13 | // WithStd creates a logger that uses the stdlib log facilities. 14 | func WithStd(cfg config.Log) Log { 15 | return stdLogger{Logger: log.New(cfg.Converted.Writer, cfg.Converted.Prefix, 0)} 16 | } 17 | 18 | func (st stdLogger) Info(v ...interface{}) { 19 | st.Print(v...) 20 | } 21 | 22 | func (st stdLogger) Infof(format string, v ...interface{}) { 23 | st.Printf(format, v...) 24 | } 25 | 26 | func (st stdLogger) Infoln(v ...interface{}) { 27 | st.Println(v...) 28 | } 29 | 30 | func (st stdLogger) Debug(v ...interface{}) { 31 | st.Print(v...) 32 | } 33 | 34 | func (st stdLogger) Debugf(format string, v ...interface{}) { 35 | st.Printf(format, v...) 36 | } 37 | 38 | func (st stdLogger) Debugln(v ...interface{}) { 39 | st.Println(v...) 40 | } 41 | -------------------------------------------------------------------------------- /parser/atom.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "bytes" 5 | "encoding/xml" 6 | "io" 7 | "time" 8 | ) 9 | 10 | type atomFeed struct { 11 | XMLName xml.Name `xml:"feed"` 12 | Title string `xml:"title"` 13 | Description string `xml:"description"` 14 | Link atomLink `xml:"link"` 15 | Image rssImage `xml:"image"` 16 | Items []atomItem `xml:"entry"` 17 | } 18 | 19 | type atomItem struct { 20 | XMLName xml.Name `xml:"entry"` 21 | Id string `xml:"id"` 22 | Title string `xml:"title"` 23 | Description rssContent `xml:"summary"` 24 | Content rssContent `xml:"content"` 25 | Link atomLink `xml:"link"` 26 | Date string `xml:"updated"` 27 | PubDate string `xml:"published"` 28 | } 29 | 30 | type atomLink struct { 31 | Rel string `xml:"rel,attr,omitempty"` 32 | Href string `xml:"href,attr"` 33 | } 34 | 35 | func ParseAtom(b []byte) (Feed, error) { 36 | var f Feed 37 | var rss atomFeed 38 | 39 | decoder := xml.NewDecoder(bytes.NewReader(b)) 40 | decoder.DefaultSpace = "parserfeed" 41 | 42 | if err := decoder.Decode(&rss); err != nil { 43 | return f, err 44 | } 45 | 46 | f = Feed{ 47 | Title: rss.Title, 48 | Description: rss.Description, 49 | SiteLink: rss.Link.Href, 50 | Image: Image{ 51 | rss.Image.Title, rss.Image.Url, 52 | rss.Image.Width, rss.Image.Height}, 53 | } 54 | 55 | var lastValidDate time.Time 56 | for _, i := range rss.Items { 57 | article := Article{Title: i.Title, Link: i.Link.Href, Guid: i.Id} 58 | article.Description = getLargerContent(i.Content, i.Description) 59 | 60 | var err error 61 | if i.PubDate != "" { 62 | article.Date, err = parseDate(i.PubDate) 63 | } else if i.Date != "" { 64 | article.Date, err = parseDate(i.Date) 65 | } else { 66 | err = io.EOF 67 | } 68 | 69 | if err == nil { 70 | lastValidDate = article.Date.Add(time.Second) 71 | } else if lastValidDate.IsZero() { 72 | article.Date = unknownTime 73 | } else { 74 | article.Date = lastValidDate 75 | } 76 | 77 | f.Articles = append(f.Articles, article) 78 | } 79 | f.HubLink = getHubLink(b) 80 | 81 | return f, nil 82 | } 83 | -------------------------------------------------------------------------------- /parser/feed.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import "time" 4 | 5 | type Feed struct { 6 | Title string 7 | Description string 8 | SiteLink string 9 | HubLink string 10 | Image Image 11 | Articles []Article 12 | TTL time.Duration 13 | SkipHours map[int]bool 14 | SkipDays map[string]bool 15 | } 16 | 17 | type Article struct { 18 | Title string 19 | Description string 20 | Link string 21 | Guid string 22 | Date time.Time 23 | } 24 | 25 | type Image struct { 26 | Title string 27 | Url string 28 | Width int 29 | Height int 30 | } 31 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/xml" 5 | "net/http" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | const ( 11 | RFC1123NoSecond = "Mon, 02 Jan 2006 15:04 MST" 12 | HackerNewsTimeFormat = "Mon, _2 Jan 2006 15:04:05 -0700" 13 | ) 14 | 15 | var ( 16 | unknownTime = time.Unix(0, 0) 17 | ) 18 | 19 | func ParseFeed(source []byte, funcs ...func([]byte) (Feed, error)) (Feed, error) { 20 | var feed Feed 21 | var err error 22 | 23 | for _, f := range funcs { 24 | feed, err = f(source) 25 | if err != nil { 26 | if _, ok := err.(xml.UnmarshalError); !ok { 27 | return feed, err 28 | } 29 | } else { 30 | break 31 | } 32 | } 33 | 34 | return feed, err 35 | } 36 | 37 | func parseDate(date string) (time.Time, error) { 38 | formats := []string{ 39 | time.ANSIC, 40 | time.UnixDate, 41 | time.RubyDate, 42 | time.RFC822, 43 | time.RFC822Z, 44 | time.RFC850, 45 | time.RFC1123, 46 | time.RFC1123Z, 47 | RFC1123NoSecond, 48 | time.RFC3339, 49 | time.RFC3339Nano, 50 | http.TimeFormat, 51 | HackerNewsTimeFormat, 52 | } 53 | 54 | date = strings.TrimSpace(date) 55 | var err error 56 | var t time.Time 57 | for _, f := range formats { 58 | t, err = time.Parse(f, date) 59 | if err == nil { 60 | return t, nil 61 | } 62 | } 63 | 64 | return t, err 65 | } 66 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import "testing" 4 | 5 | func TestParseFeed(t *testing.T) { 6 | _, err := ParseFeed([]byte(singleAtomXML), ParseRss2, ParseAtom, ParseRss1) 7 | 8 | if err != nil { 9 | t.Fatal(err) 10 | } 11 | 12 | _, err = ParseFeed([]byte(singleRss1XML), ParseRss2, ParseAtom, ParseRss1) 13 | 14 | if err != nil { 15 | t.Fatal(err) 16 | } 17 | 18 | _, err = ParseFeed([]byte(singleRss2XML), ParseRss2, ParseAtom, ParseRss1) 19 | 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | 24 | _, err = ParseFeed([]byte(singleRss1XML), ParseRss2, ParseAtom) 25 | 26 | if err == nil { 27 | t.Fatalf("Expected an error\n") 28 | } 29 | 30 | _, err = ParseFeed([]byte(singleRss2XML), ParseRss1, ParseAtom) 31 | 32 | if err == nil { 33 | t.Fatalf("Expected an error\n") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /parser/pubsub.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import "encoding/xml" 4 | 5 | type pubsubFeed struct { 6 | Link []pubsubLink `xml:"link"` 7 | Channel pubsubChannel `xml:"channel"` 8 | } 9 | 10 | type pubsubChannel struct { 11 | Link []pubsubLink `xml:"http://www.w3.org/2005/Atom link"` 12 | } 13 | 14 | type pubsubLink struct { 15 | Rel string `xml:"rel,attr,omitempty"` 16 | Href string `xml:"href,attr"` 17 | } 18 | 19 | func getHubLink(b []byte) string { 20 | var f pubsubFeed 21 | 22 | if err := xml.Unmarshal(b, &f); err == nil { 23 | links := append(f.Link, f.Channel.Link...) 24 | for _, link := range links { 25 | if link.Rel == "hub" { 26 | return link.Href 27 | } 28 | } 29 | } 30 | 31 | return "" 32 | } 33 | -------------------------------------------------------------------------------- /parser/rss.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "encoding/xml" 5 | "strings" 6 | ) 7 | 8 | type rssImage struct { 9 | XMLName xml.Name `xml:"image"` 10 | Title string `xml:"title"` 11 | Url string `xml:"url"` 12 | Width int `xml:"width"` 13 | Height int `xml:"height"` 14 | } 15 | 16 | // RssItem is the base content for both rss1 and rss2 feeds. The only reason 17 | // it's public is because of the refrect package 18 | type RssItem struct { 19 | XMLName xml.Name `xml:"item"` 20 | Id string `xml:"guid"` 21 | Title string `xml:"title"` 22 | Link string `xml:"link"` 23 | Description rssContent `xml:"description"` 24 | Content rssContent `xml:"http://purl.org/rss/1.0/modules/content/ encoded"` 25 | PubDate string `xml:"pubDate"` 26 | Date string `xml:"date"` 27 | TTL int `xml:"ttl"` 28 | SkipHours []int `xml:"skipHours>hour"` 29 | SkipDays []string `xml:"skipDays>day"` 30 | } 31 | 32 | type rssContent struct { 33 | XMLName xml.Name 34 | InnerXML string `xml:",innerxml"` 35 | Chardata string `xml:",chardata"` 36 | } 37 | 38 | func (c rssContent) Content() string { 39 | if strings.TrimSpace(c.Chardata) == "" { 40 | return c.InnerXML 41 | } else { 42 | return c.Chardata 43 | } 44 | } 45 | 46 | func getLargerContent(first, second rssContent) string { 47 | c1, c2 := first.Content(), second.Content() 48 | 49 | if len(c1) < len(c2) { 50 | return c2 51 | } else { 52 | return c1 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pool/buffer.go: -------------------------------------------------------------------------------- 1 | package pool 2 | 3 | import ( 4 | "bytes" 5 | "sync" 6 | ) 7 | 8 | type bufp struct { 9 | sync.Pool 10 | } 11 | 12 | // Buffer is a utility variable that provides bytes.Buffer objects. 13 | var Buffer bufp 14 | 15 | // Get returns a bytes.Buffer pointer from the pool. 16 | func (b *bufp) Get() *bytes.Buffer { 17 | buffer := b.Pool.Get().(*bytes.Buffer) 18 | buffer.Reset() 19 | 20 | return buffer 21 | } 22 | 23 | func init() { 24 | Buffer = bufp{ 25 | Pool: sync.Pool{ 26 | New: func() interface{} { 27 | return new(bytes.Buffer) 28 | }, 29 | }, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /popularity/link.go: -------------------------------------------------------------------------------- 1 | package popularity 2 | 3 | import ( 4 | "net/url" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | var ( 10 | dqLinkMatcher = regexp.MustCompile(`.*?`) 11 | sqLinkMatcher = regexp.MustCompile(`.*?`) 12 | ) 13 | 14 | func realLink(link, text string) string { 15 | u, err := url.Parse(link) 16 | if err != nil { 17 | return "" 18 | } 19 | u.RawQuery = "" 20 | link = u.String() 21 | 22 | if link, ok := processReddit(link, text); ok { 23 | return link 24 | } 25 | 26 | return link 27 | } 28 | 29 | func processReddit(link, text string) (string, bool) { 30 | if strings.Contains(link, "://reddit.com/r") || strings.Contains(link, "://www.reddit.com/r") { 31 | res := dqLinkMatcher.FindAllStringSubmatch(text, -1) 32 | for _, l := range res { 33 | if checkRedditLink(l[1], link) { 34 | return l[1], true 35 | } 36 | } 37 | 38 | res = sqLinkMatcher.FindAllStringSubmatch(text, -1) 39 | for _, l := range res { 40 | if checkRedditLink(l[1], link) { 41 | return l[1], true 42 | } 43 | } 44 | } 45 | 46 | return "", false 47 | } 48 | 49 | func checkRedditLink(link, original string) bool { 50 | if strings.Contains(link, "reddit.com/user") { 51 | return false 52 | } 53 | 54 | if strings.Contains(link, "reddit.com/") && strings.Contains(link, "/comments/") { 55 | return false 56 | } 57 | 58 | if link != original { 59 | return true 60 | } 61 | 62 | return false 63 | } 64 | -------------------------------------------------------------------------------- /popularity/reddit.go: -------------------------------------------------------------------------------- 1 | package popularity 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/pkg/errors" 7 | "github.com/turnage/graw/reddit" 8 | "github.com/urandom/readeef/config" 9 | "github.com/urandom/readeef/log" 10 | ) 11 | 12 | type Reddit struct { 13 | bot reddit.Bot 14 | log log.Log 15 | } 16 | 17 | func FromReddit(config config.Popularity, log log.Log) (Reddit, error) { 18 | if config.Reddit.ID == "" || config.Reddit.Secret == "" { 19 | return Reddit{}, errors.New("invalid credentials") 20 | } 21 | 22 | bot, err := reddit.NewBot(reddit.BotConfig{ 23 | Agent: fmt.Sprintf("readeef:score_bot by /u/%s", config.Reddit.Username), 24 | App: reddit.App{ 25 | ID: config.Reddit.ID, 26 | Secret: config.Reddit.Secret, 27 | Username: config.Reddit.Username, 28 | Password: config.Reddit.Password, 29 | }, 30 | }) 31 | if err != nil { 32 | return Reddit{}, errors.Wrap(err, "initializing reddit api") 33 | } 34 | return Reddit{bot: bot, log: log}, nil 35 | } 36 | 37 | func (r Reddit) Score(link string) (int64, error) { 38 | var score int64 = -1 39 | 40 | harvest, err := r.bot.ListingWithParams("/api/info", map[string]string{ 41 | "url": link, 42 | }) 43 | 44 | if err != nil { 45 | return score, errors.Wrapf(err, "getting reddit info for link %s", link) 46 | } 47 | 48 | score = 0 49 | for _, post := range harvest.Posts { 50 | score += int64(post.Score + post.NumComments) 51 | } 52 | 53 | r.log.Debugf("Popularity: Reddit score and comments for url %s: %d", link, score) 54 | 55 | return score, nil 56 | } 57 | 58 | func (re Reddit) String() string { 59 | return "Reddit score provider" 60 | } 61 | -------------------------------------------------------------------------------- /popularity/twitter.go: -------------------------------------------------------------------------------- 1 | package popularity 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | 7 | "github.com/ChimeraCoder/anaconda" 8 | "github.com/urandom/readeef/config" 9 | "github.com/urandom/readeef/log" 10 | ) 11 | 12 | type Twitter struct { 13 | api *anaconda.TwitterApi 14 | log log.Log 15 | } 16 | 17 | func FromTwitter(config config.Popularity, log log.Log) Twitter { 18 | anaconda.SetConsumerKey(config.Twitter.ConsumerKey) 19 | anaconda.SetConsumerSecret(config.Twitter.ConsumerSecret) 20 | 21 | return Twitter{api: anaconda.NewTwitterApi(config.Twitter.AccessToken, config.Twitter.AccessTokenSecret), log: log} 22 | } 23 | 24 | func (t Twitter) Score(link string) (int64, error) { 25 | link = url.QueryEscape(link) 26 | 27 | var score int64 28 | 29 | values := make(url.Values) 30 | values.Set("count", "100") 31 | for { 32 | searchResults, err := t.api.GetSearch(link, values) 33 | if err != nil { 34 | return 0, fmt.Errorf("twitter scoring: %v", err) 35 | } 36 | 37 | score += int64(len(searchResults.Statuses)) 38 | 39 | if searchResults.Metadata.NextResults == "" { 40 | break 41 | } 42 | 43 | v, err := url.ParseQuery(searchResults.Metadata.NextResults[1:]) 44 | if err != nil { 45 | panic(err) 46 | } 47 | 48 | values.Set("max_id", v.Get("max_id")) 49 | } 50 | 51 | t.log.Debugf("Popularity: Tweets for url %s: %d", link, score) 52 | 53 | return score, nil 54 | } 55 | 56 | func (t Twitter) String() string { 57 | return "Twitter score provider" 58 | } 59 | -------------------------------------------------------------------------------- /rf-ng/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | node_modules/ 3 | node_modules/* 4 | node_modules/** 5 | Makefile 6 | Dockerfile -------------------------------------------------------------------------------- /rf-ng/.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /rf-ng/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /ui/en 8 | /ui/bg 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # IDEs and editors 14 | /.idea 15 | .project 16 | .classpath 17 | .c9/ 18 | *.launch 19 | .settings/ 20 | *.sublime-workspace 21 | 22 | # IDE - VSCode 23 | .vscode/* 24 | !.vscode/settings.json 25 | !.vscode/tasks.json 26 | !.vscode/launch.json 27 | !.vscode/extensions.json 28 | 29 | # misc 30 | /.sass-cache 31 | /connect.lock 32 | /coverage 33 | /libpeerconnection.log 34 | npm-debug.log 35 | testem.log 36 | /typings 37 | yarn-error.log 38 | 39 | # e2e 40 | /e2e/*.js 41 | /e2e/*.map 42 | 43 | # System Files 44 | .DS_Store 45 | Thumbs.db 46 | -------------------------------------------------------------------------------- /rf-ng/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use a Node.js image and assign it as our build 2 | FROM mhart/alpine-node:12 as build 3 | 4 | # Install system dependencies, mostly for libpng 5 | RUN apk --no-cache update \ 6 | && apk --no-cache add g++ make bash zlib-dev libpng-dev nano bash \ 7 | && rm -fr /var/cache/apk/* 8 | 9 | COPY . /opt/rf-ng 10 | 11 | WORKDIR /opt/rf-ng 12 | RUN npm install && \ 13 | npm run-script build 14 | 15 | VOLUME /opt/rf-ng/build 16 | 17 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /rf-ng/Makefile: -------------------------------------------------------------------------------- 1 | docker-build: 2 | @docker build -t urandum/rf-ng:alpine-node12 . 3 | 4 | docker-run: 5 | @docker run -ti -p 4200:4200 urandum/rf-ng:alpine-node12 6 | -------------------------------------------------------------------------------- /rf-ng/README.md: -------------------------------------------------------------------------------- 1 | # RfNg 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.3.0. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 24 | Before running the tests make sure you are serving the app via `ng serve`. 25 | 26 | ## Further help 27 | 28 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 29 | -------------------------------------------------------------------------------- /rf-ng/e2e/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('rf-ng App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to app!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /rf-ng/e2e/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /rf-ng/e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/e2e", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "jasminewd2", 11 | "node" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /rf-ng/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/rf-ng'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /rf-ng/ngsw-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/service-worker/config/schema.json", 3 | "index": "/index.html", 4 | "assetGroups": [ 5 | { 6 | "name": "app", 7 | "installMode": "prefetch", 8 | "resources": { 9 | "files": [ 10 | "/favicon.ico", 11 | "/index.html", 12 | "/manifest.webmanifest", 13 | "/*.css", 14 | "/*.js" 15 | ], 16 | "urls": [ 17 | "https://fonts.googleapis.com/css?family=Material+Icons", 18 | "https://fonts.gstatic.com/s/materialicons/**" 19 | ] 20 | } 21 | }, 22 | { 23 | "name": "assets", 24 | "installMode": "lazy", 25 | "updateMode": "prefetch", 26 | "resources": { 27 | "files": [ 28 | "/assets/**", 29 | "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" 30 | ] 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /rf-ng/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rf-ng", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "~11.0.5", 15 | "@angular/cdk": "~11.0.3", 16 | "@angular/common": "~11.0.5", 17 | "@angular/compiler": "~11.0.5", 18 | "@angular/core": "~11.0.5", 19 | "@angular/forms": "~11.0.5", 20 | "@angular/localize": "^11.0.5", 21 | "@angular/material": "^11.0.3", 22 | "@angular/platform-browser": "~11.0.5", 23 | "@angular/platform-browser-dynamic": "~11.0.5", 24 | "@angular/router": "~11.0.5", 25 | "@angular/service-worker": "~11.0.5", 26 | "@ng-bootstrap/ng-bootstrap": "^8.0.0", 27 | "bootstrap": "^4.4.1", 28 | "hammerjs": "^2.0.8", 29 | "jwt-decode": "^2.2.0", 30 | "moment": "^2.29.4", 31 | "ngx-virtual-scroller": "^3.0.3", 32 | "rxjs": "~6.6.3", 33 | "tslib": "^2.0.0", 34 | "zone.js": "~0.10.3" 35 | }, 36 | "devDependencies": { 37 | "@angular-devkit/build-angular": "~15.2.4", 38 | "@angular/cli": "~11.2.19", 39 | "@angular/compiler-cli": "~11.0.5", 40 | "@angular/language-service": "~11.0.5", 41 | "@types/node": "^12.11.1", 42 | "@types/jasmine": "~3.6.0", 43 | "@types/jasminewd2": "~2.0.3", 44 | "jasmine-core": "~3.6.0", 45 | "jasmine-spec-reporter": "~5.0.0", 46 | "karma": "~6.3.16", 47 | "karma-chrome-launcher": "~3.1.0", 48 | "karma-coverage-istanbul-reporter": "~3.0.2", 49 | "karma-jasmine": "~4.0.0", 50 | "karma-jasmine-html-reporter": "^1.5.0", 51 | "protractor": "~7.0.0", 52 | "ts-node": "~7.0.0", 53 | "tslint": "~6.1.0", 54 | "typescript": "~4.0.5" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /rf-ng/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './e2e/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: 'e2e/tsconfig.e2e.json' 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /rf-ng/src/app/article-display/article-display.css: -------------------------------------------------------------------------------- 1 | :host { 2 | flex-grow: 1; 3 | display: flex; 4 | height: calc(100% - 64px); 5 | } 6 | 7 | ngb-carousel { 8 | user-select: text !important; 9 | overflow-y: auto; 10 | flex-grow: 1; 11 | } 12 | 13 | ::ng-deep .carousel-item { 14 | transition: -webkit-transform 0.2s ease-in-out; 15 | transition: transform 0.2s ease-in-out; 16 | transition: transform 0.2s ease-in-out, -webkit-transform 0.6s ease-in-out; 17 | } 18 | 19 | ::ng-deep span.carousel-control-next-icon { 20 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23999' viewBox='0 0 8 8'%3E%3Cpath d='M1.5 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E") !important; 21 | } 22 | 23 | ::ng-deep span.carousel-control-prev-icon { 24 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23999' viewBox='0 0 8 8'%3E%3Cpath d='M4 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E") !important; 25 | } 26 | 27 | ::ng-deep .carousel-control-prev, 28 | ::ng-deep .carousel-control-next { 29 | max-width: 100px; 30 | } 31 | 32 | ::ng-deep .carousel-indicators { 33 | display: none; 34 | } 35 | 36 | a { 37 | color: black; 38 | } 39 | 40 | .title { 41 | margin-top: 1rem; 42 | } 43 | 44 | .info { 45 | padding-bottom: 8px; 46 | } 47 | 48 | .feed { 49 | font-weight: 200; 50 | font-size: 10px; 51 | text-align: start; 52 | margin-left: 15px; 53 | margin-right: 15px; 54 | text-overflow: ellipsis; 55 | overflow: hidden; 56 | white-space: nowrap; 57 | } 58 | 59 | .index { 60 | font-size: 12px; 61 | text-align: end; 62 | } 63 | 64 | .time { 65 | font-size: 12px; 66 | text-align: end; 67 | } 68 | 69 | .description { 70 | width: 100%; 71 | } 72 | 73 | .description ::ng-deep img { 74 | max-width: 100%; 75 | height: auto; 76 | } 77 | 78 | .description ::ng-deep img.float { 79 | float: left; 80 | padding-right: 16px; 81 | padding-bottom: 16px; 82 | } 83 | 84 | .description ::ng-deep img.center { 85 | display: block; 86 | margin: 0 auto; 87 | } 88 | -------------------------------------------------------------------------------- /rf-ng/src/app/article-display/article-display.html: -------------------------------------------------------------------------------- 1 | ; 6 | 7 |
8 |

9 | 14 | 15 | 16 |

17 |
18 |
19 | {{ item.feed }} 20 |
21 |
22 | {{ index }} 23 |
24 |
25 | {{ item.time }} 26 |
27 |
28 |

29 | 30 |
31 | 36 |
37 | 40 |
41 |
42 | 45 |
46 |
47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /rf-ng/src/app/article-list/article-list.css: -------------------------------------------------------------------------------- 1 | :host { 2 | flex-grow: 1; 3 | display: flex; 4 | height: calc(100% - 64px); 5 | } 6 | 7 | .empty-list { 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | width: 100%; 12 | height: 100%; 13 | } 14 | 15 | virtual-scroller { 16 | overflow: hidden; 17 | overflow-y: auto; 18 | display: block; 19 | flex-grow: 1; 20 | background: #eee; 21 | } 22 | 23 | list-item { 24 | display: inline-block; 25 | overflow: hidden; 26 | vertical-align: top; 27 | border: 0; 28 | width: calc(25% - 16px); 29 | margin: 8px; 30 | } 31 | 32 | @media (max-width: 1350px) { 33 | list-item { 34 | width: calc(33% - 16px); 35 | } 36 | } 37 | 38 | @media (max-width: 900px) { 39 | list-item { 40 | width: calc(50% - 16px); 41 | } 42 | } 43 | 44 | @media (max-width: 600px) { 45 | list-item { 46 | width: calc(100% - 16px); 47 | } 48 | } 49 | 50 | .loader { 51 | height: 2em; 52 | width: 100%; 53 | display: block; 54 | line-height: 2em; 55 | text-align: center; 56 | position: relative; 57 | } 58 | .loader:before { 59 | content: ' '; 60 | position: absolute; 61 | top: 0; 62 | left: 0; 63 | width: 20%; 64 | height: 2px; 65 | background: red; 66 | animation: loader-animation 2s ease-out infinite; 67 | } 68 | @keyframes loader-animation { 69 | 0% { 70 | transform: translate(0%); 71 | } 72 | 100% { 73 | transform: translate(500%); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /rf-ng/src/app/article-list/article-list.html: -------------------------------------------------------------------------------- 1 |

2 | 3 | No articles to display 4 | 5 |

6 |

7 | Loading... 8 |

9 | 10 | 11 | 12 | 13 | 14 |
Loading...
15 | -------------------------------------------------------------------------------- /rf-ng/src/app/article-list/list-item.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { Router, ActivatedRoute } from "@angular/router"; 3 | import { ArticleService, Article } from "../services/article"; 4 | 5 | @Component({ 6 | selector: "list-item", 7 | templateUrl: "./list-item.html", 8 | styleUrls: ["./list-item.css"], 9 | }) 10 | export class ListItemComponent { 11 | @Input() 12 | item: Article 13 | 14 | constructor( 15 | private articleService: ArticleService, 16 | private router: Router, 17 | private route: ActivatedRoute, 18 | ) { } 19 | 20 | openArticle(article: Article) { 21 | this.router.navigate(['article', article.id], {relativeTo: this.route}); 22 | } 23 | 24 | favor(id: number, favor: boolean) { 25 | this.articleService.favor(id, favor).subscribe( 26 | success => { }, 27 | error => console.log(error) 28 | ) 29 | } 30 | } -------------------------------------------------------------------------------- /rf-ng/src/app/article-list/list-item.css: -------------------------------------------------------------------------------- 1 | :host { 2 | height: 500px; 3 | } 4 | 5 | mat-card { 6 | display: flex; 7 | flex-direction: column; 8 | height: calc(100% - 12px); 9 | cursor: pointer; 10 | } 11 | 12 | mat-card-header { 13 | overflow: hidden; 14 | } 15 | 16 | [mat-card-image] { 17 | height: 240px; 18 | flex: 0 0 auto; 19 | background-position: center center; 20 | background-repeat: no-repeat; 21 | background-size: contain; 22 | } 23 | 24 | mat-card-content { 25 | flex: 1 1 auto; 26 | overflow: hidden; 27 | text-overflow: ellipsis-lastline; 28 | font-size: 12px; 29 | line-height: 18px; 30 | max-height: 90px; 31 | display: -webkit-box; 32 | -webkit-line-clamp: 5; 33 | -webkit-box-orient: vertical; 34 | } 35 | 36 | mat-card-actions { 37 | flex: 0 0 auto; 38 | } 39 | 40 | .read { 41 | font-weight: 100; 42 | color: #666; 43 | } 44 | 45 | .read .title { 46 | font-weight: 300; 47 | } 48 | 49 | .title { 50 | font-weight: bold; 51 | font-size: 14px; 52 | overflow: hidden; 53 | text-overflow: ellipsis; 54 | line-height: 1; 55 | height: 3.85em; 56 | } 57 | 58 | .info { 59 | margin: 0; 60 | } 61 | 62 | .feed { 63 | font-weight: 200; 64 | font-size: 10px; 65 | text-align: start; 66 | text-overflow: ellipsis; 67 | overflow: hidden; 68 | white-space: nowrap; 69 | } 70 | 71 | .time { 72 | font-size: 11px; 73 | text-align: end; 74 | } 75 | 76 | button:hover, button:focus { 77 | outline: none; 78 | text-decoration: none; 79 | } 80 | -------------------------------------------------------------------------------- /rf-ng/src/app/article-list/list-item.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 |
13 |
14 | 15 |
16 |
17 | 18 |
19 |
20 | {{ item.feed }} 21 |
22 |
23 | {{ item.time }} 24 |
25 |
26 |
27 |
28 | -------------------------------------------------------------------------------- /rf-ng/src/app/components/app.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urandom/readeef/95c851e5ee6c3a12b0a8888985802698a56bf47b/rf-ng/src/app/components/app.css -------------------------------------------------------------------------------- /rf-ng/src/app/components/app.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
-------------------------------------------------------------------------------- /rf-ng/src/app/components/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { AppComponent } from './app'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(waitForAsync(() => { 7 | TestBed.configureTestingModule({ 8 | declarations: [ 9 | AppComponent 10 | ], 11 | }).compileComponents(); 12 | })); 13 | 14 | it('should create the app', waitForAsync(() => { 15 | const fixture = TestBed.createComponent(AppComponent); 16 | const app = fixture.debugElement.componentInstance; 17 | expect(app).toBeTruthy(); 18 | })); 19 | 20 | it(`should have as title 'app'`, waitForAsync(() => { 21 | const fixture = TestBed.createComponent(AppComponent); 22 | const app = fixture.debugElement.componentInstance; 23 | expect(app.title).toEqual('app'); 24 | })); 25 | 26 | it('should render title in a h1 tag', waitForAsync(() => { 27 | const fixture = TestBed.createComponent(AppComponent); 28 | fixture.detectChanges(); 29 | const compiled = fixture.debugElement.nativeElement; 30 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); 31 | })); 32 | }); 33 | -------------------------------------------------------------------------------- /rf-ng/src/app/components/app.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.html', 6 | styleUrls: ['./app.css'] 7 | }) 8 | export class AppComponent { 9 | title = 'app'; 10 | } 11 | -------------------------------------------------------------------------------- /rf-ng/src/app/guards/auth.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { Router, CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router' 3 | import * as jwt from 'jwt-decode' 4 | 5 | @Injectable({providedIn: "root"}) 6 | export class AuthGuard implements CanActivate { 7 | constructor(private router: Router) {} 8 | 9 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) : boolean { 10 | var token = localStorage.getItem("token") 11 | if (token) { 12 | var res = jwt(token) 13 | if ( 14 | (!res["exp"] || res["exp"] >= new Date().getTime() / 1000) && 15 | (!res["nbf"] || res["nbf"] < new Date().getTime() / 1000) 16 | ) { 17 | return true; 18 | } 19 | } 20 | 21 | this.router.navigate(['/login'], { queryParams: { returnUrl: state.url }}); 22 | return false; 23 | } 24 | } -------------------------------------------------------------------------------- /rf-ng/src/app/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { HttpErrorResponse } from "@angular/common/http"; 2 | import { Component, OnInit } from "@angular/core"; 3 | import { ActivatedRoute, Router } from "@angular/router"; 4 | import { TokenService } from "../services/auth"; 5 | 6 | @Component({ 7 | templateUrl: "./login.html", 8 | styleUrls: ["./login.css"], 9 | }) 10 | export class LoginComponent implements OnInit { 11 | user: string; 12 | password: string; 13 | loading = false; 14 | invalidLogin = false; 15 | 16 | returnURL: string; 17 | 18 | constructor( 19 | private router: Router, 20 | private route: ActivatedRoute, 21 | private tokenService: TokenService, 22 | ) { } 23 | 24 | ngOnInit(): void { 25 | this.tokenService.delete(); 26 | 27 | this.returnURL = this.route.snapshot.queryParams["returnURL"] || '/'; 28 | } 29 | 30 | login() { 31 | this.loading = true 32 | this.tokenService.create(this.user, this.password).subscribe( 33 | data => { 34 | this.invalidLogin = false; 35 | this.router.navigate([this.returnURL]); 36 | }, 37 | (error: HttpErrorResponse) => { 38 | if (error.status == 401) { 39 | this.invalidLogin = true; 40 | } 41 | this.loading = false; 42 | } 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /rf-ng/src/app/login/login.css: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | height: 100vh; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | 8 | button { 9 | margin-bottom: 1rem; 10 | } -------------------------------------------------------------------------------- /rf-ng/src/app/login/login.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 | 7 | 9 | 10 |
11 | 12 | Invalid username or password 13 |
14 |
-------------------------------------------------------------------------------- /rf-ng/src/app/main/main.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy, ViewChild } from "@angular/core"; 2 | import { NavigationEnd, Router, RouterEvent } from '@angular/router'; 3 | import { Subscription } from "rxjs"; 4 | import { listRoute, articleRoute } from "./routing-util" 5 | import { TokenService } from "../services/auth"; 6 | import { filter, switchMap, map, combineLatest } from "rxjs/operators"; 7 | import { MatSidenav } from '@angular/material/sidenav'; 8 | 9 | @Component({ 10 | templateUrl: "./main.html", 11 | styleUrls: ["./main.css"], 12 | }) 13 | export class MainComponent implements OnInit, OnDestroy { 14 | showsArticle = false 15 | inSearch = false 16 | 17 | private subscriptions = new Array(); 18 | 19 | @ViewChild('sidenav', {static: true}) 20 | private sidenav: MatSidenav; 21 | 22 | constructor( 23 | private tokenService: TokenService, 24 | private router: Router, 25 | ) {} 26 | 27 | ngOnInit() { 28 | this.subscriptions.push(this.tokenService.tokenObservable().pipe( 29 | filter(token => token != ""), 30 | switchMap(_ => 31 | articleRoute(this.router).pipe( 32 | map(route => route != null), 33 | combineLatest( 34 | listRoute(this.router).pipe(map(route => 35 | route != null && route.data["primary"] == "search" 36 | )), 37 | (inArticles, inSearch) : [boolean, boolean] => 38 | [inArticles, inSearch] 39 | ), 40 | ), 41 | ), 42 | ).subscribe( 43 | data => { 44 | this.showsArticle = data[0]; 45 | this.inSearch = data[1]; 46 | }, 47 | error => console.log(error) 48 | )); 49 | 50 | this.subscriptions.push(this.router.events.pipe( 51 | filter(e => e instanceof NavigationEnd) 52 | ).subscribe(_ => { 53 | this.sidenav.close(); 54 | }, err => console.log(err))); 55 | } 56 | 57 | ngOnDestroy(): void { 58 | this.subscriptions.forEach(s => s.unsubscribe()); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /rf-ng/src/app/main/main.css: -------------------------------------------------------------------------------- 1 | html, body, mat-sidenav-container, .content { 2 | width: 100%; 3 | height: 100vh; 4 | display: flex; 5 | flex-direction: column; 6 | overflow: hidden; 7 | } 8 | 9 | mat-sidenav { 10 | width: 200px; 11 | } 12 | -------------------------------------------------------------------------------- /rf-ng/src/app/main/routing-util.ts: -------------------------------------------------------------------------------- 1 | import { Router, NavigationEnd, ActivatedRouteSnapshot } from '@angular/router'; 2 | import { Observable } from "rxjs"; 3 | import { filter, map, startWith, distinctUntilChanged, shareReplay } from 'rxjs/operators'; 4 | 5 | export function listRoute(router: Router) : Observable { 6 | return router.events.pipe( 7 | filter(event => event instanceof NavigationEnd), 8 | map(_ => { 9 | return getListRoute([router.routerState.snapshot.root]) 10 | }), 11 | startWith(getListRoute([router.routerState.snapshot.root])), 12 | distinctUntilChanged(), 13 | shareReplay(1), 14 | ); 15 | } 16 | 17 | export function getListRoute(routes: ActivatedRouteSnapshot[]): ActivatedRouteSnapshot { 18 | for (let route of routes) { 19 | if ("primary" in route.data) { 20 | return route; 21 | } 22 | 23 | let r = getListRoute(route.children); 24 | if (r != null) { 25 | return r; 26 | } 27 | } 28 | 29 | return null; 30 | } 31 | 32 | export function articleRoute(router: Router): Observable { 33 | return router.events.pipe( 34 | filter(event => event instanceof NavigationEnd), 35 | map(v => { 36 | return getArticleRoute([router.routerState.snapshot.root]) 37 | }), 38 | startWith(getArticleRoute([router.routerState.snapshot.root])), 39 | distinctUntilChanged(), 40 | shareReplay(1), 41 | ); 42 | } 43 | 44 | export function getArticleRoute(routes: ActivatedRouteSnapshot[]): ActivatedRouteSnapshot { 45 | for (let route of routes) { 46 | if ("articleID" in route.params) { 47 | return route; 48 | } 49 | 50 | let r = getArticleRoute(route.children); 51 | if (r != null) { 52 | return r; 53 | } 54 | } 55 | 56 | return null; 57 | } 58 | -------------------------------------------------------------------------------- /rf-ng/src/app/services/favicon.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | 3 | @Injectable({providedIn: "root"}) 4 | export class FaviconService { 5 | private parser = document.createElement("a") 6 | 7 | iconURL(url: string) : string { 8 | this.parser.href = url; 9 | 10 | let domain = this.parser.hostname; 11 | 12 | return `//www.google.com/s2/favicons?domain=${domain}` 13 | } 14 | } -------------------------------------------------------------------------------- /rf-ng/src/app/services/features.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { Router } from '@angular/router' 3 | import { Observable, ConnectableObservable } from "rxjs"; 4 | import { APIService } from "./api"; 5 | import { TokenService } from "./auth"; 6 | import { switchMap, publishReplay, map } from 'rxjs/operators'; 7 | 8 | 9 | 10 | interface featuresPayload { 11 | features: Features 12 | } 13 | 14 | export interface Features { 15 | i18n: boolean; 16 | search: boolean; 17 | extractor: boolean; 18 | proxyHTTP: boolean; 19 | popularity: boolean; 20 | } 21 | 22 | @Injectable({providedIn: "root"}) 23 | export class FeaturesService { 24 | private features: Observable; 25 | 26 | constructor( 27 | private api: APIService, 28 | private tokenService: TokenService, 29 | ) { 30 | var features = this.tokenService.tokenObservable().pipe( 31 | switchMap(token => this.api.get("features").pipe( 32 | map(p => p.features) 33 | )), 34 | publishReplay(1) 35 | ) as ConnectableObservable; 36 | features.connect(); 37 | 38 | this.features = features; 39 | } 40 | 41 | getFeatures() : Observable { 42 | return this.features; 43 | } 44 | } -------------------------------------------------------------------------------- /rf-ng/src/app/services/interaction.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Output, EventEmitter } from '@angular/core' 2 | import { Location } from '@angular/common'; 3 | import { Router } from '@angular/router'; 4 | 5 | @Injectable({providedIn: "root"}) 6 | export class InteractionService { 7 | @Output() toolbarTitleClickEvent = new EventEmitter(); 8 | 9 | constructor( 10 | private location: Location, 11 | private router: Router, 12 | ) {} 13 | 14 | toolbarTitleClick() { 15 | this.toolbarTitleClickEvent.emit(); 16 | } 17 | 18 | navigateUp(): boolean { 19 | let path = this.location.path() 20 | let idx = path.indexOf("/article/") 21 | if (idx != -1) { 22 | this.router.navigateByUrl(path.substring(0, idx), {state: {"articleID": path.substring(idx+9)}}); 23 | return true; 24 | } 25 | return false; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /rf-ng/src/app/services/tag.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core' 2 | import { Router } from '@angular/router' 3 | import { Observable } from "rxjs"; 4 | import { APIService } from "./api"; 5 | import { map } from 'rxjs/operators'; 6 | 7 | 8 | export class Tag { 9 | id: number; 10 | value: string; 11 | } 12 | 13 | interface TagsResponse { 14 | tags: Tag[]; 15 | } 16 | 17 | export class TagFeedIDs { 18 | tag: Tag 19 | ids: number[] 20 | } 21 | 22 | interface TagsFeedIDs { 23 | tagFeeds: TagFeedIDs[]; 24 | } 25 | 26 | interface FeedIDsResponse { 27 | feedIDs: number[]; 28 | } 29 | 30 | @Injectable({providedIn: "root"}) 31 | export class TagService { 32 | constructor(private api: APIService) { } 33 | 34 | getTags() : Observable { 35 | return this.api.get("tag").pipe( 36 | map(response => response.tags) 37 | ); 38 | } 39 | 40 | getFeedIDs(tag: {id: number}): Observable { 41 | return this.api.get(`tag/${tag.id}/feedIDs`).pipe( 42 | map(response => response.feedIDs) 43 | ); 44 | } 45 | 46 | getTagsFeedIDs(): Observable { 47 | return this.api.get("tag/feedIDs").pipe( 48 | map(response => response.tagFeeds) 49 | ); 50 | } 51 | } -------------------------------------------------------------------------------- /rf-ng/src/app/settings/admin/admin.css: -------------------------------------------------------------------------------- 1 | .user { 2 | display: flex; 3 | align-items: center; 4 | border-bottom: 1px solid #ccc; 5 | padding-bottom: 4px; 6 | margin-bottom: 4px; 7 | } 8 | 9 | .user mat-checkbox { 10 | flex: 1 0 0; 11 | } 12 | 13 | .footer { 14 | text-align: end; 15 | } -------------------------------------------------------------------------------- /rf-ng/src/app/settings/admin/admin.html: -------------------------------------------------------------------------------- 1 |
Manage Users
2 |

3 | Current user: {{ current.login }} 4 |

5 |
6 |
List of users:
7 |
8 | {{ user.login }} 9 | 12 |
13 |
14 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/admin/new-user.html: -------------------------------------------------------------------------------- 1 |
Add a new user
2 |
3 | 4 | 5 | 6 | Empty user name 7 | 8 | 9 |
10 | 11 | 12 | 13 | Empty password 14 | 15 | 16 |
17 | 20 |
-------------------------------------------------------------------------------- /rf-ng/src/app/settings/common.css: -------------------------------------------------------------------------------- 1 | .title { 2 | padding-top: 16px; 3 | } -------------------------------------------------------------------------------- /rf-ng/src/app/settings/discovery/discovery.css: -------------------------------------------------------------------------------- 1 | mat-form-field { 2 | width: 100%; 3 | } 4 | 5 | input[type="file"] { 6 | width: 100%; 7 | border-bottom: 1px solid grey; 8 | padding-bottom: 4px; 9 | margin-bottom: 1em; 10 | } 11 | 12 | mat-progress-bar { 13 | margin-top: 1em; 14 | } 15 | 16 | .discovery-link { 17 | display: block; 18 | text-align: end; 19 | } 20 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/filters/filters.css: -------------------------------------------------------------------------------- 1 | .filter { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: center; 5 | margin-bottom: 20px; 6 | } 7 | 8 | .filter .info { 9 | flex: 1 0 0; 10 | } 11 | 12 | .filter span { 13 | padding-left: 4px; 14 | padding-right: 4px; 15 | } 16 | 17 | .filter .term, .filter .tag, .filter .feeds { 18 | padding-left: 8px; 19 | padding-right: 8px; 20 | font-weight: bold; 21 | } 22 | 23 | .filter .url-and-title { 24 | font-style: italic; 25 | } 26 | 27 | .delete { 28 | margin-left: 8px; 29 | } 30 | 31 | .error { 32 | margin-left: 8px; 33 | visibility: hidden; 34 | } 35 | 36 | .error.visible { 37 | visibility: visible; 38 | } 39 | 40 | .footer { 41 | text-align: end; 42 | padding-bottom: 8px; 43 | } 44 | 45 | form { 46 | display: flex; 47 | flex-direction: column; 48 | margin-bottom: 20px; 49 | } 50 | 51 | form mat-form-field { 52 | flex: 1 0 auto; 53 | } 54 | 55 | form mat-slide-toggle { 56 | align-self: center; 57 | flex: 1 0 auto; 58 | } 59 | 60 | form mat-checkbox { 61 | font-size: 14px; 62 | } 63 | 64 | form mat-checkbox ::ng-deep .mat-checkbox-layout { 65 | white-space: normal; 66 | } 67 | 68 | form p { 69 | border-bottom: 1px solid #ddd; 70 | margin-top: 12px; 71 | margin-bottom: 24px; 72 | font-size: 13px; 73 | } 74 | 75 | form mat-form-field { 76 | width: 100%; 77 | } -------------------------------------------------------------------------------- /rf-ng/src/app/settings/filters/filters.html: -------------------------------------------------------------------------------- 1 |
Manage Filters
2 |

3 | No filters have been defined 4 |

5 |
6 |
7 | 8 | Matches 9 | Does not match 10 | {{ filter.urlTerm }} 11 | by URL 12 | 13 | 14 | and 15 | 16 | 17 | Matches 18 | Does not match 19 | {{ filter.titleTerm }} 20 | by title 21 | 22 | excluding tag: 23 | excluding feeds: 24 | on tag: 25 | on feeds: 26 | {{ tagLabel(filter.tagID) }} 27 | {{ feedsLabel(filter.feedIDs) }} 28 |
29 | 32 |
33 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/general/general.css: -------------------------------------------------------------------------------- 1 | mat-form-field { 2 | width: 100%; 3 | } 4 | 5 | .footer { 6 | text-align: end; 7 | } -------------------------------------------------------------------------------- /rf-ng/src/app/settings/general/general.html: -------------------------------------------------------------------------------- 1 |
Personalize your feed reader
2 | 3 | 4 | 5 |
6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | Please enter a valid email address 14 | 15 | 16 |
17 | 18 | 19 | English 20 | Български 21 | 22 | 23 |
24 | 27 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/general/password-form.html: -------------------------------------------------------------------------------- 1 |
Change your password
2 | 3 | 4 | 5 | Invalid password 6 | 7 | 8 |
9 | 10 | 11 | 12 | Passwords do not match 13 | 14 | 15 |
16 | 17 | 18 | 19 |
20 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/management/error-dialog.html: -------------------------------------------------------------------------------- 1 |
    2 |
  • {{ error }}
  • 3 |
4 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/management/management.css: -------------------------------------------------------------------------------- 1 | .feed { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: center; 5 | margin-bottom: 20px; 6 | } 7 | 8 | .favicon { 9 | width: 16px; 10 | height: 16px; 11 | margin-right: 5px; 12 | flex-shrink: 1; 13 | } 14 | .name { 15 | flex: 1 0 0; 16 | min-width: 250px; 17 | } 18 | .tags { 19 | flex: 1 0 0; 20 | min-width: 250px; 21 | width: auto; 22 | margin-left: 8px; 23 | } 24 | 25 | .delete { 26 | margin-left: 8px; 27 | } 28 | 29 | .error { 30 | margin-left: 8px; 31 | visibility: hidden; 32 | } 33 | 34 | .error.visible { 35 | visibility: visible; 36 | } 37 | 38 | .footer { 39 | text-align: end; 40 | padding-bottom: 8px; 41 | } -------------------------------------------------------------------------------- /rf-ng/src/app/settings/management/management.html: -------------------------------------------------------------------------------- 1 |
Manage Feeds
2 |

3 | No feeds have been added 4 |

5 |
6 | 7 | {{ feed[0].title }} 8 | 11 | 12 | 13 | 14 | 17 |
18 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/settings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from "@angular/core" 2 | 3 | @Component({ 4 | selector: "settings-container", 5 | templateUrl: "./settings.html", 6 | styleUrls: ["./settings.css"], 7 | }) 8 | export class SettingsComponent implements OnInit { 9 | ngOnInit(): void { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/settings.css: -------------------------------------------------------------------------------- 1 | :host { 2 | overflow: auto; 3 | } 4 | -------------------------------------------------------------------------------- /rf-ng/src/app/settings/settings.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
-------------------------------------------------------------------------------- /rf-ng/src/app/settings/share-services/share-services.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from "@angular/core" ; 2 | import { MatSlideToggle } from "@angular/material/slide-toggle"; 3 | import { SharingService, ShareService } from "../../services/sharing" 4 | 5 | @Component({ 6 | selector: "settings-share-services", 7 | templateUrl: "./share-services.html", 8 | styleUrls: ["../common.css", "./share-services.css"] 9 | }) 10 | export class ShareServicesSettingsComponent implements OnInit { 11 | services: [ShareService, boolean][][] 12 | 13 | constructor( 14 | private sharingService: SharingService, 15 | ) {} 16 | 17 | ngOnInit(): void { 18 | this.services = this.sharingService.groupedList(); 19 | } 20 | 21 | toggleService(id: string, enabled: boolean) { 22 | this.sharingService.toggle(id, enabled); 23 | } 24 | } -------------------------------------------------------------------------------- /rf-ng/src/app/settings/share-services/share-services.css: -------------------------------------------------------------------------------- 1 | .cards-container { 2 | display: flex; 3 | flex-direction: row; 4 | flex-wrap: wrap; 5 | } 6 | 7 | mat-card { 8 | flex: 1 0 0; 9 | margin: 8px; 10 | min-width: 250px; 11 | } 12 | 13 | .service { 14 | margin-bottom: 16px; 15 | } -------------------------------------------------------------------------------- /rf-ng/src/app/settings/share-services/share-services.html: -------------------------------------------------------------------------------- 1 |
Share Services
2 |
3 | 4 | 5 | 6 |
7 | {{ group[0][0].category }} 8 |
9 |
10 |
11 | 12 |
13 | {{ service[0].description }} 14 |
15 |
16 |
17 |
-------------------------------------------------------------------------------- /rf-ng/src/app/share-service/share-service.component.ts: -------------------------------------------------------------------------------- 1 | import { Directive, OnInit, Input } from "@angular/core" 2 | import { SharingService, ShareService } from "../services/sharing" 3 | 4 | @Directive({ 5 | selector: "share-service", 6 | }) 7 | export class ShareServiceComponent implements OnInit { 8 | @Input() 9 | id: string 10 | 11 | @Input() 12 | description: string 13 | 14 | @Input() 15 | category: string 16 | 17 | @Input() 18 | link: string 19 | 20 | @Input() 21 | share: string 22 | 23 | constructor( 24 | private sharingService: SharingService 25 | ) {} 26 | 27 | ngOnInit(): void { 28 | this.sharingService.register({ 29 | id: this.id, 30 | description: this.description, 31 | category: this.category, 32 | link: this.link, 33 | template: this.share, 34 | }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /rf-ng/src/app/sidebar/side-bar-settings.html: -------------------------------------------------------------------------------- 1 | 2 | Settings 3 | 4 |
5 | General 6 | Add Feed 7 | Manage Feeds 8 | Filters 9 | Share Services 10 | Administration 11 |
12 | 13 | Feeds 14 | Logout 15 |
-------------------------------------------------------------------------------- /rf-ng/src/app/sidebar/side-bar.css: -------------------------------------------------------------------------------- 1 | .content > * { 2 | margin-bottom: 4px; 3 | } 4 | .content > a { 5 | display: flex; 6 | font-size: 16px; 7 | } 8 | 9 | .items > a { 10 | display: flex; 11 | font-size: 13px; 12 | } 13 | 14 | .category { 15 | display: flex; 16 | } 17 | 18 | .category > .expander { 19 | padding-left: 8px; 20 | padding-right: 8px; 21 | min-width: 24px; 22 | flex-shrink: 1; 23 | } 24 | 25 | .category > a { 26 | flex-grow: 1; 27 | text-align: start; 28 | } 29 | 30 | .category > .items { 31 | flex: 0 1 100%; 32 | } 33 | 34 | .content hr { 35 | margin: 0; 36 | } 37 | 38 | .favicon { 39 | padding-right: 4px; 40 | } -------------------------------------------------------------------------------- /rf-ng/src/app/sidebar/sidebar.settings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, OnDestroy } from '@angular/core'; 2 | import { UserService } from "../services/user" 3 | import { Subscription } from "rxjs"; 4 | import { map } from 'rxjs/operators'; 5 | 6 | @Component({ 7 | selector: 'side-bar', 8 | templateUrl: './side-bar-settings.html', 9 | styleUrls: ['./side-bar.css'] 10 | }) 11 | export class SideBarSettingsComponent implements OnInit, OnDestroy { 12 | admin: boolean 13 | 14 | private subscriptions = new Array() 15 | 16 | constructor( 17 | private userService: UserService, 18 | ) { } 19 | 20 | ngOnInit(): void { 21 | this.subscriptions.push(this.userService.getCurrentUser().pipe(map( 22 | user => user.admin 23 | )).subscribe( 24 | admin => this.admin = admin, 25 | error => console.log(error), 26 | )) 27 | } 28 | 29 | ngOnDestroy(): void { 30 | this.subscriptions.forEach(s => s.unsubscribe()); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /rf-ng/src/app/toolbar/toolbar-settings.html: -------------------------------------------------------------------------------- 1 | Settings 2 | -------------------------------------------------------------------------------- /rf-ng/src/app/toolbar/toolbar.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | flex: 1 1 auto; 4 | align-items: center; 5 | } 6 | 7 | .spacer { 8 | flex: 1 1 auto; 9 | } 10 | 11 | .title { 12 | cursor: default; 13 | user-select: none; 14 | } 15 | 16 | .search { 17 | min-height: 0; 18 | border: none; 19 | background: transparent; 20 | } 21 | 22 | ::ng-deep label { 23 | margin-bottom: 0; 24 | } 25 | -------------------------------------------------------------------------------- /rf-ng/src/app/toolbar/toolbar.settings.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | templateUrl: './toolbar-settings.html', 5 | styleUrls: ['./toolbar.css'] 6 | }) 7 | export class ToolbarSettingsComponent implements OnInit { 8 | constructor( 9 | ) { 10 | } 11 | 12 | ngOnInit(): void { 13 | } 14 | } -------------------------------------------------------------------------------- /rf-ng/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urandom/readeef/95c851e5ee6c3a12b0a8888985802698a56bf47b/rf-ng/src/assets/.gitkeep -------------------------------------------------------------------------------- /rf-ng/src/assets/icons/readeef-114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urandom/readeef/95c851e5ee6c3a12b0a8888985802698a56bf47b/rf-ng/src/assets/icons/readeef-114.png -------------------------------------------------------------------------------- /rf-ng/src/assets/icons/readeef-144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urandom/readeef/95c851e5ee6c3a12b0a8888985802698a56bf47b/rf-ng/src/assets/icons/readeef-144.png -------------------------------------------------------------------------------- /rf-ng/src/assets/icons/readeef-72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urandom/readeef/95c851e5ee6c3a12b0a8888985802698a56bf47b/rf-ng/src/assets/icons/readeef-72.png -------------------------------------------------------------------------------- /rf-ng/src/assets/icons/readeef-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urandom/readeef/95c851e5ee6c3a12b0a8888985802698a56bf47b/rf-ng/src/assets/icons/readeef-small.png -------------------------------------------------------------------------------- /rf-ng/src/assets/icons/readeef.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/urandom/readeef/95c851e5ee6c3a12b0a8888985802698a56bf47b/rf-ng/src/assets/icons/readeef.png -------------------------------------------------------------------------------- /rf-ng/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiEndpoint: "/api/v2/", 4 | routeTracing: false, 5 | }; 6 | -------------------------------------------------------------------------------- /rf-ng/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // The file contents for the current environment will overwrite these during build. 2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do 3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead. 4 | // The list of which env maps to which file can be found in `.angular-cli.json`. 5 | 6 | export const environment = { 7 | production: false, 8 | apiEndpoint: "/api/v2/", 9 | routeTracing: false, 10 | }; 11 | -------------------------------------------------------------------------------- /rf-ng/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | readeef: feed aggregator 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 26 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /rf-ng/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule); 12 | -------------------------------------------------------------------------------- /rf-ng/src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rf-ng", 3 | "short_name": "rf-ng", 4 | "theme_color": "#1976d2", 5 | "background_color": "#fafafa", 6 | "display": "standalone", 7 | "scope": "./", 8 | "start_url": "./", 9 | "icons": [ 10 | { 11 | "src": "assets/icons/readeef-small.png", 12 | "sizes": "48x48", 13 | "type": "image/png", 14 | "purpose": "maskable any" 15 | }, 16 | { 17 | "src": "assets/icons/readeef-72.png", 18 | "sizes": "72x72", 19 | "type": "image/png", 20 | "purpose": "maskable any" 21 | }, 22 | { 23 | "src": "assets/icons/readeef-114.png", 24 | "sizes": "114x114", 25 | "type": "image/png", 26 | "purpose": "maskable any" 27 | }, 28 | { 29 | "src": "assets/icons/readeef-144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "maskable any" 33 | }, 34 | { 35 | "src": "assets/icons/readeef.png", 36 | "sizes": "192x192", 37 | "type": "image/png", 38 | "purpose": "maskable any" 39 | } 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /rf-ng/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | @import "~@angular/material/prebuilt-themes/indigo-pink.css"; 4 | 5 | a:hover, a:focus, 6 | button:focus, button:focus { 7 | outline: none; 8 | text-decoration: none; 9 | } 10 | -------------------------------------------------------------------------------- /rf-ng/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/long-stack-trace-zone'; 4 | import 'zone.js/dist/proxy.js'; 5 | import 'zone.js/dist/sync-test'; 6 | import 'zone.js/dist/jasmine-patch'; 7 | import 'zone.js/dist/async-test'; 8 | import 'zone.js/dist/fake-async-test'; 9 | import { getTestBed } from '@angular/core/testing'; 10 | import { 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting 13 | } from '@angular/platform-browser-dynamic/testing'; 14 | 15 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. 16 | declare const __karma__: any; 17 | declare const require: any; 18 | 19 | // Prevent Karma from running prematurely. 20 | __karma__.loaded = function () {}; 21 | 22 | // First, initialize the Angular testing environment. 23 | getTestBed().initTestEnvironment( 24 | BrowserDynamicTestingModule, 25 | platformBrowserDynamicTesting() 26 | ); 27 | // Then we find all the tests. 28 | const context = require.context('./', true, /\.spec\.ts$/); 29 | // And load the modules. 30 | context.keys().map(context); 31 | // Finally, start Karma to run the tests. 32 | __karma__.start(); 33 | -------------------------------------------------------------------------------- /rf-ng/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "baseUrl": "./", 6 | "module": "es2015", 7 | "types": [] 8 | }, 9 | "exclude": [ 10 | "test.ts", 11 | "**/*.spec.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /rf-ng/src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "baseUrl": "./", 6 | "module": "commonjs", 7 | "target": "es5", 8 | "types": [ 9 | "jasmine", 10 | "node" 11 | ] 12 | }, 13 | "files": [ 14 | "test.ts" 15 | ], 16 | "include": [ 17 | "**/*.spec.ts", 18 | "**/*.d.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /rf-ng/src/typings.d.ts: -------------------------------------------------------------------------------- 1 | /* SystemJS module definition */ 2 | declare var module: NodeModule; 3 | interface NodeModule { 4 | id: string; 5 | } 6 | -------------------------------------------------------------------------------- /rf-ng/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "src/main.ts", 9 | "src/polyfills.ts" 10 | ], 11 | "include": [ 12 | "src/**/*.d.ts" 13 | ], 14 | "exclude": [ 15 | "src/test.ts", 16 | "src/**/*.spec.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /rf-ng/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "module": "es2020", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 19 | "dom" 20 | ] 21 | }, 22 | "angularCompilerOptions": { 23 | "fullTemplateTypeCheck": true, 24 | "strictInjectionParameters": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /rf-ng/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /rf-ng/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | readeef: feed aggregator 6 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /rf-ng/xliffmerge.json: -------------------------------------------------------------------------------- 1 | { 2 | "xliffmergeOptions": { 3 | "srcDir": "src/locale", 4 | "genDir": "src/locale" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /systemd/readeef.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=readeef Feed Reader 3 | After=network.target 4 | 5 | [Service] 6 | User=readeef 7 | Type=simple 8 | WorkingDirectory=/home/readeef/readeef 9 | ExecStart=/home/readeef/readeef/start.sh 10 | ExecStop=/usr/bin/killall readeef-server 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /templates/goose-format-result.tmpl: -------------------------------------------------------------------------------- 1 | {% define "content" %} 2 |
3 |
4 | 5 |
6 | {% range .paragraphs %} 7 |

{% . %}

8 | {% end %} 9 |
10 | {% end %} 11 | -------------------------------------------------------------------------------- /templates/raw.tmpl: -------------------------------------------------------------------------------- 1 | {% template "content" . %} 2 | -------------------------------------------------------------------------------- /timeout_client.go: -------------------------------------------------------------------------------- 1 | package readeef 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | func TimeoutDialer(ct time.Duration, rwt time.Duration) func(net, addr string) (c net.Conn, err error) { 10 | return func(netw, addr string) (net.Conn, error) { 11 | conn, err := net.DialTimeout(netw, addr, ct) 12 | if err != nil { 13 | return nil, err 14 | } 15 | conn.SetDeadline(time.Now().Add(rwt)) 16 | return conn, nil 17 | } 18 | } 19 | 20 | func NewTimeoutClient(connectTimeout time.Duration, readWriteTimeout time.Duration) *http.Client { 21 | return &http.Client{ 22 | Transport: &http.Transport{ 23 | Dial: TimeoutDialer(connectTimeout, readWriteTimeout), 24 | }, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /web/proxy.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "context" 5 | "io/ioutil" 6 | "net/http" 7 | "net/url" 8 | "time" 9 | 10 | "github.com/alexedwards/scs" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | func ProxyHandler(sessionManager *scs.Manager) http.HandlerFunc { 15 | return func(w http.ResponseWriter, r *http.Request) { 16 | session := sessionManager.Load(r) 17 | if ok, err := session.GetBool(visitorKey); !ok || err != nil { 18 | w.WriteHeader(http.StatusForbidden) 19 | return 20 | } 21 | 22 | r.ParseForm() 23 | var err error 24 | 25 | switch { 26 | default: 27 | var u *url.URL 28 | 29 | u, err = url.Parse(r.Form.Get("url")) 30 | if err != nil { 31 | err = errors.Wrapf(err, "parsing url to proxy (%s)", r.Form.Get("url")) 32 | break 33 | } 34 | if u.Scheme == "" { 35 | u.Scheme = "http" 36 | } 37 | 38 | var req *http.Request 39 | 40 | req, err = http.NewRequest("GET", u.String(), nil) 41 | if err != nil { 42 | err = errors.Wrapf(err, "creating proxy request to %s", u) 43 | break 44 | } 45 | 46 | ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second) 47 | defer cancel() 48 | 49 | var resp *http.Response 50 | 51 | resp, err = http.DefaultClient.Do(req.WithContext(ctx)) 52 | if err != nil { 53 | err = errors.Wrapf(err, "Error getting proxy response from %s", u) 54 | break 55 | } 56 | 57 | defer resp.Body.Close() 58 | 59 | for k, values := range resp.Header { 60 | for _, v := range values { 61 | w.Header().Add(k, v) 62 | } 63 | } 64 | 65 | var b []byte 66 | 67 | b, err = ioutil.ReadAll(resp.Body) 68 | if err != nil { 69 | err = errors.Wrapf(err, "reading proxy response from %s", u) 70 | break 71 | } 72 | 73 | _, err = w.Write(b) 74 | } 75 | 76 | if err != nil { 77 | http.Error(w, err.Error(), http.StatusNotAcceptable) 78 | return 79 | } 80 | } 81 | } 82 | --------------------------------------------------------------------------------