3 Commits

Author SHA1 Message Date
Andrew Dolgov
c0aa9d0548 minor css global define tweaks to better match claro (2) 2019-07-08 14:58:16 +03:00
Andrew Dolgov
3c7414cee1 minor css global define tweaks to better match claro 2019-07-08 14:46:39 +03:00
Andrew Dolgov
4d265a4deb use claro theme instead of flat 2019-07-08 14:29:19 +03:00
3521 changed files with 152387 additions and 397762 deletions

View File

@@ -1,104 +0,0 @@
ARG PROXY_REGISTRY
FROM ${PROXY_REGISTRY}alpine:3.22
EXPOSE 9000/tcp
ARG ALPINE_MIRROR
ENV SCRIPT_ROOT=/opt/tt-rss
ENV SRC_DIR=/src/tt-rss/
# Normally there's no need to change this, should point to a volume shared with nginx container
ENV APP_INSTALL_BASE_DIR=/var/www/html
# Used to centralize the PHP version suffix for packages and paths
ENV PHP_SUFFIX=84
RUN [ ! -z ${ALPINE_MIRROR} ] && \
sed -i.bak "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#" /etc/apk/repositories ; \
apk add --no-cache ca-certificates dcron git postgresql-client rsync sudo tzdata \
php${PHP_SUFFIX} \
$(for p in ctype curl dom exif fileinfo fpm gd iconv intl json mbstring opcache \
openssl pcntl pdo pdo_pgsql pecl-apcu pecl-xdebug phar posix session simplexml sockets sodium tokenizer xml xmlwriter zip; do \
php_pkgs="$php_pkgs php${PHP_SUFFIX}-$p"; \
done; \
echo $php_pkgs) && \
sed -i 's/\(memory_limit =\) 128M/\1 256M/' /etc/php${PHP_SUFFIX}/php.ini && \
sed -i -e 's/^listen = 127.0.0.1:9000/listen = 9000/' \
-e 's/;\(clear_env\) = .*/\1 = no/i' \
-e 's/;\(pm.status_path = \/status\)/\1/i' \
-e 's/;\(pm.status_listen\) = .*/\1 = 9001/i' \
-e 's/^\(user\|group\) = .*/\1 = app/i' \
-e 's/;\(php_admin_value\[error_log\]\) = .*/\1 = \/tmp\/error.log/' \
-e 's/;\(php_admin_flag\[log_errors\]\) = .*/\1 = on/' \
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf && \
mkdir -p /var/www ${SCRIPT_ROOT}/config.d ${SCRIPT_ROOT}/sql/post-init.d
ARG CI_COMMIT_BRANCH
ENV CI_COMMIT_BRANCH=${CI_COMMIT_BRANCH}
ARG CI_COMMIT_SHORT_SHA
ENV CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
ARG CI_COMMIT_TIMESTAMP
ENV CI_COMMIT_TIMESTAMP=${CI_COMMIT_TIMESTAMP}
ARG CI_COMMIT_SHA
ENV CI_COMMIT_SHA=${CI_COMMIT_SHA}
ADD .docker/app/startup.sh ${SCRIPT_ROOT}
ADD .docker/app/update.sh ${SCRIPT_ROOT}
ADD .docker/app/updater.sh ${SCRIPT_ROOT}
ADD .docker/app/dcron.sh ${SCRIPT_ROOT}
ADD .docker/app/backup.sh /etc/periodic/weekly/backup
RUN chmod 0755 ${SCRIPT_ROOT}/*.sh /etc/periodic/weekly/backup
ADD .docker/app/index.php ${SCRIPT_ROOT}
ADD .docker/app/config.docker.php ${SCRIPT_ROOT}
COPY . ${SRC_DIR}
ARG ORIGIN_REPO_XACCEL=https://github.com/tt-rss/tt-rss-plugin-nginx-xaccel.git
RUN git clone --depth=1 ${ORIGIN_REPO_XACCEL} ${SRC_DIR}/plugins.local/nginx_xaccel
ENV OWNER_UID=1000
ENV OWNER_GID=1000
ENV PHP_WORKER_MAX_CHILDREN=5
ENV PHP_WORKER_MEMORY_LIMIT=256M
# these are applied on every startup, if set
ENV ADMIN_USER_PASS=""
# see classes/UserHelper.php ACCESS_LEVEL_*
# setting this to -2 would effectively disable built-in admin user
# unless single user mode is enabled
ENV ADMIN_USER_ACCESS_LEVEL=""
# these are applied unless user already exists
ENV AUTO_CREATE_USER=""
ENV AUTO_CREATE_USER_PASS=""
ENV AUTO_CREATE_USER_ACCESS_LEVEL="0"
ENV AUTO_CREATE_USER_ENABLE_API=""
# TODO: remove prefix from container variables not used by tt-rss itself:
#
# - TTRSS_NO_STARTUP_PLUGIN_UPDATES -> NO_STARTUP_PLUGIN_UPDATES
# - TTRSS_XDEBUG_... -> XDEBUG_...
# don't try to update local plugins on startup
ENV TTRSS_NO_STARTUP_PLUGIN_UPDATES=""
# TTRSS_XDEBUG_HOST defaults to host IP if unset
ENV TTRSS_XDEBUG_ENABLED=""
ENV TTRSS_XDEBUG_HOST=""
ENV TTRSS_XDEBUG_PORT="9000"
ENV TTRSS_DB_HOST="db"
ENV TTRSS_DB_PORT="5432"
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php${PHP_SUFFIX}"
ENV TTRSS_PLUGINS="auth_internal, note, nginx_xaccel"
CMD ${SCRIPT_ROOT}/startup.sh

View File

@@ -1,31 +0,0 @@
#!/bin/sh -e
DST_DIR=/backups
KEEP_DAYS=28
APP_ROOT=$APP_INSTALL_BASE_DIR/tt-rss
if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; then
DST_FILE=ttrss-backup-$(date +%Y%m%d).sql.gz
echo backing up tt-rss database to $DST_DIR/$DST_FILE...
export PGPASSWORD=$TTRSS_DB_PASS
pg_dump --clean -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME | gzip > $DST_DIR/$DST_FILE
DST_FILE=ttrss-backup-$(date +%Y%m%d).tar.gz
echo backing up tt-rss local directories to $DST_DIR/$DST_FILE...
tar -cz -f $DST_DIR/$DST_FILE $APP_ROOT/*.local \
$APP_ROOT/cache/feed-icons/ \
$APP_ROOT/config.php
echo cleaning up...
find $DST_DIR -type f -name '*.gz' -mtime +$KEEP_DAYS -delete
echo done.
else
echo backup failed: database is not ready.
fi

View File

@@ -1,8 +0,0 @@
<?php
$snippets = glob(getenv("SCRIPT_ROOT")."/config.d/*.php");
foreach ($snippets as $snippet) {
require_once $snippet;
}

View File

@@ -1,5 +0,0 @@
#!/bin/sh
# https://github.com/dubiousjim/dcron/issues/13
set -e
/usr/sbin/crond "$@"

View File

@@ -1,3 +0,0 @@
<?php
header("Location: /tt-rss/");
return;

View File

@@ -1,170 +0,0 @@
#!/bin/sh -e
#
# this script initializes the working copy on a persistent volume and starts PHP FPM
#
# TODO this should do a reasonable amount of attempts and terminate with an error
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; do
echo waiting until $TTRSS_DB_HOST is ready...
sleep 3
done
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
unset HTTP_PORT
unset HTTP_HOST
if ! id app >/dev/null 2>&1; then
addgroup -g $OWNER_GID app
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
fi
update-ca-certificates || true
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
[ -e $DST_DIR ] && rm -f $DST_DIR/.app_is_ready
export PGPASSWORD=$TTRSS_DB_PASS
[ ! -e $APP_INSTALL_BASE_DIR/index.php ] && cp ${SCRIPT_ROOT}/index.php $APP_INSTALL_BASE_DIR
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
if [ ! -d $DST_DIR ]; then
mkdir -p $DST_DIR
chown $OWNER_UID:$OWNER_GID $DST_DIR
sudo -u app rsync -a --no-owner \
$SRC_DIR/ $DST_DIR/
else
chown -R $OWNER_UID:$OWNER_GID $DST_DIR
sudo -u app rsync -a --no-owner --delete \
--exclude /cache \
--exclude /lock \
--exclude /feed-icons \
--exclude /plugins/af_comics/filters.local \
--exclude /plugins.local \
--exclude /templates.local \
--exclude /themes.local \
$SRC_DIR/ $DST_DIR/
sudo -u app rsync -a --no-owner --delete \
$SRC_DIR/plugins.local/nginx_xaccel \
$DST_DIR/plugins.local/nginx_xaccel
fi
else
echo "warning: working copy in $DST_DIR won't be updated, make sure you know what you're doing."
fi
for d in cache lock feed-icons plugins.local themes.local templates.local cache/export cache/feeds cache/images cache/upload; do
sudo -u app mkdir -p $DST_DIR/$d
done
# this is some next level bullshit
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
chown -R app:app /root # /.postgresql
for d in cache lock feed-icons; do
chown -R app:app $DST_DIR/$d
chmod -R u=rwX,g=rX,o=rX $DST_DIR/$d
done
sudo -u app cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
chmod 644 $DST_DIR/config.php
chown -R $OWNER_UID:$OWNER_GID $DST_DIR \
/var/log/php${PHP_SUFFIX}
if [ -z "$TTRSS_NO_STARTUP_PLUGIN_UPDATES" ]; then
echo updating all local plugins...
find $DST_DIR/plugins.local -mindepth 1 -maxdepth 1 -type d | while read PLUGIN; do
if [ -d $PLUGIN/.git ]; then
echo updating $PLUGIN...
cd $PLUGIN && \
sudo -u app git config core.filemode false && \
sudo -u app git config pull.rebase false && \
sudo -u app git pull origin main || sudo -u app git pull origin master || echo warning: attempt to update plugin $PLUGIN failed.
fi
done
else
echo skipping local plugin updates, disabled.
fi
PSQL="psql -q -h $TTRSS_DB_HOST -p $TTRSS_DB_PORT -U $TTRSS_DB_USER $TTRSS_DB_NAME"
$PSQL -c "create extension if not exists pg_trgm"
# this was previously generated
rm -f $DST_DIR/config.php.bak
if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
if [ -z "${TTRSS_XDEBUG_HOST}" ]; then
export TTRSS_XDEBUG_HOST=$(ip ro sh 0/0 | cut -d " " -f 3)
fi
echo enabling xdebug with the following parameters:
env | grep TTRSS_XDEBUG
cat > /etc/php${PHP_SUFFIX}/conf.d/50_xdebug.ini <<EOF
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request = yes
xdebug.client_port = ${TTRSS_XDEBUG_PORT}
xdebug.client_host = ${TTRSS_XDEBUG_HOST}
EOF
fi
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
/etc/php${PHP_SUFFIX}/php.ini
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --update-schema=force-yes
if [ ! -z "$ADMIN_USER_PASS" ]; then
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-password "admin:$ADMIN_USER_PASS"
else
if sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-check-password "admin:password"; then
RANDOM_PASS=$(tr -dc A-Za-z0-9 </dev/urandom | head -c 16 ; echo '')
echo "*****************************************************************************"
echo "* Setting initial built-in admin user password to '$RANDOM_PASS' *"
echo "* If you want to set it manually, use ADMIN_USER_PASS environment variable. *"
echo "*****************************************************************************"
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-password "admin:$RANDOM_PASS"
fi
fi
if [ ! -z "$ADMIN_USER_ACCESS_LEVEL" ]; then
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --user-set-access-level "admin:$ADMIN_USER_ACCESS_LEVEL"
fi
if [ ! -z "$AUTO_CREATE_USER" ]; then
sudo -Eu app /bin/sh -c "php${PHP_SUFFIX} $DST_DIR/update.php --user-exists $AUTO_CREATE_USER ||
php${PHP_SUFFIX} $DST_DIR/update.php --force-yes --user-add \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_PASS:$AUTO_CREATE_USER_ACCESS_LEVEL\""
if [ ! -z "$AUTO_CREATE_USER_ENABLE_API" ]; then
# TODO: remove || true later
sudo -Eu app /bin/sh -c "php${PHP_SUFFIX} $DST_DIR/update.php --user-enable-api \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_ENABLE_API\"" || true
fi
fi
rm -f /tmp/error.log && mkfifo /tmp/error.log && chown app:app /tmp/error.log
(tail -q -f /tmp/error.log >> /proc/1/fd/2) &
unset ADMIN_USER_PASS
unset AUTO_CREATE_USER_PASS
find ${SCRIPT_ROOT}/sql/post-init.d/ -type f -name '*.sql' | while read F; do
echo applying SQL patch file: $F
$PSQL -f $F
done
touch $DST_DIR/.app_is_ready
exec /usr/sbin/php-fpm${PHP_SUFFIX} --nodaemonize --force-stderr

View File

@@ -1,86 +0,0 @@
#!/bin/sh -e
#
# this script kickstarts a minimal working environment and runs update.php, could be used as an entrypoint for a cronjob
# which doesn't share a volume with FPM/updater
#
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
unset HTTP_PORT
unset HTTP_HOST
if ! id app >/dev/null 2>&1; then
addgroup -g $OWNER_GID app
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
fi
update-ca-certificates || true
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
if [ ! -d $DST_DIR ]; then
mkdir -p $DST_DIR
chown $OWNER_UID:$OWNER_GID $DST_DIR
sudo -u app rsync -a --no-owner \
$SRC_DIR/ $DST_DIR/
else
chown -R $OWNER_UID:$OWNER_GID $DST_DIR
sudo -u app rsync -a --no-owner --delete \
--exclude /cache \
--exclude /lock \
--exclude /feed-icons \
--exclude /plugins/af_comics/filters.local \
--exclude /plugins.local \
--exclude /templates.local \
--exclude /themes.local \
$SRC_DIR/ $DST_DIR/
sudo -u app rsync -a --no-owner --delete \
$SRC_DIR/plugins.local/nginx_xaccel \
$DST_DIR/plugins.local/nginx_xaccel
fi
else
echo "warning: working copy in $DST_DIR won't be updated, make sure you know what you're doing."
fi
for d in cache lock feed-icons plugins.local themes.local templates.local cache/export cache/feeds cache/images cache/upload; do
sudo -u app mkdir -p $DST_DIR/$d
done
# this is some next level bullshit
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
chown -R app:app /root # /.postgresql
for d in cache lock feed-icons; do
chown -R app:app $DST_DIR/$d
chmod -R u=rwX,g=rX,o=rX $DST_DIR/$d
done
sudo -u app cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
chmod 644 $DST_DIR/config.php
if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
if [ -z "${TTRSS_XDEBUG_HOST}" ]; then
export TTRSS_XDEBUG_HOST=$(ip ro sh 0/0 | cut -d " " -f 3)
fi
echo enabling xdebug with the following parameters:
env | grep TTRSS_XDEBUG
cat > /etc/php${PHP_SUFFIX}/conf.d/50_xdebug.ini <<EOF
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request = yes
xdebug.client_port = ${TTRSS_XDEBUG_PORT}
xdebug.client_host = ${TTRSS_XDEBUG_HOST}
EOF
fi
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
/etc/php${PHP_SUFFIX}/php.ini
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php "$@"

View File

@@ -1,44 +0,0 @@
#!/bin/sh -e
#
# this scripts waits for startup.sh to finish (implying a shared volume) and runs multiprocess daemon when working copy is available
#
# We don't need those here (HTTP_HOST would cause false SELF_URL_PATH check failures)
unset HTTP_PORT
unset HTTP_HOST
unset ADMIN_USER_PASS
unset AUTO_CREATE_USER_PASS
# wait for the app container to delete .app_is_ready and perform rsync, etc.
sleep 30
if ! id app; then
addgroup -g $OWNER_GID app
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
fi
update-ca-certificates || true
# TODO this should do a reasonable amount of attempts and terminate with an error
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; do
echo waiting until $TTRSS_DB_HOST is ready...
sleep 3
done
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
/etc/php${PHP_SUFFIX}/php.ini
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
while [ ! -s $DST_DIR/config.php -a -e $DST_DIR/.app_is_ready ]; do
echo waiting for app container...
sleep 3
done
# this is some next level bullshit
# - https://stackoverflow.com/questions/65622914/why-would-i-get-a-php-pdoexception-complaining-that-it-cant-make-a-postgres-con
# - fatal error: could not open certificate file "/root/.postgresql/postgresql.crt": Permission denied
chown -R app:app /root # /.postgresql
sudo -E -u app "${TTRSS_PHP_EXECUTABLE}" $APP_INSTALL_BASE_DIR/tt-rss/update_daemon2.php "$@"

View File

@@ -1,5 +0,0 @@
ARG PROXY_REGISTRY
FROM ${PROXY_REGISTRY}nginxinc/nginx-unprivileged:1-alpine
COPY ./phpdoc /usr/share/nginx/html/ttrss-docs
COPY .docker/phpdoc/redirects.conf /etc/nginx/conf.d/

View File

@@ -1,2 +0,0 @@
port_in_redirect off;
absolute_redirect off;

View File

@@ -1,30 +0,0 @@
ARG PROXY_REGISTRY
FROM ${PROXY_REGISTRY}nginx:alpine
HEALTHCHECK CMD curl --fail http://localhost${APP_BASE}/index.php || exit 1
COPY .docker/web-nginx/nginx.conf /etc/nginx/templates/nginx.conf.template
# By default, nginx will send the php requests to "app" server, but this server
# name can be overridden at runtime by passing an APP_UPSTREAM env var
ENV APP_UPSTREAM=${APP_UPSTREAM:-app}
ENV APP_FASTCGI_PASS="${APP_FASTCGI_PASS:-\$backend}"
# Webroot (defaults to /var/www/html)
ENV APP_WEB_ROOT=${APP_WEB_ROOT:-/var/www/html}
# Base location for tt-rss (defaults to /tt-rss)
ENV APP_BASE=${APP_BASE:-/tt-rss}
# Resolver for nginx (kube-dns.kube-system.svc.cluster.local for k8s)
ENV RESOLVER=${RESOLVER:-127.0.0.11}
# In order to make tt-rss appear on website root without /tt-rss/ set above as follows in .env:
# APP_WEB_ROOT=/var/www/html/tt-rss
# APP_BASE=
# It's necessary to set the following NGINX_ENVSUBST_OUTPUT_DIR env var to tell
# nginx to replace the env vars of /etc/nginx/templates/nginx.conf.template
# and put the result in /etc/nginx/nginx.conf (instead of /etc/nginx/conf.d/nginx.conf)
# See https://github.com/docker-library/docs/tree/master/nginx#using-environment-variables-in-nginx-configuration-new-in-119
ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx

View File

@@ -1,78 +0,0 @@
worker_processes auto;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
error_log /dev/stderr warn;
sendfile on;
index index.php;
resolver ${RESOLVER} valid=5s;
server {
listen 80;
listen [::]:80;
root ${APP_WEB_ROOT};
location ${APP_BASE}/cache {
aio threads;
internal;
}
location ${APP_BASE}/backups {
internal;
}
rewrite ${APP_BASE}/healthz ${APP_BASE}/public.php?op=healthcheck;
# Regular PHP handling (without PATH_INFO)
location ~ \.php$ {
# regex to split $uri to $fastcgi_script_name and $fastcgi_path
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
# Check that the PHP script exists before passing it
try_files $fastcgi_script_name =404;
fastcgi_index index.php;
include fastcgi.conf;
set $backend "${APP_UPSTREAM}:9000";
fastcgi_pass ${APP_FASTCGI_PASS};
}
# Allow PATH_INFO for PHP files in plugins.local directories with an /api/ sub directory to allow plugins to leverage when desired
location ~ /plugins\.local/.*/api/.*\.php(/|$) {
# regex to split $uri to $fastcgi_script_name and $fastcgi_path
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
# Check that the PHP script exists before passing it
try_files $fastcgi_script_name =404;
# Bypass the fact that try_files resets $fastcgi_path_info
# see: http://trac.nginx.org/nginx/ticket/321
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;
fastcgi_index index.php;
include fastcgi.conf;
set $backend "${APP_UPSTREAM}:9000";
fastcgi_pass ${APP_FASTCGI_PASS};
}
location / {
try_files $uri $uri/ =404;
}
}
}

View File

@@ -1,5 +0,0 @@
.git/
cache/
plugins.local/
templates.local/
themes.local/

View File

@@ -4,6 +4,3 @@ insert_final_newline = true
[*.php]
indent_style = tab
[*.js]
indent_style = tab

View File

@@ -1,47 +0,0 @@
# Copy this file to .env before building the container. Put any local modifications here.
# Run FPM under this UID/GID.
# OWNER_UID=1000
# OWNER_GID=1000
# FPM settings.
#PHP_WORKER_MAX_CHILDREN=5
#PHP_WORKER_MEMORY_LIMIT=256M
# ADMIN_USER_* settings are applied on every startup.
# Set admin user password to this value. If not set, random password will be generated on startup, look for it in the 'app' container logs.
#ADMIN_USER_PASS=
# Sets admin user access level to this value. Valid values:
# -2 - forbidden to login
# -1 - readonly
# 0 - default user
# 10 - admin
#ADMIN_USER_ACCESS_LEVEL=
# Auto create another user (in addition to built-in admin) unless it already exists.
#AUTO_CREATE_USER=
#AUTO_CREATE_USER_PASS=
#AUTO_CREATE_USER_ACCESS_LEVEL=0
# Default database credentials.
TTRSS_DB_USER=postgres
TTRSS_DB_NAME=postgres
TTRSS_DB_PASS=password
# This is a fallback value for PHP CLI SAPI, it should be set to a fully-qualified tt-rss URL
# TTRSS_SELF_URL_PATH=http://example.com/tt-rss
# You can customize other config.php defines by setting overrides here. See tt-rss/.docker/app/Dockerfile for complete list. Examples:
# TTRSS_PLUGINS=auth_remote
# TTRSS_SINGLE_USER_MODE=true
# TTRSS_SESSION_COOKIE_LIFETIME=2592000
# TTRSS_FORCE_ARTICLE_PURGE=30
# ...
# Bind exposed port to 127.0.0.1 to run behind reverse proxy on the same host. If you plan expose the container, remove "127.0.0.1:".
HTTP_PORT=127.0.0.1:8280
#HTTP_PORT=8280

View File

@@ -1,300 +0,0 @@
module.exports = {
"env": {
"browser": true,
"es6": true,
"jquery": false,
"webextensions": false
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2020
},
"rules": {
"accessor-pairs": "error",
"array-bracket-newline": "off",
"array-bracket-spacing": "off",
"array-callback-return": "error",
"array-element-newline": "off",
"arrow-body-style": "error",
"arrow-parens": "error",
"arrow-spacing": "error",
"block-scoped-var": "off",
"block-spacing": [
"error",
"always"
],
"brace-style": "off",
"callback-return": "off",
"camelcase": "off",
"capitalized-comments": "off",
"class-methods-use-this": "error",
"comma-dangle": "off",
"comma-spacing": "off",
"comma-style": [
"error",
"last"
],
"complexity": "off",
"computed-property-spacing": [
"error",
"never"
],
"consistent-return": "off",
"consistent-this": "off",
"curly": "off",
"default-case": "off",
"dot-location": "off",
"dot-notation": "off",
"eol-last": "error",
"eqeqeq": "off",
"func-call-spacing": "error",
"func-name-matching": "error",
"func-names": "off",
"func-style": "off",
"function-paren-newline": "off",
"generator-star-spacing": "error",
"global-require": "error",
"guard-for-in": "off",
"handle-callback-err": "off",
"id-blacklist": "error",
"id-length": "off",
"id-match": "error",
"implicit-arrow-linebreak": "off",
"indent": "off",
"indent-legacy": "off",
"init-declarations": "off",
"jsx-quotes": "error",
"key-spacing": "off",
"keyword-spacing": [
"error",
{
"after": true,
"before": true
}
],
"line-comment-position": "off",
"linebreak-style": [
"error",
"unix"
],
"lines-around-comment": "off",
"lines-around-directive": "error",
"lines-between-class-members": "error",
"max-classes-per-file": "off",
"max-depth": "off",
"max-len": "off",
"max-lines": "off",
"max-lines-per-function": "off",
"max-nested-callbacks": "error",
"max-params": "off",
"max-statements": "off",
"max-statements-per-line": [ "warn", { "max" : 2 } ],
"multiline-comment-style": "off",
"multiline-ternary": "off",
"new-cap": "warn",
"new-parens": "error",
"newline-after-var": "off",
"newline-before-return": "off",
"newline-per-chained-call": "off",
"no-alert": "off",
"no-array-constructor": "error",
"no-async-promise-executor": "off",
"no-await-in-loop": "warn",
"no-bitwise": "off",
"no-buffer-constructor": "error",
"no-caller": "error",
"no-catch-shadow": "off",
"no-confusing-arrow": "error",
"no-continue": "off",
"no-console": "off",
"no-div-regex": "error",
"no-duplicate-imports": "error",
"no-else-return": "off",
"no-empty": [
"error",
{
"allowEmptyCatch": true
}
],
"no-empty-function": "error",
"no-eq-null": "off",
"no-eval": "error",
"no-extend-native": "off",
"no-extra-bind": "error",
"no-extra-label": "error",
"no-extra-parens": "off",
"no-floating-decimal": "error",
"no-implicit-globals": "off",
"no-implied-eval": "off",
"no-inline-comments": "off",
"no-inner-declarations": [
"error",
"functions"
],
"no-invalid-this": "error",
"no-iterator": "error",
"no-label-var": "error",
"no-labels": "error",
"no-lone-blocks": "error",
"no-lonely-if": "error",
"no-loop-func": "off",
"no-magic-numbers": "off",
"no-misleading-character-class": "off",
"no-mixed-operators": "off",
"no-mixed-requires": "error",
"no-multi-assign": "error",
"no-multi-spaces": "off",
"no-multi-str": "error",
"no-multiple-empty-lines": "error",
"no-native-reassign": "error",
"no-negated-condition": "off",
"no-negated-in-lhs": "error",
"no-nested-ternary": "error",
"no-new": "warn",
"no-new-func": "error",
"no-new-object": "off",
"no-new-require": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-param-reassign": "off",
"no-path-concat": "error",
"no-plusplus": "off",
"no-process-env": "error",
"no-process-exit": "error",
"no-proto": "error",
"no-prototype-builtins": "warn",
"no-restricted-globals": "error",
"no-restricted-imports": "error",
"no-restricted-modules": "error",
"no-restricted-properties": "error",
"no-restricted-syntax": "error",
"no-return-assign": [
"error",
"except-parens"
],
"no-return-await": "error",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-shadow": "off",
"no-shadow-restricted-names": "error",
"no-spaced-func": "error",
"no-sync": "error",
"no-tabs": "off",
"no-template-curly-in-string": "error",
"no-ternary": "off",
"no-throw-literal": "error",
"no-trailing-spaces": "error",
"no-undef-init": "error",
"no-undefined": "off",
"no-undef": "warn",
"no-underscore-dangle": "off",
"no-unmodified-loop-condition": "error",
"no-unneeded-ternary": [
"error",
{
"defaultAssignment": true
}
],
"no-unused-expressions": "off",
"no-unused-vars": "warn",
"no-use-before-define": "off",
"no-useless-call": "error",
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"no-useless-return": "off",
"no-var": "warn",
"no-void": "error",
"no-warning-comments": "off",
"no-whitespace-before-property": "error",
"no-with": "error",
"nonblock-statement-body-position": [
"error",
"any"
],
"object-curly-newline": "off",
"object-curly-spacing": "off",
"object-property-newline": "off",
"object-shorthand": "off",
"one-var": "off",
"one-var-declaration-per-line": "error",
"operator-assignment": "off",
"operator-linebreak": [
"error",
"after"
],
"padded-blocks": "off",
"padding-line-between-statements": "error",
"prefer-arrow-callback": "off",
"prefer-const": "error",
"prefer-destructuring": "off",
"prefer-numeric-literals": "error",
"prefer-object-spread": "off",
"prefer-promise-reject-errors": "error",
"prefer-reflect": "off",
"prefer-rest-params": "error",
"prefer-spread": "error",
"prefer-template": "off",
"quote-props": "off",
"quotes": "off",
"radix": [
"error",
"as-needed"
],
"require-atomic-updates": "off",
"require-await": "warn",
"require-jsdoc": "off",
"require-unicode-regexp": "off",
"rest-spread-spacing": "error",
"semi": "off",
"semi-spacing": [
"error",
{
"after": true,
"before": false
}
],
"semi-style": [
"error",
"last"
],
"sort-imports": "error",
"sort-keys": "off",
"sort-vars": "off",
"space-before-blocks": "off",
"space-before-function-paren": "off",
"space-in-parens": "off",
"space-infix-ops": "off",
"space-unary-ops": [
"error",
{
"nonwords": false,
"words": false
}
],
"spaced-comment": "off",
"strict": [
"off",
"never"
],
"switch-colon-spacing": "error",
"symbol-description": "error",
"template-curly-spacing": "error",
"template-tag-spacing": "error",
"unicode-bom": [
"error",
"never"
],
"valid-jsdoc": "error",
"vars-on-top": "off",
"wrap-iife": "error",
"wrap-regex": "error",
"yield-star-spacing": "error",
"yoda": [
"error",
"never"
]
}
};

View File

@@ -1,54 +0,0 @@
name: Bug Report
description: Report a bug
labels: [bug]
body:
- type: textarea
id: problem
attributes:
label: What is the problem?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction Steps
description: (If known) Minimal steps that can reproduce the issue.
validations:
required: false
- type: input
id: version
attributes:
label: tt-rss Version
description: Which version (commit) of tt-rss are you using?
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: |
How are you running tt-rss? Include all relevant details (e.g. Docker image, OS, PHP version, etc.).
validations:
required: true
- type: textarea
id: other
attributes:
label: Other Information
description: |
Anything else you want to provide (e.g. related issues, suggestions on how to fix, links for context, etc.).
validations:
required: false
- type: checkboxes
id: acknowledgments
attributes:
label: Acknowledge
description: Please mark any checkbox that applies (otherwise leave unchecked).
options:
- label: I'm interested in implementing the fix
required: false

View File

@@ -1,20 +0,0 @@
name: Documentation Issue
description: Report an issue with documentation
labels: [documentation]
body:
- type: textarea
id: issue
attributes:
label: What is the issue?
validations:
required: true
- type: checkboxes
id: acknowledgments
attributes:
label: Acknowledge
description: Please mark any checkbox that apply (otherwise leave unchecked).
options:
- label: I'm interested in implementing the fix
required: false

View File

@@ -1,57 +0,0 @@
name: Enhancement Request
description: Request a new enhancement/feature
labels: [enhancement]
body:
- type: textarea
id: description
attributes:
label: Description
description: Short description of the enhancement/feature you are proposing.
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use Case
description: Why do you need this enhancement/feature?
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: |
(If known) How would you propose the enhancement/feature be implemented?
validations:
required: false
- type: input
id: version
attributes:
label: tt-rss Version
description: Which version (commit) of tt-rss are you currently using?
validations:
required: true
- type: textarea
id: other
attributes:
label: Other Information
description: |
Anything else you want to provide (e.g. related issues, suggestions on how to implement, links for context, etc.).
validations:
required: false
- type: checkboxes
id: acknowledgments
attributes:
label: Acknowledge
description: Please mark any checkbox that apply (otherwise leave unchecked).
options:
- label: I'm interested in implementing the enhancement/feature
required: false
- label: This enhancement/feature might result in a breaking change
required: false

View File

@@ -1,7 +0,0 @@
version: 2
updates:
- package-ecosystem: composer
directory: /
schedule:
interval: weekly

View File

@@ -1,25 +0,0 @@
## Description
<!-- Describe your changes in detail -->
## Motivation and Context
<!-- Why is this change required? What problem does it solve? -->
<!-- If it fixes an open issue, please link to the issue here. -->
## How Has This Been Tested?
<!-- Please describe in detail how you tested your changes. -->
## Screenshots (if appropriate)
<!-- If screenshots will help in understanding the change, include them here. -->
## Types of Changes
<!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply. -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Refactoring (non-breaking change)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!-- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->
- [ ] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.

View File

@@ -1,67 +0,0 @@
name: PHP Code Quality
on:
pull_request:
paths:
- '**.php'
- 'phpstan.neon'
- 'phpunit.xml'
# Allow manual triggering
workflow_dispatch:
# Allow other workflows (e.g. Publish) to invoke this one.
workflow_call:
env:
fail-fast: true
permissions:
contents: read
jobs:
phpstan:
name: PHPStan
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
coverage: none
tools: none
- name: Run PHPStan
run: vendor/bin/phpstan analyze --no-progress
phpunit:
name: PHPUnit
runs-on: ubuntu-latest
continue-on-error: ${{ matrix.experimental }}
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
experimental: [false]
include:
- php: '8.5'
experimental: true
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Set up PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: none
tools: none
- name: Run PHPUnit
run: vendor/bin/phpunit --exclude integration --coverage-filter classes --coverage-filter include

View File

@@ -1,93 +0,0 @@
name: Publish
on:
push:
branches: [main]
paths-ignore:
- '.**'
- 'tests/**'
- '*.*-dist'
- '*.js'
- '*.json'
- '*.lock'
- '*.md'
- '*.neon'
- '*.xml'
# Allow manual triggering
workflow_dispatch:
permissions:
contents: read
jobs:
test-php:
uses: ./.github/workflows/php-code-quality.yml
publish-dockerhub:
name: Publish ${{ matrix.image.name }} to Docker Hub
needs:
- test-php
runs-on: ubuntu-latest
strategy:
matrix:
image:
- name: app
dockerfile: ./.docker/app/Dockerfile
repository: supahgreg/tt-rss
- name: web-nginx
dockerfile: ./.docker/web-nginx/Dockerfile
repository: supahgreg/tt-rss-web-nginx
steps:
- name: Check out code
uses: actions/checkout@v5
- name: Get commit timestamp
run: echo "COMMIT_TIMESTAMP=$(git show -s --format=%ci HEAD)" >> $GITHUB_ENV
- name: Get commit short SHA
run: echo "COMMIT_SHORT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ matrix.image.repository }}
tags: |
# update 'latest'
type=raw,value=latest
# short SHA with a 'sha-' prefix (e.g. sha-abc123)
type=sha
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push to Docker Hub
id: push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.image.dockerfile }}
platforms: linux/arm64,linux/amd64
# TODO: clean up build arg and environment variable naming.
build-args: |
CI_COMMIT_BRANCH=${{ github.ref_name }}
CI_COMMIT_SHA=${{ github.sha }}
CI_COMMIT_SHORT_SHA=${{ env.COMMIT_SHORT_SHA }}
CI_COMMIT_TIMESTAMP=${{ env.COMMIT_TIMESTAMP }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: true
sbom: true
push: true

35
.gitignore vendored
View File

@@ -1,18 +1,21 @@
Thumbs.db
/.env
/docker-compose.override.yml
/.app_is_ready
/deploy.exclude
/deploy.sh
/messages.mo
/node_modules
/locale/**/*.po~
/plugins.local/*
/themes.local/*
/config.php
/feed-icons/*
/cache/*/*
/lock/*
/.vscode/settings.json
/vendor/**/.git
/.phpunit.result.cache
/.phpstan-tmp
/.tools/
*~
*.DS_Store
#*
.idea/*
plugins.local/*
themes.local/*
config.php
feed-icons/*
cache/*/*
lock/*
tags
cache/htmlpurifier/*/*ser
lib/htmlpurifier/library/HTMLPurifier/DefinitionCache/Serializer/*/*ser
web.config
/.save.cson
/.tags*
/.gutentags

View File

@@ -1,266 +1,44 @@
stages:
- lint
- build
- push
- test
- publish
variables:
ESLINT_PATHS: js plugins
REGISTRY_PROJECT: cthulhoo
IMAGE_TAR_FPM: image-fpm.tar
IMAGE_TAR_WEB: image-web.tar
include:
- project: 'ci/ci-templates'
ref: master
file: .ci-build-docker-kaniko.yml
- project: 'ci/ci-templates'
ref: master
file: .ci-registry-push.yml
- project: 'ci/ci-templates'
ref: master
file: .ci-lint-common.yml
- project: 'ci/ci-templates'
ref: master
file: .ci-update-helm-imagetag.yml
phpunit:
extends: .phpunit
variables:
PHPUNIT_ARGS: --exclude integration --coverage-filter classes --coverage-filter include
eslint:
extends: .eslint
phpstan:
extends: .phpstan
ttrss-fpm-pgsql-static:build:
extends: .build-docker-kaniko-no-push
variables:
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/app/Dockerfile
IMAGE_TAR: ${IMAGE_TAR_FPM}
ttrss-fpm-pgsql-static:push-commit-only-gitlab:
extends: .crane-image-registry-push-commit-only-gitlab
variables:
IMAGE_TAR: ${IMAGE_TAR_FPM}
needs:
- job: ttrss-fpm-pgsql-static:build
ttrss-web-nginx:build:
extends: .build-docker-kaniko-no-push
variables:
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/web-nginx/Dockerfile
IMAGE_TAR: ${IMAGE_TAR_WEB}
ttrss-web-nginx:push-commit-only-gitlab:
extends: .crane-image-registry-push-commit-only-gitlab
variables:
IMAGE_TAR: ${IMAGE_TAR_WEB}
needs:
- job: ttrss-web-nginx:build
phpdoc:build:
image: ${PHP_IMAGE}
stage: publish
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- php84 /phpDocumentor.phar -d classes -d include -t phpdoc --visibility=public
artifacts:
paths:
- phpdoc
phpdoc:publish:
extends: .build-docker-kaniko
stage: publish
needs:
- job: phpdoc:build
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $REGISTRY_USER != null && $REGISTRY_PASSWORD != null
variables:
DOCKERFILE: ${CI_PROJECT_DIR}/.docker/phpdoc/Dockerfile
NAME: ttrss-phpdoc
VERSION: latest
phpunit-integration:
image: ${PHP_IMAGE}
variables:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
TTRSS_DB_HOST: db
TTRSS_DB_USER: ${POSTGRES_USER}
TTRSS_DB_NAME: ${POSTGRES_DB}
TTRSS_DB_PASS: ${POSTGRES_PASSWORD}
FF_NETWORK_PER_BUILD: "true"
APP_WEB_ROOT: /builds/shared-root
APP_INSTALL_BASE_DIR: ${APP_WEB_ROOT}
APP_BASE: "/tt-rss"
APP_FASTCGI_PASS: app:9000 # skip resolver
AUTO_CREATE_USER: test
AUTO_CREATE_USER_PASS: 'test'
AUTO_CREATE_USER_ACCESS_LEVEL: '10'
AUTO_CREATE_USER_ENABLE_API: 'true'
APP_URL: http://web-nginx/tt-rss
API_URL: ${APP_URL}/api/
HEALTHCHECK_URL: ${APP_URL}/public.php?op=healthcheck
__URLHELPER_ALLOW_LOOPBACK: 'true'
services:
- &svc_db
name: registry.fakecake.org/docker.io/postgres:15-alpine
alias: db
- &svc_app
name: ${CI_REGISTRY}/${CI_PROJECT_PATH}/ttrss-fpm-pgsql-static:${CI_COMMIT_SHORT_SHA}
alias: app
- &svc_web
name: ${CI_REGISTRY}/${CI_PROJECT_PATH}/ttrss-web-nginx:${CI_COMMIT_SHORT_SHA}
alias: web-nginx
rules:
- if: $CI_COMMIT_BRANCH
needs:
- job: ttrss-fpm-pgsql-static:push-commit-only-gitlab
- job: ttrss-web-nginx:push-commit-only-gitlab
before_script:
# wait for everything to start
- |
for a in `seq 1 15`; do
curl -fs ${HEALTHCHECK_URL} && break
sleep 5
done
script:
- cp tests/integration/feed.xml ${APP_WEB_ROOT}/${APP_BASE}/
- php84 vendor/bin/phpunit --group integration --do-not-cache-result --log-junit phpunit-report.xml --coverage-cobertura phpunit-coverage.xml --coverage-text --colors=never
artifacts:
when: always
reports:
junit: phpunit-report.xml
coverage_report:
coverage_format: cobertura
path: phpunit-coverage.xml
coverage: '/^\s*Lines:\s*\d+.\d+\%/'
phpunit-integration:root-location:
variables:
APP_WEB_ROOT: /builds/shared-root/tt-rss
APP_INSTALL_BASE_DIR: /builds/shared-root
APP_BASE: ""
APP_URL: http://web-nginx
extends: phpunit-integration
selenium:
extends: phpunit-integration
image: ${SELENIUM_IMAGE}
variables:
SELENIUM_GRID_ENDPOINT: http://selenium:4444/wd/hub
services:
- *svc_db
- *svc_app
- *svc_web
- name: registry.fakecake.org/docker.io/selenium/standalone-chrome:4.32.0-20250515
alias: selenium
script:
- |
for i in `seq 1 10`; do
echo attempt $i...
python3 tests/integration/selenium_test.py && break
sleep 10
done
artifacts:
when: always
reports:
junit: selenium-report.xml
ttrss-fpm-pgsql-static:publish:
stage: publish
extends: .crane-image-registry-push-master
variables:
IMAGE_TAR: ${IMAGE_TAR_FPM}
needs:
- job: ttrss-fpm-pgsql-static:build
- job: phpunit-integration
- job: selenium
ttrss-fpm-pgsql-static:publish-docker-hub:
stage: publish
extends: .crane-image-registry-push-master-docker-hub
variables:
IMAGE_TAR: ${IMAGE_TAR_FPM}
needs:
- job: ttrss-fpm-pgsql-static:build
- job: phpunit-integration
- job: selenium
ttrss-fpm-pgsql-static:publish-gitlab:
stage: publish
extends: .crane-image-registry-push-master-gitlab
variables:
IMAGE_TAR: ${IMAGE_TAR_FPM}
needs:
- job: ttrss-fpm-pgsql-static:build
- job: phpunit-integration
- job: selenium
ttrss-web-nginx:publish:
stage: publish
extends: .crane-image-registry-push-master
variables:
IMAGE_TAR: ${IMAGE_TAR_WEB}
needs:
- job: ttrss-web-nginx:build
- job: phpunit-integration
- job: selenium
ttrss-web-nginx:publish-docker-hub:
stage: publish
extends: .crane-image-registry-push-master-docker-hub
variables:
IMAGE_TAR: ${IMAGE_TAR_WEB}
needs:
- job: ttrss-web-nginx:build
- job: phpunit-integration
- job: selenium
ttrss-web-nginx:publish-gitlab:
stage: publish
extends: .crane-image-registry-push-master-gitlab
variables:
IMAGE_TAR: ${IMAGE_TAR_WEB}
needs:
- job: ttrss-web-nginx:build
- job: phpunit-integration
- job: selenium
update-demo:
stage: publish
extends: .update-helm-imagetag
variables:
CHART_REPO: gitlab.fakecake.org/git/helm-charts/tt-rss.git
CHART_VALUES: values-demo.yaml
ACCESS_TOKEN: ${DEMO_HELM_TOKEN}
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $DEMO_HELM_TOKEN != null
update-prod:
stage: publish
extends: .update-helm-imagetag
variables:
CHART_REPO: gitlab.fakecake.org/git/helm-charts/tt-rss-prod.git
CHART_VALUES: values-prod.yaml
ACCESS_TOKEN: ${PROD_HELM_TOKEN}
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH && $PROD_HELM_TOKEN != null
# https://about.gitlab.com/blog/how-to-automatically-create-a-new-mr-on-gitlab-with-gitlab-ci/
weblate-integration-auto-mr:
image: ${INFRA_IMAGE}
stage: publish
rules:
- if: $CI_COMMIT_BRANCH == "weblate-integration" && $AUTO_MR_TOKEN != null
script:
- HOST=${CI_PROJECT_URL} CI_PROJECT_ID=${CI_PROJECT_ID}
CI_COMMIT_REF_NAME=${CI_COMMIT_REF_NAME}
GITLAB_USER_ID=${GITLAB_USER_ID}
PRIVATE_TOKEN=${AUTO_MR_TOKEN} ./utils/autoMergeRequest.sh
phpmd:
image: php:5.6
when: manual
script:
- sh utils/gitlab-ci/php-lint.sh
- curl -o /usr/bin/phpmd -L http://static.phpmd.org/php/2.6.0/phpmd.phar
- chmod +x /usr/bin/phpmd
- sh utils/gitlab-ci/phpmd.sh
schema:
image: fox/selenium-ci
when: manual
script:
- /etc/init.d/postgresql start
- /usr/local/sbin/init-database.sh
- sh ./utils/gitlab-ci/check-schema.sh
phpunit_basic:
image: fox/selenium-ci
when: manual
script:
- /etc/init.d/postgresql start
- /usr/local/sbin/init-database.sh
- sh ./utils/gitlab-ci/check-schema.sh
- cp utils/gitlab-ci/config-template.php config.php
- su -s /bin/bash www-data -c "php ./update.php --debug-feed 1"
- wget -O /usr/bin/phpunit https://phar.phpunit.de/phpunit-5.7.phar
- chmod +x /usr/bin/phpunit
- phpunit tests/*.php
phpunit_functional:
image: fox/selenium-ci
when: manual
script:
- /etc/init.d/postgresql start
- /etc/init.d/nginx start
- /etc/init.d/php5-fpm start
- /usr/local/sbin/init-database.sh
- sh ./utils/gitlab-ci/check-schema.sh
- ln -s `pwd` ../../tt-rss
- cp utils/gitlab-ci/config-template.php config.php
- chmod -R 777 cache lock feed-icons
- /usr/local/sbin/init-selenium.sh
- phpunit tests/functional/*.php

24
.vscode/launch.json vendored
View File

@@ -1,24 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for XDebug",
"type": "php",
"request": "launch",
"pathMappings": {
"/var/www/html/tt-rss": "${workspaceRoot}",
},
"port": 9000
},
{
"name": "Launch Chrome",
"request": "launch",
"type": "chrome",
"pathMapping": {
"/tt-rss/": "${workspaceFolder}"
},
"urlFilter": "*/tt-rss/*",
"runtimeExecutable": "chrome.exe",
}
]
}

46
.vscode/tasks.json vendored
View File

@@ -1,46 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"label": "phpstan (watcher)",
"isBackground": true,
"problemMatcher": {
"fileLocation": [
"relative",
"${workspaceRoot}"
],
"owner": "phpstan-watcher",
"pattern": {
"regexp": "^/app/(.*?):([0-9\\?]*):(.*)$",
"file": 1,
"line": 2,
"message": 3
},
"background": {
"activeOnStart": true,
"beginsPattern": "Using configuration file",
"endsPattern": "All done"
}
},
"command": "chmod +x ${workspaceRoot}/utils/phpstan-watcher.sh && ${workspaceRoot}/utils/phpstan-watcher.sh"
},
{
"type": "shell",
"label": "phpunit",
"command": "chmod +x ${workspaceRoot}/utils/phpunit.sh && ${workspaceRoot}/utils/phpunit.sh",
"problemMatcher": []
},
{
"type": "gulp",
"task": "default",
"problemMatcher": [],
"label": "gulp: default",
"options": {
"env": {
"PATH": "${workspaceRoot}/node_modules/.bin:$PATH"
}
}
}
]
}

View File

@@ -1,5 +1,20 @@
Contributions (code, translations, reporting issues, etc.) are welcome.
## Contributing code the right way
> [!NOTE]
> The original tt-rss project handled translations via Weblate.
> It's yet to be determined how this project will handle things.
New user accounts on Gogs are not allowed to fork repositories because of spam. To get
initial fork access, do the following:
1. Register on the forums and on Gogs
2. Create a thread describing your proposed changes in Development subforum while
including your Gogs username
3. If your changes make sense to me, I'll update your repo limit and you'll be able to
fork things and file pull requests
If you already have a fully functional Gogs account it works pretty much like Github:
1. Fork the repository you're interested in
2. Do the needful
3. File a pull request with your changes against master branch
That's it. If you have any other questions, see this forum thread:
https://discourse.tt-rss.org/t/how-to-contribute-code-via-pull-requests-on-git-tt-rss-org/1850

View File

@@ -1,36 +1,10 @@
Tiny Tiny RSS (tt-rss)
======================
Tiny Tiny RSS
=============
Tiny Tiny RSS (tt-rss) is a free, flexible, open-source, web-based news feed (RSS/Atom/other) reader and aggregator.
Web-based news feed aggregator, designed to allow you to read news from
any location, while feeling as close to a real desktop application as possible.
## Getting started
Please refer to [the wiki](https://github.com/tt-rss/tt-rss/wiki).
## Some notes about this project
* The original tt-rss project, hosted at https://tt-rss.org/ and its various subdomains, [will be gone after 2025-11-01](https://community.tt-rss.org/t/the-end-of-tt-rss-org/7164).
* Massive thanks to fox for creating tt-rss, and maintaining it (and absolutely everything else that went along with it) for so many years.
* This project (https://github.com/tt-rss/tt-rss) is a fork of tt-rss as of 2025-10-03, created by one of its long-time contributors (`wn_`/`wn_name` on `tt-rss.org`, `supahgreg` on `github.com`).
* The goal is to continue tt-rss development, with an initial focus on replacing `tt-rss.org` references and integrations + getting things working.
* Developer note: Due to use of `invalid@email.com` on `supahgreg`'s pre-2025-10-03 commits (which were done on `tt-rss.org`) GitHub incorrectly shows `ivanivanov884`
(the GitHub user associated with that e-mail address) as the author instead of `wn_`/`supahgreg`. Apologies for any confusion. `¯\_(ツ)_/¯`
* Plugins that were under https://gitlab.tt-rss.org/tt-rss/plugins have been mirrored to `https://github.com/tt-rss/tt-rss-plugin-*`.
* Plugin repository names have changed to get a consistent `tt-rss-plugin-*` prefix.
* Documentation from https://tt-rss.org has been recreated in https://github.com/tt-rss/tt-rss/wiki .
* The repository that held the content for https://tt-rss.org was mirrored to https://github.com/tt-rss/tt-rss-web-static .
Some content tweaks were made after mirroring (prior to the wiki being set up), and the repository is now archived.
* Docker images (for `linux/amd64` and `linux/arm64`) are being built and published to Docker Hub [via GitHub Actions](https://github.com/tt-rss/tt-rss/actions/workflows/publish.yml).
* See https://hub.docker.com/r/supahgreg/tt-rss/ and https://hub.docker.com/r/supahgreg/tt-rss-web-nginx/ , and
[the installation guide](https://github.com/tt-rss/tt-rss/wiki/Installation-Guide) for how they can be used.
## Development and contributing
* Contributions (code, translations, reporting issues, etc.) are welcome.
* Development and issue tracking primarily happens in https://github.com/tt-rss/tt-rss .
* Help translate tt-rss into your own language using [Weblate](https://hosted.weblate.org/engage/tt-rss/).
## License
http://tt-rss.org
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -46,3 +20,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Copyright (c) 2005 Andrew Dolgov (unless explicitly stated otherwise).
Uses Silk icons by Mark James: http://www.famfamfam.com/lab/icons/silk/

View File

@@ -1,68 +1,91 @@
<?php
error_reporting(E_ERROR | E_PARSE);
set_include_path(__DIR__ . PATH_SEPARATOR .
dirname(__DIR__) . PATH_SEPARATOR .
dirname(__DIR__) . "/include" . PATH_SEPARATOR .
require_once "../config.php";
set_include_path(dirname(__FILE__) . PATH_SEPARATOR .
dirname(dirname(__FILE__)) . PATH_SEPARATOR .
dirname(dirname(__FILE__)) . "/include" . PATH_SEPARATOR .
get_include_path());
chdir("..");
define('TTRSS_SESSION_NAME', 'ttrss_api_sid');
define('NO_SESSION_AUTOSTART', true);
require_once "autoload.php";
require_once "db.php";
require_once "db-prefs.php";
require_once "functions.php";
require_once "sessions.php";
ini_set('session.use_cookies', "0");
ini_set("session.gc_maxlifetime", "86400");
ini_set('session.use_cookies', 0);
ini_set("session.gc_maxlifetime", 86400);
ob_start();
define('AUTH_DISABLE_OTP', true);
$_REQUEST = json_decode((string)file_get_contents("php://input"), true);
if (defined('ENABLE_GZIP_OUTPUT') && ENABLE_GZIP_OUTPUT &&
function_exists("ob_gzhandler")) {
if (!empty($_REQUEST["sid"])) {
ob_start("ob_gzhandler");
} else {
ob_start();
}
$input = file_get_contents("php://input");
if (defined('_API_DEBUG_HTTP_ENABLED') && _API_DEBUG_HTTP_ENABLED) {
// Override $_REQUEST with JSON-encoded data if available
// fallback on HTTP parameters
if ($input) {
$input = json_decode($input, true);
if ($input) $_REQUEST = $input;
}
} else {
// Accept JSON only
$input = json_decode($input, true);
$_REQUEST = $input;
}
if ($_REQUEST["sid"]) {
session_id($_REQUEST["sid"]);
session_start();
@session_start();
} else if (defined('_API_DEBUG_HTTP_ENABLED')) {
@session_start();
}
startup_gettext();
if (!init_plugins()) return;
if (!empty($_SESSION["uid"])) {
if (!Sessions::validate_session()) {
header("Content-Type: application/json");
if ($_SESSION["uid"]) {
if (!validate_session()) {
header("Content-Type: text/json");
print json_encode([
"seq" => -1,
"status" => API::STATUS_ERR,
"content" => [ "error" => API::E_NOT_LOGGED_IN ]
]);
print json_encode(array("seq" => -1,
"status" => 1,
"content" => array("error" => "NOT_LOGGED_IN")));
return;
}
UserHelper::load_user_plugins($_SESSION["uid"]);
load_user_plugins( $_SESSION["uid"]);
}
$method = strtolower($_REQUEST["op"] ?? "");
$method = strtolower($_REQUEST["op"]);
$handler = new API($_REQUEST);
if ($handler->before($method)) {
if ($method && method_exists($handler, $method)) {
$handler->$method();
} else /* if (method_exists($handler, 'index')) */ {
} else if (method_exists($handler, 'index')) {
$handler->index($method);
}
// API isn't currently overriding Handler#after()
// $handler->after();
$handler->after();
}
$content_length = ob_get_length();
header("Api-Content-Length: $content_length");
header("Content-Length: $content_length");
header("Api-Content-Length: " . ob_get_length());
ob_end_flush();

51
atom-to-html.xsl Normal file
View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
<xsl:output method="html"/>
<xsl:template match="/atom:feed">
<html>
<head>
<title><xsl:value-of select="atom:title"/></title>
<link rel="stylesheet" type="text/css" href="css/default.css"/>
<script language="javascript" src="lib/xsl_mop-up.js"></script>
</head>
<body onload="go_decoding()" class="ttrss_utility">
<div id="cometestme" style="display:none;">
<xsl:text disable-output-escaping="yes">&amp;amp;</xsl:text>
</div>
<div class="rss">
<h1><xsl:value-of select="atom:title"/></h1>
<p class="description">This feed has been exported from
<a target="_new" class="extlink" href="http://tt-rss.org">Tiny Tiny RSS</a>.
It contains the following items:</p>
<xsl:for-each select="atom:entry">
<h2><a target="_new" href="{atom:link/@href}"><xsl:value-of select="atom:title"/></a></h2>
<div name="decodeme" class="content">
<xsl:value-of select="atom:content" disable-output-escaping="yes"/>
</div>
<xsl:if test="enclosure">
<p><a href="{enclosure/@url}">Extra...</a></p>
</xsl:if>
</xsl:for-each>
</div>
</body>
</html>
</xsl:template>
</xsl:stylesheet>

View File

@@ -1,11 +1,24 @@
<?php
set_include_path(__DIR__ ."/include" . PATH_SEPARATOR .
set_include_path(dirname(__FILE__) ."/include" . PATH_SEPARATOR .
get_include_path());
$op = $_REQUEST['op'] ?? '';
$method = !empty($_REQUEST['subop']) ?
$_REQUEST['subop'] :
$_REQUEST["method"] ?? false;
/* remove ill effects of magic quotes */
if (get_magic_quotes_gpc()) {
function stripslashes_deep($value) {
$value = is_array($value) ?
array_map('stripslashes_deep', $value) : stripslashes($value);
return $value;
}
$_POST = array_map('stripslashes_deep', $_POST);
$_GET = array_map('stripslashes_deep', $_GET);
$_COOKIE = array_map('stripslashes_deep', $_COOKIE);
$_REQUEST = array_map('stripslashes_deep', $_REQUEST);
}
$op = $_REQUEST["op"];
@$method = $_REQUEST['subop'] ? $_REQUEST['subop'] : $_REQUEST["method"];
if (!$method)
$method = 'index';
@@ -14,53 +27,52 @@
/* Public calls compatibility shim */
$public_calls = array("rss", "getUnread", "getProfiles", "share");
$public_calls = array("globalUpdateFeeds", "rss", "getUnread", "getProfiles", "share",
"fbexport", "logout", "pubsub");
if (array_search($op, $public_calls) !== false) {
header("Location: public.php?" . $_SERVER['QUERY_STRING']);
return;
}
$csrf_token = $_POST['csrf_token'] ?? "";
@$csrf_token = $_REQUEST['csrf_token'];
require_once "autoload.php";
require_once "sessions.php";
$op = (string)clean($op);
$method = (string)clean($method);
require_once "functions.php";
require_once "config.php";
require_once "db.php";
require_once "db-prefs.php";
startup_gettext();
if (!init_plugins()) {
return;
$script_started = microtime(true);
if (!init_plugins()) return;
header("Content-Type: text/json; charset=utf-8");
if (ENABLE_GZIP_OUTPUT && function_exists("ob_gzhandler")) {
ob_start("ob_gzhandler");
}
header("Content-Type: application/json; charset=utf-8");
if (Config::get(Config::SINGLE_USER_MODE)) {
UserHelper::authenticate("admin", null);
if (SINGLE_USER_MODE) {
authenticate_user( "admin", null);
}
if (!empty($_SESSION["uid"])) {
if (!Sessions::validate_session()) {
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
if ($_SESSION["uid"]) {
if (!validate_session()) {
header("Content-Type: text/json");
print error_json(6);
return;
}
UserHelper::load_user_plugins($_SESSION["uid"]);
}
if (Config::is_migration_needed()) {
print Errors::to_json(Errors::E_SCHEMA_MISMATCH);
return;
load_user_plugins( $_SESSION["uid"]);
}
$purge_intervals = array(
0 => __("Use default"),
-1 => __("Never purge"),
7 => __("1 week old"),
5 => __("1 week old"),
14 => __("2 weeks old"),
31 => __("1 month old"),
60 => __("2 months old"),
@@ -87,93 +99,49 @@
1440 => __("Daily"),
10080 => __("Weekly"));
$access_level_names = [
UserHelper::ACCESS_LEVEL_DISABLED => __("Disabled"),
UserHelper::ACCESS_LEVEL_READONLY => __("Read Only"),
UserHelper::ACCESS_LEVEL_USER => __("User"),
UserHelper::ACCESS_LEVEL_POWERUSER => __("Power User"),
UserHelper::ACCESS_LEVEL_ADMIN => __("Administrator")
];
$access_level_names = array(
0 => __("User"),
5 => __("Power User"),
10 => __("Administrator"));
// shortcut syntax for plugin methods (?op=plugin--pmethod&...params)
/* if (str_contains($op, PluginHost::PUBLIC_METHOD_DELIMITER)) {
list ($plugin, $pmethod) = explode(PluginHost::PUBLIC_METHOD_DELIMITER, $op, 2);
// TODO: better implementation that won't modify $_REQUEST
$_REQUEST["plugin"] = $plugin;
$method = $pmethod;
$op = "pluginhandler";
} */
// $op = str_replace(, "_", $op);
$op = str_replace("-", "_", $op);
$override = PluginHost::getInstance()->lookup_handler($op, $method);
if (class_exists($op) || $override) {
if (str_starts_with($method, "_")) {
user_error("Refusing to invoke method $method of handler $op which starts with underscore.", E_USER_WARNING);
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
return;
}
if ($override) {
$handler = $override;
} else {
$reflection = new ReflectionClass($op);
$handler = $reflection->newInstanceWithoutConstructor();
$handler = new $op($_REQUEST);
}
if (implements_interface($handler, 'IHandler')) {
$handler->__construct($_REQUEST);
if ($handler && implements_interface($handler, 'IHandler')) {
if (validate_csrf($csrf_token) || $handler->csrf_ignore($method)) {
$before = $handler->before($method);
if ($before) {
if ($handler->before($method)) {
if ($method && method_exists($handler, $method)) {
$reflection = new ReflectionMethod($handler, $method);
if ($reflection->getNumberOfRequiredParameters() == 0) {
$handler->$method();
} else {
user_error("Refusing to invoke method $method of handler $op which has required parameters.", E_USER_WARNING);
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
$handler->$method();
} else {
if (method_exists($handler, "catchall")) {
$handler->catchall($method);
} else {
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD, ["info" => get_class($handler) . "->$method"]);
}
}
$handler->after();
return;
} else {
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
header("Content-Type: text/json");
print error_json(6);
return;
}
} else {
user_error("Refusing to invoke method $method of handler $op with invalid CSRF token.", E_USER_WARNING);
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
header("Content-Type: text/json");
print error_json(6);
return;
}
}
}
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$op) . "->$method"]);
header("Content-Type: text/json");
print error_json(13);
?>

0
cache/export/.empty vendored Normal file → Executable file
View File

0
cache/images/.empty vendored Normal file → Executable file
View File

View File

@@ -1,948 +0,0 @@
<?php
class API extends Handler {
const API_LEVEL = 23;
const STATUS_OK = 0;
const STATUS_ERR = 1;
const E_API_DISABLED = "API_DISABLED";
const E_NOT_LOGGED_IN = "NOT_LOGGED_IN";
const E_LOGIN_ERROR = "LOGIN_ERROR";
const E_INCORRECT_USAGE = "INCORRECT_USAGE";
const E_UNKNOWN_METHOD = "UNKNOWN_METHOD";
const E_OPERATION_FAILED = "E_OPERATION_FAILED";
const E_NOT_FOUND = "E_NOT_FOUND";
private ?int $seq = null;
/**
* @param array<int|string, mixed> $reply
*/
private function _wrap(int $status, array $reply): bool {
print json_encode([
"seq" => $this->seq,
"status" => $status,
"content" => $reply
]);
return true;
}
function before(string $method): bool {
if (parent::before($method)) {
header("Content-Type: application/json");
if (empty($_SESSION["uid"]) && $method != "login" && $method != "isloggedin") {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_LOGGED_IN));
return false;
}
if (!empty($_SESSION["uid"]) && $method != "logout" && !Prefs::get(Prefs::ENABLE_API_ACCESS, $_SESSION["uid"])) {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
return false;
}
$this->seq = (int) clean($_REQUEST['seq'] ?? 0);
return true;
}
return false;
}
function getVersion(): bool {
$rv = array("version" => Config::get_version());
return $this->_wrap(self::STATUS_OK, $rv);
}
function getApiLevel(): bool {
$rv = array("level" => self::API_LEVEL);
return $this->_wrap(self::STATUS_OK, $rv);
}
function login(): bool {
if (session_status() == PHP_SESSION_ACTIVE) {
session_destroy();
}
session_start();
$login = clean($_REQUEST["user"]);
$password = clean($_REQUEST["password"]);
if (Config::get(Config::SINGLE_USER_MODE)) $login = "admin";
if ($uid = UserHelper::find_user_by_login($login)) {
if (Prefs::get(Prefs::ENABLE_API_ACCESS, $uid)) {
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) {
// needed for _get_config()
UserHelper::load_user_plugins($_SESSION['uid']);
return $this->_wrap(self::STATUS_OK, array("session_id" => session_id(),
"config" => $this->_get_config(),
"api_level" => self::API_LEVEL));
} else {
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
}
} else {
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
}
}
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_LOGIN_ERROR));
}
function logout(): bool {
UserHelper::logout();
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
}
function isLoggedIn(): bool {
return $this->_wrap(self::STATUS_OK, array("status" => (bool)($_SESSION["uid"] ?? '')));
}
function getUnread(): bool {
$feed_id = clean($_REQUEST["feed_id"] ?? "");
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
if ($feed_id) {
return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_counters($feed_id, $is_cat, true)));
} else {
return $this->_wrap(self::STATUS_OK, array("unread" => Feeds::_get_global_unread()));
}
}
/* Method added for ttrss-reader for Android */
function getCounters(): bool {
return $this->_wrap(self::STATUS_OK, Counters::get_all());
}
function getFeeds(): bool {
$cat_id = (int) clean($_REQUEST["cat_id"]);
$unread_only = self::_param_to_bool($_REQUEST["unread_only"] ?? false);
$limit = (int) clean($_REQUEST["limit"] ?? 0);
$offset = (int) clean($_REQUEST["offset"] ?? 0);
$include_nested = self::_param_to_bool($_REQUEST["include_nested"] ?? false);
$feeds = $this->_api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
return $this->_wrap(self::STATUS_OK, $feeds);
}
function getCategories(): bool {
$unread_only = self::_param_to_bool($_REQUEST["unread_only"] ?? false);
$enable_nested = self::_param_to_bool($_REQUEST["enable_nested"] ?? false);
$include_empty = self::_param_to_bool($_REQUEST["include_empty"] ?? false);
// TODO do not return empty categories, return Uncategorized and standard virtual cats
$categories = ORM::for_table('ttrss_feed_categories')
->select_many('id', 'title', 'order_id')
->select_many_expr([
'num_feeds' => '(SELECT COUNT(id) FROM ttrss_feeds WHERE ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id)',
'num_cats' => '(SELECT COUNT(id) FROM ttrss_feed_categories AS c2 WHERE c2.parent_cat = ttrss_feed_categories.id)',
])
->where('owner_uid', $_SESSION['uid']);
if ($enable_nested) {
$categories->where_null('parent_cat');
}
$cats = [];
foreach ($categories->find_many() as $category) {
if ($include_empty || $category->num_feeds > 0 || $category->num_cats > 0) {
$unread = Feeds::_get_counters($category->id, true, true);
if ($enable_nested)
$unread += Feeds::_get_cat_children_unread($category->id);
if ($unread || !$unread_only) {
array_push($cats, [
'id' => (int) $category->id,
'title' => $category->title,
'unread' => (int) $unread,
'order_id' => (int) $category->order_id,
]);
}
}
}
foreach ([Feeds::CATEGORY_LABELS, Feeds::CATEGORY_SPECIAL, Feeds::CATEGORY_UNCATEGORIZED] as $cat_id) {
if ($include_empty || !$this->_is_cat_empty($cat_id)) {
$unread = Feeds::_get_counters($cat_id, true, true);
if ($unread || !$unread_only) {
array_push($cats, [
'id' => $cat_id,
'title' => Feeds::_get_cat_title($cat_id, $_SESSION['uid']),
'unread' => (int) $unread,
]);
}
}
}
return $this->_wrap(self::STATUS_OK, $cats);
}
function getHeadlines(): bool {
$feed_id = clean($_REQUEST["feed_id"] ?? "");
if (!empty($feed_id) || is_numeric($feed_id)) { // is_numeric for feed_id "0"
$limit = (int)clean($_REQUEST["limit"] ?? 0 );
if (!$limit || $limit >= 200) $limit = 200;
$offset = (int)clean($_REQUEST["skip"] ?? 0);
$filter = clean($_REQUEST["filter"] ?? "");
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
$show_excerpt = self::_param_to_bool($_REQUEST["show_excerpt"] ?? false);
$show_content = self::_param_to_bool($_REQUEST["show_content"] ?? false);
/* all_articles, unread, adaptive, marked, updated */
$view_mode = clean($_REQUEST["view_mode"] ?? null);
$include_attachments = self::_param_to_bool($_REQUEST["include_attachments"] ?? false);
$since_id = (int)clean($_REQUEST["since_id"] ?? 0);
$include_nested = self::_param_to_bool($_REQUEST["include_nested"] ?? false);
$sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true);
$force_update = self::_param_to_bool($_REQUEST["force_update"] ?? false);
$has_sandbox = self::_param_to_bool($_REQUEST["has_sandbox"] ?? false);
$excerpt_length = (int)clean($_REQUEST["excerpt_length"] ?? 0);
$check_first_id = (int)clean($_REQUEST["check_first_id"] ?? 0);
$include_header = self::_param_to_bool($_REQUEST["include_header"] ?? false);
$_SESSION['hasSandbox'] = $has_sandbox;
list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query(clean($_REQUEST["order_by"] ?? ""));
/* do not rely on params below */
$search = clean($_REQUEST["search"] ?? "");
list($headlines, $headlines_header) = $this->_api_get_headlines($feed_id, $limit, $offset,
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order,
$include_attachments, $since_id, $search,
$include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check);
if ($include_header) {
return $this->_wrap(self::STATUS_OK, array($headlines_header, $headlines));
} else {
return $this->_wrap(self::STATUS_OK, $headlines);
}
} else {
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
}
}
function updateArticle(): bool {
$article_ids = array_filter(explode(",", clean($_REQUEST["article_ids"] ?? "")));
$mode = (int) clean($_REQUEST["mode"]);
$data = clean($_REQUEST["data"] ?? "");
$field_raw = (int)clean($_REQUEST["field"]);
$field = "";
$additional_fields = "";
switch ($field_raw) {
case 0:
$field = "marked";
$additional_fields = ",last_marked = NOW()";
break;
case 1:
$field = "published";
$additional_fields = ",last_published = NOW()";
break;
case 2:
$field = "unread";
$additional_fields = ",last_read = NOW()";
break;
case 3:
$field = "note";
break;
case 4:
$field = "score";
break;
};
$set_to = match ($mode) {
0 => 'false',
1 => 'true',
2 => "NOT $field",
default => null,
};
if ($field == 'note')
$set_to = $this->pdo->quote($data);
elseif ($field == 'score')
$set_to = (int) $data;
if ($field && $set_to && count($article_ids) > 0) {
$article_qmarks = arr_qmarks($article_ids);
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
$field = $set_to $additional_fields
WHERE ref_id IN ($article_qmarks) AND owner_uid = ?");
$sth->execute([...$article_ids, $_SESSION['uid']]);
if ($field == 'marked')
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $article_ids);
if ($field == 'published')
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $article_ids);
$num_updated = $sth->rowCount();
return $this->_wrap(self::STATUS_OK, array("status" => "OK",
"updated" => $num_updated));
} else {
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
}
}
function getArticle(): bool {
$article_ids = array_filter(explode(',', clean($_REQUEST['article_id'] ?? '')));
$sanitize_content = self::_param_to_bool($_REQUEST['sanitize'] ?? true);
if (count($article_ids)) {
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->select_many('e.id', 'e.guid', 'e.title', 'e.link', 'e.author', 'e.content', 'e.lang', 'e.comments',
'ue.feed_id', 'ue.int_id', 'ue.marked', 'ue.unread', 'ue.published', 'ue.score', 'ue.note')
->select_many_expr([
'updated' => 'SUBSTRING_FOR_DATE(updated,1,16)',
'feed_title' => '(SELECT title FROM ttrss_feeds WHERE id = ue.feed_id)',
'site_url' => '(SELECT site_url FROM ttrss_feeds WHERE id = ue.feed_id)',
'hide_images' => '(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id)',
])
->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue')
->where_in('e.id', array_map('intval', $article_ids))
->where('ue.owner_uid', $_SESSION['uid'])
->find_many();
$articles = [];
foreach ($entries as $entry) {
$article = [
'id' => $entry->id,
'guid' => $entry->guid,
'title' => $entry->title,
'link' => $entry->link,
'labels' => Article::_get_labels($entry->id),
'unread' => self::_param_to_bool($entry->unread),
'marked' => self::_param_to_bool($entry->marked),
'published' => self::_param_to_bool($entry->published),
'comments' => $entry->comments,
'author' => $entry->author,
'updated' => (int) strtotime($entry->updated ?? ''),
'feed_id' => $entry->feed_id,
'attachments' => Article::_get_enclosures($entry->id),
'score' => (int) $entry->score,
'feed_title' => $entry->feed_title,
'note' => $entry->note,
'lang' => $entry->lang,
];
if ($sanitize_content) {
$article['content'] = Sanitizer::sanitize(
$entry->content,
self::_param_to_bool($entry->hide_images),
null, $entry->site_url, null, $entry->id);
} else {
$article['content'] = $entry->content;
}
$hook_object = ['article' => &$article];
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
function ($result) use (&$article) {
$article = $result;
},
$hook_object);
$article['content'] = DiskCache::rewrite_urls($article['content']);
array_push($articles, $article);
}
return $this->_wrap(self::STATUS_OK, $articles);
} else {
return $this->_wrap(self::STATUS_ERR, ['error' => self::E_INCORRECT_USAGE]);
}
}
/**
* @see RPC::_make_init_params()
* @see RPC::_make_runtime_info()
* @return array<string, array<string, string>|bool|int|string>
*/
private function _get_config(): array {
return [
'custom_sort_types' => $this->_get_custom_sort_types(),
'daemon_is_running' => file_is_locked('update_daemon.lock'),
'icons_url' => Config::get_self_url() . '/public.php',
'num_feeds' => ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->count(),
];
}
function getConfig(): bool {
$config = $this->_get_config();
return $this->_wrap(self::STATUS_OK, $config);
}
function updateFeed(): bool {
$feed_id = (int) clean($_REQUEST["feed_id"]);
if (!ini_get("open_basedir")) {
RSSUtils::update_rss_feed($feed_id);
}
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
}
function catchupFeed(): bool {
$feed_id = clean($_REQUEST["feed_id"]);
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
$mode = clean($_REQUEST["mode"] ?? "");
$search_query = clean($_REQUEST["search_query"] ?? "");
$search_lang = clean($_REQUEST["search_lang"] ?? "");
if (!in_array($mode, ["all", "1day", "1week", "2week"]))
$mode = "all";
Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode, [$search_query, $search_lang]);
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
}
function getPref(): bool {
$pref_name = clean($_REQUEST["pref_name"]);
return $this->_wrap(self::STATUS_OK, array("value" => Prefs::get($pref_name, $_SESSION["uid"], $_SESSION["profile"] ?? null)));
}
function getLabels(): bool {
$article_id = (int)clean($_REQUEST['article_id'] ?? -1);
$rv = [];
$labels = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('caption')
->find_many();
if ($article_id)
$article_labels = Article::_get_labels($article_id);
else
$article_labels = [];
foreach ($labels as $label) {
$checked = false;
foreach ($article_labels as $al) {
if (Labels::feed_to_label_id($al[0]) == $label->id) {
$checked = true;
break;
}
}
array_push($rv, [
'id' => (int) Labels::label_to_feed_id($label->id),
'caption' => $label->caption,
'fg_color' => $label->fg_color,
'bg_color' => $label->bg_color,
'checked' => $checked,
]);
}
return $this->_wrap(self::STATUS_OK, $rv);
}
function setArticleLabel(): bool {
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
$label_id = (int) clean($_REQUEST['label_id']);
$assign = self::_param_to_bool(clean($_REQUEST['assign']));
$label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]);
$num_updated = 0;
if ($label) {
foreach ($article_ids as $id) {
if ($assign)
Labels::add_article((int)$id, $label, $_SESSION["uid"]);
else
Labels::remove_article((int)$id, $label, $_SESSION["uid"]);
++$num_updated;
}
}
return $this->_wrap(self::STATUS_OK, array("status" => "OK",
"updated" => $num_updated));
}
function index(string $method): bool {
$plugin = PluginHost::getInstance()->get_api_method(strtolower($method));
if ($plugin && method_exists($plugin, $method)) {
$reply = $plugin->$method();
return $this->_wrap($reply[0], $reply[1]);
} else {
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_UNKNOWN_METHOD, "method" => $method));
}
}
function shareToPublished(): bool {
$title = clean($_REQUEST["title"]);
$url = clean($_REQUEST["url"]);
$sanitize_content = self::_param_to_bool($_REQUEST["sanitize"] ?? true);
if ($sanitize_content)
$content = clean($_REQUEST["content"]);
else
$content = $_REQUEST["content"];
if (Article::_create_published_article($title, $url, $content, "", $_SESSION["uid"])) {
return $this->_wrap(self::STATUS_OK, array("status" => 'OK'));
} else {
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_OPERATION_FAILED));
}
}
/**
* @return array<int, array{'id': int, 'title': string, 'unread': int, 'cat_id': int}>
*/
private static function _api_get_feeds(int $cat_id, bool $unread_only, int $limit, int $offset, bool $include_nested = false): array {
$feeds = [];
/* Labels */
/* API only: -4 (Feeds::CATEGORY_ALL) All feeds, including virtual feeds */
if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_LABELS) {
$counters = Counters::get_labels();
foreach (array_values($counters) as $cv) {
$unread = $cv['counter'];
if ($unread || !$unread_only) {
$row = [
'id' => (int) $cv['id'],
'title' => $cv['description'],
'unread' => $cv['counter'],
'cat_id' => Feeds::CATEGORY_LABELS,
];
array_push($feeds, $row);
}
}
}
/* Virtual feeds */
foreach (PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL) as $feed) {
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
/** @var IVirtualFeed $feed['sender'] */
$unread = $feed['sender']->get_unread($feed['id']);
if ($unread || !$unread_only) {
$row = [
'id' => PluginHost::pfeed_to_feed_id($feed['id']),
'title' => $feed['title'],
'unread' => $unread,
'cat_id' => Feeds::CATEGORY_SPECIAL,
];
array_push($feeds, $row);
}
}
if ($cat_id == Feeds::CATEGORY_ALL || $cat_id == Feeds::CATEGORY_SPECIAL) {
foreach ([Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED, Feeds::FEED_FRESH,
Feeds::FEED_ALL, Feeds::FEED_RECENTLY_READ, Feeds::FEED_ARCHIVED] as $i) {
$unread = Feeds::_get_counters($i, false, true);
if ($unread || !$unread_only) {
$title = Feeds::_get_title($i, $_SESSION['uid']);
$row = [
'id' => $i,
'title' => $title,
'unread' => $unread,
'cat_id' => Feeds::CATEGORY_SPECIAL,
];
array_push($feeds, $row);
}
}
}
/* Child cats */
if ($include_nested && $cat_id) {
$categories = ORM::for_table('ttrss_feed_categories')
->where(['parent_cat' => $cat_id, 'owner_uid' => $_SESSION['uid']])
->order_by_asc('order_id')
->order_by_asc('title')
->find_many();
foreach ($categories as $category) {
$unread = Feeds::_get_counters($category->id, true, true) +
Feeds::_get_cat_children_unread($category->id);
if ($unread || !$unread_only) {
$row = [
'id' => (int) $category->id,
'title' => $category->title,
'unread' => $unread,
'is_cat' => true,
'order_id' => (int) $category->order_id,
];
array_push($feeds, $row);
}
}
}
/* Real feeds */
/* API only: -3 (Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL) All feeds, excluding virtual feeds (e.g. Labels and such) */
$feeds_obj = ORM::for_table('ttrss_feeds')
->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id', 'last_error', 'update_interval')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc('order_id')
->order_by_asc('title');
if ($limit) $feeds_obj->limit($limit);
if ($offset) $feeds_obj->offset($offset);
if ($cat_id != Feeds::CATEGORY_ALL_EXCEPT_VIRTUAL && $cat_id != Feeds::CATEGORY_ALL) {
$feeds_obj->where_raw('(cat_id = ? OR (? = 0 AND cat_id IS NULL))', [$cat_id, $cat_id]);
}
foreach ($feeds_obj->find_many() as $feed) {
$unread = Feeds::_get_counters($feed->id, false, true);
$has_icon = Feeds::_has_icon($feed->id);
if ($unread || !$unread_only) {
$row = [
'feed_url' => $feed->feed_url,
'title' => $feed->title,
'id' => (int) $feed->id,
'unread' => (int) $unread,
'has_icon' => $has_icon,
'cat_id' => (int) $feed->cat_id,
'last_updated' => (int) strtotime($feed->last_updated ?? ''),
'order_id' => (int) $feed->order_id,
'last_error' => $feed->last_error,
'update_interval' => (int) $feed->update_interval,
];
array_push($feeds, $row);
}
}
return $feeds;
}
/**
* @return array{0: array<int, array<string, mixed>>, 1: array<string, mixed>} $headlines, $headlines_header
*/
private static function _api_get_headlines(int|string $feed_id, int $limit, int $offset,
string $filter, bool $is_cat, bool $show_excerpt, bool $show_content, ?string $view_mode, string $order,
bool $include_attachments, int $since_id, string $search = "", bool $include_nested = false,
bool $sanitize_content = true, bool $force_update = false, int $excerpt_length = 100, ?int $check_first_id = null,
bool $skip_first_id_check = false): array {
if ($force_update && is_numeric($feed_id) && $feed_id > 0) {
// Update the feed if required with some basic flood control
$feed = ORM::for_table('ttrss_feeds')
->select_many('id', 'cache_images')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->find_one($feed_id);
if ($feed) {
$last_updated = strtotime($feed->last_updated ?? '');
$cache_images = self::_param_to_bool($feed->cache_images);
if (!$cache_images && time() - $last_updated > 120) {
RSSUtils::update_rss_feed($feed_id, true);
} else {
$feed->last_updated = '1970-01-01';
$feed->last_update_started = '1970-01-01';
$feed->save();
}
}
}
$qfh_ret = [];
if (!$is_cat && is_numeric($feed_id) && $feed_id < PLUGIN_FEED_BASE_INDEX && $feed_id > LABEL_BASE_INDEX) {
$pfeed_id = PluginHost::feed_to_pfeed_id($feed_id);
$handler = PluginHost::getInstance()->get_feed_handler($pfeed_id);
if ($handler) {
$params = array(
"feed" => $feed_id,
"limit" => $limit,
"view_mode" => $view_mode,
"cat_view" => $is_cat,
"search" => $search,
"override_order" => $order,
"offset" => $offset,
"since_id" => 0,
"include_children" => $include_nested,
"check_first_id" => $check_first_id,
"skip_first_id_check" => $skip_first_id_check
);
$qfh_ret = $handler->get_headlines($pfeed_id, $params);
}
} else {
$params = array(
"feed" => $feed_id,
"limit" => $limit,
"view_mode" => $view_mode,
"cat_view" => $is_cat,
"search" => $search,
"override_order" => $order,
"offset" => $offset,
"since_id" => $since_id,
"include_children" => $include_nested,
"check_first_id" => $check_first_id,
"skip_first_id_check" => $skip_first_id_check
);
$qfh_ret = Feeds::_get_headlines($params);
}
$result = $qfh_ret[0];
$feed_title = $qfh_ret[1];
$first_id = $qfh_ret[6];
$headlines = array();
$headlines_header = array(
'id' => $feed_id,
'first_id' => $first_id,
'is_cat' => $is_cat);
if (!is_numeric($result)) {
while ($line = $result->fetch()) {
$line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
function ($result) use (&$line) {
$line = $result;
},
$line, $excerpt_length);
$is_updated = ($line["last_read"] == "" &&
($line["unread"] != "t" && $line["unread"] != "1"));
$tags = explode(",", $line["tag_cache"]);
$label_cache = $line["label_cache"];
$labels = false;
if ($label_cache) {
$label_cache = json_decode($label_cache, true);
if ($label_cache) {
if (($label_cache["no-labels"] ?? 0) == 1)
$labels = [];
else
$labels = $label_cache;
}
}
if (!is_array($labels)) $labels = Article::_get_labels($line["id"]);
$headline_row = array(
"id" => (int)$line["id"],
"guid" => $line["guid"],
"unread" => self::_param_to_bool($line["unread"]),
"marked" => self::_param_to_bool($line["marked"]),
"published" => self::_param_to_bool($line["published"]),
"updated" => (int)strtotime($line["updated"] ?? ''),
"is_updated" => $is_updated,
"title" => $line["title"],
"link" => $line["link"],
"feed_id" => $line["feed_id"] ? $line['feed_id'] : 0,
"tags" => $tags,
);
$enclosures = Article::_get_enclosures($line['id']);
if ($include_attachments)
$headline_row['attachments'] = $enclosures;
if ($show_excerpt)
$headline_row["excerpt"] = $line["content_preview"];
if ($show_content) {
if ($sanitize_content) {
$headline_row["content"] = Sanitizer::sanitize(
$line["content"],
self::_param_to_bool($line['hide_images']),
null, $line["site_url"], null, $line["id"]);
} else {
$headline_row["content"] = $line["content"];
}
}
// unify label output to ease parsing
if (($labels["no-labels"] ?? 0) == 1) $labels = [];
$headline_row["labels"] = $labels;
$headline_row["feed_title"] = $line["feed_title"] ?? $feed_title;
$headline_row["comments_count"] = (int)$line["num_comments"];
$headline_row["comments_link"] = $line["comments"];
$headline_row["always_display_attachments"] = self::_param_to_bool($line["always_display_enclosures"]);
$headline_row["author"] = $line["author"];
$headline_row["score"] = (int)$line["score"];
$headline_row["note"] = $line["note"];
$headline_row["lang"] = $line["lang"];
$headline_row["site_url"] = $line["site_url"];
if ($show_content) {
$hook_object = ["headline" => &$headline_row];
list ($flavor_image, $flavor_stream, $flavor_kind) = Article::_get_image($enclosures,
$line["content"], // unsanitized
$line["site_url"] ?? "", // could be null if archived article
$headline_row);
$headline_row["flavor_image"] = $flavor_image;
$headline_row["flavor_stream"] = $flavor_stream;
/* optional */
if ($flavor_kind)
$headline_row["flavor_kind"] = $flavor_kind;
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ARTICLE_API,
function ($result) use (&$headline_row) {
$headline_row = $result;
},
$hook_object);
$headline_row["content"] = DiskCache::rewrite_urls($headline_row['content']);
}
array_push($headlines, $headline_row);
}
} else if ($result == -1) {
$headlines_header['first_id_changed'] = true;
}
return array($headlines, $headlines_header);
}
function unsubscribeFeed(): bool {
$feed_id = (int) clean($_REQUEST["feed_id"]);
$feed_exists = ORM::for_table('ttrss_feeds')
->where(['id' => $feed_id, 'owner_uid' => $_SESSION['uid']])
->count();
if ($feed_exists) {
Pref_Feeds::remove_feed($feed_id, $_SESSION['uid']);
return $this->_wrap(self::STATUS_OK, ['status' => 'OK']);
} else {
return $this->_wrap(self::STATUS_ERR, ['error' => self::E_OPERATION_FAILED]);
}
}
function subscribeToFeed(): bool {
$feed_url = clean($_REQUEST["feed_url"]);
$category_id = (int) clean($_REQUEST["category_id"]);
$login = clean($_REQUEST["login"] ?? "");
$password = clean($_REQUEST["password"] ?? "");
if ($feed_url) {
$rc = Feeds::_subscribe($feed_url, $category_id, $login, $password);
return $this->_wrap(self::STATUS_OK, array("status" => $rc));
} else {
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_INCORRECT_USAGE));
}
}
function getFeedTree(): bool {
$include_empty = self::_param_to_bool($_REQUEST['include_empty'] ?? false);
$pf = new Pref_Feeds($_REQUEST);
$_REQUEST['mode'] = 2;
$_REQUEST['force_show_empty'] = $include_empty;
return $this->_wrap(self::STATUS_OK,
array("categories" => $pf->_makefeedtree()));
}
function getFeedIcon(): bool {
$id = (int)$_REQUEST['id'];
$cache = DiskCache::instance('feed-icons');
if ($cache->exists((string)$id)) {
return $cache->send((string)$id) > 0;
} else {
return $this->_wrap(self::STATUS_ERR, array("error" => self::E_NOT_FOUND));
}
}
// only works for labels or uncategorized for the time being
private function _is_cat_empty(int $id): bool {
if ($id == Feeds::CATEGORY_LABELS) {
$label_count = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->count();
return $label_count == 0;
} else if ($id == Feeds::CATEGORY_UNCATEGORIZED) {
$uncategorized_count = ORM::for_table('ttrss_feeds')
->where_null('cat_id')
->where('owner_uid', $_SESSION['uid'])
->count();
return $uncategorized_count == 0;
}
return false;
}
/** @return array<string, string> */
private function _get_custom_sort_types(): array {
$ret = [];
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP, function ($result) use (&$ret) {
foreach ($result as $sort_value => $sort_title) {
$ret[$sort_value] = $sort_title;
}
});
return $ret;
}
}

View File

@@ -1,706 +0,0 @@
<?php
class Article extends Handler_Protected {
const ARTICLE_KIND_ALBUM = 1;
const ARTICLE_KIND_VIDEO = 2;
const ARTICLE_KIND_YOUTUBE = 3;
const CATCHUP_MODE_MARK_AS_READ = 0;
const CATCHUP_MODE_MARK_AS_UNREAD = 1;
const CATCHUP_MODE_TOGGLE = 2;
function redirect(): void {
$article = ORM::for_table('ttrss_entries')
->table_alias('e')
->join('ttrss_user_entries', [ 'ref_id', '=', 'e.id'], 'ue')
->where('ue.owner_uid', $_SESSION['uid'])
->find_one((int)$_REQUEST['id']);
if ($article) {
$article_url = UrlHelper::validate($article->link);
if ($article_url) {
header("Location: $article_url");
return;
}
}
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
print "Article not found or has an empty URL.";
}
static function _create_published_article(string $title, string $url, string $content, string $labels_str, int $owner_uid): bool {
$guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash
if (!$content) {
$pluginhost = new PluginHost();
$pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid);
//$pluginhost->load_data();
$pluginhost->run_hooks_callback(PluginHost::HOOK_GET_FULL_TEXT,
function ($result) use (&$content) {
if ($result) {
$content = $result;
return true;
}
},
$url);
}
$content_hash = sha1($content);
if ($labels_str != "") {
$labels = explode(",", $labels_str);
} else {
$labels = array();
}
$rc = false;
if (!$title) $title = $url;
if (!$title && !$url) return false;
if (filter_var($url, FILTER_VALIDATE_URL) === false) return false;
$pdo = Db::pdo();
$pdo->beginTransaction();
// only check for our user data here, others might have shared this with different content etc
$sth = $pdo->prepare("SELECT id FROM ttrss_entries, ttrss_user_entries WHERE
guid = ? AND ref_id = id AND owner_uid = ? LIMIT 1");
$sth->execute([$guid, $owner_uid]);
if ($row = $sth->fetch()) {
$ref_id = $row['id'];
$sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
ref_id = ? AND owner_uid = ? LIMIT 1");
$sth->execute([$ref_id, $owner_uid]);
if ($row = $sth->fetch()) {
$int_id = $row['int_id'];
$sth = $pdo->prepare("UPDATE ttrss_entries SET
content = ?, content_hash = ? WHERE id = ?");
$sth->execute([$content, $content_hash, $ref_id]);
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
$params = [
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
":id" => $ref_id];
$sth->execute($params);
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true,
last_published = NOW() WHERE
int_id = ? AND owner_uid = ?");
$sth->execute([$int_id, $owner_uid]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
} else {
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
last_read, note, unread, last_published)
VALUES
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
$sth->execute([$ref_id, $owner_uid]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
}
if (count($labels) != 0) {
foreach ($labels as $label) {
Labels::add_article($ref_id, trim($label), $owner_uid);
}
}
$rc = true;
} else {
$sth = $pdo->prepare("INSERT INTO ttrss_entries
(title, guid, link, updated, content, content_hash, date_entered, date_updated)
VALUES
(?, ?, ?, NOW(), ?, ?, NOW(), NOW()) RETURNING id");
$sth->execute([$title, $guid, $url, $content, $content_hash]);
if ($row = $sth->fetch()) {
$ref_id = $row["id"];
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
$params = [
":ts_content" => mb_substr(\Soundasleep\Html2Text::convert($content), 0, 900000),
":id" => $ref_id];
$sth->execute($params);
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
last_read, note, unread, last_published)
VALUES
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
$sth->execute([$ref_id, $owner_uid]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$ref_id]);
if (count($labels) != 0) {
foreach ($labels as $label) {
Labels::add_article($ref_id, trim($label), $owner_uid);
}
}
$rc = true;
}
}
$pdo->commit();
return $rc;
}
function printArticleTags(): void {
$id = (int) clean($_REQUEST['id'] ?? 0);
print json_encode(["id" => $id,
"tags" => self::_get_tags($id)]);
}
function setScore(): void {
$ids = array_map("intval", clean($_REQUEST['ids'] ?? []));
$score = (int)clean($_REQUEST['score']);
$ids_qmarks = arr_qmarks($ids);
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
$sth->execute([$score, ...$ids, $_SESSION['uid']]);
print json_encode(["id" => $ids, "score" => $score]);
}
function setArticleTags(): void {
$id = clean($_REQUEST["id"]);
//$tags_str = clean($_REQUEST["tags_str"]);
//$tags = array_unique(array_map('trim', explode(",", $tags_str)));
$tags = FeedItem_Common::normalize_categories(explode(",", clean($_REQUEST["tags_str"] ?? "")));
$this->pdo->beginTransaction();
$sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
ref_id = ? AND owner_uid = ? LIMIT 1");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$tags_to_cache = array();
$int_id = $row['int_id'];
$dsth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE
post_int_id = ? AND owner_uid = ?");
$dsth->execute([$int_id, $_SESSION['uid']]);
$csth = $this->pdo->prepare("SELECT post_int_id FROM ttrss_tags
WHERE post_int_id = ? AND owner_uid = ? AND tag_name = ?");
$usth = $this->pdo->prepare("INSERT INTO ttrss_tags
(post_int_id, owner_uid, tag_name)
VALUES (?, ?, ?)");
foreach ($tags as $tag) {
$csth->execute([$int_id, $_SESSION['uid'], $tag]);
if (!$csth->fetch()) {
$usth->execute([$int_id, $_SESSION['uid'], $tag]);
}
array_push($tags_to_cache, $tag);
}
/* update tag cache */
$tags_str = join(",", $tags_to_cache);
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries
SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$tags_str, $id, $_SESSION['uid']]);
}
$this->pdo->commit();
// get latest tags from the database, original $tags is sometimes JSON-encoded as a hash ({}) - ???
print json_encode(["id" => (int)$id, "tags" => $this->_get_tags($id)]);
}
function completeTags(): void {
$search = clean($_REQUEST["search"]);
$sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
WHERE owner_uid = ? AND
tag_name LIKE ? ORDER BY tag_name
LIMIT 10");
$sth->execute([$_SESSION['uid'], "$search%"]);
$results = [];
while ($line = $sth->fetch()) {
array_push($results, $line["tag_name"]);
}
print json_encode($results);
}
function assigntolabel(): void {
$this->_label_ops(true);
}
function removefromlabel(): void {
$this->_label_ops(false);
}
private function _label_ops(bool $assign): void {
$reply = array();
$ids = array_map("intval", array_filter(explode(",", clean($_REQUEST["ids"] ?? "")), "strlen"));
$label_id = clean($_REQUEST["lid"]);
$label = Labels::find_caption($label_id, $_SESSION["uid"]);
$reply["labels-for"] = [];
if ($label) {
foreach ($ids as $id) {
if ($assign)
Labels::add_article($id, $label, $_SESSION["uid"]);
else
Labels::remove_article($id, $label, $_SESSION["uid"]);
array_push($reply["labels-for"],
["id" => (int)$id, "labels" => $this->_get_labels($id)]);
}
}
$reply["message"] = "UPDATE_COUNTERS";
print json_encode($reply);
}
/**
* @param int $id article id
* @return array{'formatted': string, 'entries': array<int, array<string, mixed>>}
*/
static function _format_enclosures(int $id, bool $always_display_enclosures, string $article_content, bool $hide_images = false): array {
$enclosures = self::_get_enclosures($id);
$enclosures_formatted = "";
/*foreach ($enclosures as &$enc) {
array_push($enclosures, [
"type" => $enc["content_type"],
"filename" => basename($enc["content_url"]),
"url" => $enc["content_url"],
"title" => $enc["title"],
"width" => (int) $enc["width"],
"height" => (int) $enc["height"]
]);
}*/
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_FORMAT_ENCLOSURES,
function ($result) use (&$enclosures_formatted, &$enclosures) {
if (is_array($result)) {
$enclosures_formatted = $result[0];
$enclosures = $result[1];
} else {
$enclosures_formatted = $result;
}
},
$enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images);
if (!empty($enclosures_formatted)) {
return [
'formatted' => $enclosures_formatted,
'entries' => []
];
}
$rv = [
'formatted' => '',
'entries' => []
];
$rv['can_inline'] = isset($_SESSION["uid"]) &&
empty($_SESSION["bw_limit"]) &&
!Prefs::get(Prefs::STRIP_IMAGES, $_SESSION["uid"], $_SESSION["profile"] ?? null) &&
($always_display_enclosures || !preg_match("/<img/i", $article_content));
$rv['inline_text_only'] = $hide_images && $rv['can_inline'];
foreach ($enclosures as $enc) {
// this is highly approximate
$enc["filename"] = basename($enc["content_url"]);
$rendered_enc = "";
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_RENDER_ENCLOSURE,
function ($result) use (&$rendered_enc) {
$rendered_enc = $result;
},
$enc, $id, $rv);
if ($rendered_enc) {
$rv['formatted'] .= $rendered_enc;
} else {
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ENCLOSURE_ENTRY,
function ($result) use (&$enc) {
$enc = $result;
},
$enc, $id, $rv);
array_push($rv['entries'], $enc);
}
}
return $rv;
}
/**
* @return array<int, string>
*/
static function _get_tags(int $id, int $owner_uid = 0, ?string $tag_cache = null): array {
$a_id = $id;
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT DISTINCT tag_name,
owner_uid as owner FROM ttrss_tags
WHERE post_int_id = (SELECT int_id FROM ttrss_user_entries WHERE
ref_id = ? AND owner_uid = ? LIMIT 1) ORDER BY tag_name");
$tags = array();
/* check cache first */
if (!$tag_cache) {
$csth = $pdo->prepare("SELECT tag_cache FROM ttrss_user_entries
WHERE ref_id = ? AND owner_uid = ?");
$csth->execute([$id, $owner_uid]);
if ($row = $csth->fetch()) {
$tag_cache = $row["tag_cache"];
}
}
if ($tag_cache) {
$tags = explode(",", $tag_cache);
} else {
/* do it the hard way */
$sth->execute([$a_id, $owner_uid]);
while ($tmp_line = $sth->fetch()) {
array_push($tags, $tmp_line["tag_name"]);
}
/* update the cache */
$tags_str = join(",", $tags);
$sth = $pdo->prepare("UPDATE ttrss_user_entries
SET tag_cache = ? WHERE ref_id = ?
AND owner_uid = ?");
$sth->execute([$tags_str, $id, $owner_uid]);
}
return $tags;
}
function getmetadatabyid(): void {
$article = ORM::for_table('ttrss_entries')
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
->where('ue.owner_uid', $_SESSION['uid'])
->find_one((int)$_REQUEST['id']);
if ($article) {
echo json_encode(["link" => $article->link, "title" => $article->title]);
} else {
echo json_encode([]);
}
}
/**
* @return array<int, array<string, mixed>>
*/
static function _get_enclosures(int $id): array {
$encs = ORM::for_table('ttrss_enclosures')
->where('post_id', $id)
->find_many();
$rv = [];
$cache = DiskCache::instance("images");
foreach ($encs as $enc) {
$cache_key = sha1($enc->content_url);
if ($cache->exists($cache_key)) {
$enc->content_url = $cache->get_url($cache_key);
}
array_push($rv, $enc->as_array());
}
return $rv;
}
static function _purge_orphans(): void {
$pdo = Db::pdo();
$res = $pdo->query("DELETE FROM ttrss_entries WHERE
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id)");
if (Debug::enabled()) {
$rows = $res->rowCount();
Debug::log("Purged $rows orphaned posts.");
}
}
/**
* @param array<int, int> $ids
* @param int $cmode Article::CATCHUP_MODE_*
*/
static function _catchup_by_id($ids, int $cmode, ?int $owner_uid = null): void {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
$ids_qmarks = arr_qmarks($ids);
if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
unread = true
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
} else if ($cmode == Article::CATCHUP_MODE_TOGGLE) {
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
unread = NOT unread,last_read = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
} else {
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
unread = false,last_read = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
}
$sth->execute([...$ids, $owner_uid]);
}
/**
* @return array<int, array<int, int|string>>
*/
static function _get_labels(int $id, ?int $owner_uid = null): array {
$rv = array();
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT label_cache FROM
ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$id, $owner_uid]);
if ($row = $sth->fetch()) {
$label_cache = $row["label_cache"];
if ($label_cache) {
$tmp = json_decode($label_cache, true);
if (empty($tmp) || ($tmp["no-labels"] ?? 0) == 1)
return $rv;
else
return $tmp;
}
}
$sth = $pdo->prepare("SELECT DISTINCT label_id,caption,fg_color,bg_color
FROM ttrss_labels2, ttrss_user_labels2
WHERE id = label_id
AND article_id = ?
AND owner_uid = ?
ORDER BY caption");
$sth->execute([$id, $owner_uid]);
while ($line = $sth->fetch()) {
$rk = array(Labels::label_to_feed_id($line["label_id"]),
$line["caption"], $line["fg_color"],
$line["bg_color"]);
array_push($rv, $rk);
}
if (count($rv) > 0)
// PHPStan has issues with the shape of $rv for some reason (array vs non-empty-array).
// @phpstan-ignore-next-line
Labels::update_cache($owner_uid, $id, $rv);
else
Labels::update_cache($owner_uid, $id, array("no-labels" => 1));
return $rv;
}
/**
* @param array<int, array<string, mixed>> $enclosures
* @param array<string, mixed> $headline
*
* @return array<int, Article::ARTICLE_KIND_*|string>
*/
static function _get_image(array $enclosures, string $content, string $site_url, array $headline): array {
$article_image = "";
$article_stream = "";
$article_kind = 0;
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_IMAGE,
function ($result, $plugin) use (&$article_image, &$article_stream, &$content) {
list ($article_image, $article_stream, $content) = $result;
// run until first hard match
return !empty($article_image);
},
$enclosures, $content, $site_url, $headline);
if (!$article_image && !$article_stream) {
$tmpdoc = new DOMDocument();
if (@$tmpdoc->loadHTML('<?xml encoding="UTF-8">' . mb_substr($content, 0, 131070))) {
$tmpxpath = new DOMXPath($tmpdoc);
$elems = $tmpxpath->query('(//img[@src]|//video[@poster]|//iframe[contains(@src , "youtube.com/embed/")])');
/** @var DOMElement $e */
foreach ($elems as $e) {
if ($e->nodeName == "iframe") {
$matches = [];
if (preg_match("/\/embed\/([\w-]+)/", $e->getAttribute("src"), $matches)) {
$article_image = "https://img.youtube.com/vi/" . $matches[1] . "/hqdefault.jpg";
$article_stream = "https://youtu.be/" . $matches[1];
$article_kind = Article::ARTICLE_KIND_YOUTUBE;
break;
}
} else if ($e->nodeName == "video") {
$article_image = $e->getAttribute("poster");
/** @var DOMElement|null $src */
$src = $tmpxpath->query("//source[@src]", $e)->item(0);
if ($src) {
$article_stream = $src->getAttribute("src");
$article_kind = Article::ARTICLE_KIND_VIDEO;
}
break;
} else if ($e->nodeName == 'img') {
if (mb_strpos($e->getAttribute("src"), "data:") !== 0) {
$article_image = $e->getAttribute("src");
}
break;
}
}
}
if (!$article_image)
foreach ($enclosures as $enc) {
if (str_contains($enc["content_type"], "image/")) {
$article_image = $enc["content_url"];
break;
}
}
if ($article_image) {
$article_image = UrlHelper::rewrite_relative($site_url, $article_image);
if (!$article_kind && (count($enclosures) > 1 || (isset($elems) && $elems->length > 1)))
$article_kind = Article::ARTICLE_KIND_ALBUM;
}
if ($article_stream)
$article_stream = UrlHelper::rewrite_relative($site_url, $article_stream);
}
$cache = DiskCache::instance("images");
if ($article_image && $cache->exists(sha1($article_image)))
$article_image = $cache->get_url(sha1($article_image));
if ($article_stream && $cache->exists(sha1($article_stream)))
$article_stream = $cache->get_url(sha1($article_stream));
return [$article_image, $article_stream, $article_kind];
}
/**
* only cached, returns label ids (not label feed ids)
*
* @param array<int, int> $article_ids
* @return array<int, int>
*/
static function _labels_of(array $article_ids) {
if (count($article_ids) == 0)
return [];
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->select('ue.label_cache')
->join('ttrss_user_entries', ['ue.ref_id', '=', 'e.id'], 'ue')
->where_in('e.id', $article_ids)
->where('ue.owner_uid', $_SESSION['uid'])
->find_many();
$rv = [];
foreach ($entries as $entry) {
$labels = json_decode($entry->label_cache);
if (isset($labels) && is_array($labels)) {
foreach ($labels as $label) {
if (empty($label["no-labels"]))
array_push($rv, Labels::feed_to_label_id($label[0]));
}
}
}
return array_unique($rv);
}
/**
* @param array<int, int> $article_ids
* @return array<int, int>
*/
static function _feeds_of(array $article_ids) {
if (count($article_ids) == 0)
return [];
$entries = ORM::for_table('ttrss_entries')
->table_alias('e')
->select('ue.feed_id')
->join('ttrss_user_entries', ['ue.ref_id', '=', 'e.id'], 'ue')
->where_in('e.id', $article_ids)
->where('ue.owner_uid', $_SESSION['uid'])
->find_many();
$rv = [];
foreach ($entries as $entry) {
array_push($rv, $entry->feed_id);
}
return array_unique($rv);
}
}

View File

@@ -1,56 +0,0 @@
<?php
abstract class Auth_Base extends Plugin implements IAuthModule {
protected $pdo;
const AUTH_SERVICE_API = '_api';
function __construct() {
$this->pdo = Db::pdo();
}
function hook_auth_user($login, $password, $service = '') {
return $this->authenticate($login, $password, $service);
}
/** Auto-creates specified user if allowed by system configuration.
* Can be used instead of find_user_by_login() by external auth modules
* @param string $login
* @param null|string|false $password
* @throws Exception
* @throws PDOException
*/
function auto_create_user(string $login, null|false|string $password = false): ?int {
if ($login && Config::get(Config::AUTH_AUTO_CREATE)) {
$user_id = UserHelper::find_user_by_login($login);
if (!$user_id) {
if (!$password) $password = make_password();
$user = ORM::for_table('ttrss_users')->create();
$user->salt = UserHelper::get_salt();
$user->login = mb_strtolower($login);
$user->pwd_hash = UserHelper::hash_password($password, $user->salt);
$user->access_level = 0;
$user->created = Db::NOW();
$user->save();
return UserHelper::find_user_by_login($login);
} else {
return $user_id;
}
}
return UserHelper::find_user_by_login($login);
}
/** replaced with UserHelper::find_user_by_login()
* @deprecated
*/
function find_user_by_login(string $login): ?int {
return UserHelper::find_user_by_login($login);
}
}

View File

@@ -1,36 +0,0 @@
<?php
interface Cache_Adapter {
public function set_dir(string $dir) : void;
public function get_dir(): string;
public function make_dir(): bool;
public function is_writable(?string $filename = null): bool;
public function exists(string $filename): bool;
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
*/
public function get_size(string $filename);
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise
*/
public function get_mtime(string $filename);
/**
* @param mixed $data
*
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data);
public function get(string $filename): ?string;
public function get_full_path(string $filename): string;
public function remove(string $filename) : bool;
/**
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
*/
public function get_mime_type(string $filename);
/**
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
*/
public function send(string $filename);
/** Catchall function to expire all subfolders/prefixes in the cache, invoked on the backend */
public function expire_all(): void;
}

View File

@@ -1,155 +0,0 @@
<?php
class Cache_Local implements Cache_Adapter {
private string $dir;
public function remove(string $filename): bool {
return unlink($this->get_full_path($filename));
}
public function get_mtime(string $filename) {
return filemtime($this->get_full_path($filename));
}
public function set_dir(string $dir) : void {
$cache_dir = Config::get(Config::CACHE_DIR);
// use absolute path local to current dir if CACHE_DIR is relative
// TODO: maybe add a special method to Config() for this?
if ($cache_dir[0] != '/')
$cache_dir = dirname(__DIR__) . "/$cache_dir";
$this->dir = $cache_dir . "/" . basename(clean($dir));
$this->make_dir();
}
public function get_dir(): string {
return $this->dir;
}
public function make_dir(): bool {
if (!is_dir($this->dir)) {
return mkdir($this->dir);
}
return false;
}
public function is_writable(?string $filename = null): bool {
if ($filename) {
if (file_exists($this->get_full_path($filename)))
return is_writable($this->get_full_path($filename));
else
return is_writable($this->dir);
} else {
return is_writable($this->dir);
}
}
public function exists(string $filename): bool {
return file_exists($this->get_full_path($filename));
}
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
*/
public function get_size(string $filename) {
if ($this->exists($filename))
return filesize($this->get_full_path($filename));
else
return -1;
}
public function get_full_path(string $filename): string {
return $this->dir . "/" . basename(clean($filename));
}
public function get(string $filename): ?string {
if ($this->exists($filename))
return file_get_contents($this->get_full_path($filename));
else
return null;
}
/**
* @param mixed $data
*
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data) {
return file_put_contents($this->get_full_path($filename), $data);
}
/**
* @return false|null|string false if detection failed, null if the file doesn't exist, string mime content type otherwise
*/
public function get_mime_type(string $filename) {
if ($this->exists($filename))
return mime_content_type($this->get_full_path($filename));
else
return null;
}
/**
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
*/
public function send(string $filename) {
return $this->send_local_file($this->get_full_path($filename));
}
public function expire_all(): void {
$dirs = array_filter(glob(Config::get(Config::CACHE_DIR) . "/*"), "is_dir");
foreach ($dirs as $cache_dir) {
$num_deleted = 0;
if (is_writable($cache_dir) && !file_exists("$cache_dir/.no-auto-expiry")) {
$files = glob("$cache_dir/*");
if ($files) {
foreach ($files as $file) {
if (time() - filemtime($file) > 86400 * Config::get(Config::CACHE_MAX_DAYS)) {
unlink($file);
++$num_deleted;
}
}
}
Debug::log("Expired $cache_dir: removed $num_deleted files.");
}
}
}
/**
* this is essentially a wrapper for readfile() which allows plugins to hook
* output with httpd-specific "fast" implementation i.e. X-Sendfile or whatever else
*
* hook function should return true if request was handled (or at least attempted to)
*
* note that this can be called without user context so the plugin to handle this
* should be loaded systemwide in config.php
*
* @return bool|int false if the file doesn't exist (or unreadable) or isn't audio/video, true if a plugin handled, otherwise int of bytes sent
*/
private function send_local_file(string $filename) {
if (file_exists($filename)) {
if (is_writable($filename) && !$this->exists('.no-auto-expiry')) {
touch($filename);
}
$tmppluginhost = new PluginHost();
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_SYSTEM);
//$tmppluginhost->load_data();
if ($tmppluginhost->run_hooks_until(PluginHost::HOOK_SEND_LOCAL_FILE, true, $filename))
return true;
return readfile($filename);
} else {
return false;
}
}
}

View File

@@ -1,682 +0,0 @@
<?php
class Config {
private const _ENVVAR_PREFIX = "TTRSS_";
const T_BOOL = 1;
const T_STRING = 2;
const T_INT = 3;
const SCHEMA_VERSION = 151;
/** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX:
*
* DB_TYPE becomes:
*
* .env (docker environment):
*
* TTRSS_DB_TYPE=pgsql
*
* or config.php:
*
* putenv('TTRSS_DB_HOST=my-patroni.example.com');
*
* note lack of quotes and spaces before and after "=".
*
*/
/** this is kept for backwards/plugin compatibility, the only supported database is PostgreSQL
*
* @deprecated usages of `Config::get(Config::DB_TYPE)` should be replaced with default (and only) value: `pgsql` or removed
*/
const DB_TYPE = "DB_TYPE";
/** database server hostname */
const DB_HOST = "DB_HOST";
/** database user */
const DB_USER = "DB_USER";
/** database name */
const DB_NAME = "DB_NAME";
/** database password */
const DB_PASS = "DB_PASS";
/** database server port */
const DB_PORT = "DB_PORT";
/** PostgreSQL SSL mode (prefer, require, disabled) */
const DB_SSLMODE = "DB_SSLMODE";
/** this is a fallback falue for the CLI SAPI, it should be set to a fully-qualified tt-rss URL */
const SELF_URL_PATH = "SELF_URL_PATH";
/** operate in single user mode, disables all functionality related to
* multiple users and authentication. enabling this assumes you have
* your tt-rss directory protected by other means (e.g. http auth). */
const SINGLE_USER_MODE = "SINGLE_USER_MODE";
/** use this PHP CLI executable to start various tasks */
const PHP_EXECUTABLE = "PHP_EXECUTABLE";
/** base directory for lockfiles (must be writable) */
const LOCK_DIRECTORY = "LOCK_DIRECTORY";
/** base directory for local cache (must be writable) */
const CACHE_DIR = "CACHE_DIR";
/** auto create users authenticated via external modules */
const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE";
/** auto log in users authenticated via external modules i.e. auth_remote */
const AUTH_AUTO_LOGIN = "AUTH_AUTO_LOGIN";
/** unconditinally purge all articles older than this amount, in days
* overrides user-controlled purge interval */
const FORCE_ARTICLE_PURGE = "FORCE_ARTICLE_PURGE";
/** default lifetime of a session (e.g. login) cookie. In seconds,
* 0 means cookie will be deleted when browser closes. */
const SESSION_COOKIE_LIFETIME = "SESSION_COOKIE_LIFETIME";
/** send email using this name */
const SMTP_FROM_NAME = "SMTP_FROM_NAME";
/** send email using this address */
const SMTP_FROM_ADDRESS = "SMTP_FROM_ADDRESS";
/** default subject for email digest */
const DIGEST_SUBJECT = "DIGEST_SUBJECT";
/** enable built-in update checker, both for core code and plugins (using git) */
const CHECK_FOR_UPDATES = "CHECK_FOR_UPDATES";
/** system plugins enabled for all users, comma separated list, no quotes
* keep at least one auth module in there (i.e. auth_internal) */
const PLUGINS = "PLUGINS";
/** available options: sql (default, event log), syslog, stdout (for debugging) */
const LOG_DESTINATION = "LOG_DESTINATION";
/** link this stylesheet on all pages (if it exists), should be placed in themes.local */
const LOCAL_OVERRIDE_STYLESHEET = "LOCAL_OVERRIDE_STYLESHEET";
/** same but this javascript file (you can use that for polyfills), should be placed in themes.local */
const LOCAL_OVERRIDE_JS = "LOCAL_OVERRIDE_JS";
/** in seconds, terminate update tasks that ran longer than this interval */
const DAEMON_MAX_CHILD_RUNTIME = "DAEMON_MAX_CHILD_RUNTIME";
/** max concurrent update jobs forking update daemon starts */
const DAEMON_MAX_JOBS = "DAEMON_MAX_JOBS";
/** log level for update daemon */
const DAEMON_LOG_LEVEL = "DAEMON_LOG_LEVEL";
/** How long to wait for response when requesting feed from a site (seconds) */
const FEED_FETCH_TIMEOUT = "FEED_FETCH_TIMEOUT";
/** How long to wait for response when requesting uncached feed from a site (seconds) */
const FEED_FETCH_NO_CACHE_TIMEOUT = "FEED_FETCH_NO_CACHE_TIMEOUT";
/** Default timeout when fetching files from remote sites */
const FILE_FETCH_TIMEOUT = "FILE_FETCH_TIMEOUT";
/** How long to wait for initial response from website when fetching remote files */
const FILE_FETCH_CONNECT_TIMEOUT = "FILE_FETCH_CONNECT_TIMEOUT";
/** stop updating feeds if user haven't logged in for X days */
const DAEMON_UPDATE_LOGIN_LIMIT = "DAEMON_UPDATE_LOGIN_LIMIT";
/** how many feeds to update in one batch */
const DAEMON_FEED_LIMIT = "DAEMON_FEED_LIMIT";
/** default sleep interval between feed updates (sec) */
const DAEMON_SLEEP_INTERVAL = "DAEMON_SLEEP_INTERVAL";
/** do not cache files larger than that (bytes) */
const MAX_CACHE_FILE_SIZE = "MAX_CACHE_FILE_SIZE";
/** do not download files larger than that (bytes) */
const MAX_DOWNLOAD_FILE_SIZE = "MAX_DOWNLOAD_FILE_SIZE";
/** max file size for downloaded favicons (bytes) */
const MAX_FAVICON_FILE_SIZE = "MAX_FAVICON_FILE_SIZE";
/** max age in days for various automatically cached (temporary) files */
const CACHE_MAX_DAYS = "CACHE_MAX_DAYS";
/** max interval between forced unconditional updates for servers
* not complying with http if-modified-since (seconds) */
const MAX_CONDITIONAL_INTERVAL = "MAX_CONDITIONAL_INTERVAL";
/** automatically disable updates for feeds which failed to
* update for this amount of days; 0 disables */
const DAEMON_UNSUCCESSFUL_DAYS_LIMIT = "DAEMON_UNSUCCESSFUL_DAYS_LIMIT";
/** log all sent emails in the event log */
const LOG_SENT_MAIL = "LOG_SENT_MAIL";
/** use HTTP proxy for requests */
const HTTP_PROXY = "HTTP_PROXY";
/** prevent users from changing passwords */
const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES";
/** default session cookie name */
const SESSION_NAME = "SESSION_NAME";
/** enable plugin update checker (using git) */
const CHECK_FOR_PLUGIN_UPDATES = "CHECK_FOR_PLUGIN_UPDATES";
/** allow installing first party plugins using plugin installer in prefs */
const ENABLE_PLUGIN_INSTALLER = "ENABLE_PLUGIN_INSTALLER";
/** minimum amount of seconds required between authentication attempts */
const AUTH_MIN_INTERVAL = "AUTH_MIN_INTERVAL";
/** http user agent (changing this is not recommended) */
const HTTP_USER_AGENT = "HTTP_USER_AGENT";
/** delay updates for this feed if received HTTP 429 (Too Many Requests) for this amount of seconds (base value, actual delay is base...base*2) */
const HTTP_429_THROTTLE_INTERVAL = "HTTP_429_THROTTLE_INTERVAL";
/** disables login form controls except HOOK_LOGINFORM_ADDITIONAL_BUTTONS (for SSO providers), also prevents logging in through auth_internal */
const DISABLE_LOGIN_FORM = "DISABLE_LOGIN_FORM";
/** optional key to transparently encrypt sensitive data (currently limited to sessions and feed passwords),
* key is a 32 byte hex string which may be generated using `update.php --gen-encryption-key` */
const ENCRYPTION_KEY = "ENCRYPTION_KEY";
/** scheduled task to purge orphaned articles, value should be valid cron expression
* @see https://github.com/dragonmantank/cron-expression/blob/master/README.md#cron-expressions
*/
const SCHEDULE_PURGE_ORPHANS = "SCHEDULE_PURGE_ORPHANS";
/** scheduled task to expire disk cache, value should be valid cron expression */
const SCHEDULE_DISK_CACHE_EXPIRE_ALL = "SCHEDULE_DISK_CACHE_EXPIRE_ALL";
/** scheduled task, value should be valid cron expression */
const SCHEDULE_DISABLE_FAILED_FEEDS = "SCHEDULE_DISABLE_FAILED_FEEDS";
/** scheduled task to cleanup feed icons, value should be valid cron expression */
const SCHEDULE_CLEANUP_FEED_ICONS = "SCHEDULE_CLEANUP_FEED_ICONS";
/** scheduled task to disable feed updates of inactive users, value should be valid cron expression */
const SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS = "SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS";
/** scheduled task to cleanup error log, value should be valid cron expression */
const SCHEDULE_EXPIRE_ERROR_LOG = "SCHEDULE_EXPIRE_ERROR_LOG";
/** scheduled task to cleanup update daemon lock files, value should be valid cron expression */
const SCHEDULE_EXPIRE_LOCK_FILES = "SCHEDULE_EXPIRE_LOCK_FILES";
/** scheduled task to send digests, value should be valid cron expression */
const SCHEDULE_SEND_HEADLINES_DIGESTS = "SCHEDULE_SEND_HEADLINES_DIGESTS";
/** default (fallback) light theme path */
const DEFAULT_LIGHT_THEME = "DEFAULT_LIGHT_THEME";
/** default (fallback) dark (night) theme path */
const DEFAULT_DARK_THEME = "DEFAULT_DARK_THEME";
/** default values for all global configuration options */
private const _DEFAULTS = [
Config::DB_TYPE => [ "pgsql", Config::T_STRING ],
Config::DB_HOST => [ "db", Config::T_STRING ],
Config::DB_USER => [ "", Config::T_STRING ],
Config::DB_NAME => [ "", Config::T_STRING ],
Config::DB_PASS => [ "", Config::T_STRING ],
Config::DB_PORT => [ "5432", Config::T_STRING ],
Config::DB_SSLMODE => [ "prefer", Config::T_STRING ],
Config::SELF_URL_PATH => [ "https://example.com/tt-rss", Config::T_STRING ],
Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ],
Config::PHP_EXECUTABLE => [ "/usr/bin/php", Config::T_STRING ],
Config::LOCK_DIRECTORY => [ "lock", Config::T_STRING ],
Config::CACHE_DIR => [ "cache", Config::T_STRING ],
Config::AUTH_AUTO_CREATE => [ "true", Config::T_BOOL ],
Config::AUTH_AUTO_LOGIN => [ "true", Config::T_BOOL ],
Config::FORCE_ARTICLE_PURGE => [ 0, Config::T_INT ],
Config::SESSION_COOKIE_LIFETIME => [ 86400, Config::T_INT ],
Config::SMTP_FROM_NAME => [ "Tiny Tiny RSS", Config::T_STRING ],
Config::SMTP_FROM_ADDRESS => [ "noreply@localhost", Config::T_STRING ],
Config::DIGEST_SUBJECT => [ "[tt-rss] New headlines for last 24 hours",
Config::T_STRING ],
Config::CHECK_FOR_UPDATES => [ "true", Config::T_BOOL ],
Config::PLUGINS => [ "auth_internal", Config::T_STRING ],
Config::LOG_DESTINATION => [ Logger::LOG_DEST_SQL, Config::T_STRING ],
Config::LOCAL_OVERRIDE_STYLESHEET => [ "local-overrides.css",
Config::T_STRING ],
Config::LOCAL_OVERRIDE_JS => [ "local-overrides.js",
Config::T_STRING ],
Config::DAEMON_MAX_CHILD_RUNTIME => [ 1800, Config::T_INT ],
Config::DAEMON_MAX_JOBS => [ 2, Config::T_INT ],
Config::DAEMON_LOG_LEVEL => [ Debug::LOG_NORMAL, Config::T_INT ],
Config::FEED_FETCH_TIMEOUT => [ 45, Config::T_INT ],
Config::FEED_FETCH_NO_CACHE_TIMEOUT => [ 15, Config::T_INT ],
Config::FILE_FETCH_TIMEOUT => [ 45, Config::T_INT ],
Config::FILE_FETCH_CONNECT_TIMEOUT => [ 15, Config::T_INT ],
Config::DAEMON_UPDATE_LOGIN_LIMIT => [ 30, Config::T_INT ],
Config::DAEMON_FEED_LIMIT => [ 50, Config::T_INT ],
Config::DAEMON_SLEEP_INTERVAL => [ 120, Config::T_INT ],
Config::MAX_CACHE_FILE_SIZE => [ 64*1024*1024, Config::T_INT ],
Config::MAX_DOWNLOAD_FILE_SIZE => [ 16*1024*1024, Config::T_INT ],
Config::MAX_FAVICON_FILE_SIZE => [ 1*1024*1024, Config::T_INT ],
Config::CACHE_MAX_DAYS => [ 7, Config::T_INT ],
Config::MAX_CONDITIONAL_INTERVAL => [ 3600*12, Config::T_INT ],
Config::DAEMON_UNSUCCESSFUL_DAYS_LIMIT => [ 30, Config::T_INT ],
Config::LOG_SENT_MAIL => [ "", Config::T_BOOL ],
Config::HTTP_PROXY => [ "", Config::T_STRING ],
Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ],
Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ],
Config::CHECK_FOR_PLUGIN_UPDATES => [ "true", Config::T_BOOL ],
Config::ENABLE_PLUGIN_INSTALLER => [ "true", Config::T_BOOL ],
Config::AUTH_MIN_INTERVAL => [ 5, Config::T_INT ],
Config::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://github.com/tt-rss/tt-rss)',
Config::T_STRING ],
Config::HTTP_429_THROTTLE_INTERVAL => [ 3600, Config::T_INT ],
Config::DISABLE_LOGIN_FORM => [ "", Config::T_BOOL ],
Config::ENCRYPTION_KEY => [ "", Config::T_STRING ],
Config::SCHEDULE_PURGE_ORPHANS => ["@daily", Config::T_STRING],
Config::SCHEDULE_DISK_CACHE_EXPIRE_ALL => ["@daily", Config::T_STRING],
Config::SCHEDULE_DISABLE_FAILED_FEEDS => ["@daily", Config::T_STRING],
Config::SCHEDULE_CLEANUP_FEED_ICONS => ["@daily", Config::T_STRING],
Config::SCHEDULE_LOG_DAEMON_UPDATE_LOGIN_LIMIT_USERS =>
["@daily", Config::T_STRING],
Config::SCHEDULE_EXPIRE_ERROR_LOG => ["@hourly", Config::T_STRING],
Config::SCHEDULE_EXPIRE_LOCK_FILES => ["@hourly", Config::T_STRING],
Config::SCHEDULE_SEND_HEADLINES_DIGESTS => ["@hourly", Config::T_STRING],
Config::DEFAULT_LIGHT_THEME => [ "light.css", Config::T_STRING],
Config::DEFAULT_DARK_THEME => [ "night.css", Config::T_STRING],
];
private static ?Config $instance = null;
/** @var array<string, array<bool|int|string>> */
private array $params = [];
/** @var array<string, mixed> */
private array $version = [];
private Db_Migrations $migrations;
public static function get_instance() : Config {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
private function __clone() {
//
}
function __construct() {
$ref = new ReflectionClass(get_class($this));
foreach ($ref->getConstants() as $const => $cvalue) {
if (isset(self::_DEFAULTS[$const])) {
$override = getenv(self::_ENVVAR_PREFIX . $const);
list ($defval, $deftype) = self::_DEFAULTS[$const];
$this->params[$cvalue] = [ self::cast_to($override !== false ? $override : $defval, $deftype), $deftype ];
}
}
}
/** determine tt-rss version (using git)
*
* package maintainers who don't use git: if version_static.txt exists in tt-rss root
* directory, its contents are displayed instead of git commit-based version, this could be generated
* based on source git tree commit used when creating the package
* @return array<string, mixed>|string
*/
static function get_version(bool $as_string = true): array|string {
return self::get_instance()->_get_version($as_string);
}
// returns version showing (if possible) full timestamp of commit id
static function get_version_html() : string {
$version = self::get_version(false);
return sprintf("<span title=\"%s\n%s\n%s\">%s</span>",
date("Y-m-d H:i:s", ($version['timestamp'] ?? 0)),
$version['commit'] ?? '',
$version['branch'] ?? '',
$version['version']);
}
/**
* @return array<string, mixed>|string
*/
private function _get_version(bool $as_string = true): array|string {
$root_dir = self::get_self_dir();
if (empty($this->version)) {
$this->version["status"] = -1;
if (getenv("CI_COMMIT_SHORT_SHA") && getenv("CI_COMMIT_TIMESTAMP")) {
$this->version["branch"] = getenv("CI_COMMIT_BRANCH");
$this->version["timestamp"] = strtotime(getenv("CI_COMMIT_TIMESTAMP"));
$this->version["version"] = sprintf("%s-%s", date("y.m", $this->version["timestamp"]), getenv("CI_COMMIT_SHORT_SHA"));
$this->version["commit"] = getenv("CI_COMMIT_SHORT_SHA");
$this->version["status"] = 0;
} else if (PHP_OS === "Darwin") {
$this->version["version"] = "UNKNOWN (Unsupported, Darwin)";
} else if (file_exists("$root_dir/version_static.txt")) {
$this->version["version"] = trim(file_get_contents("$root_dir/version_static.txt")) . " (Unsupported)";
} else if (ini_get("open_basedir")) {
$this->version["version"] = "UNKNOWN (Unsupported, open_basedir)";
} else if (is_dir("$root_dir/.git")) {
$this->version = self::get_version_from_git($root_dir);
if ($this->version["status"] != 0) {
user_error("Unable to determine version: " . $this->version["version"], E_USER_WARNING);
$this->version["version"] = "UNKNOWN (Unsupported, Git error)";
} else if (!getenv("SCRIPT_ROOT") || !file_exists("/.dockerenv")) {
$this->version["version"] .= " (Unsupported)";
}
} else {
$this->version["version"] = "UNKNOWN (Unsupported)";
}
}
return $as_string ? $this->version["version"] : $this->version;
}
/**
* @return array<string, int|string>
*/
static function get_version_from_git(string $dir): array {
$descriptorspec = [
1 => ["pipe", "w"], // STDOUT
2 => ["pipe", "w"], // STDERR
];
$rv = [
"status" => -1,
"version" => "",
"branch" => "",
"commit" => "",
"timestamp" => 0,
];
$proc = proc_open("git --no-pager log --pretty=\"version-%ct-%h\" -n1 HEAD",
$descriptorspec, $pipes, $dir);
if (is_resource($proc)) {
$stdout = trim(stream_get_contents($pipes[1]));
$stderr = trim(stream_get_contents($pipes[2]));
$status = proc_close($proc);
$rv["status"] = $status;
list($check, $timestamp, $commit) = explode("-", $stdout);
if ($check == "version") {
$rv["version"] = sprintf("%s-%s", date("y.m", (int)$timestamp), $commit);
$rv["commit"] = $commit;
$rv["timestamp"] = $timestamp;
// proc_close() may return -1 even if command completed successfully
// so if it looks like we got valid data, we ignore it
if ($rv["status"] == -1)
$rv["status"] = 0;
} else {
$rv["version"] = T_sprintf("Git error [RC=%d]: %s", $status, $stderr);
}
}
return $rv;
}
static function get_migrations() : Db_Migrations {
return self::get_instance()->_get_migrations();
}
private function _get_migrations() : Db_Migrations {
if (empty($this->migrations)) {
$this->migrations = new Db_Migrations();
$this->migrations->initialize(self::get_self_dir() . "/sql", "ttrss_version", true, self::SCHEMA_VERSION);
}
return $this->migrations;
}
static function is_migration_needed() : bool {
return self::get_migrations()->is_migration_needed();
}
static function get_schema_version() : int {
return self::get_migrations()->get_version();
}
static function cast_to(string $value, int $type_hint): bool|int|string {
return match ($type_hint) {
self::T_BOOL => sql_bool_to_bool($value),
self::T_INT => (int) $value,
default => $value,
};
}
private function _get(string $param): bool|int|string {
list ($value, $type_hint) = $this->params[$param];
return $this->cast_to($value, $type_hint);
}
private function _add(string $param, string $default, int $type_hint): void {
$override = getenv(self::_ENVVAR_PREFIX . $param);
$this->params[$param] = [ self::cast_to($override !== false ? $override : $default, $type_hint), $type_hint ];
}
static function add(string $param, string $default, int $type_hint = Config::T_STRING): void {
$instance = self::get_instance();
$instance->_add($param, $default, $type_hint);
}
static function get(string $param): bool|int|string {
$instance = self::get_instance();
return $instance->_get($param);
}
static function is_server_https() : bool {
return (!empty($_SERVER['HTTPS']) && ($_SERVER['HTTPS'] != 'off')) ||
(!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https');
}
/** returns fully-qualified external URL to tt-rss (no trailing slash)
* SELF_URL_PATH configuration variable is used as a fallback for the CLI SAPI
* */
static function get_self_url(bool $always_detect = false) : string {
if (!$always_detect && php_sapi_name() == "cli") {
return self::get(Config::SELF_URL_PATH);
} else {
$proto = self::is_server_https() ? 'https' : 'http';
$self_url_path = $proto . '://' . $_SERVER["HTTP_HOST"] . parse_url($_SERVER["REQUEST_URI"], PHP_URL_PATH);
$self_url_path = preg_replace("/(\/api\/{1,})?(\w+\.php)?(\?.*$)?$/", "", $self_url_path);
$self_url_path = preg_replace("/(\/plugins(.local)?)\/.{1,}$/", "", $self_url_path);
return rtrim($self_url_path, "/");
}
}
/* sanity check stuff */
static function sanity_check(): void {
/*
we don't actually need the DB object right now but some checks below might use ORM which won't be initialized
because it is set up in the Db constructor, which is why it's a good idea to invoke it as early as possible
it is a bit of a hack, maybe ORM should be initialized somewhere else (functions.php?)
*/
$pdo = Db::pdo();
$errors = [];
if (!str_contains(self::get(Config::PLUGINS), "auth_")) {
array_push($errors, "Please enable at least one authentication module via PLUGINS");
}
/* we assume our dependencies are sane under docker, so some sanity checks are skipped.
this also allows tt-rss process to run under root if requested (I'm using this for development
under podman because of uidmapping issues with rootless containers, don't use in production -fox) */
if (!getenv("container")) {
if (function_exists('posix_getuid') && posix_getuid() == 0) {
array_push($errors, "Please don't run this script as root.");
}
if (version_compare(PHP_VERSION, '8.2.0', '<')) {
array_push($errors, "PHP version 8.2.0 or newer required. You're using " . PHP_VERSION . ".");
}
if (!class_exists("UConverter")) {
array_push($errors, "PHP UConverter class is missing, it's provided by the Internationalization (intl) module.");
}
if (!function_exists("curl_init") && !ini_get("allow_url_fopen")) {
array_push($errors, "PHP configuration option allow_url_fopen is disabled, and CURL functions are not present. Either enable allow_url_fopen or install PHP extension for CURL.");
}
if (!function_exists("json_encode")) {
array_push($errors, "PHP support for JSON is required, but was not found.");
}
if (!function_exists("flock")) {
array_push($errors, "PHP support for flock() function is required.");
}
if (!class_exists("PDO")) {
array_push($errors, "PHP support for PDO is required but was not found.");
}
if (!function_exists("mb_strlen")) {
array_push($errors, "PHP support for mbstring functions is required but was not found.");
}
if (!function_exists("hash")) {
array_push($errors, "PHP support for hash() function is required but was not found.");
}
if (ini_get("safe_mode")) {
array_push($errors, "PHP safe mode setting is obsolete and not supported by tt-rss.");
}
if (!function_exists("mime_content_type")) {
array_push($errors, "PHP function mime_content_type() is missing, try enabling fileinfo module.");
}
if (!class_exists("DOMDocument")) {
array_push($errors, "PHP support for DOMDocument is required, but was not found.");
}
}
if (!is_writable(self::get(Config::CACHE_DIR) . "/images")) {
array_push($errors, "Image cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/images)");
}
if (!is_writable(self::get(Config::CACHE_DIR) . "/upload")) {
array_push($errors, "Upload cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/upload)");
}
if (!is_writable(self::get(Config::CACHE_DIR) . "/export")) {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)");
}
if (!is_writable(self::get(Config::LOCK_DIRECTORY))) {
array_push($errors, "LOCK_DIRECTORY is not writable (chmod -R 777 ".self::get(Config::LOCK_DIRECTORY).").\n");
}
// ttrss_users won't be there on initial startup (before migrations are done)
if (!Config::is_migration_needed() && self::get(Config::SINGLE_USER_MODE)) {
if (UserHelper::get_login_by_id(1) != "admin") {
array_push($errors, "SINGLE_USER_MODE is enabled but default admin account (ID: 1) is not found.");
}
}
// skip check for CLI scripts so that we could install database schema if it is missing.
if (php_sapi_name() != "cli") {
if (self::get_schema_version() < 0) {
array_push($errors, "Base database schema is missing. Either load it manually or perform a migration (<code>update.php --update-schema</code>)");
}
}
if (count($errors) > 0 && php_sapi_name() != "cli") {
http_response_code(503); ?>
<!DOCTYPE html>
<html>
<head>
<title>Startup failed</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link rel="stylesheet" type="text/css" href="themes/light.css">
</head>
<body class="sanity_failed flat ttrss_utility">
<div class="content">
<h1>Startup failed</h1>
<p>Please fix errors indicated by the following messages:</p>
<?php foreach ($errors as $error) { echo self::format_error($error); } ?>
<p>You might want to check the tt-rss <a target="_blank" href="https://github.com/tt-rss/tt-rss/wiki">wiki</a> or
<a target="_blank" href="https://github.com/tt-rss/tt-rss/discussions">discussions</a> for more information.
Please search before creating a new topic for your question.</p>
</div>
</body>
</html>
<?php
die;
} else if (count($errors) > 0) {
echo "Please fix errors indicated by the following messages:\n\n";
foreach ($errors as $error) {
echo " * " . strip_tags($error)."\n";
}
echo "\nYou might want to check the tt-rss wiki or forums for more information.\n";
echo "Please search the forums before creating a new topic for your question.\n";
exit(1);
}
}
private static function format_error(string $msg): string {
return "<div class=\"alert alert-danger\">$msg</div>";
}
static function get_override_links(): string {
$rv = "";
$local_css = get_theme_path(self::get(self::LOCAL_OVERRIDE_STYLESHEET));
if ($local_css) $rv .= stylesheet_tag($local_css);
$local_js = get_theme_path(self::get(self::LOCAL_OVERRIDE_JS));
if ($local_js) $rv .= javascript_tag($local_js);
return $rv;
}
static function get_user_agent(): string {
return sprintf(self::get(self::HTTP_USER_AGENT), self::get_version());
}
static function get_self_dir() : string {
return dirname(__DIR__); # we're in classes/Config.php
}
}

View File

@@ -1,327 +0,0 @@
<?php
class Counters {
/**
* @return array<int, array<string, int|string>>
*/
static function get_all(): array {
return [
...self::get_global(),
...self::get_virt(),
...self::get_labels(),
...self::get_feeds(),
...self::get_cats(),
];
}
/**
* @param array<int>|null $feed_ids
* @param array<int>|null $label_ids
* @return array<int, array<string, int|string>>
*/
static function get_conditional(?array $feed_ids = null, ?array $label_ids = null): array {
return [
...self::get_global(),
...self::get_virt(),
...self::get_labels($label_ids),
...self::get_feeds($feed_ids),
...self::get_cats(is_array($feed_ids) ? Feeds::_cats_of($feed_ids, $_SESSION["uid"], true) : null)
];
}
/**
* @return array<int, int>
*/
static private function get_cat_children(int $cat_id, int $owner_uid): array {
$unread = 0;
$marked = 0;
$published = 0;
$cats = ORM::for_table('ttrss_feed_categories')
->where('owner_uid', $owner_uid)
->where('parent_cat', $cat_id)
->find_many();
foreach ($cats as $cat) {
list ($tmp_unread, $tmp_marked, $tmp_published) = self::get_cat_children($cat->id, $owner_uid);
$unread += $tmp_unread + Feeds::_get_cat_unread($cat->id, $owner_uid);
$marked += $tmp_marked + Feeds::_get_cat_marked($cat->id, $owner_uid);
$published += $tmp_published + Feeds::_get_cat_published($cat->id, $owner_uid);
}
return [$unread, $marked, $published];
}
/**
* @param array<int>|null $cat_ids
* @return array<int, array<string, int|string>>
*/
private static function get_cats(?array $cat_ids = null): array {
$ret = [];
/* Labels category */
$cv = array("id" => Feeds::CATEGORY_LABELS, "kind" => "cat",
"counter" => Feeds::_get_cat_unread(Feeds::CATEGORY_LABELS));
array_push($ret, $cv);
$pdo = Db::pdo();
if (is_array($cat_ids)) {
if (count($cat_ids) == 0)
return [];
$cat_ids_qmarks = arr_qmarks($cat_ids);
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
WHERE fc.owner_uid = ? AND fc.id IN ($cat_ids_qmarks)
GROUP BY fc.id
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
ue.owner_uid = ?");
$sth->execute([$_SESSION['uid'], ...$cat_ids, $_SESSION['uid']]);
} else {
$sth = $pdo->prepare("SELECT fc.id,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
(SELECT COUNT(id) FROM ttrss_feed_categories fcc
WHERE fcc.parent_cat = fc.id) AS num_children
FROM ttrss_feed_categories fc
LEFT JOIN ttrss_feeds f ON (f.cat_id = fc.id)
LEFT JOIN ttrss_user_entries ue ON (ue.feed_id = f.id)
WHERE fc.owner_uid = :uid
GROUP BY fc.id
UNION
SELECT 0,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN published THEN 1 ELSE 0 END) AS count_published,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
ue.owner_uid = :uid");
$sth->execute(["uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
if ($line["num_children"] > 0) {
list ($child_counter, $child_marked_counter, $child_published_counter) = self::get_cat_children($line["id"], $_SESSION["uid"]);
} else {
$child_counter = 0;
$child_marked_counter = 0;
$child_published_counter = 0;
}
$cv = [
"id" => (int)$line["id"],
"kind" => "cat",
"markedcounter" => (int) $line["count_marked"] + $child_marked_counter,
"publishedcounter" => (int) $line["count_published"] + $child_published_counter,
"counter" => (int) $line["count"] + $child_counter
];
array_push($ret, $cv);
}
return $ret;
}
/**
* @param array<int>|null $feed_ids
* @return array<int, array<string, int|string>>
*/
private static function get_feeds(?array $feed_ids = null): array {
$ret = [];
if (is_array($feed_ids) && count($feed_ids) === 0)
return $ret;
$feeds = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('f.id', 'f.title', 'f.last_error')
->select_many_expr([
'count' => 'SUM(CASE WHEN ue.unread THEN 1 ELSE 0 END)',
'count_marked' => 'SUM(CASE WHEN ue.marked THEN 1 ELSE 0 END)',
'count_published' => 'SUM(CASE WHEN ue.published THEN 1 ELSE 0 END)',
'last_updated' => 'SUBSTRING_FOR_DATE(f.last_updated,1,19)',
])
->join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->where('ue.owner_uid', $_SESSION['uid'])
->group_by('f.id');
if (is_array($feed_ids))
$feeds->where_in('f.id', $feed_ids);
foreach ($feeds->find_many() as $feed) {
$ret[] = [
'id' => $feed->id,
'title' => truncate_string($feed->title, 30),
'error' => $feed->last_error,
'updated' => TimeHelper::make_local_datetime($feed->last_updated),
'counter' => (int) $feed->count,
'markedcounter' => (int) $feed->count_marked,
'publishedcounter' => (int) $feed->count_published,
'ts' => Feeds::_has_icon($feed->id) ? (int) filemtime(Feeds::_get_icon_file($feed->id)) : 0,
];
}
return $ret;
}
/**
* @return array<int, array<string, int|string>>
*/
private static function get_global(): array {
$ret = [
[
"id" => "global-unread",
"counter" => (int) Feeds::_get_global_unread()
]
];
$subcribed_feeds = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->count();
array_push($ret, [
"id" => "subscribed-feeds",
"counter" => $subcribed_feeds
]);
return $ret;
}
/**
* @return array<int, array<string, int|string>>
*/
private static function get_virt(): array {
$ret = [];
foreach ([Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED,
Feeds::FEED_FRESH, Feeds::FEED_ALL] as $feed_id) {
$count = Feeds::_get_counters($feed_id, false, true);
if (in_array($feed_id, [Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED]))
$auxctr = Feeds::_get_counters($feed_id, false);
else
$auxctr = 0;
$cv = [
"id" => $feed_id,
"counter" => (int) $count,
"auxcounter" => (int) $auxctr
];
if ($feed_id == Feeds::FEED_STARRED)
$cv["markedcounter"] = $auxctr;
if ($feed_id == Feeds::FEED_PUBLISHED)
$cv["publishedcounter"] = $auxctr;
array_push($ret, $cv);
}
foreach (PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL) as $feed) {
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
/** @var Plugin&IVirtualFeed $feed['sender'] */
$cv = [
"id" => PluginHost::pfeed_to_feed_id($feed['id']),
"counter" => $feed['sender']->get_unread($feed['id'])
];
if (method_exists($feed['sender'], 'get_total'))
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
array_push($ret, $cv);
}
return $ret;
}
/**
* @param array<int>|null $label_ids
* @return array<int, array<string, int|string>>
*/
static function get_labels(?array $label_ids = null): array {
$ret = [];
$pdo = Db::pdo();
if (is_array($label_ids)) {
if (count($label_ids) == 0)
return [];
$label_ids_qmarks = arr_qmarks($label_ids);
$sth = $pdo->prepare("SELECT id,
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN u1.published = true THEN 1 ELSE 0 END) AS count_published,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = ?
WHERE ttrss_labels2.owner_uid = ? AND ttrss_labels2.id IN ($label_ids_qmarks)
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
$sth->execute([$_SESSION["uid"], $_SESSION["uid"], ...$label_ids]);
} else {
$sth = $pdo->prepare("SELECT id,
caption,
SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS count_unread,
SUM(CASE WHEN u1.marked = true THEN 1 ELSE 0 END) AS count_marked,
SUM(CASE WHEN u1.published = true THEN 1 ELSE 0 END) AS count_published,
COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id AND u1.owner_uid = :uid
WHERE ttrss_labels2.owner_uid = :uid
GROUP BY ttrss_labels2.id, ttrss_labels2.caption");
$sth->execute([":uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
$id = Labels::label_to_feed_id($line["id"]);
$cv = [
"id" => $id,
"counter" => (int) $line["count_unread"],
"auxcounter" => (int) $line["total"],
"markedcounter" => (int) $line["count_marked"],
"publishedcounter" => (int) $line["count_published"],
"description" => $line["caption"]
];
array_push($ret, $cv);
}
return $ret;
}
}

View File

@@ -1,62 +0,0 @@
<?php
class Crypt {
/** the only algo supported at the moment */
private const ENCRYPT_ALGO = 'xchacha20poly1305_ietf';
/** currently only generates keys using sodium_crypto_aead_chacha20poly1305_keygen() i.e. one supported Crypt::ENCRYPT_ALGO
* @return string random 256-bit (for ChaCha20-Poly1305) binary string
*/
static function generate_key() : string {
return sodium_crypto_aead_chacha20poly1305_keygen();
}
/** encrypts provided ciphertext using Config::ENCRYPTION_KEY into an encrypted object
*
* @return array{'algo': string, 'nonce': string, 'payload': string} encrypted data object containing algo, nonce, and encrypted data
*/
static function encrypt_string(string $ciphertext) : array {
$key = Config::get(Config::ENCRYPTION_KEY);
if (!$key)
throw new Exception("Crypt::encrypt_string() failed to encrypt - key is not available");
$nonce = \random_bytes(\SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES);
$payload = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt($ciphertext, '', $nonce, hex2bin($key));
if ($payload) {
$encrypted_data = [
'algo' => self::ENCRYPT_ALGO,
'nonce' => $nonce,
'payload' => $payload,
];
return $encrypted_data;
}
throw new Exception("Crypt::encrypt_string() failed to encrypt ciphertext");
}
/** decrypts payload of a valid encrypted object using Config::ENCRYPTION_KEY
*
* @param array{'algo': string, 'nonce': string, 'payload': string} $encrypted_data
*
* @return string decrypted string payload
*/
static function decrypt_string(array $encrypted_data) : string {
$key = Config::get(Config::ENCRYPTION_KEY);
if (!$key)
throw new Exception("Crypt::decrypt_string() failed to decrypt - key is not available");
// only one is supported for the time being
switch ($encrypted_data['algo']) {
case self::ENCRYPT_ALGO:
return sodium_crypto_aead_xchacha20poly1305_ietf_decrypt($encrypted_data['payload'], '', $encrypted_data['nonce'], hex2bin($key));
}
throw new Exception('Crypt::decrypt_string() failed to decrypt passed encrypted data object, unsupported algo: ' . $encrypted_data['algo']);
}
}

View File

@@ -1,80 +0,0 @@
<?php
class Db {
private static ?Db $instance = null;
private ?PDO $pdo = null;
function __construct() {
ORM::configure(self::get_dsn());
ORM::configure('username', Config::get(Config::DB_USER));
ORM::configure('password', Config::get(Config::DB_PASS));
ORM::configure('return_result_sets', true);
}
/**
* @param int $delta adjust generated timestamp by this value in seconds (either positive or negative)
* @return string
*/
static function NOW(int $delta = 0): string {
return date("Y-m-d H:i:s", time() + $delta);
}
private function __clone() {
//
}
public static function get_dsn(): string {
$db_port = Config::get(Config::DB_PORT) ? ';port=' . Config::get(Config::DB_PORT) : '';
$db_host = Config::get(Config::DB_HOST) ? ';host=' . Config::get(Config::DB_HOST) : '';
$db_sslmode = Config::get(Config::DB_SSLMODE);
return 'pgsql:dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port .
";sslmode=$db_sslmode";
}
// this really shouldn't be used unless a separate PDO connection is needed
// normal usage is Db::pdo()->prepare(...) etc
public function pdo_connect() : PDO {
try {
$pdo = new PDO(self::get_dsn(),
Config::get(Config::DB_USER),
Config::get(Config::DB_PASS));
} catch (Exception $e) {
print "<pre>Exception while creating PDO object:" . $e->getMessage() . "</pre>";
exit(101);
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->query("set client_encoding = 'UTF-8'");
$pdo->query("set datestyle = 'ISO, european'");
$pdo->query("set TIME ZONE 0");
$pdo->query("set cpu_tuple_cost = 0.5");
return $pdo;
}
public static function instance() : Db {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
public static function pdo() : PDO {
if (self::$instance == null)
self::$instance = new self();
if (empty(self::$instance->pdo)) {
self::$instance->pdo = self::$instance->pdo_connect();
}
return self::$instance->pdo;
}
/** @deprecated usages should be replaced with `RANDOM()` */
public static function sql_random_function(): string {
return "RANDOM()";
}
}

View File

@@ -1,200 +0,0 @@
<?php
class Db_Migrations {
private string $base_filename = "schema.sql";
private string $base_path;
private string $migrations_path;
private string $migrations_table;
private bool $base_is_latest;
private PDO $pdo;
private int $cached_version = 0;
private int $cached_max_version = 0;
private int $max_version_override;
function __construct() {
$this->pdo = Db::pdo();
}
function initialize_for_plugin(Plugin $plugin, bool $base_is_latest = true, string $schema_suffix = "sql"): void {
$plugin_dir = PluginHost::getInstance()->get_plugin_dir($plugin);
$this->initialize("{$plugin_dir}/{$schema_suffix}",
strtolower("ttrss_migrations_plugin_" . get_class($plugin)),
$base_is_latest);
}
function initialize(string $root_path, string $migrations_table, bool $base_is_latest = true, int $max_version_override = 0): void {
$this->base_path = "$root_path/pgsql";
$this->migrations_path = $this->base_path . "/migrations";
$this->migrations_table = $migrations_table;
$this->base_is_latest = $base_is_latest;
$this->max_version_override = $max_version_override;
}
private function set_version(int $version): void {
Debug::log("Updating table {$this->migrations_table} with version {$version}...", Debug::LOG_EXTENDED);
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
if ($sth->fetch()) {
$sth = $this->pdo->prepare("UPDATE {$this->migrations_table} SET schema_version = ?");
} else {
$sth = $this->pdo->prepare("INSERT INTO {$this->migrations_table} (schema_version) VALUES (?)");
}
$sth->execute([$version]);
$this->cached_version = $version;
}
function get_version() : int {
if ($this->cached_version)
return $this->cached_version;
try {
$sth = $this->pdo->query("SELECT * FROM {$this->migrations_table}");
if ($res = $sth->fetch()) {
return (int) $res['schema_version'];
} else {
return -1;
}
} catch (PDOException $e) {
$this->create_migrations_table();
return -1;
}
}
private function create_migrations_table(): void {
$this->pdo->query("CREATE TABLE IF NOT EXISTS {$this->migrations_table} (schema_version integer not null)");
}
/**
* @throws PDOException
* @return bool false if the migration failed, otherwise true (or an exception)
*/
private function migrate_to(int $version): bool {
try {
if ($version <= $this->get_version()) {
Debug::log("Refusing to apply version $version: current version is higher", Debug::LOG_VERBOSE);
return false;
}
if ($version == 0)
Debug::log("Loading base database schema...", Debug::LOG_VERBOSE);
else
Debug::log("Starting migration to $version...", Debug::LOG_VERBOSE);
$lines = $this->get_lines($version);
if (count($lines) > 0) {
$this->pdo->beginTransaction();
foreach ($lines as $line) {
Debug::log($line, Debug::LOG_EXTENDED);
try {
$this->pdo->query($line);
} catch (PDOException $e) {
Debug::log("Failed on line: $line", Debug::LOG_VERBOSE);
throw $e;
}
}
if ($version == 0 && $this->base_is_latest)
$this->set_version($this->get_max_version());
else
$this->set_version($version);
$this->pdo->commit();
Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE);
Logger::log(E_USER_NOTICE, "Applied migration to version $version for {$this->migrations_table}");
return true;
} else {
Debug::log("Migration failed: schema file is empty or missing.", Debug::LOG_VERBOSE);
return false;
}
} catch (PDOException $e) {
Debug::log("Migration failed: " . $e->getMessage(), Debug::LOG_VERBOSE);
try {
$this->pdo->rollback();
} catch (PDOException $ie) {
//
}
throw $e;
}
}
function get_max_version() : int {
if ($this->max_version_override > 0)
return $this->max_version_override;
if ($this->cached_max_version)
return $this->cached_max_version;
$migrations = glob("{$this->migrations_path}/*.sql");
if (count($migrations) > 0) {
natsort($migrations);
$this->cached_max_version = (int) basename(array_pop($migrations), ".sql");
} else {
$this->cached_max_version = 0;
}
return $this->cached_max_version;
}
function is_migration_needed() : bool {
return $this->get_version() != $this->get_max_version();
}
function migrate() : bool {
if ($this->get_version() == -1) {
try {
$this->migrate_to(0);
} catch (PDOException $e) {
user_error("Failed to load base schema for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
return false;
}
}
for ($i = $this->get_version() + 1; $i <= $this->get_max_version(); $i++) {
try {
$this->migrate_to($i);
} catch (PDOException $e) {
user_error("Failed to apply migration {$i} for {$this->migrations_table}: " . $e->getMessage(), E_USER_WARNING);
return false;
//throw $e;
}
}
return !$this->is_migration_needed();
}
/**
* @return array<int, string>
*/
private function get_lines(int $version) : array {
if ($version > 0)
$filename = "{$this->migrations_path}/{$version}.sql";
else
$filename = "{$this->base_path}/{$this->base_filename}";
if (file_exists($filename)) {
$lines = array_filter(preg_split("/[\r\n]/", file_get_contents($filename)),
fn($line) => strlen(trim($line)) > 0 && !str_starts_with($line, "--"));
return array_filter(explode(";", implode("", $lines)),
fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]));
} else {
user_error("Requested schema file {$filename} not found.", E_USER_ERROR);
return [];
}
}
}

View File

@@ -1,12 +0,0 @@
<?php
class Db_Prefs {
// this class is a stub for the time being (to be removed)
function read(string $pref_name, ?int $user_id = null, bool $die_on_error = false): bool|int|null|string {
return Prefs::get($pref_name, $user_id ?: $_SESSION['uid'], $_SESSION['profile'] ?? null);
}
function write(string $pref_name, mixed $value, ?int $user_id = null, bool $strip_tags = true): bool {
return Prefs::set($pref_name, $value, $user_id ?: $_SESSION['uid'], $_SESSION['profile'] ?? null, $strip_tags);
}
}

View File

@@ -1,161 +0,0 @@
<?php
class Debug {
const LOG_DISABLED = -1;
const LOG_NORMAL = 0;
const LOG_VERBOSE = 1;
const LOG_EXTENDED = 2;
const SEPARATOR = "<-{log-separator}->";
const ALL_LOG_LEVELS = [
Debug::LOG_DISABLED,
Debug::LOG_NORMAL,
Debug::LOG_VERBOSE,
Debug::LOG_EXTENDED,
];
/**
* @deprecated
*/
public static int $LOG_DISABLED = self::LOG_DISABLED;
/**
* @deprecated
*/
public static int $LOG_NORMAL = self::LOG_NORMAL;
/**
* @deprecated
*/
public static int $LOG_VERBOSE = self::LOG_VERBOSE;
/**
* @deprecated
*/
public static int $LOG_EXTENDED = self::LOG_EXTENDED;
private static bool $enabled = false;
private static bool $quiet = false;
private static ?string $logfile = null;
private static bool $enable_html = false;
private static int $loglevel = self::LOG_NORMAL;
public static function set_logfile(string $logfile): void {
self::$logfile = $logfile;
}
public static function enabled(): bool {
return self::$enabled;
}
public static function set_enabled(bool $enable): void {
self::$enabled = $enable;
}
public static function set_quiet(bool $quiet): void {
self::$quiet = $quiet;
}
/**
* @param Debug::LOG_* $level
*/
public static function set_loglevel(int $level): void {
self::$loglevel = $level;
}
/**
* @return int Debug::LOG_*
*/
public static function get_loglevel(): int {
return self::$loglevel;
}
/**
* @param int $level integer loglevel value
* @return Debug::LOG_* if valid, warn and return LOG_DISABLED otherwise
*/
public static function map_loglevel(int $level) : int {
if (in_array($level, self::ALL_LOG_LEVELS)) {
/** @phpstan-ignore return.type (yes it is a Debug::LOG_* value) */
return $level;
} else {
user_error("Passed invalid debug log level: $level", E_USER_WARNING);
return self::LOG_DISABLED;
}
}
public static function enable_html(bool $enable) : void {
self::$enable_html = $enable;
}
/**
* @param Debug::LOG_* $level log level
*/
public static function log(string $message, int $level = Debug::LOG_NORMAL): bool {
if (!self::$enabled || self::$loglevel < $level) return false;
$ts = date("H:i:s", time());
if (function_exists('posix_getpid')) {
$ts = "$ts/" . posix_getpid();
}
$orig_message = $message;
if ($message === self::SEPARATOR) {
$message = self::$enable_html ? "<hr/>" :
"=================================================================================================================================";
}
if (self::$logfile) {
$fp = fopen(self::$logfile, 'a+');
if ($fp) {
$locked = false;
if (function_exists("flock")) {
$tries = 0;
// try to lock logfile for writing
while ($tries < 5 && !$locked = flock($fp, LOCK_EX | LOCK_NB)) {
sleep(1);
++$tries;
}
if (!$locked) {
fclose($fp);
user_error("Unable to lock debugging log file: " . self::$logfile, E_USER_WARNING);
return false;
}
}
fputs($fp, "[$ts] $message\n");
if (function_exists("flock")) {
flock($fp, LOCK_UN);
}
fclose($fp);
if (self::$quiet)
return false;
} else {
user_error("Unable to open debugging log file: " . self::$logfile, E_USER_WARNING);
}
}
if (self::$enable_html) {
if ($orig_message === self::SEPARATOR) {
print "$message\n";
} else {
print "<span class='log-timestamp'>$ts</span> <span class='log-message'>$message</span>\n";
}
} else {
print "[$ts] $message\n";
}
return true;
}
}

View File

@@ -1,193 +0,0 @@
<?php
class Digest
{
static function send_headlines_digests(): void {
$user_limit = 15; // amount of users to process (e.g. emails to send out)
$limit = 1000; // maximum amount of headlines to include
Debug::log("Sending digests, batch of max $user_limit users, headline limit = $limit");
$pdo = Db::pdo();
$res = $pdo->query("SELECT id, login, email FROM ttrss_users
WHERE email != '' AND (last_digest_sent IS NULL OR last_digest_sent < NOW() - INTERVAL '1 day')");
while ($line = $res->fetch()) {
if (Prefs::get(Prefs::DIGEST_ENABLE, $line['id'])) {
$preferred_ts = strtotime(Prefs::get(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? '');
// try to send digests within 2 hours of preferred time
if ($preferred_ts && time() >= $preferred_ts &&
time() - $preferred_ts <= 7200
) {
Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]);
$do_catchup = Prefs::get(Prefs::DIGEST_CATCHUP, $line['id']);
global $tz_offset;
// reset tz_offset global to prevent tz cache clash between users
$tz_offset = -1;
$tuple = Digest::prepare_headlines_digest($line["id"], 1, $limit);
$digest = $tuple[0];
$headlines_count = $tuple[1];
$affected_ids = $tuple[2];
$digest_text = $tuple[3];
if ($headlines_count > 0) {
$mailer = new Mailer();
//$rc = $mail->quickMail($line["email"], $line["login"], Config::get(Config::DIGEST_SUBJECT), $digest, $digest_text);
$rc = $mailer->mail(["to_name" => $line["login"],
"to_address" => $line["email"],
"subject" => Config::get(Config::DIGEST_SUBJECT),
"message" => $digest_text,
"message_html" => $digest]);
//if (!$rc && $debug) Debug::log("ERROR: " . $mailer->lastError());
Debug::log("RC=$rc");
if ($rc && $do_catchup) {
Debug::log("Marking affected articles as read...");
Article::_catchup_by_id($affected_ids, Article::CATCHUP_MODE_MARK_AS_READ, $line["id"]);
}
} else {
Debug::log("No headlines");
}
$sth = $pdo->prepare("UPDATE ttrss_users SET last_digest_sent = NOW()
WHERE id = ?");
$sth->execute([$line["id"]]);
}
}
}
Debug::log("All done.");
}
/**
* @return array{0: string, 1: int, 2: array<int>, 3: string}
*/
static function prepare_headlines_digest(int $user_id, int $days = 1, int $limit = 1000) {
$tpl = new Templator();
$tpl_t = new Templator();
$tpl->readTemplateFromFile("digest_template_html.txt");
$tpl_t->readTemplateFromFile("digest_template.txt");
$user_tz_string = Prefs::get(Prefs::USER_TIMEZONE, $user_id);
$min_score = Prefs::get(Prefs::DIGEST_MIN_SCORE, $user_id);
if ($user_tz_string == 'Automatic')
$user_tz_string = 'GMT';
$local_ts = TimeHelper::convert_timestamp(time(), 'UTC', $user_tz_string);
$tpl->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
$tpl->setVariable('CUR_TIME', date('G:i', $local_ts));
$tpl->setVariable('TTRSS_HOST', Config::get_self_url());
$tpl_t->setVariable('CUR_DATE', date('Y/m/d', $local_ts));
$tpl_t->setVariable('CUR_TIME', date('G:i', $local_ts));
$tpl_t->setVariable('TTRSS_HOST', Config::get_self_url());
$affected_ids = array();
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT ttrss_entries.title,
ttrss_feeds.title AS feed_title,
COALESCE(ttrss_feed_categories.title, '" . __('Uncategorized') . "') AS cat_title,
date_updated,
ttrss_user_entries.ref_id,
link,
score,
content,
SUBSTRING_FOR_DATE(last_updated,1,19) AS last_updated
FROM
ttrss_user_entries,ttrss_entries,ttrss_feeds
LEFT JOIN
ttrss_feed_categories ON (cat_id = ttrss_feed_categories.id)
WHERE
ref_id = ttrss_entries.id AND feed_id = ttrss_feeds.id
AND include_in_digest = true
AND ttrss_entries.date_updated > NOW() - INTERVAL '$days day'
AND ttrss_user_entries.owner_uid = :user_id
AND unread = true
AND score >= :min_score
ORDER BY ttrss_feed_categories.title, ttrss_feeds.title, score DESC, date_updated DESC
LIMIT " . (int)$limit);
$sth->execute([':user_id' => $user_id, ':min_score' => $min_score]);
$headlines_count = 0;
$headlines = array();
while ($line = $sth->fetch()) {
array_push($headlines, $line);
$headlines_count++;
}
for ($i = 0; $i < sizeof($headlines); $i++) {
$line = $headlines[$i];
array_push($affected_ids, $line["ref_id"]);
$updated = TimeHelper::make_local_datetime($line['last_updated'], owner_uid: $user_id);
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $user_id)) {
$line['feed_title'] = $line['cat_title'] . " / " . $line['feed_title'];
}
$article_labels = Article::_get_labels($line["ref_id"], $user_id);
$article_labels_formatted = "";
if (count($article_labels) > 0) {
$article_labels_formatted = implode(", ", array_map(fn($a) => $a[1], $article_labels));
}
$tpl->setVariable('FEED_TITLE', $line["feed_title"]);
$tpl->setVariable('ARTICLE_TITLE', $line["title"]);
$tpl->setVariable('ARTICLE_LINK', $line["link"]);
$tpl->setVariable('ARTICLE_UPDATED', $updated);
$tpl->setVariable('ARTICLE_EXCERPT',
truncate_string(strip_tags($line["content"]), 300));
// $tpl->setVariable('ARTICLE_CONTENT',
// strip_tags($article_content));
$tpl->setVariable('ARTICLE_LABELS', $article_labels_formatted, true);
$tpl->addBlock('article');
$tpl_t->setVariable('FEED_TITLE', $line["feed_title"]);
$tpl_t->setVariable('ARTICLE_TITLE', $line["title"]);
$tpl_t->setVariable('ARTICLE_LINK', $line["link"]);
$tpl_t->setVariable('ARTICLE_UPDATED', $updated);
$tpl_t->setVariable('ARTICLE_LABELS', $article_labels_formatted, true);
$tpl_t->setVariable('ARTICLE_EXCERPT',
truncate_string(strip_tags($line["content"]), 300, "..."), true);
$tpl_t->addBlock('article');
if (!isset($headlines[$i + 1]) || $headlines[$i]['feed_title'] != $headlines[$i + 1]['feed_title']) {
$tpl->addBlock('feed');
$tpl_t->addBlock('feed');
}
}
$tpl->addBlock('digest');
$tpl->generateOutputToString($tmp);
$tpl_t->addBlock('digest');
$tpl_t->generateOutputToString($tmp_t);
return array($tmp, $headlines_count, $affected_ids, $tmp_t);
}
}

View File

@@ -1,458 +0,0 @@
<?php
class DiskCache implements Cache_Adapter {
private Cache_Adapter $adapter;
/** @var array<string, DiskCache> $instances */
private static array $instances = [];
/**
* https://stackoverflow.com/a/53662733
*
* @var array<string, string>
*/
private array $mimeMap = [
'video/3gpp2' => '3g2',
'video/3gp' => '3gp',
'video/3gpp' => '3gp',
'application/x-compressed' => '7zip',
'audio/x-acc' => 'aac',
'audio/ac3' => 'ac3',
'application/postscript' => 'ai',
'audio/x-aiff' => 'aif',
'audio/aiff' => 'aif',
'audio/x-au' => 'au',
'video/x-msvideo' => 'avi',
'video/msvideo' => 'avi',
'video/avi' => 'avi',
'application/x-troff-msvideo' => 'avi',
'application/macbinary' => 'bin',
'application/mac-binary' => 'bin',
'application/x-binary' => 'bin',
'application/x-macbinary' => 'bin',
'image/bmp' => 'bmp',
'image/x-bmp' => 'bmp',
'image/x-bitmap' => 'bmp',
'image/x-xbitmap' => 'bmp',
'image/x-win-bitmap' => 'bmp',
'image/x-windows-bmp' => 'bmp',
'image/ms-bmp' => 'bmp',
'image/x-ms-bmp' => 'bmp',
'application/bmp' => 'bmp',
'application/x-bmp' => 'bmp',
'application/x-win-bitmap' => 'bmp',
'application/cdr' => 'cdr',
'application/coreldraw' => 'cdr',
'application/x-cdr' => 'cdr',
'application/x-coreldraw' => 'cdr',
'image/cdr' => 'cdr',
'image/x-cdr' => 'cdr',
'zz-application/zz-winassoc-cdr' => 'cdr',
'application/mac-compactpro' => 'cpt',
'application/pkix-crl' => 'crl',
'application/pkcs-crl' => 'crl',
'application/x-x509-ca-cert' => 'crt',
'application/pkix-cert' => 'crt',
'text/css' => 'css',
'text/x-comma-separated-values' => 'csv',
'text/comma-separated-values' => 'csv',
'application/vnd.msexcel' => 'csv',
'application/x-director' => 'dcr',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/x-dvi' => 'dvi',
'message/rfc822' => 'eml',
'application/x-msdownload' => 'exe',
'video/x-f4v' => 'f4v',
'audio/x-flac' => 'flac',
'video/x-flv' => 'flv',
'image/gif' => 'gif',
'application/gpg-keys' => 'gpg',
'application/x-gtar' => 'gtar',
'application/x-gzip' => 'gzip',
'application/mac-binhex40' => 'hqx',
'application/mac-binhex' => 'hqx',
'application/x-binhex40' => 'hqx',
'application/x-mac-binhex40' => 'hqx',
'text/html' => 'html',
'image/x-icon' => 'ico',
'image/x-ico' => 'ico',
'image/vnd.microsoft.icon' => 'ico',
'text/calendar' => 'ics',
'application/java-archive' => 'jar',
'application/x-java-application' => 'jar',
'application/x-jar' => 'jar',
'image/jp2' => 'jp2',
'video/mj2' => 'jp2',
'image/jpx' => 'jp2',
'image/jpm' => 'jp2',
'image/jpeg' => 'jpg',
'image/pjpeg' => 'jpg',
'application/x-javascript' => 'js',
'application/json' => 'json',
'text/json' => 'json',
'application/vnd.google-earth.kml+xml' => 'kml',
'application/vnd.google-earth.kmz' => 'kmz',
'text/x-log' => 'log',
'audio/x-m4a' => 'm4a',
'audio/mp4' => 'm4a',
'application/vnd.mpegurl' => 'm4u',
'audio/midi' => 'mid',
'application/vnd.mif' => 'mif',
'video/quicktime' => 'mov',
'video/x-sgi-movie' => 'movie',
'audio/mpeg' => 'mp3',
'audio/mpg' => 'mp3',
'audio/mpeg3' => 'mp3',
'audio/mp3' => 'mp3',
'video/mp4' => 'mp4',
'video/mpeg' => 'mpeg',
'application/oda' => 'oda',
'audio/ogg' => 'ogg',
'video/ogg' => 'ogg',
'application/ogg' => 'ogg',
'font/otf' => 'otf',
'application/x-pkcs10' => 'p10',
'application/pkcs10' => 'p10',
'application/x-pkcs12' => 'p12',
'application/x-pkcs7-signature' => 'p7a',
'application/pkcs7-mime' => 'p7c',
'application/x-pkcs7-mime' => 'p7c',
'application/x-pkcs7-certreqresp' => 'p7r',
'application/pkcs7-signature' => 'p7s',
'application/pdf' => 'pdf',
'application/octet-stream' => 'pdf',
'application/x-x509-user-cert' => 'pem',
'application/x-pem-file' => 'pem',
'application/pgp' => 'pgp',
'application/x-httpd-php' => 'php',
'application/php' => 'php',
'application/x-php' => 'php',
'text/php' => 'php',
'text/x-php' => 'php',
'application/x-httpd-php-source' => 'php',
'image/png' => 'png',
'image/x-png' => 'png',
'application/powerpoint' => 'ppt',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.ms-office' => 'ppt',
'application/msword' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'application/x-photoshop' => 'psd',
'image/vnd.adobe.photoshop' => 'psd',
'audio/x-realaudio' => 'ra',
'audio/x-pn-realaudio' => 'ram',
'application/x-rar' => 'rar',
'application/rar' => 'rar',
'application/x-rar-compressed' => 'rar',
'audio/x-pn-realaudio-plugin' => 'rpm',
'application/x-pkcs7' => 'rsa',
'text/rtf' => 'rtf',
'text/richtext' => 'rtx',
'video/vnd.rn-realvideo' => 'rv',
'application/x-stuffit' => 'sit',
'application/smil' => 'smil',
'text/srt' => 'srt',
'image/svg+xml' => 'svg',
'application/x-shockwave-flash' => 'swf',
'application/x-tar' => 'tar',
'application/x-gzip-compressed' => 'tgz',
'image/tiff' => 'tiff',
'font/ttf' => 'ttf',
'text/plain' => 'txt',
'text/x-vcard' => 'vcf',
'application/videolan' => 'vlc',
'text/vtt' => 'vtt',
'audio/x-wav' => 'wav',
'audio/wave' => 'wav',
'audio/wav' => 'wav',
'application/wbxml' => 'wbxml',
'video/webm' => 'webm',
'image/webp' => 'webp',
'audio/x-ms-wma' => 'wma',
'application/wmlc' => 'wmlc',
'video/x-ms-wmv' => 'wmv',
'video/x-ms-asf' => 'wmv',
'font/woff' => 'woff',
'font/woff2' => 'woff2',
'application/xhtml+xml' => 'xhtml',
'application/excel' => 'xl',
'application/msexcel' => 'xls',
'application/x-msexcel' => 'xls',
'application/x-ms-excel' => 'xls',
'application/x-excel' => 'xls',
'application/x-dos_ms_excel' => 'xls',
'application/xls' => 'xls',
'application/x-xls' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-excel' => 'xlsx',
'application/xml' => 'xml',
'text/xml' => 'xml',
'text/xsl' => 'xsl',
'application/xspf+xml' => 'xspf',
'application/x-compress' => 'z',
'application/x-zip' => 'zip',
'application/zip' => 'zip',
'application/x-zip-compressed' => 'zip',
'application/s-compressed' => 'zip',
'multipart/x-zip' => 'zip',
'text/x-scriptzsh' => 'zsh'
];
public static function instance(string $dir) : DiskCache {
if ((self::$instances[$dir] ?? null) == null)
self::$instances[$dir] = new self($dir);
return self::$instances[$dir];
}
public function __construct(string $dir) {
foreach (PluginHost::getInstance()->get_plugins() as $p) {
if (implements_interface($p, "Cache_Adapter")) {
/** @var Cache_Adapter $p */
$this->adapter = clone $p; // we need separate object instances for separate directories
$this->adapter->set_dir($dir);
return;
}
}
$this->adapter = new Cache_Local();
$this->adapter->set_dir($dir);
}
public function remove(string $filename): bool {
$rc = $this->adapter->remove($filename);
return $rc;
}
public function set_dir(string $dir) : void {
$this->adapter->set_dir($dir);
}
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, timestamp otherwise
*/
public function get_mtime(string $filename) {
return $this->adapter->get_mtime(basename($filename));
}
public function make_dir(): bool {
return $this->adapter->make_dir();
}
/** @param string|null $filename null means check that cache directory itself is writable */
public function is_writable(?string $filename = null): bool {
return $this->adapter->is_writable($filename ? basename($filename) : null);
}
public function exists(string $filename): bool {
$rc = $this->adapter->exists(basename($filename));
return $rc;
}
/**
* @return int|false -1 if the file doesn't exist, false if an error occurred, size in bytes otherwise
*/
public function get_size(string $filename) {
$rc = $this->adapter->get_size(basename($filename));
return $rc;
}
/**
* @param mixed $data
*
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data) {
return $this->adapter->put(basename($filename), $data);
}
/** @deprecated we can't assume cached files are local, and other storages
* might not support this operation (object metadata may be immutable) */
public function touch(string $filename): bool {
user_error("DiskCache: called unsupported method touch() for $filename", E_USER_DEPRECATED);
return false;
}
public function get(string $filename): ?string {
return $this->adapter->get(basename($filename));
}
public function expire_all(): void {
$this->adapter->expire_all();
}
public function get_dir(): string {
return $this->adapter->get_dir();
}
/** Downloads $url to cache as $local_filename if its missing (unless $force-ed)
* @param string $url
* @param string $local_filename
* @param array<string,string|int|false> $options (additional params to UrlHelper::fetch())
* @param bool $force
* @return bool
*/
public function download(string $url, string $local_filename, array $options = [], bool $force = false) : bool {
if ($this->exists($local_filename) && !$force)
return true;
$data = UrlHelper::fetch([
'url' => $url,
'max_size' => Config::get(Config::MAX_CACHE_FILE_SIZE),
...$options,
]);
if ($data)
return $this->put($local_filename, $data) > 0;
return false;
}
public function send(string $filename) {
$filename = basename($filename);
if (!$this->exists($filename)) {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
return false;
}
$file_mtime = $this->get_mtime($filename);
$gmt_modified = gmdate("D, d M Y H:i:s", (int)$file_mtime) . " GMT";
if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified || ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') == $file_mtime) {
header('HTTP/1.1 304 Not Modified');
return false;
}
$mimetype = $this->get_mime_type($filename);
if ($mimetype == "application/octet-stream")
$mimetype = "video/mp4";
# block SVG because of possible embedded javascript (.....)
$mimetype_blacklist = [ "image/svg+xml" ];
/* only serve video and images */
if (!preg_match("/(image|audio|video)\//", (string)$mimetype) || in_array($mimetype, $mimetype_blacklist)) {
http_response_code(400);
header("Content-type: text/plain");
print "Stored file has disallowed content type ($mimetype)";
return false;
}
$fake_extension = $this->get_fake_extension($filename);
if ($fake_extension)
$fake_extension = ".$fake_extension";
header("Content-Disposition: inline; filename=\"{$filename}{$fake_extension}\"");
header("Content-type: $mimetype");
$stamp_expires = gmdate("D, d M Y H:i:s",
(int)$this->get_mtime($filename) + 86400 * Config::get(Config::CACHE_MAX_DAYS)) . " GMT";
header("Expires: $stamp_expires", true);
header("Last-Modified: $gmt_modified", true);
header("Cache-Control: no-cache");
header("ETag: $file_mtime");
header_remove("Pragma");
return $this->adapter->send($filename);
}
public function get_full_path(string $filename): string {
return $this->adapter->get_full_path(basename($filename));
}
public function get_mime_type(string $filename) {
return $this->adapter->get_mime_type(basename($filename));
}
public function get_fake_extension(string $filename): string {
$mimetype = $this->adapter->get_mime_type(basename($filename));
if ($mimetype)
return $this->mimeMap[$mimetype] ?? "";
else
return "";
}
public function get_url(string $filename): string {
return Config::get_self_url() . "/public.php?op=cached&file=" . basename($this->adapter->get_dir()) . "/" . basename($filename);
}
// check for locally cached (media) URLs and rewrite to local versions
// this is called separately after sanitize() and plugin render article hooks to allow
// plugins work on original source URLs used before caching
// NOTE: URLs should be already absolutized because this is called after sanitize()
static public function rewrite_urls(string $str): string {
$res = trim($str);
if (!$res) {
return '';
}
$doc = new DOMDocument();
if (@$doc->loadHTML('<?xml encoding="UTF-8">' . $res)) {
$xpath = new DOMXPath($doc);
$cache = DiskCache::instance("images");
$need_saving = false;
$entries = $xpath->query('(//img[@src]|//source[@src|@srcset]|//video[@poster|@src])');
/** @var DOMElement $entry */
foreach ($entries as $entry) {
foreach (array('src', 'poster') as $attr) {
if ($entry->hasAttribute($attr)) {
$url = $entry->getAttribute($attr);
$cached_filename = sha1($url);
if ($cache->exists($cached_filename)) {
$url = $cache->get_url($cached_filename);
$entry->setAttribute($attr, $url);
$entry->removeAttribute("srcset");
$need_saving = true;
}
}
}
if ($entry->hasAttribute("srcset")) {
$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
for ($i = 0; $i < count($matches); $i++) {
$cached_filename = sha1($matches[$i]["url"]);
if ($cache->exists($cached_filename)) {
$matches[$i]["url"] = $cache->get_url($cached_filename);
$need_saving = true;
}
}
$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
}
}
if ($need_saving) {
if (isset($doc->firstChild))
$doc->removeChild($doc->firstChild); //remove doctype
$res = $doc->saveHTML();
}
}
return $res;
}
}

View File

@@ -1,40 +0,0 @@
<?php
class Errors {
const E_SUCCESS = "E_SUCCESS";
const E_UNAUTHORIZED = "E_UNAUTHORIZED";
const E_UNKNOWN_METHOD = "E_UNKNOWN_METHOD";
const E_UNKNOWN_PLUGIN = "E_UNKNOWN_PLUGIN";
const E_SCHEMA_MISMATCH = "E_SCHEMA_MISMATCH";
const E_URL_SCHEME_MISMATCH = "E_URL_SCHEME_MISMATCH";
/**
* @param Errors::E_* $code
* @param array<string, string> $params
*/
static function to_json(string $code, array $params = []): string {
return json_encode(["error" => ["code" => $code, "params" => $params]]);
}
static function libxml_last_error() : string {
$error = libxml_get_last_error();
$error_formatted = "";
if ($error) {
foreach (libxml_get_errors() as $error) {
if ($error->level == LIBXML_ERR_FATAL) {
// currently only the first error is reported
$error_formatted = self::format_libxml_error($error);
break;
}
}
}
return UConverter::transcode($error_formatted, 'UTF-8', 'UTF-8');
}
static function format_libxml_error(LibXMLError $error) : string {
return sprintf("LibXML error %s at line %d (column %d): %s",
$error->code, $error->line, $error->column,
$error->message);
}
}

View File

@@ -1,9 +0,0 @@
<?php
class FeedEnclosure {
public string $link = '';
public string $type = '';
public string $length = '';
public string $title = '';
public string $height = '';
public string $width = '';
}

View File

@@ -1,24 +0,0 @@
<?php
abstract class FeedItem {
abstract function get_id(): string;
/** @return int|false a timestamp on success, false otherwise */
abstract function get_date(): false|int;
abstract function get_link(): string;
abstract function get_title(): string;
abstract function get_description(): string;
abstract function get_content(): string;
abstract function get_comments_url(): string;
abstract function get_comments_count(): int;
/** @return array<int, string> */
abstract function get_categories(): array;
/** @return array<int, FeedEnclosure> */
abstract function get_enclosures(): array;
abstract function get_author(): string;
abstract function get_language(): string;
}

View File

@@ -1,223 +0,0 @@
<?php
class FeedItem_Atom extends FeedItem_Common {
const NS_XML = "http://www.w3.org/XML/1998/namespace";
function get_id(): string {
$id = $this->elem->getElementsByTagName("id")->item(0);
if ($id) {
return $id->nodeValue;
} else {
return clean($this->get_link());
}
}
/**
* @return int|false a timestamp on success, false otherwise
*/
function get_date(): false|int {
$updated = $this->elem->getElementsByTagName("updated")->item(0);
if ($updated) {
return strtotime($updated->nodeValue ?? '');
}
$published = $this->elem->getElementsByTagName("published")->item(0);
if ($published) {
return strtotime($published->nodeValue ?? '');
}
$date = $this->xpath->query("dc:date", $this->elem)->item(0);
if ($date) {
return strtotime($date->nodeValue ?? '');
}
// consistent with strtotime failing to parse
return false;
}
function get_link(): string {
$links = $this->elem->getElementsByTagName("link");
foreach ($links as $link) {
if ($link->hasAttribute("href") &&
(!$link->hasAttribute("rel")
|| $link->getAttribute("rel") == "alternate"
|| $link->getAttribute("rel") == "standout")) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link);
if ($base)
return UrlHelper::rewrite_relative($base, clean(trim($link->getAttribute("href"))));
else
return clean(trim($link->getAttribute("href")));
}
}
return '';
}
function get_title(): string {
$title = $this->elem->getElementsByTagName("title")->item(0);
return $title ? clean(trim($title->nodeValue)) : '';
}
/**
* @param string|null $base optional (returns $content if $base is null)
* @param string $content an HTML string
*
* @return string the rewritten XML or original $content
*/
private function rewrite_content_to_base(?string $base = null, ?string $content = '') {
if (!empty($base) && !empty($content)) {
$tmpdoc = new DOMDocument();
if (@$tmpdoc->loadHTML('<?xml encoding="UTF-8">' . $content)) {
$tmpxpath = new DOMXPath($tmpdoc);
$elems = $tmpxpath->query("(//*[@href]|//*[@src])");
/** @var DOMElement $elem */
foreach ($elems as $elem) {
if ($elem->hasAttribute("href")) {
$elem->setAttribute("href",
UrlHelper::rewrite_relative($base, $elem->getAttribute("href")));
} else if ($elem->hasAttribute("src")) {
$elem->setAttribute("src",
UrlHelper::rewrite_relative($base, $elem->getAttribute("src")));
}
}
// Fall back to $content if saveXML somehow fails (i.e. returns false)
$modified_content = $tmpdoc->saveXML();
return $modified_content !== false ? $modified_content : $content;
}
}
return $content;
}
function get_content(): string {
/** @var DOMElement|null */
$content = $this->elem->getElementsByTagName("content")->item(0);
if ($content) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
if ($content->hasAttribute('type')) {
if ($content->getAttribute('type') == 'xhtml') {
for ($i = 0; $i < $content->childNodes->length; $i++) {
$child = $content->childNodes->item($i);
if ($child->hasChildNodes()) {
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
}
}
}
}
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
}
return '';
}
// TODO: duplicate code should be merged with get_content()
function get_description(): string {
/** @var DOMElement|null */
$content = $this->elem->getElementsByTagName("summary")->item(0);
if ($content) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $content);
if ($content->hasAttribute('type')) {
if ($content->getAttribute('type') == 'xhtml') {
for ($i = 0; $i < $content->childNodes->length; $i++) {
$child = $content->childNodes->item($i);
if ($child->hasChildNodes()) {
return $this->rewrite_content_to_base($base, $this->doc->saveHTML($child));
}
}
}
}
return $this->rewrite_content_to_base($base, $this->subtree_or_text($content));
}
return '';
}
/**
* @return array<int, string>
*/
function get_categories(): array {
$categories = $this->elem->getElementsByTagName("category");
$cats = [];
foreach ($categories as $cat) {
if ($cat->hasAttribute("term"))
array_push($cats, $cat->getAttribute("term"));
}
$categories = $this->xpath->query("dc:subject", $this->elem);
foreach ($categories as $cat) {
array_push($cats, $cat->nodeValue);
}
return $this->normalize_categories($cats);
}
/**
* @return array<int, FeedEnclosure>
*/
function get_enclosures(): array {
$links = $this->elem->getElementsByTagName("link");
$encs = [];
foreach ($links as $link) {
if ($link->hasAttribute("href") && $link->hasAttribute("rel")) {
$base = $this->xpath->evaluate("string(ancestor-or-self::*[@xml:base][1]/@xml:base)", $link);
if ($link->getAttribute("rel") == "enclosure") {
$enc = new FeedEnclosure();
$enc->type = clean($link->getAttribute('type'));
$enc->length = clean($link->getAttribute('length'));
$enc->link = clean($link->getAttribute('href'));
if (!empty($base)) {
$enc->link = UrlHelper::rewrite_relative($base, $enc->link);
}
array_push($encs, $enc);
}
}
}
array_push($encs, ...parent::get_enclosures());
return $encs;
}
function get_language(): string {
$lang = $this->elem->getAttributeNS(self::NS_XML, "lang");
if (!empty($lang)) {
return clean($lang);
} else {
// Fall back to the language declared on the feed, if any.
/** @var DOMElement|DOMNode $child */
foreach ($this->doc->childNodes as $child) {
if (method_exists($child, "getAttributeNS")) {
return clean($child->getAttributeNS(self::NS_XML, "lang"));
}
}
}
return '';
}
}

View File

@@ -1,212 +0,0 @@
<?php
abstract class FeedItem_Common extends FeedItem {
protected readonly DOMElement $elem;
protected readonly DOMDocument $doc;
protected readonly DOMXPath $xpath;
function __construct(DOMElement $elem, DOMDocument $doc, DOMXPath $xpath) {
$this->elem = $elem;
$this->doc = $doc;
$this->xpath = $xpath;
try {
$source = $elem->getElementsByTagName("source")->item(0);
// we don't need <source> element
if ($source)
$elem->removeChild($source);
} catch (DOMException $e) {
//
}
}
function get_element(): DOMElement {
return $this->elem;
}
function get_author(): string {
/** @var DOMElement|null */
$author = $this->elem->getElementsByTagName("author")->item(0);
if ($author) {
$name = $author->getElementsByTagName("name")->item(0);
if ($name) return clean($name->nodeValue);
$email = $author->getElementsByTagName("email")->item(0);
if ($email) return clean($email->nodeValue);
if ($author->nodeValue)
return clean($author->nodeValue);
}
$author_elems = $this->xpath->query("dc:creator", $this->elem);
$authors = [];
foreach ($author_elems as $author) {
array_push($authors, clean($author->nodeValue));
}
return implode(", ", $authors);
}
function get_comments_url(): string {
//RSS only. Use a query here to avoid namespace clashes (e.g. with slash).
//might give a wrong result if a default namespace was declared (possible with XPath 2.0)
$com_url = $this->xpath->query("comments", $this->elem)->item(0);
if ($com_url)
return clean($com_url->nodeValue);
//Atom Threading Extension (RFC 4685) stuff. Could be used in RSS feeds, so it's in common.
//'text/html' for type is too restrictive?
$com_url = $this->xpath->query("atom:link[@rel='replies' and contains(@type,'text/html')]/@href", $this->elem)->item(0);
if ($com_url)
return clean($com_url->nodeValue);
return '';
}
function get_comments_count(): int {
//also query for ATE stuff here
$query = "slash:comments|thread:total|atom:link[@rel='replies']/@thread:count";
$comments = $this->xpath->query($query, $this->elem)->item(0);
if ($comments && is_numeric($comments->nodeValue)) {
return (int) clean($comments->nodeValue);
}
return 0;
}
/**
* this is common for both Atom and RSS types and deals with various 'media:' elements
*
* @see https://www.rssboard.org/media-rss
* @return array<int, FeedEnclosure>
*/
function get_enclosures(): array {
$encs = [];
$enclosures = $this->xpath->query("media:content", $this->elem);
/** @var DOMElement $enclosure */
foreach ($enclosures as $enclosure) {
$enc = new FeedEnclosure();
$enc->type = clean($enclosure->getAttribute('type'));
$enc->link = clean($enclosure->getAttribute('url'));
$enc->length = clean($enclosure->getAttribute('length'));
$enc->height = clean($enclosure->getAttribute('height'));
$enc->width = clean($enclosure->getAttribute('width'));
$medium = clean($enclosure->getAttribute("medium"));
if (!$enc->type && $medium) {
$enc->type = strtolower("$medium/generic");
}
$desc = $this->xpath->query("media:description", $enclosure)->item(0);
if ($desc) $enc->title = clean($desc->nodeValue);
array_push($encs, $enc);
}
$enclosures = $this->xpath->query("media:group", $this->elem);
foreach ($enclosures as $enclosure) {
/** @var DOMElement|null */
$content = $this->xpath->query("media:content", $enclosure)->item(0);
if ($content) {
$enc = new FeedEnclosure();
$enc->type = clean($content->getAttribute('type'));
$enc->link = clean($content->getAttribute('url'));
$enc->length = clean($content->getAttribute('length'));
$enc->height = clean($content->getAttribute('height'));
$enc->width = clean($content->getAttribute('width'));
$medium = clean($content->getAttribute("medium"));
if (!$enc->type && $medium) {
$enc->type = strtolower("$medium/generic");
}
$desc = $this->xpath->query("media:description", $content)->item(0);
if ($desc) {
$enc->title = clean($desc->nodeValue);
} else {
$desc = $this->xpath->query("media:description", $enclosure)->item(0);
if ($desc) $enc->title = clean($desc->nodeValue);
}
array_push($encs, $enc);
}
}
$enclosures = $this->xpath->query("(.|media:content|media:group|media:group/media:content)/media:thumbnail", $this->elem);
/** @var DOMElement $enclosure */
foreach ($enclosures as $enclosure) {
$enc = new FeedEnclosure();
$enc->type = 'image/generic';
$enc->link = clean($enclosure->getAttribute('url'));
$enc->height = clean($enclosure->getAttribute('height'));
$enc->width = clean($enclosure->getAttribute('width'));
array_push($encs, $enc);
}
return $encs;
}
function count_children(DOMElement $node): int {
return $node->getElementsByTagName("*")->length;
}
/**
* @return false|string false on failure, otherwise string contents
*/
function subtree_or_text(DOMElement $node): false|string {
if ($this->count_children($node) == 0) {
return $node->nodeValue;
} else {
return $node->c14n();
}
}
/**
* @param array<int, string> $cats
*
* @return array<int, string>
*/
static function normalize_categories(array $cats): array {
$tmp = [];
foreach ($cats as $rawcat) {
array_push($tmp, ...explode(",", $rawcat));
}
$tmp = array_map(function($srccat) {
$cat = clean(trim(mb_strtolower($srccat)));
// we don't support numeric tags
if (is_numeric($cat))
$cat = 't:' . $cat;
$cat = preg_replace('/[,\'\"]/', "", $cat);
if (mb_strlen($cat) > 250)
$cat = mb_substr($cat, 0, 250);
return $cat;
}, $tmp);
// remove empty values
$tmp = array_filter($tmp, 'strlen');
asort($tmp);
return array_unique($tmp);
}
}

View File

@@ -1,169 +0,0 @@
<?php
class FeedItem_RSS extends FeedItem_Common {
function get_id(): string {
$id = $this->elem->getElementsByTagName("guid")->item(0);
if ($id) {
return clean($id->nodeValue);
} else {
return clean($this->get_link());
}
}
/**
* @return int|false a timestamp on success, false otherwise
*/
function get_date(): false|int {
$pubDate = $this->elem->getElementsByTagName("pubDate")->item(0);
if ($pubDate) {
return strtotime($pubDate->nodeValue ?? '');
}
$date = $this->xpath->query("dc:date", $this->elem)->item(0);
if ($date) {
return strtotime($date->nodeValue ?? '');
}
// consistent with strtotime failing to parse
return false;
}
function get_link(): string {
$links = $this->xpath->query("atom:link", $this->elem);
/** @var DOMElement $link */
foreach ($links as $link) {
if ($link->hasAttribute("href") &&
(!$link->hasAttribute("rel")
|| $link->getAttribute("rel") == "alternate"
|| $link->getAttribute("rel") == "standout")) {
return clean(trim($link->getAttribute("href")));
}
}
/** @var DOMElement|null */
$link = $this->elem->getElementsByTagName("guid")->item(0);
if ($link && $link->hasAttributes() && $link->getAttribute("isPermaLink") == "true") {
return clean(trim($link->nodeValue));
}
$link = $this->elem->getElementsByTagName("link")->item(0);
if ($link) {
return clean(trim($link->nodeValue));
}
return '';
}
function get_title(): string {
$title = $this->xpath->query("title", $this->elem)->item(0);
if ($title) {
return clean(trim($title->nodeValue));
}
// if the document has a default namespace then querying for
// title would fail because of reasons so let's try the old way
$title = $this->elem->getElementsByTagName("title")->item(0);
if ($title) {
return clean(trim($title->nodeValue));
}
return '';
}
function get_content(): string {
/** @var DOMElement|null */
$contentA = $this->xpath->query("content:encoded", $this->elem)->item(0);
/** @var DOMElement|null */
$contentB = $this->elem->getElementsByTagName("description")->item(0);
if ($contentA && $contentB) {
$resultA = $this->subtree_or_text($contentA);
$resultB = $this->subtree_or_text($contentB);
return mb_strlen($resultA) > mb_strlen($resultB) ? $resultA : $resultB;
}
if ($contentA) {
return $this->subtree_or_text($contentA);
}
if ($contentB) {
return $this->subtree_or_text($contentB);
}
return '';
}
function get_description(): string {
$summary = $this->elem->getElementsByTagName("description")->item(0);
if ($summary) {
return $summary->nodeValue;
}
return '';
}
/**
* @return array<int, string>
*/
function get_categories(): array {
$categories = $this->elem->getElementsByTagName("category");
$cats = [];
foreach ($categories as $cat) {
array_push($cats, $cat->nodeValue);
}
$categories = $this->xpath->query("dc:subject", $this->elem);
foreach ($categories as $cat) {
array_push($cats, $cat->nodeValue);
}
return $this->normalize_categories($cats);
}
/**
* @return array<int, FeedEnclosure>
*/
function get_enclosures(): array {
$enclosures = $this->elem->getElementsByTagName("enclosure");
$encs = array();
foreach ($enclosures as $enclosure) {
$enc = new FeedEnclosure();
$enc->type = clean($enclosure->getAttribute('type'));
$enc->link = clean($enclosure->getAttribute('url'));
$enc->length = clean($enclosure->getAttribute('length'));
$enc->height = clean($enclosure->getAttribute('height'));
$enc->width = clean($enclosure->getAttribute('width'));
array_push($encs, $enc);
}
array_push($encs, ...parent::get_enclosures());
return $encs;
}
function get_language(): string {
$languages = $this->doc->getElementsByTagName('language');
if (count($languages) == 0) {
return "";
}
return clean($languages[0]->textContent);
}
}

View File

@@ -1,249 +0,0 @@
<?php
class FeedParser {
private DOMDocument $doc;
private ?string $error = null;
/** @var array<string> */
private array $libxml_errors = [];
/** @var array<FeedItem> */
private array $items = [];
private ?string $link = null;
private ?string $title = null;
/** @var FeedParser::FEED_* */
private int $type;
private ?DOMXPath $xpath = null;
const FEED_UNKNOWN = -1;
const FEED_RDF = 0;
const FEED_RSS = 1;
const FEED_ATOM = 2;
function __construct(string $data) {
libxml_use_internal_errors(true);
libxml_clear_errors();
$this->type = $this::FEED_UNKNOWN;
$this->doc = new DOMDocument();
$this->doc->loadXML($data);
mb_substitute_character("none");
$error = libxml_get_last_error();
if ($error) {
foreach (libxml_get_errors() as $error) {
if ($error->level == LIBXML_ERR_FATAL) {
// currently only the first error is reported
$this->error ??= Errors::format_libxml_error($error);
$this->libxml_errors[] = Errors::format_libxml_error($error);
}
}
}
libxml_clear_errors();
if ($this->error)
return;
$this->xpath = new DOMXPath($this->doc);
$this->xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
$this->xpath->registerNamespace('atom03', 'http://purl.org/atom/ns#');
$this->xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/');
$this->xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
$this->xpath->registerNamespace('slash', 'http://purl.org/rss/1.0/modules/slash/');
$this->xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');
$this->xpath->registerNamespace('content', 'http://purl.org/rss/1.0/modules/content/');
$this->xpath->registerNamespace('thread', 'http://purl.org/syndication/thread/1.0');
}
/**
* @return bool false if initialization couldn't occur (e.g. parsing error or unrecognized feed type), otherwise true
*/
function init(): bool {
if ($this->error)
return false;
$type = $this->get_type();
if ($type === self::FEED_UNKNOWN)
return false;
$xpath = $this->xpath;
switch ($type) {
case $this::FEED_ATOM:
$title = $xpath->query('//atom:feed/atom:title')->item(0)
?? $xpath->query('//atom03:feed/atom03:title')->item(0);
if ($title) {
$this->title = $title->nodeValue;
}
/** @var DOMElement|null $link */
$link = $xpath->query('//atom:feed/atom:link[not(@rel)]')->item(0)
?? $xpath->query("//atom:feed/atom:link[@rel='alternate']")->item(0)
?? $xpath->query('//atom03:feed/atom03:link[not(@rel)]')->item(0)
?? $xpath->query("//atom03:feed/atom03:link[@rel='alternate']")->item(0);
if ($link?->getAttribute('href'))
$this->link = $link->getAttribute('href');
$articles = $xpath->query("//atom:entry");
if (empty($articles) || $articles->length == 0)
$articles = $xpath->query("//atom03:entry");
foreach ($articles as $article) {
array_push($this->items, new FeedItem_Atom($article, $this->doc, $this->xpath));
}
break;
case $this::FEED_RSS:
$title = $xpath->query("//channel/title")->item(0);
if ($title) {
$this->title = $title->nodeValue;
}
/** @var DOMElement|null $link */
$link = $xpath->query("//channel/link")->item(0);
if ($link) {
if ($link->getAttribute("href"))
$this->link = $link->getAttribute("href");
else if ($link->nodeValue)
$this->link = $link->nodeValue;
}
$articles = $xpath->query("//channel/item");
foreach ($articles as $article) {
array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath));
}
break;
case $this::FEED_RDF:
$xpath->registerNamespace('rssfake', 'http://purl.org/rss/1.0/');
$title = $xpath->query("//rssfake:channel/rssfake:title")->item(0);
if ($title) {
$this->title = $title->nodeValue;
}
$link = $xpath->query("//rssfake:channel/rssfake:link")->item(0);
if ($link) {
$this->link = $link->nodeValue;
}
$articles = $xpath->query("//rssfake:item");
foreach ($articles as $article) {
array_push($this->items, new FeedItem_RSS($article, $this->doc, $this->xpath));
}
break;
}
if ($this->title)
$this->title = trim($this->title);
if ($this->link)
$this->link = trim($this->link);
return true;
}
/** @deprecated use Errors::format_libxml_error() instead */
function format_error(LibXMLError $error) : string {
return Errors::format_libxml_error($error);
}
// libxml may have invalid unicode data in error messages
function error() : string {
return UConverter::transcode($this->error ?? '', 'UTF-8', 'UTF-8');
}
/** @return array<string> - WARNING: may return invalid unicode data */
function errors() : array {
return $this->libxml_errors;
}
/**
* @return FeedParser::FEED_*
*/
function get_type(): int {
if ($this->type !== self::FEED_UNKNOWN || $this->error)
return $this->type;
$root_list = $this->xpath->query('(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)');
if ($root_list && $root_list->length > 0) {
/** @var DOMElement $root */
$root = $root_list->item(0);
$this->type = match (mb_strtolower($root->tagName)) {
'rdf:rdf' => self::FEED_RDF,
'channel' => self::FEED_RSS,
'feed', 'atom:feed' => self::FEED_ATOM,
default => self::FEED_UNKNOWN,
};
}
if ($this->type === self::FEED_UNKNOWN)
$this->error ??= 'Unknown/unsupported feed type';
return $this->type;
}
function get_link() : string {
return clean($this->link ?? '');
}
function get_title() : string {
return clean($this->title ?? '');
}
/** @return array<FeedItem> */
function get_items() : array {
return $this->items;
}
/** @return array<string> */
function get_links(string $rel) : array {
$rv = array();
switch ($this->type) {
case $this::FEED_ATOM:
$links = $this->xpath->query("//atom:feed/atom:link");
/** @var DOMElement $link */
foreach ($links as $link) {
if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) {
array_push($rv, clean(trim($link->getAttribute('href'))));
}
}
break;
case $this::FEED_RSS:
$links = $this->xpath->query("//atom:link");
/** @var DOMElement $link */
foreach ($links as $link) {
if (!$rel || $link->hasAttribute('rel') && $link->getAttribute('rel') == $rel) {
array_push($rv, clean(trim($link->getAttribute('href'))));
}
}
break;
}
return $rv;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
<?php
class Handler implements IHandler {
protected PDO $pdo;
/** @var array<int|string, mixed> */
protected array $args;
/**
* @param array<int|string, mixed> $args
*/
function __construct(array $args) {
$this->pdo = Db::pdo();
$this->args = $args;
}
function csrf_ignore(string $method): bool {
return false;
}
function before(string $method): bool {
return true;
}
function after(): bool {
return true;
}
/**
* @param mixed $p
*/
public static function _param_to_bool($p): bool {
$p = clean($p);
return $p && ($p !== "f" && $p !== "false");
}
}

View File

@@ -1,11 +0,0 @@
<?php
class Handler_Administrative extends Handler_Protected {
function before(string $method): bool {
if (parent::before($method)) {
if (($_SESSION["access_level"] ?? 0) >= UserHelper::ACCESS_LEVEL_ADMIN) {
return true;
}
}
return false;
}
}

View File

@@ -1,7 +0,0 @@
<?php
class Handler_Protected extends Handler {
function before(string $method): bool {
return parent::before($method) && !empty($_SESSION['uid']);
}
}

View File

@@ -1,861 +0,0 @@
<?php
class Handler_Public extends Handler {
/**
* @param string $feed may be a feed ID or tag
*/
private function generate_syndicated_feed(int $owner_uid, string $feed, bool $is_cat,
int $limit, int $offset, string $search, string $view_mode = "",
string $format = 'atom', string $order = "", string $orig_guid = "", string $start_ts = ""): void {
// fail early if the requested format isn't recognized
if (!in_array($format, ['atom', 'json'])) {
header('Content-Type: text/plain; charset=utf-8');
print "Unknown format: $format.";
return;
}
$note_style = 'color: #9a8c59; background-color: #fff7d5; '
. 'border: 1px dashed #e7d796; padding: 5px; margin-bottom: 1em;';
if (!$limit)
$limit = 60;
list($override_order, $skip_first_id_check) = Feeds::_order_to_override_query($order);
if (!$override_order) {
$override_order = match (true) {
$feed == Feeds::FEED_PUBLISHED && !$is_cat => 'last_published DESC',
$feed == Feeds::FEED_STARRED && !$is_cat => 'last_marked DESC',
default => 'date_entered DESC, updated DESC',
};
}
$params = array(
"owner_uid" => $owner_uid,
"feed" => $feed,
"limit" => $limit,
"view_mode" => $view_mode,
"cat_view" => $is_cat,
"search" => $search,
"override_order" => $override_order,
"include_children" => true,
"ignore_vfeed_group" => true,
"offset" => $offset,
"start_ts" => $start_ts
);
if (!$is_cat && is_numeric($feed) && $feed < PLUGIN_FEED_BASE_INDEX && $feed > LABEL_BASE_INDEX) {
// TODO: _ENABLED_PLUGINS is profile-specific, so use of the default profile's plugins here should
// be called out in the docs, and/or access key stuff (see 'rss()') should also consider the profile
$user_plugins = Prefs::get(Prefs::_ENABLED_PLUGINS, $owner_uid);
$tmppluginhost = new PluginHost();
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
$tmppluginhost->load((string)$user_plugins, PluginHost::KIND_USER, $owner_uid);
//$tmppluginhost->load_data();
$handler = $tmppluginhost->get_feed_handler(
PluginHost::feed_to_pfeed_id((int)$feed));
if ($handler) {
$qfh_ret = $handler->get_headlines(PluginHost::feed_to_pfeed_id((int)$feed), $params);
} else {
user_error("Failed to find handler for plugin feed ID: $feed", E_USER_ERROR);
return;
}
} else {
$qfh_ret = Feeds::_get_headlines($params);
}
$result = $qfh_ret[0];
$feed_title = htmlspecialchars($qfh_ret[1]);
$feed_site_url = $qfh_ret[2];
/* $last_error = $qfh_ret[3]; */
$feed_self_url = Config::get_self_url() .
"/public.php?op=rss&id=$feed&key=" .
Feeds::_get_access_key($feed, false, $owner_uid);
if (!$feed_site_url)
$feed_site_url = Config::get_self_url();
if ($format == 'atom') {
$tpl = new Templator();
$tpl->readTemplateFromFile("generated_feed.txt");
$tpl->setVariable('FEED_TITLE', $feed_title, true);
$tpl->setVariable('FEED_UPDATED', date('c'), true);
$tpl->setVariable('VERSION', Config::get_version(), true);
$tpl->setVariable('FEED_URL', htmlspecialchars($feed_self_url), true);
$tpl->setVariable('SELF_URL', htmlspecialchars(Config::get_self_url()), true);
while ($line = $result->fetch()) {
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
$line["tags"] = Article::_get_tags($line["id"], $owner_uid);
$max_excerpt_length = 250;
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
function ($result) use (&$line) {
$line = $result;
},
$line, $max_excerpt_length);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED,
function ($result) use (&$line) {
$line = $result;
},
$line, $feed, $is_cat, $owner_uid);
$tpl->setVariable('ARTICLE_ID',
htmlspecialchars($orig_guid ? $line['link'] :
$this->_make_article_tag_uri($line['id'], $line['date_entered'])), true);
$tpl->setVariable('ARTICLE_LINK', htmlspecialchars($line['link']), true);
$tpl->setVariable('ARTICLE_TITLE', htmlspecialchars($line['title']), true);
$tpl->setVariable('ARTICLE_EXCERPT', $line["content_preview"], true);
$content = Sanitizer::sanitize($line["content"], false, $owner_uid,
$feed_site_url, null, $line["id"]);
$content = DiskCache::rewrite_urls($content);
if ($line['note']) {
$content = "<div style=\"$note_style\">Article note: " . $line['note'] . "</div>" . $content;
$tpl->setVariable('ARTICLE_NOTE', htmlspecialchars($line['note']), true);
}
$tpl->setVariable('ARTICLE_CONTENT', $content, true);
$tpl->setVariable('ARTICLE_UPDATED_ATOM',
date('c', strtotime($line["updated"] ?? '')), true);
$tpl->setVariable('ARTICLE_UPDATED_RFC822',
date(DATE_RFC822, strtotime($line["updated"] ?? '')), true);
$tpl->setVariable('ARTICLE_AUTHOR', htmlspecialchars($line['author']), true);
$tpl->setVariable('ARTICLE_SOURCE_LINK', htmlspecialchars($line['site_url'] ? $line["site_url"] : Config::get_self_url()), true);
$tpl->setVariable('ARTICLE_SOURCE_TITLE', htmlspecialchars($line['feed_title'] ?? $feed_title), true);
foreach ($line["tags"] as $tag) {
$tpl->setVariable('ARTICLE_CATEGORY', htmlspecialchars($tag), true);
$tpl->addBlock('category');
}
$enclosures = Article::_get_enclosures($line["id"]);
if (count($enclosures) > 0) {
foreach ($enclosures as $e) {
$type = htmlspecialchars($e['content_type']);
$url = htmlspecialchars($e['content_url']);
$length = $e['duration'] ?: 1;
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', $url, true);
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', $type, true);
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', $length, true);
$tpl->addBlock('enclosure');
}
} else {
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', "", true);
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', "", true);
$tpl->setVariable('ARTICLE_ENCLOSURE_LENGTH', "", true);
}
list ($og_image, $og_stream) = Article::_get_image($enclosures, $line['content'], $feed_site_url, $line);
$tpl->setVariable('ARTICLE_OG_IMAGE', $og_image, true);
$tpl->addBlock('entry');
}
$tmp = "";
$tpl->addBlock('feed');
$tpl->generateOutputToString($tmp);
if (empty($_REQUEST["noxml"])) {
header("Content-Type: text/xml; charset=utf-8");
} else {
header("Content-Type: text/plain; charset=utf-8");
}
print $tmp;
} else { // $format == 'json'
$feed = [
'title' => $feed_title,
'feed_url' => $feed_self_url,
'self_url' => Config::get_self_url(),
'articles' => [],
];
while ($line = $result->fetch()) {
$line["content_preview"] = Sanitizer::sanitize(truncate_string(strip_tags($line["content"]), 100, '...'));
$line["tags"] = Article::_get_tags($line["id"], $owner_uid);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
function ($result) use (&$line) {
$line = $result;
},
$line);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_ARTICLE_EXPORT_FEED,
function ($result) use (&$line) {
$line = $result;
},
$line, $feed, $is_cat, $owner_uid);
$article = [
'id' => $line['link'],
'link' => $line['link'],
'title' => $line['title'],
'content' => Sanitizer::sanitize($line['content'], false, $owner_uid, $feed_site_url, null, $line['id']),
'updated' => date('c', strtotime($line['updated'] ?? '')),
'source' => [
'link' => $line['site_url'] ?: Config::get_self_url(),
'title' => $line['feed_title'] ?? $feed_title,
],
];
if (!empty($line['note']))
$article['note'] = $line['note'];
if (!empty($line['author']))
$article['author'] = $line['author'];
if (count($line['tags']) > 0)
$article['tags'] = $line['tags'];
$enclosures = Article::_get_enclosures($line["id"]);
if (count($enclosures) > 0) {
$article['enclosures'] = array();
foreach ($enclosures as $e) {
$article['enclosures'][] = [
'url' => $e['content_url'],
'type' => $e['content_type'],
'length' => $e['duration'],
];
}
}
array_push($feed['articles'], $article);
}
header("Content-Type: application/json; charset=utf-8");
print json_encode($feed);
}
}
function getUnread(): void {
$login = clean($_REQUEST["login"]);
$fresh = clean($_REQUEST["fresh"] ?? "0") == "1";
$uid = UserHelper::find_user_by_login($login);
if ($uid) {
print Feeds::_get_global_unread($uid);
if ($fresh) {
print ";";
print Feeds::_get_counters(Feeds::FEED_FRESH, false, true, $uid);
}
} else {
print "-1;User not found";
}
}
function getProfiles(): void {
$login = clean($_REQUEST["login"]);
$rv = [];
if ($login) {
$profiles = ORM::for_table('ttrss_settings_profiles')
->table_alias('p')
->select_many('title' , 'p.id')
->join('ttrss_users', ['owner_uid', '=', 'u.id'], 'u')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->order_by_asc('title')
->find_many();
$rv = [ [ "value" => 0, "label" => __("Default profile") ] ];
foreach ($profiles as $profile) {
array_push($rv, [ "label" => $profile->title, "value" => $profile->id ]);
}
}
print json_encode($rv);
}
function logout(): void {
if (validate_csrf($_POST["csrf_token"])) {
$login = $_SESSION["name"];
$user_id = $_SESSION["uid"];
UserHelper::logout();
$redirect_url = "";
PluginHost::getInstance()->run_hooks_callback(PluginHost::HOOK_POST_LOGOUT,
function ($result) use (&$redirect_url) {
if (!empty($result[0]))
$redirect_url = UrlHelper::validate($result[0]);
},
$login, $user_id);
if (!$redirect_url)
$redirect_url = Config::get_self_url() . "/index.php";
header("Location: " . $redirect_url);
} else {
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
}
function rss(): void {
$feed = clean($_REQUEST["id"]);
$key = clean($_REQUEST["key"]);
$is_cat = self::_param_to_bool($_REQUEST["is_cat"] ?? false);
$limit = (int)clean($_REQUEST["limit"] ?? 0);
$offset = (int)clean($_REQUEST["offset"] ?? 0);
$search = clean($_REQUEST["q"] ?? "");
$view_mode = clean($_REQUEST["view-mode"] ?? "");
$order = clean($_REQUEST["order"] ?? "");
$start_ts = clean($_REQUEST["ts"] ?? "");
$format = clean($_REQUEST['format'] ?? "atom");
$orig_guid = clean($_REQUEST["orig_guid"] ?? "");
if (Config::get(Config::SINGLE_USER_MODE)) {
UserHelper::authenticate("admin", null);
}
if ($key) {
$access_key = ORM::for_table('ttrss_access_keys')
->select('owner_uid')
->where(['access_key' => $key, 'feed_id' => $feed])
->find_one();
if ($access_key) {
$this->generate_syndicated_feed($access_key->owner_uid, $feed, $is_cat, $limit,
$offset, $search, $view_mode, $format, $order, $orig_guid, $start_ts);
return;
}
}
header('HTTP/1.1 403 Forbidden');
}
function login(): void {
if (!Config::get(Config::SINGLE_USER_MODE)) {
$login = clean($_POST["login"]);
$password = clean($_POST["password"]);
$remember_me = clean($_POST["remember_me"] ?? false);
$safe_mode = checkbox_to_sql_bool($_POST["safe_mode"] ?? false);
if (session_status() != PHP_SESSION_ACTIVE) {
if ($remember_me) {
session_set_cookie_params(Config::get(Config::SESSION_COOKIE_LIFETIME));
} else {
session_set_cookie_params(0);
}
}
if (UserHelper::authenticate($login, $password)) {
$_POST["password"] = "";
$_SESSION["ref_schema_version"] = Config::get_schema_version();
$_SESSION["bw_limit"] = !!clean($_POST["bw_limit"] ?? false);
$_SESSION["safe_mode"] = $safe_mode;
if (!empty($_POST["profile"])) {
$profile = (int) clean($_POST["profile"]);
$profile_obj = ORM::for_table('ttrss_settings_profiles')
->where(['id' => $profile, 'owner_uid' => $_SESSION['uid']])
->find_one();
$_SESSION["profile"] = $profile_obj ? $profile : null;
}
} else {
// start an empty session to deliver login error message
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
$_SESSION["login_error_msg"] ??= __("Incorrect username or password");
}
$return = clean($_REQUEST['return'] ?? '');
if ($return && mb_strpos($return, Config::get_self_url()) === 0) {
header("Location: $return");
} else {
header("Location: " . Config::get_self_url());
}
}
}
function index(): void {
header("Content-Type: text/plain");
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
}
function forgotpass(): void {
if (Config::get(Config::DISABLE_LOGIN_FORM) || !str_contains(Config::get(Config::PLUGINS), "auth_internal")) {
header($_SERVER["SERVER_PROTOCOL"]." 403 Forbidden");
echo "Forbidden.";
return;
}
startup_gettext();
session_start();
$hash = clean($_REQUEST["hash"] ?? '');
header('Content-Type: text/html; charset=utf-8');
?>
<!DOCTYPE html>
<html>
<head>
<title>Tiny Tiny RSS</title>
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<?php
echo javascript_tag("lib/dojo/dojo.js");
echo javascript_tag("lib/dojo/tt-rss-layer.js");
echo javascript_tag("js/common.js");
echo javascript_tag("js/utility.js");
?>
<?= Config::get_override_links() ?>
</head>
<body class='flat ttrss_utility css_loading'>
<div class='container'>
<script type="text/javascript">
const __csrf_token = "<?= $_SESSION["csrf_token"]; ?>";
const __default_light_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_LIGHT_THEME), 'themes/light.css') ?>";
const __default_dark_theme = "<?= get_theme_path(Config::get(Config::DEFAULT_DARK_THEME), 'themes/night.css') ?>";
</script>
<script type="text/javascript">
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
ready(function() {
parser.parse();
});
});
</script>
<style type="text/css">
@media (prefers-color-scheme: dark) {
body {
background : #303030;
}
}
body.css_loading * {
display : none;
}
</style>
<?php
print "<h1>".__("Password recovery")."</h1>";
print "<div class='content'>";
$method = clean($_POST['method'] ?? '');
if ($hash) {
$login = clean($_REQUEST["login"]);
if ($login) {
$user = ORM::for_table('ttrss_users')
->select_many('id', 'resetpass_token')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->find_one();
if ($user) {
list($timestamp, $resetpass_token) = explode(":", $user->resetpass_token);
if ($timestamp && $resetpass_token &&
$timestamp >= time() - 15*60*60 &&
$resetpass_token === $hash) {
$user->resetpass_token = null;
$user->save();
UserHelper::reset_password($user->id, true);
print "<p>"."Completed."."</p>";
} else {
print_error("Some of the information provided is missing or incorrect.");
}
} else {
print_error("Some of the information provided is missing or incorrect.");
}
} else {
print_error("Some of the information provided is missing or incorrect.");
}
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
} else if (!$method) {
print_notice(__("You will need to provide valid account name and email. Password reset link will be sent to your email address."));
print "<form method='POST' action='public.php'>
<input type='hidden' name='method' value='do'>
<input type='hidden' name='op' value='forgotpass'>
<fieldset>
<label>".__("Login:")."</label>
<input dojoType='dijit.form.TextBox' type='text' name='login' value='' required>
</fieldset>
<fieldset>
<label>".__("Email:")."</label>
<input dojoType='dijit.form.TextBox' type='email' name='email' value='' required>
</fieldset>";
$_SESSION["pwdreset:testvalue1"] = rand(1,10);
$_SESSION["pwdreset:testvalue2"] = rand(1,10);
print "<fieldset>
<label>".T_sprintf("How much is %d + %d:", $_SESSION["pwdreset:testvalue1"], $_SESSION["pwdreset:testvalue2"])."</label>
<input dojoType='dijit.form.TextBox' type='text' name='test' value='' required>
</fieldset>
<hr/>
<fieldset>
<button dojoType='dijit.form.Button' type='submit' class='alt-danger'>".__("Reset password")."</button>
<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>
</fieldset>
</form>";
} else if ($method == 'do') {
$login = clean($_POST["login"]);
$email = clean($_POST["email"]);
$test = clean($_POST["test"]);
if ($test != ($_SESSION["pwdreset:testvalue1"] + $_SESSION["pwdreset:testvalue2"]) || !$email || !$login) {
print_error(__('Some of the required form parameters are missing or incorrect.'));
print "<form method='GET' action='public.php'>
<input type='hidden' name='op' value='forgotpass'>
<button dojoType='dijit.form.Button' type='submit' class='alt-primary'>".__("Go back")."</button>
</form>";
} else {
// prevent submitting this form multiple times
$_SESSION["pwdreset:testvalue1"] = rand(1, 1000);
$_SESSION["pwdreset:testvalue2"] = rand(1, 1000);
$user = ORM::for_table('ttrss_users')
->select('id')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->where('email', $email)
->find_one();
if ($user) {
print_notice("Password reset instructions are being sent to your email address.");
$resetpass_token = sha1(get_random_bytes(128));
$resetpass_link = Config::get_self_url() . "/public.php?op=forgotpass&hash=" . $resetpass_token .
"&login=" . urlencode($login);
$tpl = new Templator();
$tpl->readTemplateFromFile("resetpass_link_template.txt");
$tpl->setVariable('LOGIN', $login);
$tpl->setVariable('RESETPASS_LINK', $resetpass_link);
$tpl->setVariable('TTRSS_HOST', Config::get_self_url());
$tpl->addBlock('message');
$message = "";
$tpl->generateOutputToString($message);
$mailer = new Mailer();
$rc = $mailer->mail(["to_name" => $login,
"to_address" => $email,
"subject" => __("[tt-rss] Password reset request"),
"message" => $message]);
if (!$rc) print_error($mailer->error());
$user->resetpass_token = time() . ":" . $resetpass_token;
$user->save();
print "<a href='index.php'>".__("Return to Tiny Tiny RSS")."</a>";
} else {
print_error(__("Sorry, login and email combination not found."));
print "<form method='GET' action='public.php'>
<input type='hidden' name='op' value='forgotpass'>
<button dojoType='dijit.form.Button' type='submit'>".__("Go back")."</button>
</form>";
}
}
}
print "</div>";
print "</div>";
print "</body>";
print "</html>";
}
function dbupdate(): void {
startup_gettext();
if (!Config::get(Config::SINGLE_USER_MODE) && ($_SESSION["access_level"] ?? 0) < 10) {
$_SESSION["login_error_msg"] = __("Your access level is insufficient to run this script.");
$this->_render_login_form();
exit;
}
?>
<!DOCTYPE html>
<html>
<head>
<title>Tiny Tiny RSS: Database Updater</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<link rel="icon" type="image/png" sizes="72x72" href="images/favicon-72px.png">
<link rel="shortcut icon" type="image/png" href="images/favicon.png">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<?php
foreach (["lib/dojo/dojo.js",
"lib/dojo/tt-rss-layer.js",
"js/common.js",
"js/utility.js"] as $jsfile) {
echo javascript_tag($jsfile);
} ?>
<?= Config::get_override_links() ?>
<style type="text/css">
@media (prefers-color-scheme: dark) {
body {
background : #303030;
}
}
body.css_loading * {
display : none;
}
</style>
<script type="text/javascript">
require({cache:{}});
</script>
</head>
<body class="flat ttrss_utility css_loading">
<script type="text/javascript">
const UtilityApp = {
init: function() {
require(['dojo/parser', "dojo/ready", 'dijit/form/Button','dijit/form/CheckBox', 'dijit/form/Form',
'dijit/form/Select','dijit/form/TextBox','dijit/form/ValidationTextBox'],function(parser, ready){
ready(function() {
parser.parse();
});
});
}
}
function confirmDbUpdate() {
return confirm(__("Proceed with update?"));
}
</script>
<div class="container">
<h1><?= __("Database Updater") ?></h1>
<div class="content">
<?php
@$op = clean($_REQUEST["subop"] ?? "");
$migrations = Config::get_migrations();
if ($op == "performupdate") {
if ($migrations->is_migration_needed()) {
?>
<h2><?= T_sprintf("Performing updates to version %d", Config::SCHEMA_VERSION) ?></h2>
<code><pre class="small pre-wrap"><?php
Debug::set_enabled(true);
Debug::set_loglevel(Debug::LOG_VERBOSE);
$result = $migrations->migrate();
Debug::set_loglevel(Debug::LOG_NORMAL);
Debug::set_enabled(false);
?></pre></code>
<?php if (!$result) { ?>
<?= format_error("One of migrations failed. Either retry the process or perform updates manually.") ?>
<form method="post">
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
</form>
<?php } else { ?>
<?= format_notice("Update successful.") ?>
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
<?php }
} else { ?>
<?= format_notice("Database is already up to date.") ?>
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
<?php
}
} else {
if ($migrations->is_migration_needed()) {
?>
<h2><?= T_sprintf("Database schema needs update to the latest version (%d to %d).",
Config::get_schema_version(), Config::SCHEMA_VERSION) ?></h2>
<?= format_warning("Please backup your database before proceeding.") ?>
<form method="post">
<?= \Controls\hidden_tag('subop', 'performupdate') ?>
<?= \Controls\submit_tag(__("Update"), ["onclick" => "return confirmDbUpdate()"]) ?>
</form>
<?php
} else { ?>
<?= format_notice("Database is already up to date.") ?>
<a href="index.php"><?= __("Return to Tiny Tiny RSS") ?></a>
<?php
}
}
?>
</div>
</div>
</body>
</html>
<?php
}
function cached(): void {
list ($cache_dir, $filename) = explode("/", $_GET["file"], 2);
// we do not allow files with extensions at the moment
$filename = str_replace(".", "", $filename);
$cache = DiskCache::instance($cache_dir);
if ($cache->exists($filename)) {
$size = $cache->get_size($filename);
if ($size && $size > 0)
header("Content-Length: $size");
$cache->send($filename);
} else {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
}
}
function feed_icon() : void {
$id = (int)$_REQUEST['id'];
$cache = DiskCache::instance('feed-icons');
if ($cache->exists((string)$id)) {
$cache->send((string)$id);
} else {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
}
}
private function _make_article_tag_uri(int $id, string $timestamp): string {
$timestamp = date("Y-m-d", strtotime($timestamp));
return "tag:" . parse_url(Config::get_self_url(), PHP_URL_HOST) . ",$timestamp:/$id";
}
// this should be used very carefully because this endpoint is exposed to unauthenticated users
// plugin data is not loaded because there's no user context and owner_uid/session may or may not be available
// in general, don't do anything user-related in here and do not modify $_SESSION
public function pluginhandler(): void {
$host = new PluginHost();
$plugin_name = basename(clean($_REQUEST["plugin"]));
$method = clean($_REQUEST["pmethod"]);
$host->load($plugin_name, PluginHost::KIND_ALL, 0);
//$host->load_data();
$plugin = $host->get_plugin($plugin_name);
if ($plugin) {
if (method_exists($plugin, $method)) {
if ($plugin->is_public_method($method)) {
$plugin->$method();
} else {
user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} else {
user_error("PluginHandler[PUBLIC]: Requested unknown method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
}
} else {
user_error("PluginHandler[PUBLIC]: Requested method '$method' of unknown plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: application/json");
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN, ['plugin' => $plugin_name]);
}
}
static function _render_login_form(string $return_to = ""): void {
header('Cache-Control: public');
if ($return_to)
$_REQUEST['return'] = $return_to;
require_once "login_form.php";
exit;
}
// implicit Config::sanity_check() does the actual checking */
public function healthcheck() : void {
header("Content-Type: text/plain");
print "OK";
}
}
?>

View File

@@ -1,18 +0,0 @@
<?php
interface IAuthModule {
/**
* @param string $login
* @param string $password
* @param string $service
* @return int|false user_id
*/
function authenticate($login, $password, $service = '');
/** this is a pluginhost compatibility wrapper that invokes $this->authenticate(...$args) (Auth_Base)
* @param string $login
* @param string $password
* @param string $service
* @return int|false user_id
*/
function hook_auth_user($login, $password, $service = '');
}

View File

@@ -1,4 +0,0 @@
<?php
interface IAuthModule2 extends IAuthModule {
function change_password(int $owner_uid, string $old_password, string $new_password) : string;
}

View File

@@ -1,4 +0,0 @@
<?php
interface ICatchall {
function catchall(string $method): void;
}

View File

@@ -1,6 +0,0 @@
<?php
interface IHandler {
function csrf_ignore(string $method): bool;
function before(string $method): bool;
function after(): bool;
}

View File

@@ -1,11 +0,0 @@
<?php
interface IVirtualFeed {
function get_unread(int $feed_id) : int;
function get_total(int $feed_id) : int;
/**
* @param int $feed_id
* @param array<string,int|string|bool> $options
* @return array<int,int|string>
*/
function get_headlines(int $feed_id, array $options) : array;
}

View File

@@ -1,233 +0,0 @@
<?php
class Labels
{
static function label_to_feed_id(int $label): int {
return LABEL_BASE_INDEX - 1 - abs($label);
}
static function feed_to_label_id(int $feed): int {
return LABEL_BASE_INDEX - 1 + abs($feed);
}
static function find_id(string $label, int $owner_uid): int {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id FROM ttrss_labels2 WHERE LOWER(caption) = LOWER(?)
AND owner_uid = ? LIMIT 1");
$sth->execute([$label, $owner_uid]);
if ($row = $sth->fetch()) {
return $row['id'];
} else {
return 0;
}
}
static function find_caption(int $label, int $owner_uid): string {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT caption FROM ttrss_labels2 WHERE id = ?
AND owner_uid = ? LIMIT 1");
$sth->execute([$label, $owner_uid]);
if ($row = $sth->fetch()) {
return $row['caption'];
} else {
return "";
}
}
/**
* @return array<int, array<string, string>>
*/
static function get_as_hash(int $owner_uid): array {
$rv = [];
$labels = Labels::get_all($owner_uid);
foreach ($labels as $i => $label) {
$rv[(int)$label["id"]] = $labels[$i];
}
return $rv;
}
/**
* @return array<int, array<string, string>> An array of label detail arrays
*/
static function get_all(int $owner_uid) {
$rv = array();
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id, fg_color, bg_color, caption FROM ttrss_labels2
WHERE owner_uid = ? ORDER BY caption");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
array_push($rv, $line);
}
return $rv;
}
/**
* @param array{'no-labels': 1}|array<int, array<int, array{0: int, 1: string, 2: string, 3: string}>> $labels
* [label_id, caption, fg_color, bg_color]
*
* @see Article::_get_labels()
*/
static function update_cache(int $owner_uid, int $id, array $labels, bool $force = false): void {
$pdo = Db::pdo();
if ($force)
self::clear_cache($id);
if (!$labels)
$labels = Article::_get_labels($id);
$labels = json_encode($labels);
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
label_cache = ? WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$labels, $id, $owner_uid]);
}
static function clear_cache(int $id): void {
$pdo = Db::pdo();
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
label_cache = '' WHERE ref_id = ?");
$sth->execute([$id]);
}
static function remove_article(int $id, string $label, int $owner_uid): void {
$label_id = self::find_id($label, $owner_uid);
if (!$label_id) return;
$pdo = Db::pdo();
$sth = $pdo->prepare("DELETE FROM ttrss_user_labels2
WHERE
label_id = ? AND
article_id = ?");
$sth->execute([$label_id, $id]);
self::clear_cache($id);
}
static function add_article(int $id, string $label, int $owner_uid): void {
$label_id = self::find_id($label, $owner_uid);
if (!$label_id) return;
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT
article_id FROM ttrss_labels2, ttrss_user_labels2
WHERE
label_id = id AND
label_id = ? AND
article_id = ? AND owner_uid = ?
LIMIT 1");
$sth->execute([$label_id, $id, $owner_uid]);
if (!$sth->fetch()) {
$sth = $pdo->prepare("INSERT INTO ttrss_user_labels2
(label_id, article_id) VALUES (?, ?)");
$sth->execute([$label_id, $id]);
}
self::clear_cache($id);
}
static function remove(int $id, int $owner_uid): void {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
$tr_in_progress = false;
try {
$pdo->beginTransaction();
} catch (Exception $e) {
$tr_in_progress = true;
}
$sth = $pdo->prepare("SELECT caption FROM ttrss_labels2
WHERE id = ?");
$sth->execute([$id]);
$row = $sth->fetch();
$caption = $row['caption'];
$sth = $pdo->prepare("DELETE FROM ttrss_labels2 WHERE id = ?
AND owner_uid = ?");
$sth->execute([$id, $owner_uid]);
if ($sth->rowCount() != 0 && $caption) {
/* Remove access key for the label */
$ext_id = LABEL_BASE_INDEX - 1 - $id;
$sth = $pdo->prepare("DELETE FROM ttrss_access_keys WHERE
feed_id = ? AND owner_uid = ?");
$sth->execute([$ext_id, $owner_uid]);
/* Remove cached data */
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
WHERE owner_uid = ?");
$sth->execute([$owner_uid]);
}
if (!$tr_in_progress) $pdo->commit();
}
/**
* @return false|int false if the check for an existing label failed, otherwise the number of rows inserted (1 on success)
*/
static function create(string $caption, ?string $fg_color = '', ?string $bg_color = '', ?int $owner_uid = null): false|int {
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
$pdo = Db::pdo();
$tr_in_progress = false;
try {
$pdo->beginTransaction();
} catch (Exception $e) {
$tr_in_progress = true;
}
$sth = $pdo->prepare("SELECT id FROM ttrss_labels2
WHERE LOWER(caption) = LOWER(?) AND owner_uid = ?");
$sth->execute([$caption, $owner_uid]);
if (!$sth->fetch()) {
$sth = $pdo->prepare("INSERT INTO ttrss_labels2
(caption,owner_uid,fg_color,bg_color) VALUES (?, ?, ?, ?)");
$sth->execute([$caption, $owner_uid, $fg_color, $bg_color]);
$result = $sth->rowCount();
} else {
$result = false;
}
if (!$tr_in_progress) $pdo->commit();
return $result;
}
}

View File

@@ -1,80 +0,0 @@
<?php
class Logger {
private static ?Logger $instance = null;
private ?Logger_Adapter $adapter = null;
const LOG_DEST_SQL = "sql";
const LOG_DEST_STDOUT = "stdout";
const LOG_DEST_SYSLOG = "syslog";
const ERROR_NAMES = [
1 => 'E_ERROR',
2 => 'E_WARNING',
4 => 'E_PARSE',
8 => 'E_NOTICE',
16 => 'E_CORE_ERROR',
32 => 'E_CORE_WARNING',
64 => 'E_COMPILE_ERROR',
128 => 'E_COMPILE_WARNING',
256 => 'E_USER_ERROR',
512 => 'E_USER_WARNING',
1024 => 'E_USER_NOTICE',
2048 => 'E_STRICT',
4096 => 'E_RECOVERABLE_ERROR',
8192 => 'E_DEPRECATED',
16384 => 'E_USER_DEPRECATED',
32767 => 'E_ALL'];
static function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
return self::get_instance()->_log_error($errno, $errstr, $file, $line, $context);
}
private function _log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
//if ($errno == E_NOTICE) return false;
if ($this->adapter)
return $this->adapter->log_error($errno, $errstr, $file, $line, $context);
else
return false;
}
static function log(int $errno, string $errstr, string $context = ""): bool {
return self::get_instance()->_log($errno, $errstr, $context);
}
private function _log(int $errno, string $errstr, string $context = ""): bool {
if ($this->adapter)
return $this->adapter->log_error($errno, $errstr, '', 0, $context);
else
return user_error($errstr, $errno);
}
private function __clone() {
//
}
function __construct() {
$this->adapter = match (Config::get(Config::LOG_DESTINATION)) {
self::LOG_DEST_SQL => new Logger_SQL(),
self::LOG_DEST_SYSLOG => new Logger_Syslog(),
self::LOG_DEST_STDOUT => new Logger_Stdout(),
default => null,
};
if ($this->adapter && !implements_interface($this->adapter, "Logger_Adapter"))
user_error("Adapter for LOG_DESTINATION: " . Config::LOG_DESTINATION . " does not implement required interface.", E_USER_ERROR);
}
private static function get_instance() : Logger {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
static function get() : Logger {
user_error("Please don't use Logger::get(), call Logger::log(...) instead.", E_USER_DEPRECATED);
return self::get_instance();
}
}

View File

@@ -1,4 +0,0 @@
<?php
interface Logger_Adapter {
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool;
}

View File

@@ -1,61 +0,0 @@
<?php
class Logger_SQL implements Logger_Adapter {
function __construct() {
$conn = get_class($this);
ORM::configure(Db::get_dsn(), null, $conn);
ORM::configure('username', Config::get(Config::DB_USER), $conn);
ORM::configure('password', Config::get(Config::DB_PASS), $conn);
ORM::configure('return_result_sets', true, $conn);
}
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
if (Config::get_schema_version() > 117) {
// limit context length, DOMDocument dumps entire XML in here sometimes, which may be huge
$context = mb_substr($context, 0, 8192);
$server_params = [
"Real IP" => "HTTP_X_REAL_IP",
"Forwarded For" => "HTTP_X_FORWARDED_FOR",
"Forwarded Protocol" => "HTTP_X_FORWARDED_PROTO",
"Remote IP" => "REMOTE_ADDR",
"Request URI" => "REQUEST_URI",
"User agent" => "HTTP_USER_AGENT",
];
foreach ($server_params as $n => $p) {
if (isset($_SERVER[$p]))
$context .= "\n$n: " . $_SERVER[$p];
}
// passed error message may contain invalid unicode characters, failing to insert an error here
// would break the execution entirely by generating an actual fatal error instead of a E_WARNING etc
$errstr = UConverter::transcode($errstr, 'UTF-8', 'UTF-8');
$context = UConverter::transcode($context, 'UTF-8', 'UTF-8');
// can't use $_SESSION["uid"] ?? null because what if its, for example, false? or zero?
// this would cause a PDOException on insert below
$owner_uid = !empty($_SESSION["uid"]) ? $_SESSION["uid"] : null;
$entry = ORM::for_table('ttrss_error_log', get_class($this))->create();
$entry->set([
'errno' => $errno,
'errstr' => $errstr,
'filename' => $file,
'lineno' => (int)$line,
'context' => $context,
'owner_uid' => $owner_uid,
'created_at' => Db::NOW(),
]);
return $entry->save();
}
return false;
}
}

View File

@@ -1,31 +0,0 @@
<?php
class Logger_Stdout implements Logger_Adapter {
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
switch ($errno) {
case E_ERROR:
case E_PARSE:
case E_CORE_ERROR:
case E_COMPILE_ERROR:
case E_USER_ERROR:
$priority = LOG_ERR;
break;
case E_WARNING:
case E_CORE_WARNING:
case E_COMPILE_WARNING:
case E_USER_WARNING:
$priority = LOG_WARNING;
break;
default:
$priority = LOG_INFO;
}
$errname = Logger::ERROR_NAMES[$errno] . " ($errno)";
print "[EEE] $priority $errname ($file:$line) $errstr\n";
return true;
}
}

View File

@@ -1,31 +0,0 @@
<?php
class Logger_Syslog implements Logger_Adapter {
function log_error(int $errno, string $errstr, string $file, int $line, string $context): bool {
switch ($errno) {
case E_ERROR:
case E_PARSE:
case E_CORE_ERROR:
case E_COMPILE_ERROR:
case E_USER_ERROR:
$priority = LOG_ERR;
break;
case E_WARNING:
case E_CORE_WARNING:
case E_COMPILE_WARNING:
case E_USER_WARNING:
$priority = LOG_WARNING;
break;
default:
$priority = LOG_INFO;
}
$errname = Logger::ERROR_NAMES[$errno] . " ($errno)";
syslog($priority, "[tt-rss] $errname ($file:$line) $errstr");
return true;
}
}

View File

@@ -1,65 +0,0 @@
<?php
class Mailer {
private string $last_error = "";
/**
* @param array<string, mixed> $params
* @return bool|int bool if the default mail function handled the request, otherwise an int as described in Mailer#mail()
*/
function mail(array $params): bool|int {
$to_name = $params["to_name"] ?? "";
$to_address = $params["to_address"];
$subject = $params["subject"];
$message = $params["message"];
// $message_html = $params["message_html"] ?? "";
$from_name = $params["from_name"] ?? Config::get(Config::SMTP_FROM_NAME);
$from_address = $params["from_address"] ?? Config::get(Config::SMTP_FROM_ADDRESS);
$additional_headers = $params["headers"] ?? [];
$from_combined = $from_name ? "$from_name <$from_address>" : $from_address;
$to_combined = $to_name ? "$to_name <$to_address>" : $to_address;
if (Config::get(Config::LOG_SENT_MAIL))
Logger::log(E_USER_NOTICE, "Sending mail from $from_combined to $to_combined [$subject]: $message");
// HOOK_SEND_MAIL plugin instructions:
// 1. return 1 or true if mail is handled
// 2. return -1 if there's been a fatal error and no further action is allowed
// 3. any other return value will allow cycling to the next handler and, eventually, to default mail() function
// 4. set error message if needed via passed Mailer instance function set_error()
$hooks_tried = 0;
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_SEND_MAIL) as $p) {
$rc = $p->hook_send_mail($this, $params);
if ($rc == 1)
return $rc;
if ($rc == -1)
return 0;
++$hooks_tried;
}
$headers = [ "From: $from_combined", "Content-Type: text/plain; charset=UTF-8" ];
$rc = mail($to_combined, $subject, $message, implode("\r\n", [...$headers, ...$additional_headers]));
if (!$rc) {
$this->set_error(error_get_last()['message'] ?? T_sprintf("Unknown error while sending mail. Hooks tried: %d.", $hooks_tried));
}
return $rc;
}
function set_error(string $message): void {
$this->last_error = $message;
user_error("Error sending mail: $message", E_USER_WARNING);
}
function error(): string {
return $this->last_error;
}
}

View File

@@ -1,657 +0,0 @@
<?php
class OPML extends Handler_Protected {
function csrf_ignore(string $method): bool {
$csrf_ignored = array("export", "import");
return array_search($method, $csrf_ignored) !== false;
}
/**
* @return bool|int|null false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or null if $owner_uid is missing
*/
function export(): bool|int|null {
$output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d"));
$include_settings = $_REQUEST["include_settings"] == "1";
$owner_uid = $_SESSION["uid"];
$rc = $this->opml_export($output_name, $owner_uid, false, $include_settings);
return $rc;
}
// Export
private function opml_export_category(int $owner_uid, int $cat_id, bool $hide_private_feeds = false, bool $include_settings = true): string {
if ($hide_private_feeds)
$hide_qpart = "(private IS false AND auth_login = '' AND auth_pass = '')";
else
$hide_qpart = "true";
$out = "";
$ttrss_specific_qpart = "";
if ($cat_id) {
$sth = $this->pdo->prepare("SELECT title,order_id
FROM ttrss_feed_categories WHERE id = ?
AND owner_uid = ?");
$sth->execute([$cat_id, $owner_uid]);
$row = $sth->fetch();
$cat_title = htmlspecialchars($row['title']);
if ($include_settings) {
$order_id = (int)$row["order_id"];
$ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\"";
}
} else {
$cat_title = "";
}
if ($cat_title) $out .= "<outline text=\"$cat_title\" $ttrss_specific_qpart>\n";
$sth = $this->pdo->prepare("SELECT id,title
FROM ttrss_feed_categories WHERE
(parent_cat = :cat OR (:cat = 0 AND parent_cat IS NULL)) AND
owner_uid = :uid ORDER BY order_id, title");
$sth->execute([':cat' => $cat_id, ':uid' => $owner_uid]);
while ($line = $sth->fetch()) {
$out .= $this->opml_export_category($owner_uid, $line["id"], $hide_private_feeds, $include_settings);
}
$fsth = $this->pdo->prepare("select title, feed_url, site_url, update_interval, order_id, purge_interval
FROM ttrss_feeds WHERE
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND owner_uid = :uid AND $hide_qpart
ORDER BY order_id, title");
$fsth->execute([':cat' => $cat_id, ':uid' => $owner_uid]);
while ($fline = $fsth->fetch()) {
$title = htmlspecialchars($fline["title"]);
$url = htmlspecialchars($fline["feed_url"]);
$site_url = htmlspecialchars($fline["site_url"]);
if ($include_settings) {
$update_interval = (int)$fline["update_interval"];
$order_id = (int)$fline["order_id"];
$purge_interval = (int)$fline["purge_interval"];
$ttrss_specific_qpart = "ttrssSortOrder=\"$order_id\" ttrssPurgeInterval=\"$purge_interval\" ttrssUpdateInterval=\"$update_interval\"";
} else {
$ttrss_specific_qpart = "";
}
if ($site_url) {
$html_url_qpart = "htmlUrl=\"$site_url\"";
} else {
$html_url_qpart = "";
}
$out .= "<outline type=\"rss\" text=\"$title\" xmlUrl=\"$url\" $ttrss_specific_qpart $html_url_qpart/>\n";
}
if ($cat_title) $out .= "</outline>\n";
return $out;
}
/**
* @return bool|int|null false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or null if $owner_uid is missing
*/
function opml_export(string $filename, int $owner_uid, bool $hide_private_feeds = false, bool $include_settings = true, bool $file_output = false): bool|int|null {
if (!$owner_uid) return null;
if (!$file_output)
if (!isset($_REQUEST["debug"])) {
header("Content-type: application/xml+opml");
header("Content-Disposition: attachment; filename=$filename");
} else {
header("Content-type: text/xml");
}
$out = "<?xml version=\"1.0\" encoding=\"utf-8\"?".">";
$out .= "<opml version=\"1.0\">";
$out .= "<head>
<dateCreated>" . date("r", time()) . "</dateCreated>
<title>Tiny Tiny RSS Feed Export</title>
</head>";
$out .= "<body>";
$out .= $this->opml_export_category($owner_uid, 0, $hide_private_feeds, $include_settings);
# export tt-rss settings
if ($include_settings) {
$out .= "<outline text=\"tt-rss-prefs\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2 WHERE
profile IS NULL AND owner_uid = ? ORDER BY pref_name");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch()) {
$name = $line["pref_name"];
$value = htmlspecialchars($line["value"]);
$out .= "<outline pref-name=\"$name\" value=\"$value\"/>";
}
$out .= "</outline>";
$out .= "<outline text=\"tt-rss-labels\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT * FROM ttrss_labels2 WHERE
owner_uid = ?");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch()) {
$name = htmlspecialchars($line['caption']);
$fg_color = htmlspecialchars($line['fg_color']);
$bg_color = htmlspecialchars($line['bg_color']);
$out .= "<outline label-name=\"$name\" label-fg-color=\"$fg_color\" label-bg-color=\"$bg_color\"/>";
}
$out .= "</outline>";
$out .= "<outline text=\"tt-rss-filters\" schema-version=\"".Config::SCHEMA_VERSION."\">";
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2
WHERE owner_uid = ? ORDER BY id");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch(PDO::FETCH_ASSOC)) {
$line["rules"] = array();
$line["actions"] = array();
$tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_rules
WHERE filter_id = ?");
$tmph->execute([$line['id']]);
while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) {
unset($tmp_line["id"]);
unset($tmp_line["filter_id"]);
$cat_filter = $tmp_line["cat_filter"];
if (!$tmp_line["match_on"]) {
if ($cat_filter && $tmp_line["cat_id"] || $tmp_line["feed_id"]) {
$tmp_line["feed"] = Feeds::_get_title(
$cat_filter ? $tmp_line["cat_id"] : $tmp_line["feed_id"],
$owner_uid,
$cat_filter);
} else {
$tmp_line["feed"] = "";
}
} else {
$match = [];
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
if (str_starts_with($feed_id, "CAT:")) {
$feed_id = (int)substr($feed_id, 4);
if ($feed_id) {
array_push($match, [Feeds::_get_cat_title($feed_id, $owner_uid), true, false]);
} else {
array_push($match, [0, true, true]);
}
} else {
if ($feed_id) {
array_push($match, [Feeds::_get_title((int)$feed_id, $owner_uid), false, false]);
} else {
array_push($match, [0, false, true]);
}
}
}
$tmp_line["match"] = $match;
unset($tmp_line["match_on"]);
}
unset($tmp_line["feed_id"]);
unset($tmp_line["cat_id"]);
array_push($line["rules"], $tmp_line);
}
$tmph = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
WHERE filter_id = ?");
$tmph->execute([$line['id']]);
while ($tmp_line = $tmph->fetch(PDO::FETCH_ASSOC)) {
unset($tmp_line["id"]);
unset($tmp_line["filter_id"]);
array_push($line["actions"], $tmp_line);
}
unset($line["id"]);
unset($line["owner_uid"]);
$filter = json_encode($line);
$out .= "<outline filter-type=\"2\"><![CDATA[$filter]]></outline>";
}
$out .= "</outline>";
}
$out .= "</body></opml>";
// Format output.
$doc = new DOMDocument();
$doc->formatOutput = true;
$doc->preserveWhiteSpace = false;
$doc->loadXML($out);
$xpath = new DOMXPath($doc);
$outlines = $xpath->query("//outline[@title]");
// cleanup empty categories
foreach ($outlines as $node) {
if ($node->getElementsByTagName('outline')->length == 0)
$node->parentNode->removeChild($node);
}
$res = $doc->saveXML();
/* // saveXML uses a two-space indent. Change to tabs.
$res = preg_replace_callback('/^(?: )+/mu',
create_function(
'$matches',
'return str_repeat("\t", intval(strlen($matches[0])/2));'),
$res); */
if ($file_output)
return file_put_contents($filename, $res) > 0;
print $res;
return true;
}
// Import
private function opml_import_feed(DOMNode $node, int $cat_id, int $owner_uid, int $nest): void {
$attrs = $node->attributes;
$feed_title = mb_substr($attrs->getNamedItem('text')->nodeValue, 0, 250);
if (!$feed_title) $feed_title = mb_substr($attrs->getNamedItem('title')->nodeValue, 0, 250);
$feed_url = $attrs->getNamedItem('xmlUrl')->nodeValue;
if (!$feed_url) $feed_url = $attrs->getNamedItem('xmlURL')->nodeValue;
$site_url = mb_substr($attrs->getNamedItem('htmlUrl')->nodeValue, 0, 250);
if ($feed_url) {
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
feed_url = ? AND owner_uid = ?");
$sth->execute([$feed_url, $owner_uid]);
if (!$feed_title) $feed_title = '[Unknown]';
if (!$sth->fetch()) {
#$this->opml_notice("[FEED] [$feed_title/$feed_url] dst_CAT=$cat_id");
$this->opml_notice(T_sprintf("Adding feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
if (!$cat_id) $cat_id = null;
$update_interval = (int) $attrs->getNamedItem('ttrssUpdateInterval')->nodeValue;
if (!$update_interval) $update_interval = 0;
$order_id = (int) $attrs->getNamedItem('ttrssSortOrder')->nodeValue;
if (!$order_id) $order_id = 0;
$purge_interval = (int) $attrs->getNamedItem('ttrssPurgeInterval')->nodeValue;
if (!$purge_interval) $purge_interval = 0;
$sth = $this->pdo->prepare("INSERT INTO ttrss_feeds
(title, feed_url, owner_uid, cat_id, site_url, order_id, update_interval, purge_interval) VALUES
(?, ?, ?, ?, ?, ?, ?, ?)");
$sth->execute([$feed_title, $feed_url, $owner_uid, $cat_id, $site_url, $order_id, $update_interval, $purge_interval]);
} else {
$this->opml_notice(T_sprintf("Duplicate feed: %s", $feed_title == '[Unknown]' ? $feed_url : $feed_title), $nest);
}
}
}
private function opml_import_label(DOMNode $node, int $owner_uid, int $nest): void {
$attrs = $node->attributes;
$label_name = $attrs->getNamedItem('label-name')->nodeValue;
if ($label_name) {
$fg_color = $attrs->getNamedItem('label-fg-color')->nodeValue;
$bg_color = $attrs->getNamedItem('label-bg-color')->nodeValue;
if (!Labels::find_id($label_name, $owner_uid)) {
$this->opml_notice(T_sprintf("Adding label %s", htmlspecialchars($label_name)), $nest);
Labels::create($label_name, $fg_color, $bg_color, $owner_uid);
} else {
$this->opml_notice(T_sprintf("Duplicate label: %s", htmlspecialchars($label_name)), $nest);
}
}
}
/**
* @todo support passing in $profile so 'update.php --opml-import' can import prefs to a user profile
*/
private function opml_import_preference(DOMNode $node, int $owner_uid, int $nest): void {
$attrs = $node->attributes;
$pref_name = $attrs->getNamedItem('pref-name')->nodeValue;
if ($pref_name) {
$pref_value = $attrs->getNamedItem('value')->nodeValue;
$this->opml_notice(T_sprintf("Setting preference key %s to %s",
$pref_name, $pref_value), $nest);
Prefs::set($pref_name, $pref_value, $owner_uid, $_SESSION['profile'] ?? null);
}
}
private function opml_import_filter(DOMNode $node, int $owner_uid, int $nest): void {
$attrs = $node->attributes;
$filter_type = $attrs->getNamedItem('filter-type')->nodeValue;
if ($filter_type == '2') {
$filter = json_decode($node->nodeValue, true);
if ($filter) {
$match_any_rule = bool_to_sql_bool($filter["match_any_rule"]);
$enabled = bool_to_sql_bool($filter["enabled"]);
$inverse = bool_to_sql_bool($filter["inverse"]);
$title = $filter["title"];
//print "F: $title, $inverse, $enabled, $match_any_rule";
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2 (match_any_rule,enabled,inverse,title,owner_uid)
VALUES (?, ?, ?, ?, ?) RETURNING id");
$sth->execute([$match_any_rule, $enabled, $inverse, $title, $owner_uid]);
$row = $sth->fetch();
$filter_id = $row['id'];
if ($filter_id) {
$this->opml_notice(T_sprintf("Adding filter %s...", $title), $nest);
//$this->opml_notice(json_encode($filter));
foreach ($filter["rules"] as $rule) {
$feed_id = null;
$cat_id = null;
if ($rule["match"] ?? false) {
$match_on = [];
foreach ($rule["match"] as $match) {
list ($name, $is_cat, $is_id) = $match;
if ($is_id) {
array_push($match_on, ($is_cat ? "CAT:" : "") . $name);
} else {
$match_id = Feeds::_find_by_title($name, $is_cat, $owner_uid);
if ($match_id) {
if ($is_cat) {
array_push($match_on, "CAT:$match_id");
} else {
array_push($match_on, $match_id);
}
}
/*if (!$is_cat) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$name, $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$match_id = $row['id'];
array_push($match_on, $match_id);
}
} else {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$name, $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$match_id = $row['id'];
array_push($match_on, "CAT:$match_id");
}
} */
}
}
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$match_on = json_encode($match_on);
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
(feed_id,cat_id,match_on,filter_id,filter_type,reg_exp,cat_filter,inverse)
VALUES
(NULL, NULL, ?, ?, ?, ?, false, ?)");
$usth->execute([$match_on, $filter_id, $filter_type, $reg_exp, $inverse]);
} else {
$match_id = Feeds::_find_by_title($rule['feed'] ?? "", $rule['cat_filter'], $owner_uid);
if ($match_id) {
if ($rule['cat_filter']) {
$cat_id = $match_id;
} else {
$feed_id = $match_id;
}
}
/*if (!$rule["cat_filter"]) {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feeds
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
} else {
$tsth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = ? AND owner_uid = ?");
$tsth->execute([$rule['feed'], $_SESSION['uid']]);
if ($row = $tsth->fetch()) {
$feed_id = $row['id'];
}
} */
$cat_filter = bool_to_sql_bool($rule["cat_filter"]);
$reg_exp = $rule["reg_exp"];
$filter_type = (int)$rule["filter_type"];
$inverse = bool_to_sql_bool($rule["inverse"]);
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_rules
(feed_id,cat_id,filter_id,filter_type,reg_exp,cat_filter,inverse)
VALUES
(?, ?, ?, ?, ?, ?, ?)");
$usth->execute([$feed_id, $cat_id, $filter_id, $filter_type, $reg_exp, $cat_filter, $inverse]);
}
}
foreach ($filter["actions"] as $action) {
$action_id = (int)$action["action_id"];
$action_param = $action["action_param"];
$usth = $this->pdo->prepare("INSERT INTO ttrss_filters2_actions
(filter_id,action_id,action_param)
VALUES
(?, ?, ?)");
$usth->execute([$filter_id, $action_id, $action_param]);
}
}
}
}
}
private function opml_import_category(DOMDocument $doc, ?DOMNode $root_node, int $owner_uid, int $parent_id, int $nest): void {
$default_cat_id = (int) $this->get_feed_category('Imported feeds', $owner_uid, 0);
if ($root_node) {
$cat_title = mb_substr($root_node->attributes->getNamedItem('text')->nodeValue, 0, 250);
if (!$cat_title)
$cat_title = mb_substr($root_node->attributes->getNamedItem('title')->nodeValue, 0, 250);
if (!in_array($cat_title, array("tt-rss-filters", "tt-rss-labels", "tt-rss-prefs"))) {
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
if ($cat_id === 0) {
$order_id = (int) $root_node->attributes->getNamedItem('ttrssSortOrder')->nodeValue;
Feeds::_add_cat($cat_title, $owner_uid, $parent_id ? $parent_id : null, (int)$order_id);
$cat_id = $this->get_feed_category($cat_title, $owner_uid, $parent_id);
}
} else {
$cat_id = 0;
}
$outlines = $root_node->childNodes;
} else {
$xpath = new DOMXPath($doc);
$outlines = $xpath->query("//opml/body/outline");
$cat_id = 0;
$cat_title = false;
}
//$this->opml_notice("[CAT] $cat_title id: $cat_id P_id: $parent_id");
$this->opml_notice(T_sprintf("Processing category: %s", $cat_title ? $cat_title : __("Uncategorized")), $nest);
foreach ($outlines as $node) {
if ($node->hasAttributes() && strtolower($node->tagName) == "outline") {
$attrs = $node->attributes;
$node_cat_title = $attrs->getNamedItem('text') ? $attrs->getNamedItem('text')->nodeValue : false;
if (!$node_cat_title)
$node_cat_title = $attrs->getNamedItem('title') ? $attrs->getNamedItem('title')->nodeValue : false;
$node_feed_url = $attrs->getNamedItem('xmlUrl') ? $attrs->getNamedItem('xmlUrl')->nodeValue : false;
if ($node_cat_title && !$node_feed_url) {
$this->opml_import_category($doc, $node, $owner_uid, $cat_id, $nest+1);
} else {
if (!$cat_id) {
$dst_cat_id = $default_cat_id;
} else {
$dst_cat_id = $cat_id;
}
match ($cat_title) {
'tt-rss-prefs' => $this->opml_import_preference($node, $owner_uid, $nest+1),
'tt-rss-labels' => $this->opml_import_label($node, $owner_uid, $nest+1),
'tt-rss-filters' => $this->opml_import_filter($node, $owner_uid, $nest+1),
default => $this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1),
};
}
}
}
}
/** $filename is optional; assumes HTTP upload with $_FILES otherwise */
/**
* @return bool|null false on failure, true if successful, null if $owner_uid is missing
*/
function opml_import(int $owner_uid, string $filename = ""): ?bool {
if (!$owner_uid) return null;
if (!$filename) {
if ($_FILES['opml_file']['error'] != 0) {
print_error(T_sprintf("Upload failed with error code %d",
$_FILES['opml_file']['error']));
return false;
}
if (is_uploaded_file($_FILES['opml_file']['tmp_name'])) {
$tmp_file = (string)tempnam(Config::get(Config::CACHE_DIR) . '/upload', 'opml');
$result = move_uploaded_file($_FILES['opml_file']['tmp_name'],
$tmp_file);
if (!$result) {
print_error(__("Unable to move uploaded file."));
return false;
}
} else {
print_error(__('Error: please upload OPML file.'));
return false;
}
} else {
$tmp_file = $filename;
}
if (!is_readable($tmp_file)) {
$this->opml_notice(T_sprintf("Error: file is not readable: %s", $filename));
return false;
}
$doc = new DOMDocument();
$loaded = $doc->load($tmp_file);
// only remove temporary i.e. HTTP uploaded files
if (!$filename)
unlink($tmp_file);
if ($loaded) {
// we're using ORM while importing so we can't transaction-lock things anymore
//$this->pdo->beginTransaction();
$this->opml_import_category($doc, null, $owner_uid, 0, 0);
//$this->pdo->commit();
} else {
$this->opml_notice(__('Error while parsing document.'));
return false;
}
return true;
}
private function opml_notice(string $msg, int $prefix_length = 0): void {
if (php_sapi_name() == "cli") {
Debug::log(str_repeat(" ", $prefix_length) . $msg);
} else {
// TODO: use better separator i.e. CSS-defined span of certain width or something
print str_repeat("&nbsp;&nbsp;&nbsp;", $prefix_length) . $msg . "<br/>";
}
}
function get_feed_category(string $feed_cat, int $owner_uid, int $parent_cat_id) : int {
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feed_categories
WHERE title = :title
AND (parent_cat = :parent OR (:parent = 0 AND parent_cat IS NULL))
AND owner_uid = :uid");
$sth->execute([':title' => $feed_cat, ':parent' => $parent_cat_id, ':uid' => $owner_uid]);
if ($row = $sth->fetch()) {
return $row['id'];
} else {
return 0;
}
}
}

View File

@@ -1,738 +0,0 @@
<?php
abstract class Plugin {
const API_VERSION_COMPAT = 1;
/** @var PDO $pdo */
protected $pdo;
/**
* @param PluginHost $host
*
* @return void
* */
abstract function init($host);
/** @return array<null|float|string|bool> */
abstract function about();
// return array(1.0, "plugin", "No description", "No author", false);
function __construct() {
$this->pdo = Db::pdo();
}
/** @return array<string,bool> */
function flags() {
/* associative array, possible keys:
needs_curl = boolean
*/
return array();
}
/**
* @param string $method
*
* @return bool */
function is_public_method($method) {
return false;
}
/**
* @param string $method
*
* @return bool */
function csrf_ignore($method) {
return false;
}
/** @return string */
function get_js() {
return "";
}
/** @return string */
function get_login_js() {
return "";
}
/** @return string */
function get_css() {
return "";
}
/** @return string */
function get_prefs_js() {
return "";
}
/** @return int */
function api_version() {
return Plugin::API_VERSION_COMPAT;
}
/* gettext-related helpers */
/**
* @param string $msgid
*
* @return string */
function __($msgid) {
// this is a strictly template-related hack
return _dgettext(PluginHost::object_to_domain($this), $msgid);
}
/**
* @param string $singular
* @param string $plural
* @param int $number
*
* @return string */
function _ngettext($singular, $plural, $number) {
// this is a strictly template-related hack
return _dngettext(PluginHost::object_to_domain($this), $singular, $plural, $number);
}
/** @return string */
function T_sprintf() {
$args = func_get_args();
$msgid = array_shift($args);
return vsprintf($this->__($msgid), $args);
}
/* plugin hook methods */
/* GLOBAL hooks are invoked in global context, only available to system plugins (loaded via .env for all users) */
/** Adds buttons for article (on the right) - e.g. mail, share, add note. Generated markup must be valid XML.
* @param array<string,mixed> $line
* @return string
* @see PluginHost::HOOK_ARTICLE_BUTTON
* @see Plugin::hook_article_left_button()
*/
function hook_article_button($line) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Allows plugins to alter article data as gathered from feed XML, i.e. embed images, get full text content, etc.
* @param array<string,mixed> $article
* @return array<string,mixed>
* @see PluginHost::HOOK_ARTICLE_FILTER
*/
function hook_article_filter($article) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Allow adding new UI elements (e.g. accordion panes) to (top) tab contents in Preferences
* @param string $tab
* @return void
* @see PluginHost::HOOK_PREFS_TAB
*/
function hook_prefs_tab($tab) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Allow adding new content to various sections of preferences UI (i.e. OPML import/export pane)
* @param string $section
* @return void
* @see PluginHost::HOOK_PREFS_TAB_SECTION
*/
function hook_prefs_tab_section($section) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Allows adding new (top) tabs in preferences UI
* @return void
* @see PluginHost::HOOK_PREFS_TABS
*/
function hook_prefs_tabs() {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Invoked when feed XML is processed by FeedParser class
* @param FeedParser $parser
* @param int $feed_id
* @return void
* @see PluginHost::HOOK_FEED_PARSED
*/
function hook_feed_parsed($parser, $feed_id) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** GLOBAL: Invoked when a feed update task finishes
* @param array<string,string> $cli_options
* @return void
* @see PluginHost::HOOK_UPDATE_TASK
*/
function hook_update_task($cli_options) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** This is a pluginhost compatibility wrapper that invokes $this->authenticate(...$args) (Auth_Base)
* @param string $login
* @param string $password
* @param string $service
* @return int|false user_id
* @see PluginHost::HOOK_AUTH_USER
*/
function hook_auth_user($login, $password, $service = '') {
user_error("Dummy method invoked.", E_USER_ERROR);
return false;
}
/** IAuthModule only
* @param string $login
* @param string $password
* @param string $service
* @return int|false user_id
*/
function authenticate($login, $password, $service = '') {
user_error("Dummy method invoked.", E_USER_ERROR);
return false;
}
/** Allows plugins to modify global hotkey map (hotkey sequence -> action)
* @param array<string, string> $hotkeys
* @return array<string, string>
* @see PluginHost::HOOK_HOTKEY_MAP
* @see Plugin::hook_hotkey_info()
*/
function hook_hotkey_map($hotkeys) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - three panel mode
* @param array<string, mixed> $article
* @return array<string, mixed>
* @see PluginHost::HOOK_RENDER_ARTICLE
*/
function hook_render_article($article) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - combined mode
* @param array<string, mixed> $article
* @return array<string, mixed>
* @see PluginHost::HOOK_RENDER_ARTICLE_CDM
*/
function hook_render_article_cdm($article) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Invoked when raw feed XML data has been successfully downloaded (but not parsed yet)
* @param string $feed_data
* @param string $fetch_url
* @param int $owner_uid
* @param int $feed
* @return string
* @see PluginHost::HOOK_FEED_FETCHED
*/
function hook_feed_fetched($feed_data, $fetch_url, $owner_uid, $feed) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Invoked on article content when it is sanitized (i.e. potentially harmful tags removed)
* @param DOMDocument $doc
* @param string $site_url
* @param array<string> $allowed_elements
* @param array<string> $disallowed_attributes
* @param int $article_id
* @return DOMDocument|array<int,DOMDocument|array<string>>
* @see PluginHost::HOOK_SANITIZE
*/
function hook_sanitize($doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id) {
user_error("Dummy method invoked.", E_USER_ERROR);
return $doc;
}
/** Invoked when article is rendered by backend (before it gets passed to frontent JS code) - exclusive to API clients
* @param array{'article': array<string,mixed>|null, 'headline': array<string,mixed>|null} $params
* @return array<string, string>
* @see PluginHost::HOOK_RENDER_ARTICLE_API
*/
function hook_render_article_api($params) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Allows adding new UI elements to tt-rss main toolbar (to the right, before Actions... dropdown)
* @return string
* @see PluginHost::HOOK_TOOLBAR_BUTTON
*/
function hook_toolbar_button() {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Allows adding new items to tt-rss main Actions... dropdown menu
* @return string
* @see PluginHost::HOOK_ACTION_ITEM
*/
function hook_action_item() {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Allows adding new UI elements to the toolbar area related to currently loaded feed headlines
* @param int $feed_id
* @param bool $is_cat
* @return string
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_BUTTON
*/
function hook_headline_toolbar_button($feed_id, $is_cat) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Allows adding new hotkey action names and descriptions
* @param array<string, array<string, string>> $hotkeys
* @return array<string, array<string, string>>
* @see PluginHost::HOOK_HOTKEY_INFO
* @see Plugin::hook_hotkey_map()
*/
function hook_hotkey_info($hotkeys) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Adds per-article buttons on the left side. Generated markup must be valid XML.
* @param array<string,mixed> $row
* @return string
* @see PluginHost::HOOK_ARTICLE_LEFT_BUTTON
* @see Plugin::hook_article_button()
*/
function hook_article_left_button($row) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Allows adding new UI elements to the "Plugins" tab of the feed editor UI
* @param int $feed_id
* @return void
* @see PluginHost::HOOK_PREFS_EDIT_FEED
*/
function hook_prefs_edit_feed($feed_id) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Invoked when data is saved in the feed editor
* @param int $feed_id
* @return void
* @see PluginHost::HOOK_PREFS_SAVE_FEED
*/
function hook_prefs_save_feed($feed_id) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Allows overriding built-in fetching mechanism for feeds, substituting received data if necessary
* (i.e. origin site doesn't actually provide any RSS feeds), or XML is invalid
* @param string $feed_data
* @param string $fetch_url
* @param int $owner_uid
* @param int $feed
* @param int $last_article_timestamp
* @param string $auth_login
* @param string $auth_pass
* @return string (possibly mangled feed data)
* @see PluginHost::HOOK_FETCH_FEED
*/
function hook_fetch_feed($feed_data, $fetch_url, $owner_uid, $feed, $last_article_timestamp, $auth_login, $auth_pass) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Invoked when headlines data ($row) has been retrieved from the database
* @param array<string,mixed> $row
* @param int $excerpt_length
* @return array<string,mixed>
* @see PluginHost::HOOK_QUERY_HEADLINES
*/
function hook_query_headlines($row, $excerpt_length) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** This is run periodically by the update daemon when idle (available both to user and system plugins)
* @return void
* @see PluginHost::HOOK_HOUSE_KEEPING */
function hook_house_keeping() {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Allows overriding built-in article search
* @param string $query
* @return array<int, string|array<string>> - list(SQL search query, highlight keywords)
* @see PluginHost::HOOK_SEARCH
*/
function hook_search($query) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Invoked when enclosures are rendered to HTML (when article itself is rendered)
* @param string $enclosures_formatted
* @param array<int, array<string, mixed>> $enclosures
* @param int $article_id
* @param bool $always_display_enclosures
* @param string $article_content
* @param bool $hide_images
* @return string|array<string,array<int, array<string, mixed>>> ($enclosures_formatted, $enclosures)
* @see PluginHost::HOOK_FORMAT_ENCLOSURES
*/
function hook_format_enclosures($enclosures_formatted, $enclosures, $article_id, $always_display_enclosures, $article_content, $hide_images) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Invoked during feed subscription (after data has been fetched)
* @param string $contents
* @param string $url
* @param string $auth_login
* @param string $auth_pass
* @return string (possibly mangled feed data)
* @see PluginHost::HOOK_SUBSCRIBE_FEED
*/
function hook_subscribe_feed($contents, $url, $auth_login, $auth_pass) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/**
* @param int $feed
* @param bool $is_cat
* @param array<string,mixed> $qfh_ret (headlines object)
* @return string
* @see PluginHost::HOOK_HEADLINES_BEFORE
*/
function hook_headlines_before($feed, $is_cat, $qfh_ret) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/**
* @param array<string,mixed> $entry
* @param int $article_id
* @param array<string,mixed> $rv
* @return string
* @see PluginHost::HOOK_RENDER_ENCLOSURE
*/
function hook_render_enclosure($entry, $article_id, $rv) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/**
* @param array<string,mixed> $article
* @param string $action
* @return array<string,mixed> ($article)
* @see PluginHost::HOOK_ARTICLE_FILTER_ACTION
*/
function hook_article_filter_action($article, $action) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/**
* @param array<string,mixed> $line
* @param int $feed
* @param bool $is_cat
* @param int $owner_uid
* @return array<string,mixed> ($line)
* @see PluginHost::HOOK_ARTICLE_EXPORT_FEED
*/
function hook_article_export_feed($line, $feed, $is_cat, $owner_uid) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Allows adding custom buttons to tt-rss main toolbar (left side)
* @return void
* @see PluginHost::HOOK_MAIN_TOOLBAR_BUTTON
*/
function hook_main_toolbar_button() {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Invoked for every enclosure entry as article is being rendered
* @param array<string,string> $entry
* @param int $id article id
* @param array{'formatted': string, 'entries': array<int, array<string, mixed>>} $rv
* @return array<string,string> ($entry)
* @see PluginHost::HOOK_ENCLOSURE_ENTRY
*/
function hook_enclosure_entry($entry, $id, $rv) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [];
}
/** Share plugins run this when article is being rendered as HTML for sharing
* @param string $html
* @param array<string,mixed> $row
* @return string ($html)
* @see PluginHost::HOOK_FORMAT_ARTICLE
*/
function hook_format_article($html, $row) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Invoked when basic feed information (title, site_url) is being collected, useful to override default if feed doesn't provide anything (or feed itself is synthesized)
* @param array{"title": string, "site_url": string} $basic_info
* @param string $fetch_url
* @param int $owner_uid
* @param int $feed_id
* @param string $auth_login
* @param string $auth_pass
* @return array{"title": string, "site_url": string}
* @see PluginHost::HOOK_FEED_BASIC_INFO
*/
function hook_feed_basic_info($basic_info, $fetch_url, $owner_uid, $feed_id, $auth_login, $auth_pass) {
user_error("Dummy method invoked.", E_USER_ERROR);
return $basic_info;
}
/** Invoked when file (e.g. cache entry, static data) is being sent to client, may override default mechanism
* using faster httpd-specific implementation (see nginx_xaccel)
* @param string $filename
* @return bool
* @see PluginHost::HOOK_SEND_LOCAL_FILE
*/
function hook_send_local_file($filename) {
user_error("Dummy method invoked.", E_USER_ERROR);
return false;
}
/** Invoked when user tries to unsubscribe from a feed, returning true would prevent any further default actions
* @param int $feed_id
* @param int $owner_uid
* @return bool
* @see PluginHost::HOOK_UNSUBSCRIBE_FEED
*/
function hook_unsubscribe_feed($feed_id, $owner_uid) {
user_error("Dummy method invoked.", E_USER_ERROR);
return false;
}
/** Invoked when mail is being sent (if no hooks are registered, tt-rss uses PHP mail() as a fallback)
* @param Mailer $mailer
* @param array<string,mixed> $params
* @return int
* @see PluginHost::HOOK_SEND_MAIL
*/
function hook_send_mail($mailer, $params) {
user_error("Dummy method invoked.", E_USER_ERROR);
return -1;
}
/**
* Invoked when filtering is triggered on an article. May be used to implement logging for filters, etc.
* @param int $feed_id
* @param int $owner_uid
* @param array<string,mixed> $article
* @param array<string,mixed> $matched_filters
* @param array<string,string|bool|int> $matched_rules
* @param array<int, array{'type': string, 'param': string}> $article_filter_actions An array of filter actions from matched filters
* @return void
* @see PluginHost::HOOK_FILTER_TRIGGERED
*/
function hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filter_actions) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Plugins may provide this to allow getting full article text (af_readbility implements this)
* @param string $url
* @return string|false
* @see PluginHost::HOOK_GET_FULL_TEXT
*/
function hook_get_full_text($url) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Invoked when article flavor image is being determined, allows overriding default selection logic
* @param array<string,string> $enclosures
* @param string $content
* @param string $site_url
* @param array<string,mixed> $article
* @return string|array<int,string>
* @see PluginHost::HOOK_ARTICLE_IMAGE
*/
function hook_article_image($enclosures, $content, $site_url, $article) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Allows adding arbitrary elements before feed tree
* @return string HTML
* @see PluginHost::HOOK_FEED_TREE
* */
function hook_feed_tree() {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Invoked for every iframe to determine if it is allowed to be displayed
* @param string $url
* @return bool
* @see PluginHost::HOOK_IFRAME_WHITELISTED
*/
function hook_iframe_whitelisted($url) {
user_error("Dummy method invoked.", E_USER_ERROR);
return false;
}
/**
* @param object $enclosure
* @param int $feed
* @return object ($enclosure)
* @see PluginHost::HOOK_ENCLOSURE_IMPORTED
*/
function hook_enclosure_imported($enclosure, $feed) {
user_error("Dummy method invoked.", E_USER_ERROR);
return $enclosure;
}
/** Allows adding custom elements to headline sort dropdown (name -> caption)
* @return array<string,string>
* @see PluginHost::HOOK_HEADLINES_CUSTOM_SORT_MAP
*/
function hook_headlines_custom_sort_map() {
user_error("Dummy method invoked.", E_USER_ERROR);
return ["" => ""];
}
/** Allows overriding headline sorting (or provide custom sort methods)
* @param string $order
* @return array<int, string|bool> -- (query, skip_first_id)
* @see PluginHost::HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE
*/
function hook_headlines_custom_sort_override($order) {
user_error("Dummy method invoked.", E_USER_ERROR);
return ["", false];
}
/** Allows adding custom elements to headlines Select... dropdown
* @deprecated removed, see Plugin::hook_headline_toolbar_select_menu_item2()
* @param int $feed_id
* @param int $is_cat
* @return string
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM
*/
function hook_headline_toolbar_select_menu_item($feed_id, $is_cat) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Allows adding custom elements to headlines Select... select dropdown (<option> format)
* @param int $feed_id
* @param int $is_cat
* @return string
* @see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2
*/
function hook_headline_toolbar_select_menu_item2($feed_id, $is_cat) {
user_error("Dummy method invoked.", E_USER_ERROR);
return "";
}
/** Invoked when user tries to subscribe to feed, may override information (i.e. feed URL) used afterwards
* @param string $url
* @param string $auth_login
* @param string $auth_pass
* @return bool
* @see PluginHost::HOOK_PRE_SUBSCRIBE
*/
function hook_pre_subscribe(&$url, $auth_login, $auth_pass) {
user_error("Dummy method invoked.", E_USER_ERROR);
return false;
}
/** Invoked after user logout, may override built-in behavior (redirect back to login page)
* @param string $login
* @param int $user_id
* @return array<mixed> - [0] - if set, url to redirect to
*/
function hook_post_logout($login, $user_id) {
user_error("Dummy method invoked.", E_USER_ERROR);
return [""];
}
/** Adds buttons to the right of default Login button
* @return void
*/
function hook_loginform_additional_buttons() {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Returns false if session is considered invalid, true cascades to next handler */
function hook_validate_session(): bool {
user_error("Dummy method invoked.", E_USER_ERROR);
return false;
}
/** Invoked after passed article IDs were either marked (i.e. starred) or unmarked.
*
* **Note** resulting state of the articles is not passed to this function (because
* tt-rss may do invert operation on ID range), you will need to get this from the database.
* @param array<int> $article_ids ref_ids
* @return void
*/
function hook_articles_mark_toggled(array $article_ids) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
/** Invoked after passed article IDs were either published or unpublished.
*
* **Note** resulting state of the articles is not passed to this function (because
* tt-rss may do invert operation on ID range), you will need to get this from the database.
*
* @param array<int> $article_ids ref_ids
* @return void
*/
function hook_articles_publish_toggled(array $article_ids) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
}

View File

@@ -1,29 +0,0 @@
<?php
class PluginHandler extends Handler_Protected {
function csrf_ignore(string $method): bool {
return true;
}
function catchall(string $method): void {
$plugin_name = clean($_REQUEST["plugin"]);
$plugin = PluginHost::getInstance()->get_plugin($plugin_name);
$csrf_token = ($_POST["csrf_token"] ?? "");
if ($plugin) {
if (method_exists($plugin, $method)) {
if (validate_csrf($csrf_token) || $plugin->csrf_ignore($method)) {
$plugin->$method();
} else {
user_error("Rejected {$plugin_name}->{$method}(): invalid CSRF token.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} else {
user_error("Rejected {$plugin_name}->{$method}(): unknown method.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNKNOWN_METHOD);
}
} else {
user_error("Rejected {$plugin_name}->{$method}(): unknown plugin.", E_USER_WARNING);
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN);
}
}
}

View File

@@ -1,943 +0,0 @@
<?php
class PluginHost {
private ?PDO $pdo = null;
/**
* separate handle for plugin data so transaction while saving wouldn't clash with possible main
* tt-rss code transactions; only initialized when first needed
*/
private ?PDO $pdo_data = null;
/** @var array<string, array<int, array<int, Plugin>>> hook types -> priority levels -> Plugins */
private array $hooks = [];
/** @var array<string, Plugin> */
private array $plugins = [];
/** @var array<string, array<string, Plugin>> handler type -> method type -> Plugin */
private array $handlers = [];
/** @var array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */
private array $commands = [];
/** @var array<string, array<string, mixed>> plugin name -> (potential profile array) -> key -> value */
private array $storage = [];
/** @var array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}> */
private array $special_feeds = [];
/** @var array<string, Plugin> API method name, Plugin sender */
private array $api_methods = [];
/** @var array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>> */
private array $plugin_actions = [];
private ?int $owner_uid = null;
private bool $data_loaded = false;
private static ?PluginHost $instance = null;
private ?Scheduler $scheduler = null;
const API_VERSION = 2;
const PUBLIC_METHOD_DELIMITER = "--";
/** @see Plugin::hook_article_button() */
const HOOK_ARTICLE_BUTTON = "hook_article_button";
/** @see Plugin::hook_article_filter() */
const HOOK_ARTICLE_FILTER = "hook_article_filter";
/** @see Plugin::hook_prefs_tab() */
const HOOK_PREFS_TAB = "hook_prefs_tab";
/** @see Plugin::hook_prefs_tab_section() */
const HOOK_PREFS_TAB_SECTION = "hook_prefs_tab_section";
/** @see Plugin::hook_prefs_tabs() */
const HOOK_PREFS_TABS = "hook_prefs_tabs";
/** @see Plugin::hook_feed_parsed() */
const HOOK_FEED_PARSED = "hook_feed_parsed";
/** @see Plugin::hook_update_task() */
const HOOK_UPDATE_TASK = "hook_update_task"; //*1
/** @see Plugin::hook_auth_user() */
const HOOK_AUTH_USER = "hook_auth_user";
/** @see Plugin::hook_hotkey_map() */
const HOOK_HOTKEY_MAP = "hook_hotkey_map";
/** @see Plugin::hook_render_article() */
const HOOK_RENDER_ARTICLE = "hook_render_article";
/** @see Plugin::hook_render_article_cdm() */
const HOOK_RENDER_ARTICLE_CDM = "hook_render_article_cdm";
/** @see Plugin::hook_feed_fetched() */
const HOOK_FEED_FETCHED = "hook_feed_fetched";
/** @see Plugin::hook_sanitize() */
const HOOK_SANITIZE = "hook_sanitize";
/** @see Plugin::hook_render_article_api() */
const HOOK_RENDER_ARTICLE_API = "hook_render_article_api";
/** @see Plugin::hook_toolbar_button() */
const HOOK_TOOLBAR_BUTTON = "hook_toolbar_button";
/** @see Plugin::hook_action_item() */
const HOOK_ACTION_ITEM = "hook_action_item";
/** @see Plugin::hook_headline_toolbar_button() */
const HOOK_HEADLINE_TOOLBAR_BUTTON = "hook_headline_toolbar_button";
/** @see Plugin::hook_hotkey_info() */
const HOOK_HOTKEY_INFO = "hook_hotkey_info";
/** @see Plugin::hook_article_left_button() */
const HOOK_ARTICLE_LEFT_BUTTON = "hook_article_left_button";
/** @see Plugin::hook_prefs_edit_feed() */
const HOOK_PREFS_EDIT_FEED = "hook_prefs_edit_feed";
/** @see Plugin::hook_prefs_save_feed() */
const HOOK_PREFS_SAVE_FEED = "hook_prefs_save_feed";
/** @see Plugin::hook_fetch_feed() */
const HOOK_FETCH_FEED = "hook_fetch_feed";
/** @see Plugin::hook_query_headlines() */
const HOOK_QUERY_HEADLINES = "hook_query_headlines";
/** @see Plugin::hook_house_keeping() */
const HOOK_HOUSE_KEEPING = "hook_house_keeping"; //*1
/** @see Plugin::hook_search() */
const HOOK_SEARCH = "hook_search";
/** @see Plugin::hook_format_enclosures() */
const HOOK_FORMAT_ENCLOSURES = "hook_format_enclosures";
/** @see Plugin::hook_subscribe_feed() */
const HOOK_SUBSCRIBE_FEED = "hook_subscribe_feed";
/** @see Plugin::hook_headlines_before() */
const HOOK_HEADLINES_BEFORE = "hook_headlines_before";
/** @see Plugin::hook_render_enclosure() */
const HOOK_RENDER_ENCLOSURE = "hook_render_enclosure";
/** @see Plugin::hook_article_filter_action() */
const HOOK_ARTICLE_FILTER_ACTION = "hook_article_filter_action";
/** @see Plugin::hook_article_export_feed() */
const HOOK_ARTICLE_EXPORT_FEED = "hook_article_export_feed";
/** @see Plugin::hook_main_toolbar_button() */
const HOOK_MAIN_TOOLBAR_BUTTON = "hook_main_toolbar_button";
/** @see Plugin::hook_enclosure_entry() */
const HOOK_ENCLOSURE_ENTRY = "hook_enclosure_entry";
/** @see Plugin::hook_format_article() */
const HOOK_FORMAT_ARTICLE = "hook_format_article";
/** @see Plugin::hook_feed_basic_info() */
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info";
/** @see Plugin::hook_send_local_file() */
const HOOK_SEND_LOCAL_FILE = "hook_send_local_file";
/** @see Plugin::hook_unsubscribe_feed() */
const HOOK_UNSUBSCRIBE_FEED = "hook_unsubscribe_feed";
/** @see Plugin::hook_send_mail() */
const HOOK_SEND_MAIL = "hook_send_mail";
/** @see Plugin::hook_filter_triggered() */
const HOOK_FILTER_TRIGGERED = "hook_filter_triggered";
/** @see Plugin::hook_get_full_text() */
const HOOK_GET_FULL_TEXT = "hook_get_full_text";
/** @see Plugin::hook_article_image() */
const HOOK_ARTICLE_IMAGE = "hook_article_image";
/** @see Plugin::hook_feed_tree() */
const HOOK_FEED_TREE = "hook_feed_tree";
/** @see Plugin::hook_iframe_whitelisted() */
const HOOK_IFRAME_WHITELISTED = "hook_iframe_whitelisted";
/** @see Plugin::hook_enclosure_imported() */
const HOOK_ENCLOSURE_IMPORTED = "hook_enclosure_imported";
/** @see Plugin::hook_headlines_custom_sort_map() */
const HOOK_HEADLINES_CUSTOM_SORT_MAP = "hook_headlines_custom_sort_map";
/** @see Plugin::hook_headlines_custom_sort_override() */
const HOOK_HEADLINES_CUSTOM_SORT_OVERRIDE = "hook_headlines_custom_sort_override";
/** @see Plugin::hook_headline_toolbar_select_menu_item()
* @deprecated removed, see PluginHost::HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2
*/
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM = "hook_headline_toolbar_select_menu_item";
/** @see Plugin::hook_headline_toolbar_select_menu_item() */
const HOOK_HEADLINE_TOOLBAR_SELECT_MENU_ITEM2 = "hook_headline_toolbar_select_menu_item2";
/** @see Plugin::hook_pre_subscribe() */
const HOOK_PRE_SUBSCRIBE = "hook_pre_subscribe";
/** @see Plugin::hook_post_logout() */
const HOOK_POST_LOGOUT = "hook_post_logout";
/** @see Plugin::hook_loginform_additional_buttons() */
const HOOK_LOGINFORM_ADDITIONAL_BUTTONS = "hook_loginform_additional_buttons";
/** @see Plugin::hook_validate_session() */
const HOOK_VALIDATE_SESSION = "hook_validate_session";
/** @see Plugin::hook_articles_mark_toggled() */
const HOOK_ARTICLES_MARK_TOGGLED = "hook_articles_mark_toggled";
/** @see Plugin::hook_articles_publish_toggled() */
const HOOK_ARTICLES_PUBLISH_TOGGLED = "hook_articles_publish_toggled";
const KIND_ALL = 1;
const KIND_SYSTEM = 2;
const KIND_USER = 3;
static function object_to_domain(Plugin $plugin): string {
return strtolower(get_class($plugin));
}
function __construct() {
$this->pdo = Db::pdo();
$this->scheduler = new Scheduler('PluginHost Scheduler');
$this->storage = [];
}
private function __clone() {
//
}
public static function getInstance(): PluginHost {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
private function register_plugin(string $name, Plugin $plugin): void {
//array_push($this->plugins, $plugin);
$this->plugins[$name] = $plugin;
}
/** needed for compatibility with API 1 */
function get_link(): bool {
return false;
}
/** needed for compatibility with API 2 (?) */
function get_dbh(): bool {
return false;
}
function get_pdo(): PDO {
return $this->pdo;
}
/**
* @return array<int, string>
*/
function get_plugin_names(): array {
$names = [];
foreach ($this->plugins as $p) {
array_push($names, get_class($p));
}
return $names;
}
/**
* @return array<Plugin>
*/
function get_plugins(): array {
return $this->plugins;
}
function get_plugin(string $name): ?Plugin {
return $this->plugins[strtolower($name)] ?? null;
}
/**
* @param PluginHost::HOOK_* $hook
* @param mixed $args
*/
function run_hooks(string $hook, ...$args): void {
$method = strtolower((string)$hook);
foreach ($this->get_hooks((string)$hook) as $plugin) {
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
try {
$plugin->$method(...$args);
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
}
/**
* @param PluginHost::HOOK_* $hook
* @param mixed $args
* @param mixed $check
*/
function run_hooks_until(string $hook, $check, ...$args): bool {
$method = strtolower((string)$hook);
foreach ($this->get_hooks((string)$hook) as $plugin) {
try {
$result = $plugin->$method(...$args);
if ($result == $check)
return true;
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
return false;
}
/**
* @param PluginHost::HOOK_* $hook
* @param mixed $args
*/
function run_hooks_callback(string $hook, Closure $callback, ...$args): void {
$method = strtolower((string)$hook);
foreach ($this->get_hooks((string)$hook) as $plugin) {
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
try {
if ($callback($plugin->$method(...$args), $plugin))
break;
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
}
/**
* @param PluginHost::HOOK_* $hook
* @param mixed $args
*/
function chain_hooks_callback(string $hook, Closure $callback, &...$args): void {
$method = strtolower((string)$hook);
foreach ($this->get_hooks((string)$hook) as $plugin) {
try {
if ($callback($plugin->$method(...$args), $plugin))
break;
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
}
/**
* @param PluginHost::HOOK_* $type
*/
function add_hook(string $type, Plugin $sender, int $priority = 50): void {
$priority = (int) $priority;
if (!method_exists($sender, strtolower((string)$type))) {
user_error(
sprintf("Plugin %s tried to register a hook without implementation: %s",
get_class($sender), $type),
E_USER_WARNING
);
return;
}
if (empty($this->hooks[$type])) {
$this->hooks[$type] = [];
}
if (empty($this->hooks[$type][$priority])) {
$this->hooks[$type][$priority] = [];
}
array_push($this->hooks[$type][$priority], $sender);
ksort($this->hooks[$type]);
}
/**
* @param PluginHost::HOOK_* $type
*/
function del_hook(string $type, Plugin $sender): void {
if (array_key_exists($type, $this->hooks)) {
foreach (array_keys($this->hooks[$type]) as $prio) {
$key = array_search($sender, $this->hooks[$type][$prio]);
if ($key !== false) {
unset($this->hooks[$type][$prio][$key]);
}
}
}
}
/**
* @param PluginHost::HOOK_* $type
* @return array<int, Plugin>
*/
function get_hooks(string $type) {
if (isset($this->hooks[$type])) {
$tmp = [];
foreach (array_keys($this->hooks[$type]) as $prio) {
array_push($tmp, ...$this->hooks[$type][$prio]);
}
return $tmp;
}
return [];
}
/**
* @param PluginHost::KIND_* $kind
*/
function load_all(int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
$plugins = [...(glob("plugins/*") ?: []), ...(glob("plugins.local/*") ?: [])];
$plugins = array_filter($plugins, "is_dir");
$plugins = array_map("basename", $plugins);
asort($plugins);
$this->load(join(",", $plugins), (int)$kind, $owner_uid, $skip_init);
}
/**
* @param PluginHost::KIND_* $kind
*/
function load(string $classlist, int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
$plugins = explode(",", $classlist);
$this->owner_uid = (int) $owner_uid;
if ($this->owner_uid) {
$this->set_scheduler_name("PluginHost Scheduler for UID $owner_uid");
}
foreach ($plugins as $class) {
$class = trim($class);
$class_file = strtolower(basename(clean($class)));
// try system plugin directory first
$file = Config::get_self_dir() . "/plugins/$class_file/init.php";
if (!file_exists($file)) {
$file = Config::get_self_dir() . "/plugins.local/$class_file/init.php";
if (!file_exists($file)) {
continue;
}
}
if (!isset($this->plugins[$class])) {
// WIP hack
// we can't catch incompatible method signatures via Throwable
// this also enables global tt-rss safe mode in case there are more plugins like this
if (!getenv('TTRSS_XDEBUG_ENABLED') && ($_SESSION["plugin_blacklist"][$class] ?? 0)) {
// only report once per-plugin per-session
if ($_SESSION["plugin_blacklist"][$class] < 2) {
user_error("Plugin $class has caused a PHP fatal error so it won't be loaded again in this session.", E_USER_WARNING);
$_SESSION["plugin_blacklist"][$class] = 2;
}
$_SESSION["safe_mode"] = 1;
continue;
}
try {
$_SESSION["plugin_blacklist"][$class] = 1;
require_once $file;
unset($_SESSION["plugin_blacklist"][$class]);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
continue;
}
if (class_exists($class) && is_subclass_of($class, "Plugin")) {
$plugin = new $class($this);
$plugin_api = $plugin->api_version();
if ($plugin_api < self::API_VERSION) {
user_error("Plugin $class is not compatible with current API version (need: " . self::API_VERSION . ", got: $plugin_api)", E_USER_WARNING);
continue;
}
if (file_exists(dirname($file) . "/locale")) {
_bindtextdomain($class, dirname($file) . "/locale");
_bind_textdomain_codeset($class, "UTF-8");
}
try {
switch ($kind) {
case $this::KIND_SYSTEM:
if ($this->is_system($plugin)) {
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
}
break;
case $this::KIND_USER:
if (!$this->is_system($plugin)) {
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
}
break;
case $this::KIND_ALL:
if (!$skip_init) $plugin->init($this);
$this->register_plugin($class, $plugin);
break;
}
} catch (Exception $ex) {
user_error($ex, E_USER_WARNING);
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
}
}
}
$this->load_data();
}
function is_system(Plugin $plugin): bool {
$about = $plugin->about();
return ($about[3] ?? false) === true;
}
// only system plugins are allowed to modify routing
function add_handler(string $handler, string $method, Plugin $sender): void {
$handler = str_replace("-", "_", strtolower($handler));
$method = strtolower($method);
if ($this->is_system($sender)) {
$this->handlers[$handler] ??= [];
$this->handlers[$handler][$method] = $sender;
}
}
function del_handler(string $handler, string $method, Plugin $sender): void {
$handler = str_replace("-", "_", strtolower($handler));
$method = strtolower($method);
if ($this->is_system($sender)) {
unset($this->handlers[$handler][$method]);
}
}
/**
* @return false|Plugin false if the handler couldn't be found, otherwise the Plugin/handler
*/
function lookup_handler(string $handler, string $method): false|Plugin {
$handler = str_replace("-", "_", strtolower($handler));
$method = strtolower($method);
if (isset($this->handlers[$handler])) {
return $this->handlers[$handler]["*"] ?? $this->handlers[$handler][$method];
}
return false;
}
function add_command(string $command, string $description, Plugin $sender, string $suffix = "", string $arghelp = ""): void {
$command = str_replace("-", "_", strtolower($command));
$this->commands[$command] = array("description" => $description,
"suffix" => $suffix,
"arghelp" => $arghelp,
"class" => $sender);
}
function del_command(string $command): void {
$command = "-" . strtolower($command);
unset($this->commands[$command]);
}
/**
* @return false|Plugin false if the command couldn't be found, otherwise the registered Plugin
*/
function lookup_command(string $command): false|Plugin {
$command = "-" . strtolower($command);
if (array_key_exists($command, $this->commands)) {
return $this->commands[$command]["class"];
} else {
return false;
}
}
/** @return array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */
function get_commands() {
return $this->commands;
}
/**
* @param array<string, mixed> $args
*/
function run_commands(array $args): void {
foreach ($this->get_commands() as $command => $data) {
if (isset($args[$command])) {
$command = str_replace("-", "", $command);
$data["class"]->$command($args);
}
}
}
private function load_data(): void {
if ($this->owner_uid && !$this->data_loaded && Config::get_schema_version() > 100) {
$sth = $this->pdo->prepare("SELECT name, content FROM ttrss_plugin_storage
WHERE owner_uid = ?");
$sth->execute([$this->owner_uid]);
while ($line = $sth->fetch()) {
$this->storage[$line["name"]] = unserialize($line["content"]);
}
$this->data_loaded = true;
}
}
private function save_data(string $plugin): void {
if ($this->owner_uid) {
if (!$this->pdo_data)
$this->pdo_data = Db::instance()->pdo_connect();
$this->pdo_data->beginTransaction();
$sth = $this->pdo_data->prepare("SELECT id FROM ttrss_plugin_storage WHERE
owner_uid= ? AND name = ?");
$sth->execute([$this->owner_uid, $plugin]);
$this->storage[$plugin] ??= [];
$content = serialize($this->storage[$plugin]);
if ($sth->fetch()) {
$sth = $this->pdo_data->prepare("UPDATE ttrss_plugin_storage SET content = ?
WHERE owner_uid= ? AND name = ?");
$sth->execute([$content, $this->owner_uid, $plugin]);
} else {
$sth = $this->pdo_data->prepare("INSERT INTO ttrss_plugin_storage
(name,owner_uid,content) VALUES
(?, ?, ?)");
$sth->execute([$plugin, $this->owner_uid, $content]);
}
$this->pdo_data->commit();
}
}
/**
* same as set(), but sets data to current preference profile
*
* @param mixed $value
*/
function profile_set(Plugin $sender, string $name, $value): void {
$profile_id = $_SESSION["profile"] ?? null;
if ($profile_id) {
$idx = get_class($sender);
$this->storage[$idx] ??= [];
$this->storage[$idx][$profile_id] ??= [];
$this->storage[$idx][$profile_id][$name] = $value;
$this->save_data(get_class($sender));
} else {
$this->set($sender, $name, $value);
}
}
/**
* @param mixed $value
*/
function set(Plugin $sender, string $name, $value): void {
$idx = get_class($sender);
$this->storage[$idx] ??= [];
$this->storage[$idx][$name] = $value;
$this->save_data(get_class($sender));
}
/**
* @param array<int|string, mixed> $params
*/
function set_array(Plugin $sender, array $params): void {
$idx = get_class($sender);
$this->storage[$idx] ??= [];
foreach ($params as $name => $value)
$this->storage[$idx][$name] = $value;
$this->save_data(get_class($sender));
}
/**
* same as get(), but sets data to current preference profile
*
* @param mixed $default_value
* @return mixed
*/
function profile_get(Plugin $sender, string $name, $default_value = false) {
$profile_id = $_SESSION["profile"] ?? null;
if ($profile_id) {
$idx = get_class($sender);
$this->load_data();
return $this->storage[$idx][$profile_id][$name] ?? $default_value;
} else {
return $this->get($sender, $name, $default_value);
}
}
/**
* @param mixed $default_value
* @return mixed
*/
function get(Plugin $sender, string $name, $default_value = false) {
$idx = get_class($sender);
$this->load_data();
return $this->storage[$idx][$name] ?? $default_value;
}
/**
* @param array<int|string, mixed> $default_value
* @return array<int|string, mixed>
*/
function get_array(Plugin $sender, string $name, array $default_value = []): array {
$tmp = $this->get($sender, $name);
if (!is_array($tmp)) $tmp = $default_value;
return $tmp;
}
/**
* @return array<string, mixed>
*/
function get_all(Plugin $sender) {
$idx = get_class($sender);
return $this->storage[$idx] ?? [];
}
function clear_data(Plugin $sender): void {
if ($this->owner_uid) {
$idx = get_class($sender);
unset($this->storage[$idx]);
$sth = $this->pdo->prepare("DELETE FROM ttrss_plugin_storage WHERE name = ?
AND owner_uid = ?");
$sth->execute([$idx, $this->owner_uid]);
}
}
/**
* Add a special (plugin-provided) feed
*
* @param int $cat_id only -1 (Feeds::CATEGORY_SPECIAL) is supported
* @return false|int false if the feed wasn't added (e.g. $cat_id wasn't Feeds::CATEGORY_SPECIAL),
* otherwise an integer "feed ID" that might change between executions
*/
function add_feed(int $cat_id, string $title, string $icon, Plugin $sender): false|int {
if ($cat_id !== Feeds::CATEGORY_SPECIAL)
return false;
$id = count($this->special_feeds);
$this->special_feeds[] = [
'id' => $id,
'title' => $title,
'sender' => $sender,
'icon' => $icon,
];
return $id;
}
/**
* Get special (plugin-provided) feeds
*
* @param int $cat_id only -1 (Feeds::CATEGORY_SPECIAL) is supported
* @return array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>
*/
function get_feeds(int $cat_id) {
return $cat_id === Feeds::CATEGORY_SPECIAL ? $this->special_feeds : [];
}
/**
* Get the Plugin handling a specific virtual feed.
*
* Convert feed_id (e.g. -129) to pfeed_id first.
*
* @return (Plugin&IVirtualFeed)|null a Plugin that implements IVirtualFeed, otherwise null
*/
function get_feed_handler(int $pfeed_id): ?Plugin {
foreach ($this->special_feeds as $feed) {
if ($feed['id'] == $pfeed_id) {
/** @var Plugin&IVirtualFeed $feed['sender'] */
return implements_interface($feed['sender'], 'IVirtualFeed') ? $feed['sender'] : null;
}
}
return null;
}
static function pfeed_to_feed_id(int $pfeed): int {
return PLUGIN_FEED_BASE_INDEX - 1 - abs($pfeed);
}
static function feed_to_pfeed_id(int $feed): int {
return PLUGIN_FEED_BASE_INDEX - 1 + abs($feed);
}
function add_api_method(string $name, Plugin $sender): void {
if ($this->is_system($sender)) {
$this->api_methods[strtolower($name)] = $sender;
}
}
function get_api_method(string $name): ?Plugin {
return $this->api_methods[$name] ?? null;
}
function add_filter_action(Plugin $sender, string $action_name, string $action_desc): void {
$sender_class = get_class($sender);
$this->plugin_actions[$sender_class] ??= [];
$this->plugin_actions[$sender_class][] = [
"action" => $action_name,
"description" => $action_desc,
"sender" => $sender,
];
}
/**
* @return array<string, array<int, array{'action': string, 'description': string, 'sender': Plugin}>>
*/
function get_filter_actions() {
return $this->plugin_actions;
}
function get_owner_uid(): ?int {
return $this->owner_uid;
}
/**
* handled by classes/PluginHandler.php, requires valid session
*
* @param array<int|string, mixed> $params
*/
function get_method_url(Plugin $sender, string $method, array $params = []): string {
return Config::get_self_url() . "/backend.php?" .
http_build_query([
'op' => 'pluginhandler',
'plugin' => strtolower(get_class($sender)),
'method' => $method,
...$params,
]);
}
// shortcut syntax (disabled for now)
/* function get_method_url(Plugin $sender, string $method, $params) {
return Config::get_self_url() . "/backend.php?" .
http_build_query(
array_merge(
[
"op" => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
],
$params));
} */
/**
* WARNING: endpoint in public.php, exposed to unauthenticated users
*
* @param array<int|string, mixed> $params
*/
function get_public_method_url(Plugin $sender, string $method, array $params = []): ?string {
if ($sender->is_public_method($method)) {
return Config::get_self_url() . "/public.php?" .
http_build_query([
'op' => strtolower(get_class($sender) . self::PUBLIC_METHOD_DELIMITER . $method),
...$params,
]);
}
user_error("get_public_method_url: requested method '$method' of '" . get_class($sender) . "' is private.");
return null;
}
function get_plugin_dir(Plugin $plugin): string {
$ref = new ReflectionClass(get_class($plugin));
return dirname($ref->getFileName());
}
// TODO: use get_plugin_dir()
function is_local(Plugin $plugin): bool {
$ref = new ReflectionClass(get_class($plugin));
return basename(dirname(dirname($ref->getFileName()))) == "plugins.local";
}
/**
* This exposes sheduled tasks functionality to plugins. For system plugins, tasks registered here are
* executed (if due) during housekeeping. For user plugins, tasks are only run after any feeds owned by
* the user have been processed in an update batch (which means user is not inactive).
*
* This behaviour mirrors that of `HOOK_HOUSE_KEEPING` for user plugins.
*
* @see Scheduler::add_scheduled_task()
* @see Plugin::hook_house_keeping()
*/
function add_scheduled_task(Plugin $sender, string $task_name, string $cron_expression, Closure $callback): bool {
if ($this->is_system($sender))
$task_name = get_class($sender) . ':' . $task_name;
else
$task_name = get_class($sender) . ':' . $task_name . ':' . $this->owner_uid;
return $this->scheduler->add_scheduled_task($task_name, $cron_expression, $callback);
}
function run_due_tasks() : void {
$this->scheduler->run_due_tasks();
}
private function set_scheduler_name(string $name) : void {
$this->scheduler->set_name($name);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +0,0 @@
<?php
class Pref_Labels extends Handler_Protected {
function csrf_ignore(string $method): bool {
$csrf_ignored = array("index", "getlabeltree");
return array_search($method, $csrf_ignored) !== false;
}
function edit(): void {
$label = ORM::for_table('ttrss_labels2')
->where('owner_uid', $_SESSION['uid'])
->find_one($_REQUEST['id']);
if ($label) {
print json_encode($label->as_array());
}
}
function getlabeltree(): void {
$root = array();
$root['id'] = 'root';
$root['name'] = __('Labels');
$root['items'] = array();
$sth = $this->pdo->prepare("SELECT *
FROM ttrss_labels2
WHERE owner_uid = ?
ORDER BY caption");
$sth->execute([$_SESSION['uid']]);
while ($line = $sth->fetch()) {
$label = array();
$label['id'] = 'LABEL:' . $line['id'];
$label['bare_id'] = $line['id'];
$label['name'] = $line['caption'];
$label['fg_color'] = $line['fg_color'];
$label['bg_color'] = $line['bg_color'];
$label['type'] = 'label';
$label['checkbox'] = false;
array_push($root['items'], $label);
}
$fl = array();
$fl['identifier'] = 'id';
$fl['label'] = 'name';
$fl['items'] = array($root);
print json_encode($fl);
}
function colorset(): void {
$kind = clean($_REQUEST["kind"]);
$ids = explode(',', clean($_REQUEST["ids"]));
$color = clean($_REQUEST["color"]);
$fg = clean($_REQUEST["fg"]);
$bg = clean($_REQUEST["bg"]);
foreach ($ids as $id) {
if ($kind == "fg" || $kind == "bg") {
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
{$kind}_color = ? WHERE id = ?
AND owner_uid = ?");
$sth->execute([$color, $id, $_SESSION['uid']]);
} else {
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
fg_color = ?, bg_color = ? WHERE id = ?
AND owner_uid = ?");
$sth->execute([$fg, $bg, $id, $_SESSION['uid']]);
}
/* Remove cached data */
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
}
}
function colorreset(): void {
$ids = explode(',', clean($_REQUEST["ids"]));
foreach ($ids as $id) {
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
fg_color = '', bg_color = '' WHERE id = ?
AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
/* Remove cached data */
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET label_cache = ''
WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
}
}
function save(): void {
$id = clean($_REQUEST["id"]);
$caption = clean($_REQUEST["caption"]);
$this->pdo->beginTransaction();
$sth = $this->pdo->prepare("SELECT caption FROM ttrss_labels2
WHERE id = ? AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$old_caption = $row["caption"];
$sth = $this->pdo->prepare("SELECT id FROM ttrss_labels2
WHERE caption = ? AND owner_uid = ?");
$sth->execute([$caption, $_SESSION['uid']]);
if (!$sth->fetch()) {
if ($caption) {
$sth = $this->pdo->prepare("UPDATE ttrss_labels2 SET
caption = ? WHERE id = ? AND
owner_uid = ?");
$sth->execute([$caption, $id, $_SESSION['uid']]);
/* Update filters that reference label being renamed */
$sth = $this->pdo->prepare("UPDATE ttrss_filters2_actions SET
action_param = ? WHERE action_param = ?
AND action_id = 7
AND filter_id IN (SELECT id FROM ttrss_filters2 WHERE owner_uid = ?)");
$sth->execute([$caption, $old_caption, $_SESSION['uid']]);
print clean($_REQUEST["caption"]);
} else {
print $old_caption;
}
} else {
print $old_caption;
}
}
$this->pdo->commit();
}
function remove(): void {
/** @var array<int, int> */
$ids = array_map("intval", explode(",", clean($_REQUEST["ids"])));
foreach ($ids as $id) {
Labels::remove($id, $_SESSION["uid"]);
}
}
function add(): void {
$caption = clean($_REQUEST["caption"]);
$output = clean($_REQUEST["output"] ?? false);
if ($caption) {
if (Labels::create($caption)) {
if (!$output) {
print T_sprintf("Created label <b>%s</b>", htmlspecialchars($caption));
}
}
}
}
function index(): void {
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
<div dojoType='fox.Toolbar'>
<div dojoType='fox.form.DropDownButton'>
<span><?= __('Select') ?></span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="dijit.byId('labelTree').model.setAllChecked(true)"
dojoType='dijit.MenuItem'><?=('All') ?></div>
<div onclick="dijit.byId('labelTree').model.setAllChecked(false)"
dojoType='dijit.MenuItem'><?=('None') ?></div>
</div>
</div>
<button dojoType='dijit.form.Button' onclick='CommonDialogs.addLabel()'>
<?=('Create label') ?></button dojoType='dijit.form.Button'>
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').removeSelected()">
<?=('Remove') ?></button dojoType='dijit.form.Button'>
<button dojoType='dijit.form.Button' onclick="dijit.byId('labelTree').resetColors()">
<?=('Clear colors') ?></button dojoType='dijit.form.Button'>
</div>
</div>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
<div dojoType='dojo.data.ItemFileWriteStore' jsId='labelStore'
url='backend.php?op=Pref_Labels&method=getlabeltree'>
</div>
<div dojoType='lib.CheckBoxStoreModel' jsId='labelModel' store='labelStore'
query="{id:'root'}" rootId='root'
childrenAttrs='items' checkboxStrict='false' checkboxAll='false'>
</div>
<div dojoType='fox.PrefLabelTree' id='labelTree' model='labelModel' openOnClick='true'>
<script type='dojo/method' event='onClick' args='item'>
var id = String(item.id);
var bare_id = id.substr(id.indexOf(':')+1);
if (id.match('LABEL:')) {
dijit.byId('labelTree').editLabel(bare_id);
}
</script>
</div>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefLabels") ?>
</div>
<?php
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,270 +0,0 @@
<?php
class Pref_System extends Handler_Administrative {
private const LOG_PAGE_LIMIT = 15;
function csrf_ignore(string $method): bool {
$csrf_ignored = array("index");
return array_search($method, $csrf_ignored) !== false;
}
function clearLog(): void {
$this->pdo->query("DELETE FROM ttrss_error_log");
}
function sendTestEmail(): void {
$mail_address = clean($_REQUEST["mail_address"]);
$mailer = new Mailer();
$rc = $mailer->mail(["to_name" => "",
"to_address" => $mail_address,
"subject" => __("Test message from tt-rss"),
"message" => ("This message confirms that tt-rss can send outgoing mail.")
]);
print json_encode(['rc' => $rc, 'error' => $mailer->error()]);
}
function getscheduledtasks(): void {
?>
<table width='100%' class='event-log'>
<tr>
<th><?= __("Task name") ?></th>
<th><?= __("Schedule") ?></th>
<th><?= __("Last executed") ?></th>
<th><?= __("Duration (seconds)") ?></th>
<th><?= __("Return code") ?></th>
</tr>
<?php
$task_records = ORM::for_table('ttrss_scheduled_tasks')
->order_by_asc(['last_cron_expression', 'task_name'])
->find_many();
foreach ($task_records as $task) {
$row_style = $task->last_rc === 0 ? 'text-success' : 'text-error';
?>
<tr>
<td class="<?= $row_style ?>"><?= $task->task_name ?></td>
<td><?= $task->last_cron_expression ?></td>
<td><?= TimeHelper::make_local_datetime($task->last_run) ?></td>
<td><?= $task->last_duration ?></td>
<td><?= $task->last_rc ?></td>
</tr>
<?php
}
?>
</table>
<?php
}
function getphpinfo(): void {
ob_start();
phpinfo();
$info = ob_get_contents();
ob_end_clean();
print preg_replace( '%^.*<body>(.*)</body>.*$%ms','$1', (string)$info);
}
private function _log_viewer(int $page, int $severity): void {
$errno_values = match ($severity) {
E_USER_ERROR => [E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR],
E_USER_WARNING => [E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED],
default => [],
};
if (count($errno_values) > 0) {
$errno_qmarks = arr_qmarks($errno_values);
$errno_filter_qpart = "errno IN ($errno_qmarks)";
} else {
$errno_filter_qpart = "true";
}
$offset = self::LOG_PAGE_LIMIT * $page;
$sth = $this->pdo->prepare("SELECT
COUNT(id) AS total_pages
FROM
ttrss_error_log
WHERE
$errno_filter_qpart");
$sth->execute($errno_values);
if ($res = $sth->fetch()) {
$total_pages = (int)($res["total_pages"] / self::LOG_PAGE_LIMIT);
} else {
$total_pages = 0;
}
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div region='top' dojoType='fox.Toolbar'>
<button dojoType='dijit.form.Button' onclick='Helpers.EventLog.refresh()'>
<?= __('Refresh') ?>
</button>
<button dojoType='dijit.form.Button' <?= ($page <= 0 ? "disabled" : "") ?>
onclick='Helpers.EventLog.prevPage()'>
<?= __('&lt;&lt;') ?>
</button>
<button dojoType='dijit.form.Button' disabled>
<?= T_sprintf('Page %d of %d', $page+1, $total_pages+1) ?>
</button>
<button dojoType='dijit.form.Button' <?= ($page >= $total_pages ? "disabled" : "") ?>
onclick='Helpers.EventLog.nextPage()'>
<?= __('&gt;&gt;') ?>
</button>
<button dojoType='dijit.form.Button'
onclick='Helpers.EventLog.clear()'>
<?= __('Clear') ?>
</button>
<div class='pull-right'>
<label><?= __("Severity:") ?></label>
<?= \Controls\select_hash("severity", $severity,
[
E_USER_ERROR => __("Errors"),
E_USER_WARNING => __("Warnings"),
E_USER_NOTICE => __("Everything")
], ["onchange"=> "Helpers.EventLog.refresh()"], "severity") ?>
</div>
</div>
<div style="padding : 0px" dojoType="dijit.layout.ContentPane" region="center">
<table width='100%' class='event-log'>
<tr>
<th width='5%'><?= __("Error") ?></th>
<th><?= __("Filename") ?></th>
<th><?= __("Message") ?></th>
<th width='5%'><?= __("User") ?></th>
<th width='5%'><?= __("Date") ?></th>
</tr>
<?php
$sth = $this->pdo->prepare("SELECT
errno, errstr, filename, lineno, created_at, login, context
FROM
ttrss_error_log LEFT JOIN ttrss_users ON (owner_uid = ttrss_users.id)
WHERE
$errno_filter_qpart
ORDER BY
ttrss_error_log.id DESC
LIMIT ". self::LOG_PAGE_LIMIT ." OFFSET $offset");
$sth->execute($errno_values);
while ($line = $sth->fetch()) {
foreach ($line as $k => $v) { $line[$k] = htmlspecialchars($v ?? ''); }
?>
<tr>
<td class='errno'>
<?= Logger::ERROR_NAMES[$line["errno"]] . " (" . $line["errno"] . ")" ?>
</td>
<td class='filename'><?= $line["filename"] . ":" . $line["lineno"] ?></td>
<td class='errstr'><?= $line["errstr"] . "\n" . $line["context"] ?></td>
<td class='login'><?= $line["login"] ?></td>
<td class='timestamp'>
<?= TimeHelper::make_local_datetime($line['created_at']) ?>
</td>
</tr>
<?php } ?>
</table>
</div>
</div>
<?php
}
function index(): void {
$severity = (int) ($_REQUEST["severity"] ?? E_USER_WARNING);
$page = (int) ($_REQUEST["page"] ?? 0);
?>
<div dojoType='dijit.layout.AccordionContainer' region='center'>
<?php if (Config::get(Config::LOG_DESTINATION) == Logger::LOG_DEST_SQL) { ?>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">report</i> <?= __('Event log') ?>'>
<?php
$this->_log_viewer($page, $severity);
?>
</div>
<?php } ?>
<div dojoType='dijit.layout.AccordionPane' style='padding : 0' title='<i class="material-icons">mail</i> <?= __('Mail configuration') ?>'>
<div dojoType="dijit.layout.ContentPane">
<form dojoType="dijit.form.Form">
<script type="dojo/method" event="onSubmit" args="evt">
evt.preventDefault();
if (this.validate()) {
xhr.json("backend.php", this.getValues(), (reply) => {
const msg = App.byId("mail-test-result");
if (reply.rc) {
msg.innerHTML = __("Mail sent.");
msg.className = 'alert alert-success';
} else {
msg.innerHTML = reply.error;
msg.className = 'alert alert-danger';
}
msg.show();
})
}
</script>
<?= \Controls\hidden_tag("op", "Pref_System") ?>
<?= \Controls\hidden_tag("method", "sendTestEmail") ?>
<?php
$user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
?>
<fieldset>
<label><?= __("To:") ?></label>
<?= \Controls\input_tag("mail_address",$user->email, "text", ['required' => 1]) ?>
<?= \Controls\submit_tag(__("Send test email")) ?>
<span style="display: none; margin-left : 10px" class="alert alert-error" id="mail-test-result">...</span>
</fieldset>
</form>
</div>
</div>
<div dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">alarm</i> <?= __('Scheduled tasks') ?>'>
<script type='dojo/method' event='onSelected' args='evt'>
if (this.domNode.querySelector('.loading'))
window.setTimeout(() => {
xhr.post("backend.php", {op: 'Pref_System', method: 'getscheduledtasks'}, (reply) => {
this.attr('content', `<div class='phpinfo'>${reply}</div>`);
});
}, 200);
</script>
<span class='loading'><?= __("Loading, please wait...") ?></span>
</div>
<div dojoType='dijit.layout.AccordionPane' title='<i class="material-icons">info</i> <?= __('PHP Information') ?>'>
<script type='dojo/method' event='onSelected' args='evt'>
if (this.domNode.querySelector('.loading'))
window.setTimeout(() => {
xhr.post("backend.php", {op: 'Pref_System', method: 'getphpinfo'}, (reply) => {
this.attr('content', `<div class='phpinfo'>${reply}</div>`);
});
}, 200);
</script>
<span class='loading'><?= __("Loading, please wait...") ?></span>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefSystem") ?>
</div>
<?php
}
}

View File

@@ -1,278 +0,0 @@
<?php
class Pref_Users extends Handler_Administrative {
function csrf_ignore(string $method): bool {
return $method == "index";
}
function edit(): void {
$user = ORM::for_table('ttrss_users')
->select_many('id', 'login', 'access_level', 'email', 'full_name', 'otp_enabled')
->find_one((int)$_REQUEST["id"])
->as_array();
global $access_level_names;
if ($user) {
print json_encode([
"user" => $user,
"access_level_names" => $access_level_names
]);
}
}
function userdetails(): void {
$id = (int) clean($_REQUEST["id"]);
$user = ORM::for_table('ttrss_users')
->table_alias('u')
->select_many('u.login', 'u.access_level')
->select_many_expr([
'created' => 'SUBSTRING_FOR_DATE(u.created,1,16)',
'last_login' => 'SUBSTRING_FOR_DATE(u.last_login,1,16)',
'stored_articles' => '(SELECT COUNT(ue.int_id) FROM ttrss_user_entries ue WHERE ue.owner_uid = u.id)',
])
->find_one($id);
if ($user) {
$created = TimeHelper::make_local_datetime($user->created);
$last_login = TimeHelper::make_local_datetime($user->last_login);
$user_owned_feeds = ORM::for_table('ttrss_feeds')
->select_many('id', 'title', 'site_url')
->where('owner_uid', $id)
->order_by_expr('LOWER(title)')
->find_many();
?>
<fieldset>
<label><?= __('Registered') ?>:</label>
<?= $created ?>
</fieldset>
<fieldset>
<label><?= __('Last logged in') ?>:</label>
<?= $last_login ?>
</fieldset>
<fieldset>
<label><?= __('Subscribed feeds') ?>:</label>
<?= count($user_owned_feeds) ?>
</fieldset>
<fieldset>
<label><?= __('Stored articles') ?>:</label>
<?= $user->stored_articles ?>
</fieldset>
<ul class="panel panel-scrollable list list-unstyled">
<?php foreach ($user_owned_feeds as $feed) { ?>
<li>
<?php
$icon_url = Feeds::_get_icon_url($feed->id, 'images/blank_icon.gif');
?>
<img class="icon" src="<?= htmlspecialchars($icon_url) ?>">
<a target="_blank" href="<?= htmlspecialchars($feed->site_url) ?>">
<?= htmlspecialchars($feed->title) ?>
</a>
</li>
<?php } ?>
</ul>
<?php
} else {
print_error(__('User not found'));
}
}
function editSave(): void {
$id = (int)$_REQUEST['id'];
$password = clean($_REQUEST["password"]);
$user = ORM::for_table('ttrss_users')->find_one($id);
if ($user) {
$login = clean($_REQUEST["login"]);
if ($id == 1) $login = "admin";
if (!$login) return;
$user->login = mb_strtolower($login);
$user->access_level = (int) clean($_REQUEST["access_level"]);
$user->email = clean($_REQUEST["email"]);
$user->otp_enabled = checkbox_to_sql_bool($_REQUEST["otp_enabled"] ?? "");
// force new OTP secret when next enabled
if (Config::get_schema_version() >= 143 && !$user->otp_enabled) {
$user->otp_secret = null;
}
$user->save();
}
if ($password) {
UserHelper::reset_password($id, false, $password);
}
}
function remove(): void {
$ids = explode(",", clean($_REQUEST["ids"]));
foreach ($ids as $id) {
if ($id != $_SESSION["uid"] && $id != 1) {
ORM::for_table('ttrss_tags')->where('owner_uid', $id)->delete_many();
ORM::for_table('ttrss_feeds')->where('owner_uid', $id)->delete_many();
ORM::for_table('ttrss_users')->where('id', $id)->delete_many();
}
}
}
function add(): void {
$login = clean($_REQUEST["login"]);
if (!$login) return; // no blank usernames
if (!UserHelper::find_user_by_login($login)) {
$new_password = make_password();
$user = ORM::for_table('ttrss_users')->create();
$user->salt = UserHelper::get_salt();
$user->login = mb_strtolower($login);
$user->pwd_hash = UserHelper::hash_password($new_password, $user->salt);
$user->access_level = 0;
$user->created = Db::NOW();
$user->save();
if (!is_null(UserHelper::find_user_by_login($login))) {
print T_sprintf("Added user %s with password %s",
$login, $new_password);
} else {
print T_sprintf("Could not create user %s", $login);
}
} else {
print T_sprintf("User %s already exists.", $login);
}
}
function resetPass(): void {
UserHelper::reset_password(clean($_REQUEST["id"]));
}
function index(): void {
global $access_level_names;
$user_search = clean($_REQUEST["search"] ?? "");
if (array_key_exists("search", $_REQUEST)) {
$_SESSION["prefs_user_search"] = $user_search;
} else {
$user_search = ($_SESSION["prefs_user_search"] ?? "");
}
$sort = clean($_REQUEST["sort"] ?? "");
if (!$sort || $sort == "undefined") {
$sort = "login";
}
if (!in_array($sort, ["login", "access_level", "created", "num_feeds", "created", "last_login"]))
$sort = "login";
if ($sort != "login") $sort = "$sort DESC";
?>
<div dojoType='dijit.layout.BorderContainer' gutters='false'>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='top'>
<div dojoType='fox.Toolbar'>
<div style='float : right'>
<form dojoType="dijit.form.Form" onsubmit="Users.reload(); return false;">
<input dojoType='dijit.form.TextBox' id='user_search' size='20' type='search'
value="<?= htmlspecialchars($user_search) ?>">
<button dojoType='dijit.form.Button' type='submit'>
<?= __('Search') ?>
</button>
</form>
</div>
<div dojoType='fox.form.DropDownButton'>
<span><?= __('Select') ?></span>
<div dojoType='dijit.Menu' style='display: none'>
<div onclick="Tables.select('users-list', true)"
dojoType='dijit.MenuItem'><?= __('All') ?></div>
<div onclick="Tables.select('users-list', false)"
dojoType='dijit.MenuItem'><?= __('None') ?></div>
</div>
</div>
<button dojoType='dijit.form.Button' onclick='Users.add()'>
<?= __('Create user') ?>
</button>
<button dojoType='dijit.form.Button' onclick='Users.removeSelected()'>
<?= __('Remove') ?>
</button>
<button dojoType='dijit.form.Button' onclick='Users.resetSelected()'>
<?= __('Reset password') ?>
</button>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB_SECTION, "prefUsersToolbar") ?>
</div>
</div>
<div style='padding : 0px' dojoType='dijit.layout.ContentPane' region='center'>
<table width='100%' class='users-list' id='users-list'>
<tr>
<th></th>
<th><a href='#' onclick="Users.reload('login')"><?= __('Login') ?></a></th>
<th><a href='#' onclick="Users.reload('access_level')"><?= __('Access level') ?></a></th>
<th><a href='#' onclick="Users.reload('num_feeds')"><?= __('Subscribed feeds') ?></a></th>
<th><a href='#' onclick="Users.reload('created')"><?= __('Registered') ?></a></th>
<th><a href='#' onclick="Users.reload('last_login')"><?= __('Last login') ?></a></th>
</tr>
<?php
$users = ORM::for_table('ttrss_users')
->table_alias('u')
->left_outer_join("ttrss_feeds", ["owner_uid", "=", "u.id"], 'f')
->select_expr('u.*,COUNT(f.id) AS num_feeds')
->where_like("login", $user_search ? "%$user_search%" : "%")
->order_by_expr($sort)
->group_by_expr('u.id')
->find_many();
foreach ($users as $user) { ?>
<tr data-row-id='<?= $user["id"] ?>' onclick='Users.edit(<?= $user["id"] ?>)' title="<?= __('Click to edit') ?>">
<td class='checkbox'>
<input onclick='Tables.onRowChecked(this); event.stopPropagation();'
dojoType='dijit.form.CheckBox' type='checkbox'>
</td>
<td width='30%'>
<i class='material-icons'>person</i>
<strong><?= htmlspecialchars($user["login"]) ?></strong>
</td>
<td><?= $access_level_names[$user["access_level"]] ?></td>
<td><?= $user["num_feeds"] ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user['created']) ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user['last_login']) ?></td>
</tr>
<?php } ?>
</table>
</div>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_PREFS_TAB, "prefUsers") ?>
</div>
<?php
}
}

View File

@@ -1,405 +0,0 @@
<?php
class Prefs {
// (this is the database-backed version of Config.php)
const PURGE_OLD_DAYS = "PURGE_OLD_DAYS";
const DEFAULT_UPDATE_INTERVAL = "DEFAULT_UPDATE_INTERVAL";
//const DEFAULT_ARTICLE_LIMIT = "DEFAULT_ARTICLE_LIMIT";
//const ALLOW_DUPLICATE_POSTS = "ALLOW_DUPLICATE_POSTS";
const ENABLE_FEED_CATS = "ENABLE_FEED_CATS";
const SHOW_CONTENT_PREVIEW = "SHOW_CONTENT_PREVIEW";
const SHORT_DATE_FORMAT = "SHORT_DATE_FORMAT";
const LONG_DATE_FORMAT = "LONG_DATE_FORMAT";
const COMBINED_DISPLAY_MODE = "COMBINED_DISPLAY_MODE";
const HIDE_READ_FEEDS = "HIDE_READ_FEEDS";
const ON_CATCHUP_SHOW_NEXT_FEED = "ON_CATCHUP_SHOW_NEXT_FEED";
const FEEDS_SORT_BY_UNREAD = "FEEDS_SORT_BY_UNREAD";
const REVERSE_HEADLINES = "REVERSE_HEADLINES";
const DIGEST_ENABLE = "DIGEST_ENABLE";
const CONFIRM_FEED_CATCHUP = "CONFIRM_FEED_CATCHUP";
const CDM_AUTO_CATCHUP = "CDM_AUTO_CATCHUP";
const _DEFAULT_VIEW_MODE = "_DEFAULT_VIEW_MODE";
const _DEFAULT_VIEW_LIMIT = "_DEFAULT_VIEW_LIMIT";
//const _PREFS_ACTIVE_TAB = "_PREFS_ACTIVE_TAB";
//const STRIP_UNSAFE_TAGS = "STRIP_UNSAFE_TAGS";
const BLACKLISTED_TAGS = "BLACKLISTED_TAGS";
const FRESH_ARTICLE_MAX_AGE = "FRESH_ARTICLE_MAX_AGE";
const DIGEST_CATCHUP = "DIGEST_CATCHUP";
const CDM_EXPANDED = "CDM_EXPANDED";
const PURGE_UNREAD_ARTICLES = "PURGE_UNREAD_ARTICLES";
const HIDE_READ_SHOWS_SPECIAL = "HIDE_READ_SHOWS_SPECIAL";
const VFEED_GROUP_BY_FEED = "VFEED_GROUP_BY_FEED";
const STRIP_IMAGES = "STRIP_IMAGES";
const _DEFAULT_VIEW_ORDER_BY = "_DEFAULT_VIEW_ORDER_BY";
const ENABLE_API_ACCESS = "ENABLE_API_ACCESS";
//const _COLLAPSED_SPECIAL = "_COLLAPSED_SPECIAL";
//const _COLLAPSED_LABELS = "_COLLAPSED_LABELS";
//const _COLLAPSED_UNCAT = "_COLLAPSED_UNCAT";
//const _COLLAPSED_FEEDLIST = "_COLLAPSED_FEEDLIST";
//const _MOBILE_ENABLE_CATS = "_MOBILE_ENABLE_CATS";
//const _MOBILE_SHOW_IMAGES = "_MOBILE_SHOW_IMAGES";
//const _MOBILE_HIDE_READ = "_MOBILE_HIDE_READ";
//const _MOBILE_SORT_FEEDS_UNREAD = "_MOBILE_SORT_FEEDS_UNREAD";
//const _MOBILE_BROWSE_CATS = "_MOBILE_BROWSE_CATS";
//const _THEME_ID = "_THEME_ID";
const USER_TIMEZONE = "USER_TIMEZONE";
const USER_STYLESHEET = "USER_STYLESHEET";
//const SORT_HEADLINES_BY_FEED_DATE = "SORT_HEADLINES_BY_FEED_DATE";
const SSL_CERT_SERIAL = "SSL_CERT_SERIAL";
const DIGEST_PREFERRED_TIME = "DIGEST_PREFERRED_TIME";
//const _PREFS_SHOW_EMPTY_CATS = "_PREFS_SHOW_EMPTY_CATS";
const _DEFAULT_INCLUDE_CHILDREN = "_DEFAULT_INCLUDE_CHILDREN";
//const AUTO_ASSIGN_LABELS = "AUTO_ASSIGN_LABELS";
const _ENABLED_PLUGINS = "_ENABLED_PLUGINS";
//const _MOBILE_REVERSE_HEADLINES = "_MOBILE_REVERSE_HEADLINES";
const USER_CSS_THEME = "USER_CSS_THEME";
const USER_LANGUAGE = "USER_LANGUAGE";
const DEFAULT_SEARCH_LANGUAGE = "DEFAULT_SEARCH_LANGUAGE";
const _PREFS_MIGRATED = "_PREFS_MIGRATED";
const HEADLINES_NO_DISTINCT = "HEADLINES_NO_DISTINCT";
const DEBUG_HEADLINE_IDS = "DEBUG_HEADLINE_IDS";
const DISABLE_CONDITIONAL_COUNTERS = "DISABLE_CONDITIONAL_COUNTERS";
const WIDESCREEN_MODE = "WIDESCREEN_MODE";
const CDM_ENABLE_GRID = "CDM_ENABLE_GRID";
const DIGEST_MIN_SCORE = "DIGEST_MIN_SCORE";
private const _DEFAULTS = [
Prefs::PURGE_OLD_DAYS => [ 60, Config::T_INT ],
Prefs::DEFAULT_UPDATE_INTERVAL => [ 30, Config::T_INT ],
//Prefs::DEFAULT_ARTICLE_LIMIT => [ 30, Config::T_INT ],
//Prefs::ALLOW_DUPLICATE_POSTS => [ false, Config::T_BOOL ],
Prefs::ENABLE_FEED_CATS => [ true, Config::T_BOOL ],
Prefs::SHOW_CONTENT_PREVIEW => [ true, Config::T_BOOL ],
Prefs::SHORT_DATE_FORMAT => [ "M d, G:i", Config::T_STRING ],
Prefs::LONG_DATE_FORMAT => [ "D, M d Y - G:i", Config::T_STRING ],
Prefs::COMBINED_DISPLAY_MODE => [ true, Config::T_BOOL ],
Prefs::HIDE_READ_FEEDS => [ false, Config::T_BOOL ],
Prefs::ON_CATCHUP_SHOW_NEXT_FEED => [ false, Config::T_BOOL ],
Prefs::FEEDS_SORT_BY_UNREAD => [ false, Config::T_BOOL ],
Prefs::REVERSE_HEADLINES => [ false, Config::T_BOOL ],
Prefs::DIGEST_ENABLE => [ false, Config::T_BOOL ],
Prefs::CONFIRM_FEED_CATCHUP => [ true, Config::T_BOOL ],
Prefs::CDM_AUTO_CATCHUP => [ false, Config::T_BOOL ],
Prefs::_DEFAULT_VIEW_MODE => [ "adaptive", Config::T_STRING ],
Prefs::_DEFAULT_VIEW_LIMIT => [ 30, Config::T_INT ],
//Prefs::_PREFS_ACTIVE_TAB => [ "", Config::T_STRING ],
//Prefs::STRIP_UNSAFE_TAGS => [ true, Config::T_BOOL ],
Prefs::BLACKLISTED_TAGS => [ 'main, generic, misc, uncategorized, blog, blogroll, general, news', Config::T_STRING ],
Prefs::FRESH_ARTICLE_MAX_AGE => [ 24, Config::T_INT ],
Prefs::DIGEST_CATCHUP => [ false, Config::T_BOOL ],
Prefs::CDM_EXPANDED => [ true, Config::T_BOOL ],
Prefs::PURGE_UNREAD_ARTICLES => [ true, Config::T_BOOL ],
Prefs::HIDE_READ_SHOWS_SPECIAL => [ true, Config::T_BOOL ],
Prefs::VFEED_GROUP_BY_FEED => [ false, Config::T_BOOL ],
Prefs::STRIP_IMAGES => [ false, Config::T_BOOL ],
Prefs::_DEFAULT_VIEW_ORDER_BY => [ "default", Config::T_STRING ],
Prefs::ENABLE_API_ACCESS => [ false, Config::T_BOOL ],
//Prefs::_COLLAPSED_SPECIAL => [ false, Config::T_BOOL ],
//Prefs::_COLLAPSED_LABELS => [ false, Config::T_BOOL ],
//Prefs::_COLLAPSED_UNCAT => [ false, Config::T_BOOL ],
//Prefs::_COLLAPSED_FEEDLIST => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_ENABLE_CATS => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_SHOW_IMAGES => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_HIDE_READ => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_SORT_FEEDS_UNREAD => [ false, Config::T_BOOL ],
//Prefs::_MOBILE_BROWSE_CATS => [ true, Config::T_BOOL ],
//Prefs::_THEME_ID => [ 0, Config::T_BOOL ],
Prefs::USER_TIMEZONE => [ "Automatic", Config::T_STRING ],
Prefs::USER_STYLESHEET => [ "", Config::T_STRING ],
//Prefs::SORT_HEADLINES_BY_FEED_DATE => [ false, Config::T_BOOL ],
Prefs::SSL_CERT_SERIAL => [ "", Config::T_STRING ],
Prefs::DIGEST_PREFERRED_TIME => [ "00:00", Config::T_STRING ],
//Prefs::_PREFS_SHOW_EMPTY_CATS => [ false, Config::T_BOOL ],
Prefs::_DEFAULT_INCLUDE_CHILDREN => [ false, Config::T_BOOL ],
//Prefs::AUTO_ASSIGN_LABELS => [ false, Config::T_BOOL ],
Prefs::_ENABLED_PLUGINS => [ "", Config::T_STRING ],
//Prefs::_MOBILE_REVERSE_HEADLINES => [ false, Config::T_BOOL ],
Prefs::USER_CSS_THEME => [ "" , Config::T_STRING ],
Prefs::USER_LANGUAGE => [ "" , Config::T_STRING ],
Prefs::DEFAULT_SEARCH_LANGUAGE => [ "" , Config::T_STRING ],
Prefs::_PREFS_MIGRATED => [ false, Config::T_BOOL ],
Prefs::HEADLINES_NO_DISTINCT => [ false, Config::T_BOOL ],
Prefs::DEBUG_HEADLINE_IDS => [ false, Config::T_BOOL ],
Prefs::DISABLE_CONDITIONAL_COUNTERS => [ false, Config::T_BOOL ],
Prefs::WIDESCREEN_MODE => [ false, Config::T_BOOL ],
Prefs::CDM_ENABLE_GRID => [ false, Config::T_BOOL ],
Prefs::DIGEST_MIN_SCORE => [ 0, Config::T_INT ],
];
const _PROFILE_BLACKLIST = [
//Prefs::ALLOW_DUPLICATE_POSTS,
Prefs::PURGE_OLD_DAYS,
Prefs::PURGE_UNREAD_ARTICLES,
Prefs::DIGEST_ENABLE,
Prefs::DIGEST_CATCHUP,
Prefs::BLACKLISTED_TAGS,
Prefs::ENABLE_API_ACCESS,
//Prefs::UPDATE_POST_ON_CHECKSUM_CHANGE,
Prefs::DEFAULT_UPDATE_INTERVAL,
Prefs::USER_TIMEZONE,
//Prefs::SORT_HEADLINES_BY_FEED_DATE,
Prefs::SSL_CERT_SERIAL,
Prefs::DIGEST_PREFERRED_TIME,
Prefs::DIGEST_MIN_SCORE,
Prefs::_PREFS_MIGRATED
];
private static ?Prefs $instance = null;
/** @var array<string, bool|int|string> */
private array $cache = [];
private ?PDO $pdo = null;
public static function get_instance() : Prefs {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
static function is_valid(string $pref_name): bool {
return isset(self::_DEFAULTS[$pref_name]);
}
static function get_default(string $pref_name): bool|int|null|string {
if (self::is_valid($pref_name))
return self::_DEFAULTS[$pref_name][0];
else
return null;
}
function __construct() {
$this->pdo = Db::pdo();
if (!empty($_SESSION["uid"])) {
$owner_uid = (int) $_SESSION["uid"];
$profile_id = $_SESSION["profile"] ?? null;
$this->cache_all($owner_uid, $profile_id);
$this->migrate($owner_uid, $profile_id);
};
}
private function __clone() {
//
}
/**
* @return array<int, array<string, bool|int|null|string>>
*/
static function get_all(int $owner_uid, ?int $profile_id = null): array {
return self::get_instance()->_get_all($owner_uid, $profile_id);
}
/**
* @return array<int, array<string, bool|int|null|string>>
*/
private function _get_all(int $owner_uid, ?int $profile_id = null): array {
$rv = [];
$ref = new ReflectionClass(get_class($this));
foreach ($ref->getConstants() as $const => $cvalue) {
if (isset($this::_DEFAULTS[$const])) {
list ($def_val, $type_hint) = $this::_DEFAULTS[$const];
array_push($rv, [
"pref_name" => $const,
"value" => $this->_get($const, $owner_uid, $profile_id),
"type_hint" => $type_hint,
]);
}
}
return $rv;
}
private function cache_all(int $owner_uid, ?int $profile_id): void {
if (!$profile_id) $profile_id = null;
// fill cache with defaults
$ref = new ReflectionClass(get_class($this));
foreach ($ref->getConstants() as $const => $cvalue) {
if (isset($this::_DEFAULTS[$const])) {
list ($def_val, $type_hint) = $this::_DEFAULTS[$const];
$this->_set_cache($const, $def_val, $owner_uid, $profile_id);
}
}
if (Config::get_schema_version() >= 141) {
// fill in any overrides from the database
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs2
WHERE owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id]);
while ($row = $sth->fetch()) {
$this->_set_cache($row["pref_name"], $row["value"], $owner_uid, $profile_id);
}
}
}
static function get(string $pref_name, int $owner_uid, ?int $profile_id = null): bool|int|null|string {
return self::get_instance()->_get($pref_name, $owner_uid, $profile_id);
}
private function _get(string $pref_name, int $owner_uid, ?int $profile_id): bool|int|null|string {
if (isset(self::_DEFAULTS[$pref_name])) {
if (!$profile_id || in_array($pref_name, self::_PROFILE_BLACKLIST)) $profile_id = null;
list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name];
if ($this->_is_cached($pref_name, $owner_uid, $profile_id)) {
$cached_value = $this->_get_cache($pref_name, $owner_uid, $profile_id);
return Config::cast_to($cached_value, $type_hint);
} else if (Config::get_schema_version() >= 141) {
$sth = $this->pdo->prepare("SELECT value FROM ttrss_user_prefs2
WHERE pref_name = :name AND owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
if ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
$this->_set_cache($pref_name, $row["value"], $owner_uid, $profile_id);
return Config::cast_to($row["value"], $type_hint);
} else {
$this->_set_cache($pref_name, $def_val, $owner_uid, $profile_id);
return $def_val;
}
} else {
return Config::cast_to($def_val, $type_hint);
}
} else {
user_error("Attempt to get invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING);
}
return null;
}
private function _is_cached(string $pref_name, int $owner_uid, ?int $profile_id): bool {
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
return isset($this->cache[$cache_key]);
}
private function _get_cache(string $pref_name, int $owner_uid, ?int $profile_id): bool|int|null|string {
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
return $this->cache[$cache_key] ?? null;
}
private function _set_cache(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id): void {
$cache_key = sprintf("%d/%d/%s", $owner_uid, $profile_id, $pref_name);
$this->cache[$cache_key] = $value;
}
static function set(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
return self::get_instance()->_set($pref_name, $value, $owner_uid, $profile_id);
}
private function _set(string $pref_name, bool|int|string $value, int $owner_uid, ?int $profile_id, bool $strip_tags = true): bool {
if (!$profile_id) $profile_id = null;
if ($profile_id && in_array($pref_name, self::_PROFILE_BLACKLIST))
return false;
if (isset(self::_DEFAULTS[$pref_name])) {
list ($def_val, $type_hint) = self::_DEFAULTS[$pref_name];
if ($strip_tags)
$value = trim(strip_tags($value));
$value = Config::cast_to($value, $type_hint);
if ($value == $this->_get($pref_name, $owner_uid, $profile_id))
return true; // no need to actually set this to the same value, let's just say we did
$this->_set_cache($pref_name, $value, $owner_uid, $profile_id);
$sth = $this->pdo->prepare("SELECT COUNT(pref_name) AS count FROM ttrss_user_prefs2
WHERE pref_name = :name AND owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name ]);
if ($row = $sth->fetch()) {
if ($row["count"] == 0) {
$sth = $this->pdo->prepare("INSERT INTO ttrss_user_prefs2
(pref_name, value, owner_uid, profile)
VALUES
(:name, :value, :uid, :profile)");
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]);
} else {
$sth = $this->pdo->prepare("UPDATE ttrss_user_prefs2
SET value = :value
WHERE pref_name = :name AND owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
return $sth->execute(["uid" => $owner_uid, "profile" => $profile_id, "name" => $pref_name, "value" => $value ]);
}
}
} else {
user_error("Attempt to set invalid preference key: $pref_name (UID: $owner_uid, profile: $profile_id)", E_USER_WARNING);
}
return false;
}
function migrate(int $owner_uid, ?int $profile_id): void {
if (Config::get_schema_version() < 141)
return;
if (!$profile_id) $profile_id = null;
if (!$this->_get(Prefs::_PREFS_MIGRATED, $owner_uid, $profile_id)) {
$in_nested_tr = false;
try {
$this->pdo->beginTransaction();
} catch (PDOException $e) {
$in_nested_tr = true;
}
$sth = $this->pdo->prepare("SELECT pref_name, value FROM ttrss_user_prefs
WHERE owner_uid = :uid AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "profile" => $profile_id]);
while ($row = $sth->fetch(PDO::FETCH_ASSOC)) {
if (isset(self::_DEFAULTS[$row["pref_name"]])) {
list ($def_val, $type_hint) = self::_DEFAULTS[$row["pref_name"]];
$user_val = Config::cast_to($row["value"], $type_hint);
if ($user_val != $def_val) {
$this->_set($row["pref_name"], $user_val, $owner_uid, $profile_id);
}
}
}
$this->_set(Prefs::_PREFS_MIGRATED, "1", $owner_uid, $profile_id);
if (!$in_nested_tr)
$this->pdo->commit();
Logger::log(E_USER_NOTICE, sprintf("Migrated preferences of user %d (profile %d)", $owner_uid, $profile_id));
}
}
static function reset(int $owner_uid, ?int $profile_id): void {
if (!$profile_id) $profile_id = null;
$sth = Db::pdo()->prepare("DELETE FROM ttrss_user_prefs2
WHERE owner_uid = :uid AND pref_name != :mig_key AND
(profile = :profile OR (:profile IS NULL AND profile IS NULL))");
$sth->execute(["uid" => $owner_uid, "mig_key" => self::_PREFS_MIGRATED, "profile" => $profile_id]);
}
}

View File

@@ -1,725 +0,0 @@
<?php
class RPC extends Handler_Protected {
/*function csrf_ignore(string $method): bool {
$csrf_ignored = array("completelabels");
return array_search($method, $csrf_ignored) !== false;
}*/
/**
* @return array<string, string>
*/
private function _translations_as_array(): array {
global $text_domains;
$rv = [];
foreach (array_keys($text_domains) as $domain) {
/** @var gettext_reader $l10n */
$l10n = _get_reader($domain);
for ($i = 0; $i < $l10n->total; $i++) {
if (isset($l10n->table_originals[$i * 2 + 2]) && $orig = $l10n->get_original_string($i)) {
if(str_contains($orig, "\000")) { // Plural forms
$key = explode(chr(0), $orig);
$rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular
$rv[$key[1]] = _ngettext($key[0], $key[1], 2); // Plural
} else {
$translation = _dgettext($domain,$orig);
$rv[$orig] = $translation;
}
}
}
}
return $rv;
}
function togglepref(): void {
$key = clean($_REQUEST["key"]);
$profile = $_SESSION['profile'] ?? null;
Prefs::set($key, !Prefs::get($key, $_SESSION['uid'], $profile), $_SESSION['uid'], $profile);
$value = Prefs::get($key, $_SESSION['uid'], $profile);
print json_encode(array("param" =>$key, "value" => $value));
}
function setpref(): void {
// set_pref escapes input, so no need to double escape it here
$key = clean($_REQUEST['key']);
$value = $_REQUEST['value'];
Prefs::set($key, $value, $_SESSION['uid'], $_SESSION['profile'] ?? null, $key != 'USER_STYLESHEET');
print json_encode(array("param" =>$key, "value" => $value));
}
function mark(): void {
$mark = clean($_REQUEST["mark"]);
$id = clean($_REQUEST["id"]);
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET marked = ?,
last_marked = NOW()
WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$mark, $id, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, [$id]);
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function delete(): void {
$ids = explode(",", clean($_REQUEST["ids"]));
$ids_qmarks = arr_qmarks($ids);
$sth = $this->pdo->prepare("DELETE FROM ttrss_user_entries
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
$sth->execute([...$ids, $_SESSION['uid']]);
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function publ(): void {
$pub = clean($_REQUEST["pub"]);
$id = clean($_REQUEST["id"]);
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
published = ?, last_published = NOW()
WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$pub, $id, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$id]);
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function getRuntimeInfo(): void {
$reply = [
'runtime-info' => $this->_make_runtime_info()
];
print json_encode($reply);
}
function getAllCounters(): void {
@$seq = (int) $_REQUEST['seq'];
$feed_id_count = (int) ($_REQUEST["feed_id_count"] ?? -1);
$label_id_count = (int) ($_REQUEST["label_id_count"] ?? -1);
// it seems impossible to distinguish empty array [] from a null - both become unset in $_REQUEST
// so, count is >= 0 means we had an array, -1 means null
// we need null because it means "return all counters"; [] would return nothing
if ($feed_id_count == -1)
$feed_ids = null;
else
$feed_ids = array_map("intval", clean($_REQUEST["feed_ids"] ?? []));
if ($label_id_count == -1)
$label_ids = null;
else
$label_ids = array_map("intval", clean($_REQUEST["label_ids"] ?? []));
$counters = is_array($feed_ids)
&& !Prefs::get(Prefs::DISABLE_CONDITIONAL_COUNTERS, $_SESSION['uid'], $_SESSION['profile'] ?? null) ?
Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
$reply = [
'counters' => $counters,
'seq' => $seq
];
print json_encode($reply);
}
/* GET["cmode"] = 0 - mark as read, 1 - as unread, 2 - toggle */
function catchupSelected(): void {
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
if (count($ids) > 0)
Article::_catchup_by_id($ids, $cmode);
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function markSelected(): void {
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
if (count($ids) > 0)
$this->markArticlesById($ids, $cmode);
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function publishSelected(): void {
$ids = array_map("intval", clean($_REQUEST["ids"] ?? []));
$cmode = (int)clean($_REQUEST["cmode"]);
if (count($ids) > 0)
$this->publishArticlesById($ids, $cmode);
print json_encode(["message" => "UPDATE_COUNTERS",
"labels" => Article::_labels_of($ids),
"feeds" => Article::_feeds_of($ids)]);
}
function sanityCheck(): void {
$_SESSION["hasSandbox"] = self::_param_to_bool($_REQUEST["hasSandbox"] ?? false);
$_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
$client_location = $_REQUEST["clientLocation"];
$error = Errors::E_SUCCESS;
$error_params = [];
$client_scheme = parse_url($client_location, PHP_URL_SCHEME);
$server_scheme = parse_url(Config::get_self_url(), PHP_URL_SCHEME);
if (Config::is_migration_needed()) {
$error = Errors::E_SCHEMA_MISMATCH;
} else if ($client_scheme != $server_scheme) {
$error = Errors::E_URL_SCHEME_MISMATCH;
$error_params["client_scheme"] = $client_scheme;
$error_params["server_scheme"] = $server_scheme;
$error_params["self_url_path"] = Config::get_self_url();
}
if ($error == Errors::E_SUCCESS) {
$reply = [];
$reply['init-params'] = $this->_make_init_params();
$reply['runtime-info'] = $this->_make_runtime_info();
$reply['translations'] = $this->_translations_as_array();
print json_encode($reply);
} else {
print Errors::to_json($error, $error_params);
}
}
/*function completeLabels() {
$search = clean($_REQUEST["search"]);
$sth = $this->pdo->prepare("SELECT DISTINCT caption FROM
ttrss_labels2
WHERE owner_uid = ? AND
LOWER(caption) LIKE LOWER(?) ORDER BY caption
LIMIT 5");
$sth->execute([$_SESSION['uid'], "%$search%"]);
print "<ul>";
while ($line = $sth->fetch()) {
print "<li>" . $line["caption"] . "</li>";
}
print "</ul>";
}*/
function catchupFeed(): void {
$feed_id = clean($_REQUEST['feed_id']);
$is_cat = self::_param_to_bool($_REQUEST['is_cat'] ?? false);
$mode = clean($_REQUEST['mode'] ?? '');
$search_query = clean($_REQUEST['search_query']);
$search_lang = clean($_REQUEST['search_lang']);
Feeds::_catchup($feed_id, $is_cat, null, $mode, [$search_query, $search_lang]);
// return counters here synchronously so that frontend can figure out next unread feed properly
print json_encode(['counters' => Counters::get_all()]);
//print json_encode(array("message" => "UPDATE_COUNTERS"));
}
function setWidescreen(): void {
$wide = (int) clean($_REQUEST["wide"]);
Prefs::set(Prefs::WIDESCREEN_MODE, $wide, $_SESSION['uid'], $_SESSION['profile'] ?? null);
print json_encode(["wide" => $wide]);
}
/**
* @param array<int, int> $ids
*/
private function markArticlesById(array $ids, int $cmode): void {
$ids_qmarks = arr_qmarks($ids);
if ($cmode == Article::CATCHUP_MODE_MARK_AS_READ) {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
marked = false, last_marked = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
} else if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
marked = true, last_marked = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
} else {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
marked = NOT marked,last_marked = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
}
$sth->execute([...$ids, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $ids);
}
/**
* @param array<int, int> $ids
*/
private function publishArticlesById(array $ids, int $cmode): void {
$ids_qmarks = arr_qmarks($ids);
if ($cmode == Article::CATCHUP_MODE_MARK_AS_READ) {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
published = false, last_published = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
} else if ($cmode == Article::CATCHUP_MODE_MARK_AS_UNREAD) {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
published = true, last_published = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
} else {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
published = NOT published,last_published = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
}
$sth->execute([...$ids, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $ids);
}
function log(): void {
$msg = clean($_REQUEST['msg'] ?? "");
$file = basename(clean($_REQUEST['file'] ?? ""));
$line = (int) clean($_REQUEST['line'] ?? 0);
$context = clean($_REQUEST['context'] ?? "");
if ($msg) {
Logger::log_error(E_USER_WARNING,
$msg, 'client-js:' . $file, $line, $context);
echo json_encode(array("message" => "HOST_ERROR_LOGGED"));
}
}
function checkforupdates(): void {
$rv = ["changeset" => [], "plugins" => []];
$version = Config::get_version(false);
$git_timestamp = $version["timestamp"] ?? false;
$git_commit = $version["commit"] ?? false;
// TODO: Get this working again. https://tt-rss.org/version.json won't exist after 2025-11-01 (probably).
if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && $git_timestamp) {
// $content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
$content = false;
/** @phpstan-ignore if.alwaysFalse (intentionally disabling for now) */
if ($content) {
$content = json_decode($content, true);
if ($content && isset($content["changeset"])) {
if ($git_timestamp < (int)$content["changeset"]["timestamp"] &&
$git_commit != $content["changeset"]["id"]) {
$rv["changeset"] = $content["changeset"];
}
}
}
$rv["plugins"] = Pref_Prefs::_get_updated_plugins();
}
print json_encode($rv);
}
/**
* @return array<string, mixed>
*/
private function _make_init_params(): array {
$profile = $_SESSION['profile'] ?? null;
$params = array();
foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS,
Prefs::ENABLE_FEED_CATS, Prefs::FEEDS_SORT_BY_UNREAD,
Prefs::CONFIRM_FEED_CATCHUP, Prefs::CDM_AUTO_CATCHUP,
Prefs::FRESH_ARTICLE_MAX_AGE, Prefs::HIDE_READ_SHOWS_SPECIAL,
Prefs::COMBINED_DISPLAY_MODE, Prefs::DEBUG_HEADLINE_IDS, Prefs::CDM_ENABLE_GRID] as $param) {
$params[strtolower($param)] = (int) Prefs::get($param, $_SESSION['uid'], $profile);
}
$params["safe_mode"] = !empty($_SESSION["safe_mode"]);
$params["check_for_updates"] = Config::get(Config::CHECK_FOR_UPDATES);
$params["icons_url"] = Config::get_self_url() . '/public.php';
$params["cookie_lifetime"] = Config::get(Config::SESSION_COOKIE_LIFETIME);
$params["default_view_mode"] = Prefs::get(Prefs::_DEFAULT_VIEW_MODE, $_SESSION['uid'], $profile);
$params["default_view_limit"] = (int) Prefs::get(Prefs::_DEFAULT_VIEW_LIMIT, $_SESSION['uid'], $profile);
$params["default_view_order_by"] = Prefs::get(Prefs::_DEFAULT_VIEW_ORDER_BY, $_SESSION['uid'], $profile);
$params["bw_limit"] = (int) ($_SESSION["bw_limit"] ?? false);
$params["is_default_pw"] = UserHelper::is_default_password();
$params["label_base_index"] = LABEL_BASE_INDEX;
$theme = Prefs::get(Prefs::USER_CSS_THEME, $_SESSION['uid'], $profile);
$params["theme"] = theme_exists($theme) ? $theme : "";
$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
$params["php_platform"] = PHP_OS;
$params["php_version"] = PHP_VERSION;
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
$max_feed_id = $row["mid"];
$num_feeds = $row["nf"];
$params["self_url_prefix"] = Config::get_self_url();
$params["max_feed_id"] = (int) $max_feed_id;
$params["num_feeds"] = (int) $num_feeds;
$params["hotkeys"] = $this->get_hotkeys_map();
$params["widescreen"] = (int) Prefs::get(Prefs::WIDESCREEN_MODE, $_SESSION['uid'], $profile);
$params["icon_indicator_white"] = $this->image_to_base64("images/indicator_white.gif");
$params["icon_oval"] = $this->image_to_base64("images/oval.svg");
$params["icon_three_dots"] = $this->image_to_base64("images/three-dots.svg");
$params["icon_blank"] = $this->image_to_base64("images/blank_icon.gif");
$params["labels"] = Labels::get_all($_SESSION["uid"]);
return $params;
}
private function image_to_base64(string $filename): string {
if (file_exists($filename)) {
$ext = pathinfo($filename, PATHINFO_EXTENSION);
if ($ext == "svg") $ext = "svg+xml";
return "data:image/$ext;base64," . base64_encode((string)file_get_contents($filename));
} else {
return "";
}
}
/**
* @return array<string, mixed>
*/
static function _make_runtime_info(): array {
$data = array();
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT MAX(id) AS mid, COUNT(*) AS nf FROM
ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
$max_feed_id = $row['mid'];
$num_feeds = $row['nf'];
$data["max_feed_id"] = (int) $max_feed_id;
$data["num_feeds"] = (int) $num_feeds;
$data['cdm_expanded'] = Prefs::get(Prefs::CDM_EXPANDED, $_SESSION['uid'], $_SESSION['profile'] ?? null);
$data["labels"] = Labels::get_all($_SESSION["uid"]);
if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN) {
$sth = $pdo->prepare("SELECT COUNT(id) AS cid
FROM ttrss_error_log
WHERE
errno NOT IN (".E_USER_NOTICE.", ".E_USER_DEPRECATED.") AND
created_at > NOW() - INTERVAL '1 hour' AND
errstr NOT LIKE '%Returning bool from comparison function is deprecated%' AND
errstr NOT LIKE '%imagecreatefromstring(): Data is not in a recognized format%'");
$sth->execute();
if ($row = $sth->fetch()) {
$data['recent_log_events'] = $row['cid'];
}
}
if (file_exists(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.lock")) {
$data['daemon_is_running'] = (int) file_is_locked("update_daemon.lock");
if (time() - ($_SESSION["daemon_stamp_check"] ?? 0) > 30) {
$stamp = (int) @file_get_contents(Config::get(Config::LOCK_DIRECTORY) . "/update_daemon.stamp");
if ($stamp) {
$stamp_delta = time() - $stamp;
if ($stamp_delta > 1800) {
$stamp_check = 0;
} else {
$stamp_check = 1;
$_SESSION["daemon_stamp_check"] = time();
}
$data['daemon_stamp_ok'] = $stamp_check;
$stamp_fmt = date("Y.m.d, G:i", $stamp);
$data['daemon_stamp'] = $stamp_fmt;
}
}
}
return $data;
}
/**
* @return array<string, array<string, string>>
*/
static function get_hotkeys_info(): array {
$hotkeys = array(
__("Navigation") => array(
"next_feed" => __("Open next feed"),
"next_unread_feed" => __("Open next unread feed"),
"prev_feed" => __("Open previous feed"),
"prev_unread_feed" => __("Open previous unread feed"),
"next_article_or_scroll" => __("Open next article (in combined mode, scroll down)"),
"prev_article_or_scroll" => __("Open previous article (in combined mode, scroll up)"),
"next_headlines_page" => __("Scroll headlines by one page down"),
"prev_headlines_page" => __("Scroll headlines by one page up"),
"next_article_noscroll" => __("Open next article"),
"prev_article_noscroll" => __("Open previous article"),
"next_article_noexpand" => __("Move to next article (don't expand)"),
"prev_article_noexpand" => __("Move to previous article (don't expand)"),
"search_dialog" => __("Show search dialog"),
"cancel_search" => __("Cancel active search")),
__("Article") => array(
"toggle_mark" => __("Toggle starred"),
"toggle_publ" => __("Toggle published"),
"toggle_unread" => __("Toggle unread"),
"edit_tags" => __("Edit tags"),
"open_in_new_window" => __("Open in new window"),
"catchup_below" => __("Mark below as read"),
"catchup_above" => __("Mark above as read"),
"article_scroll_down" => __("Scroll down"),
"article_scroll_up" => __("Scroll up"),
"article_page_down" => __("Scroll down page"),
"article_page_up" => __("Scroll up page"),
"select_article_cursor" => __("Select article under cursor"),
"email_article" => __("Email article"),
"close_article" => __("Close/collapse article"),
"toggle_expand" => __("Toggle article expansion (combined mode)"),
"toggle_widescreen" => __("Toggle widescreen mode"),
"toggle_full_text" => __("Toggle full article text via Readability")),
__("Article selection") => array(
"select_all" => __("Select all articles"),
"select_unread" => __("Select unread"),
"select_marked" => __("Select starred"),
"select_published" => __("Select published"),
"select_invert" => __("Invert selection"),
"select_none" => __("Deselect everything")),
__("Feed") => array(
"feed_refresh" => __("Refresh current feed"),
"feed_unhide_read" => __("Un/hide read feeds"),
"feed_subscribe" => __("Subscribe to feed"),
"feed_edit" => __("Edit feed"),
"feed_catchup" => __("Mark as read"),
"feed_reverse" => __("Reverse headlines"),
"feed_toggle_vgroup" => __("Toggle headline grouping"),
"feed_toggle_grid" => __("Toggle grid view"),
"feed_debug_update" => __("Debug feed update"),
"feed_debug_viewfeed" => __("Debug viewfeed()"),
"catchup_all" => __("Mark all feeds as read"),
"cat_toggle_collapse" => __("Un/collapse current category"),
"toggle_cdm_expanded" => __("Toggle auto expand in combined mode"),
"toggle_combined_mode" => __("Toggle combined mode")),
__("Go to") => array(
"goto_all" => __("All articles"),
"goto_fresh" => __("Fresh"),
"goto_marked" => __("Starred"),
"goto_published" => __("Published"),
"goto_read" => __("Recently read"),
"goto_prefs" => __("Preferences")),
__("Other") => array(
"create_label" => __("Create label"),
"create_filter" => __("Create filter"),
"collapse_sidebar" => __("Un/collapse sidebar"),
"help_dialog" => __("Show help dialog"))
);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_INFO,
function ($result) use (&$hotkeys) {
$hotkeys = $result;
},
$hotkeys);
return $hotkeys;
}
/**
* {3} - 3 panel mode only
* {C} - combined mode only
*
* @return array{0: array<int, string>, 1: array<string, string>} $prefixes, $hotkeys
*/
static function get_hotkeys_map() {
$hotkeys = array(
"k" => "next_feed",
"K" => "next_unread_feed",
"j" => "prev_feed",
"J" => "prev_unread_feed",
"n" => "next_article_noscroll",
"p" => "prev_article_noscroll",
"N" => "article_page_down",
"P" => "article_page_up",
"*(33)|Shift+PgUp" => "article_page_up",
"*(34)|Shift+PgDn" => "article_page_down",
"{3}(38)|Up" => "prev_article_or_scroll",
"{3}(40)|Down" => "next_article_or_scroll",
"*(38)|Shift+Up" => "article_scroll_up",
"*(40)|Shift+Down" => "article_scroll_down",
"^(38)|Ctrl+Up" => "prev_article_noscroll",
"^(40)|Ctrl+Down" => "next_article_noscroll",
"/" => "search_dialog",
"\\" => "cancel_search",
"s" => "toggle_mark",
"S" => "toggle_publ",
"u" => "toggle_unread",
"T" => "edit_tags",
"o" => "open_in_new_window",
"c p" => "catchup_below",
"c n" => "catchup_above",
"a W" => "toggle_widescreen",
"a e" => "toggle_full_text",
"e" => "email_article",
"a q" => "close_article",
"a s" => "article_span_grid",
"a a" => "select_all",
"a u" => "select_unread",
"a U" => "select_marked",
"a p" => "select_published",
"a i" => "select_invert",
"a n" => "select_none",
"f r" => "feed_refresh",
"f a" => "feed_unhide_read",
"f s" => "feed_subscribe",
"f e" => "feed_edit",
"f q" => "feed_catchup",
"f x" => "feed_reverse",
"f g" => "feed_toggle_vgroup",
"f G" => "feed_toggle_grid",
"f D" => "feed_debug_update",
"f %" => "feed_debug_viewfeed",
"f C" => "toggle_combined_mode",
"f c" => "toggle_cdm_expanded",
"Q" => "catchup_all",
"x" => "cat_toggle_collapse",
"g a" => "goto_all",
"g f" => "goto_fresh",
"g s" => "goto_marked",
"g p" => "goto_published",
"g r" => "goto_read",
"g P" => "goto_prefs",
"r" => "select_article_cursor",
"c l" => "create_label",
"c f" => "create_filter",
"c s" => "collapse_sidebar",
"?" => "help_dialog",
);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_HOTKEY_MAP,
function ($result) use (&$hotkeys) {
$hotkeys = $result;
},
$hotkeys);
$prefixes = array();
foreach (array_keys($hotkeys) as $hotkey) {
$pair = explode(" ", (string)$hotkey, 2);
if (count($pair) > 1 && !in_array($pair[0], $prefixes)) {
array_push($prefixes, $pair[0]);
}
}
return array($prefixes, $hotkeys);
}
function hotkeyHelp(): void {
$info = self::get_hotkeys_info();
$imap = self::get_hotkeys_map();
$omap = [];
foreach ($imap[1] as $sequence => $action) {
$omap[$action] ??= [];
$omap[$action][] = $sequence;
}
?>
<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>
<?php
foreach ($info as $section => $hotkeys) {
?>
<li><h3><?= $section ?></h3></li>
<?php
foreach ($hotkeys as $action => $description) {
if (!empty($omap[$action])) {
foreach ($omap[$action] as $sequence) {
if (str_contains($sequence, "|")) {
$sequence = substr($sequence,
strpos($sequence, "|")+1,
strlen($sequence));
} else {
$keys = explode(" ", $sequence);
for ($i = 0; $i < count($keys); $i++) {
if (strlen($keys[$i]) > 1) {
$tmp = '';
foreach (str_split($keys[$i]) as $c) {
$tmp .= match ($c) {
'*' => __('Shift') . '+',
'^' => __('Ctrl') . '+',
default => $c,
};
}
$keys[$i] = $tmp;
}
}
$sequence = join(" ", $keys);
}
?>
<li>
<div class='hk'><code><?= $sequence ?></code></div>
<div class='desc'><?= $description ?></div>
</li>
<?php
}
}
}
}
?>
</ul>
<footer class='text-center'>
<?= \Controls\submit_tag(__('Close this window')) ?>
</footer>
<?php
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,274 +0,0 @@
<?php
class Sanitizer {
/**
* @param array<int, string> $allowed_elements
* @param array<int, string> $disallowed_attributes
*/
private static function strip_harmful_tags(DOMDocument $doc, array $allowed_elements, $disallowed_attributes): DOMDocument {
$xpath = new DOMXPath($doc);
$entries = $xpath->query('//*');
foreach ($entries as $entry) {
/** @var DOMElement $entry */
if (!in_array($entry->nodeName, $allowed_elements)) {
$entry->parentNode->removeChild($entry);
}
if ($entry->hasAttributes()) {
$attrs_to_remove = array();
foreach ($entry->attributes as $attr) {
if (str_starts_with($attr->nodeName, 'on')) {
array_push($attrs_to_remove, $attr);
}
if (str_starts_with($attr->nodeName, 'data-')) {
array_push($attrs_to_remove, $attr);
}
if ($attr->nodeName == 'href' && stripos($attr->value, 'javascript:') === 0) {
array_push($attrs_to_remove, $attr);
}
if (in_array($attr->nodeName, $disallowed_attributes)) {
array_push($attrs_to_remove, $attr);
}
}
foreach ($attrs_to_remove as $attr) {
$entry->removeAttributeNode($attr);
}
}
}
return $doc;
}
public static function iframe_whitelisted(DOMElement $entry): bool {
$src = parse_url($entry->getAttribute("src"), PHP_URL_HOST);
if (!empty($src))
return PluginHost::getInstance()->run_hooks_until(PluginHost::HOOK_IFRAME_WHITELISTED, true, $src);
return false;
}
private static function is_prefix_https(): bool {
return parse_url(Config::get_self_url(), PHP_URL_SCHEME) == 'https';
}
/** @param array<string> $words */
public static function highlight_words_str(string $str, array $words) : string {
$doc = new DOMDocument();
if ($doc->loadHTML('<?xml encoding="UTF-8"><span>' . $str . '</span>')) {
$xpath = new DOMXPath($doc);
if (self::highlight_words($doc, $xpath, $words)) {
$res = $doc->saveHTML();
/* strip everything outside of <body>...</body> */
$res_frag = array();
if (preg_match('/<body>(.*)<\/body>/is', $res, $res_frag)) {
return $res_frag[1];
} else {
return $res;
}
}
}
return $str;
}
/** @param array<string> $words */
public static function highlight_words(DOMDocument &$doc, DOMXPath $xpath, array $words) : bool {
$rv = false;
foreach ($words as $word) {
// http://stackoverflow.com/questions/4081372/highlight-keywords-in-a-paragraph
$elements = $xpath->query("//*/text()");
foreach ($elements as $child) {
$fragment = $doc->createDocumentFragment();
$text = $child->textContent;
while (($pos = mb_stripos($text, $word)) !== false) {
$fragment->appendChild(new DOMText(mb_substr($text, 0, (int)$pos)));
$word = mb_substr($text, (int)$pos, mb_strlen($word));
$highlight = $doc->createElement('span');
$highlight->appendChild(new DOMText($word));
$highlight->setAttribute('class', 'highlight');
$fragment->appendChild($highlight);
$text = mb_substr($text, $pos + mb_strlen($word));
}
if (!empty($text)) $fragment->appendChild(new DOMText($text));
$child->parentNode->replaceChild($fragment, $child);
$rv = true;
}
}
return $rv;
}
/**
* @param array<int, string>|null $highlight_words Words to highlight in the HTML output.
*
* @return false|string The HTML, or false if an error occurred.
*/
public static function sanitize(string $str, ?bool $force_remove_images = false, ?int $owner = null, ?string $site_url = null, ?array $highlight_words = null, ?int $article_id = null): false|string {
if (!$owner && isset($_SESSION["uid"]))
$owner = $_SESSION["uid"];
$profile = isset($_SESSION['uid']) && $owner == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
$res = trim($str); if (!$res) return '';
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $res);
$xpath = new DOMXPath($doc);
// is it a good idea to possibly rewrite urls to our own prefix?
// $rewrite_base_url = $site_url ? $site_url : Config::get_self_url();
$rewrite_base_url = $site_url ? $site_url : "http://domain.invalid/";
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src]|//video[@poster])');
/** @var DOMElement $entry */
foreach ($entries as $entry) {
if ($entry->hasAttribute('href')) {
$entry->setAttribute('href',
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('href'), $entry->tagName, "href"));
$entry->setAttribute('rel', 'noopener noreferrer');
$entry->setAttribute("target", "_blank");
}
if ($entry->hasAttribute('src')) {
$entry->setAttribute('src',
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('src'), $entry->tagName, "src"));
}
if ($entry->nodeName == 'img') {
$entry->setAttribute('referrerpolicy', 'no-referrer');
$entry->setAttribute('loading', 'lazy');
}
if ($entry->hasAttribute('srcset')) {
$matches = RSSUtils::decode_srcset($entry->getAttribute('srcset'));
for ($i = 0; $i < count($matches); $i++) {
$matches[$i]["url"] = UrlHelper::rewrite_relative($rewrite_base_url, $matches[$i]["url"]);
}
$entry->setAttribute("srcset", RSSUtils::encode_srcset($matches));
}
if ($entry->hasAttribute('poster')) {
$entry->setAttribute('poster',
UrlHelper::rewrite_relative($rewrite_base_url, $entry->getAttribute('poster'), $entry->tagName, "poster"));
}
if ($entry->hasAttribute('src') &&
($owner && Prefs::get(Prefs::STRIP_IMAGES, $owner, $profile)) || $force_remove_images || ($_SESSION['bw_limit'] ?? false)) {
$p = $doc->createElement('p');
$a = $doc->createElement('a');
$a->setAttribute('href', $entry->getAttribute('src'));
$a->appendChild(new DOMText($entry->getAttribute('src')));
$a->setAttribute('target', '_blank');
$a->setAttribute('rel', 'noopener noreferrer');
$p->appendChild($a);
if ($entry->nodeName == 'source') {
if ($entry->parentNode && $entry->parentNode->parentNode)
$entry->parentNode->parentNode->replaceChild($p, $entry->parentNode);
} else if ($entry->nodeName == 'img') {
if ($entry->parentNode)
$entry->parentNode->replaceChild($p, $entry);
}
}
}
$entries = $xpath->query('//iframe');
/** @var DOMElement $entry */
foreach ($entries as $entry) {
if (!self::iframe_whitelisted($entry)) {
$entry->setAttribute('sandbox', 'allow-scripts');
} else {
if (self::is_prefix_https()) {
$entry->setAttribute("src",
str_replace("http://", "https://",
$entry->getAttribute("src")));
}
}
}
$allowed_elements = array('a', 'abbr', 'address', 'acronym', 'audio', 'article', 'aside',
'b', 'bdi', 'bdo', 'big', 'blockquote', 'body', 'br',
'caption', 'cite', 'center', 'code', 'col', 'colgroup',
'data', 'dd', 'del', 'details', 'description', 'dfn', 'div', 'dl', 'font',
'dt', 'em', 'footer', 'figure', 'figcaption',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'html', 'i',
'img', 'ins', 'kbd', 'li', 'main', 'mark', 'nav', 'noscript',
'ol', 'p', 'picture', 'pre', 'q', 'ruby', 'rp', 'rt', 's', 'samp', 'section',
'small', 'source', 'span', 'strike', 'strong', 'sub', 'summary',
'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time',
'tr', 'track', 'tt', 'u', 'ul', 'var', 'wbr', 'video', 'xml:namespace' );
if ($_SESSION['hasSandbox'] ?? false) $allowed_elements[] = 'iframe';
$disallowed_attributes = array('id', 'style', 'class', 'width', 'height', 'allow');
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_SANITIZE,
function ($result) use (&$doc, &$allowed_elements, &$disallowed_attributes) {
if (is_array($result)) {
$doc = $result[0];
$allowed_elements = $result[1];
$disallowed_attributes = $result[2];
} else {
$doc = $result;
}
},
$doc, $site_url, $allowed_elements, $disallowed_attributes, $article_id);
$doc->removeChild($doc->firstChild); //remove doctype
$doc = self::strip_harmful_tags($doc, $allowed_elements, $disallowed_attributes);
$entries = $xpath->query('//iframe');
foreach ($entries as $entry) {
$div = $doc->createElement('div');
$div->setAttribute('class', 'embed-responsive');
$entry->parentNode->replaceChild($div, $entry);
$div->appendChild($entry);
}
if (is_array($highlight_words))
self::highlight_words($doc, $xpath, $highlight_words);
$res = $doc->saveHTML();
/* strip everything outside of <body>...</body> */
$res_frag = array();
if (preg_match('/<body>(.*)<\/body>/is', $res, $res_frag)) {
return $res_frag[1];
} else {
return $res;
}
}
}

View File

@@ -1,171 +0,0 @@
<?php
class Scheduler {
private static ?Scheduler $instance = null;
const TASK_RC_EXCEPTION = -100;
const DEFAULT_NAME = 'Default Scheduler';
/** @var array<string, mixed> */
private array $scheduled_tasks = [];
private string $name;
function __construct(string $name = self::DEFAULT_NAME) {
$this->set_name($name);
if ($name === self::DEFAULT_NAME) {
$this->add_scheduled_task('purge_orphaned_scheduled_tasks', '@weekly',
function() {
return $this->purge_orphaned_tasks();
}
);
}
}
public static function getInstance(): Scheduler {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
/** Sets specific identifier for this instance of Scheduler used in debug logging */
public function set_name(string $name) : void {
$this->name = $name;
}
/**
* Adds a backend scheduled task which will be executed by updater (if due) during housekeeping.
*
* The granularity is not strictly guaranteed, housekeeping is invoked several times per hour
* depending on how fast feed batch was processed, but no more than once per minute.
*
* Tasks do not run in user context. Task names may not overlap. Plugins should register tasks
* via PluginHost methods (to be implemented later).
*
* Tasks should return an integer value (return code) which is stored in the database, a value of
* 0 is considered successful.
*
* @param string $task_name unique name for this task, plugins should prefix this with plugin name
* @param string $cron_expression schedule for this task in cron format
* @param Closure $callback task code that gets executed
*/
function add_scheduled_task(string $task_name, string $cron_expression, Closure $callback) : bool {
$task_name = strtolower($task_name);
if (isset($this->scheduled_tasks[$task_name])) {
user_error("[$this->name] Attempted to override already registered scheduled task $task_name", E_USER_WARNING);
return false;
} else {
try {
$cron = new Cron\CronExpression($cron_expression);
} catch (InvalidArgumentException $e) {
user_error("[$this->name] Attempt to register scheduled task $task_name failed: " . $e->getMessage(), E_USER_WARNING);
return false;
}
$this->scheduled_tasks[$task_name] = [
"cron" => $cron,
"callback" => $callback,
];
return true;
}
}
/**
* Execute scheduled tasks which are due to run and record last run timestamps.
*/
function run_due_tasks() : void {
Debug::log("[$this->name] Processing all scheduled tasks...");
$tasks_succeeded = 0;
$tasks_failed = 0;
foreach ($this->scheduled_tasks as $task_name => $task) {
$task_record = ORM::for_table('ttrss_scheduled_tasks')
->where('task_name', $task_name)
->find_one();
if ($task_record)
$last_run = $task_record->last_run;
else
$last_run = '1970-01-01 00:00';
// because we don't schedule tasks every minute, we assume that task is due if its
// next estimated run based on previous timestamp is in the past
if ($task['cron']->getNextRunDate($last_run)->getTimestamp() - time() < 0) {
Debug::log("=> Scheduled task $task_name is due, executing...");
$task_started = time();
try {
$rc = (int) $task['callback']();
} catch (Exception $e) {
user_error("[$this->name] Scheduled task $task_name failed with exception: " . $e->getMessage(), E_USER_WARNING);
$rc = self::TASK_RC_EXCEPTION;
}
$task_duration = time() - $task_started;
if ($rc === 0) {
++$tasks_succeeded;
Debug::log("<= Scheduled task $task_name has finished in $task_duration seconds.");
} else {
$tasks_failed++;
Debug::log("!! Scheduled task $task_name has failed with RC: $rc after $task_duration seconds.");
}
if ($task_record) {
$task_record->last_run = Db::NOW();
$task_record->last_duration = $task_duration;
$task_record->last_rc = $rc;
$task_record->last_cron_expression = $task['cron']->getExpression();
$task_record->save();
} else {
$task_record = ORM::for_table('ttrss_scheduled_tasks')->create();
$task_record->set([
'task_name' => $task_name,
'last_duration' => $task_duration,
'last_rc' => $rc,
'last_run' => Db::NOW(),
'last_cron_expression' => $task['cron']->getExpression()
]);
$task_record->save();
}
}
}
Debug::log("[$this->name] Processing scheduled tasks finished with $tasks_succeeded tasks succeeded and $tasks_failed tasks failed.");
}
/**
* Purge records of scheduled tasks that aren't currently registered
* and haven't ran for a long time.
*
* @return int 0 if successful, 1 on failure
*/
private function purge_orphaned_tasks(): int {
if (!$this->scheduled_tasks) {
Debug::log(__METHOD__ . ' was invoked before scheduled tasks have been registered. This should never happen.');
return 1;
}
$result = ORM::for_table('ttrss_scheduled_tasks')
->where_not_in('task_name', array_keys($this->scheduled_tasks))
->where_raw("last_run < NOW() - INTERVAL '5 weeks'")
->delete_many();
if ($result) {
$deleted_count = ORM::get_last_statement()->rowCount();
if ($deleted_count)
Debug::log("Purged {$deleted_count} orphaned scheduled tasks.");
}
return $result ? 0 : 1;
}
}

View File

@@ -1,156 +0,0 @@
<?php
require_once 'lib/gettext/gettext.inc.php';
/**
* @todo look into making this behave closer to what SessionHandlerInterface intends
*/
class Sessions implements \SessionHandlerInterface {
private int $session_expire;
private string $session_name;
public function __construct() {
$this->session_expire = min(2147483647 - time() - 1, Config::get(Config::SESSION_COOKIE_LIFETIME));
$this->session_name = Config::get(Config::SESSION_NAME);
}
/**
* Adjusts session-related PHP configuration options
*/
public function configure(): void {
if (Config::is_server_https()) {
ini_set('session.cookie_secure', 'true');
}
ini_set('session.gc_probability', '75');
ini_set('session.name', $this->session_name);
ini_set('session.use_only_cookies', 'true');
ini_set('session.gc_maxlifetime', $this->session_expire);
ini_set('session.cookie_lifetime', '0');
}
/**
* Extend the validity of the PHP session cookie (if it exists) and is persistent (expire > 0)
* @return bool Whether the new cookie was set successfully
*/
public function extend_session(): bool {
if (isset($_COOKIE[$this->session_name]) && $this->session_expire > 0) {
return setcookie($this->session_name,
$_COOKIE[$this->session_name],
time() + $this->session_expire,
ini_get('session.cookie_path'),
ini_get('session.cookie_domain'),
ini_get('session.cookie_secure'),
ini_get('session.cookie_httponly'));
}
return false;
}
public function open(string $path, string $name): bool {
return true;
}
public function close(): bool {
return true;
}
public function read(string $id): false|string {
$sth = Db::pdo()->prepare('SELECT data FROM ttrss_sessions WHERE id=?');
$sth->execute([$id]);
if ($row = $sth->fetch()) {
$data = base64_decode($row['data']);
if (Config::get(Config::ENCRYPTION_KEY)) {
$unserialized_data = @unserialize($data); // avoid leaking plaintext session via error message
if ($unserialized_data !== false)
return Crypt::decrypt_string($unserialized_data);
}
// if encryption key is missing or session data is not in serialized format, assume plaintext data and return as-is
return $data;
}
$expire = time() + $this->session_expire;
$sth = Db::pdo()->prepare("INSERT INTO ttrss_sessions (id, data, expire)
VALUES (?, '', ?)");
return $sth->execute([$id, $expire]) ? '' : false;
}
public function write(string $id, string $data): bool {
if (Config::get(Config::ENCRYPTION_KEY))
$data = serialize(Crypt::encrypt_string($data));
$data = base64_encode($data);
$expire = time() + $this->session_expire;
$sth = Db::pdo()->prepare('SELECT id FROM ttrss_sessions WHERE id=?');
$sth->execute([$id]);
if ($sth->fetch()) {
$sth = Db::pdo()->prepare('UPDATE ttrss_sessions SET data=?, expire=? WHERE id=?');
return $sth->execute([$data, $expire, $id]);
}
$sth = Db::pdo()->prepare('INSERT INTO ttrss_sessions (id, data, expire) VALUES (?, ?, ?)');
return $sth->execute([$id, $data, $expire]);
}
public function destroy(string $id): bool {
$sth = Db::pdo()->prepare('DELETE FROM ttrss_sessions WHERE id = ?');
return $sth->execute([$id]);
}
/**
* @return int|false the number of deleted sessions on success, or false on failure
*/
public function gc(int $max_lifetime): false|int {
$result = Db::pdo()->query('DELETE FROM ttrss_sessions WHERE expire < ' . time());
return $result === false ? false : $result->rowCount();
}
public static function validate_session(): bool {
if (Config::get(Config::SINGLE_USER_MODE)) return true;
$pdo = Db::pdo();
if (!empty($_SESSION['uid'])) {
$user = ORM::for_table('ttrss_users')->find_one($_SESSION['uid']);
if ($user) {
if ($user->pwd_hash != $_SESSION['pwd_hash']) {
$_SESSION['login_error_msg'] = __('Session failed to validate (password changed)');
return false;
}
if ($user->access_level == UserHelper::ACCESS_LEVEL_DISABLED) {
$_SESSION['login_error_msg'] = __('Session failed to validate (account is disabled)');
return false;
}
// default to true because there might not be any hooks and this is our last check
$hook_result = true;
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_VALIDATE_SESSION,
function ($result) use (&$hook_result) {
$hook_result = $result;
if (!$result) {
return true;
}
});
return $hook_result;
} else {
$_SESSION['login_error_msg'] = __('Session failed to validate (user not found)');
return false;
}
}
return true;
}
}

View File

@@ -1,21 +0,0 @@
<?php
require_once "lib/MiniTemplator.class.php";
class Templator extends MiniTemplator {
/* this reads tt-rss template from templates.local/ or templates/ if only base filename is given */
function readTemplateFromFile ($fileName) {
if (!str_contains($fileName, "/")) {
$fileName = basename($fileName);
if (file_exists("templates.local/$fileName"))
return parent::readTemplateFromFile("templates.local/$fileName");
else
return parent::readTemplateFromFile("templates/$fileName");
} else {
return parent::readTemplateFromFile($fileName);
}
}
}

View File

@@ -1,99 +0,0 @@
<?php
class TimeHelper {
static function smart_date_time(int $timestamp, int $tz_offset = 0, ?int $owner_uid = null, bool $eta_min = false): string {
// i.e. if the Unix epoch
if ($timestamp - $tz_offset === 0)
return __('Never');
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
if ($eta_min && time() + $tz_offset - $timestamp < 3600) {
return T_sprintf("%d min", date("i", time() + $tz_offset - $timestamp));
} else if (date("Y.m.d", $timestamp) == date("Y.m.d", time() + $tz_offset)) {
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
if (!str_contains((strtolower($format)), "a"))
return date("G:i", $timestamp);
else
return date("g:i a", $timestamp);
} else if (date("Y", $timestamp) == date("Y", time() + $tz_offset)) {
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
return date($format, $timestamp);
} else {
$format = Prefs::get(Prefs::LONG_DATE_FORMAT, $owner_uid, $profile);
return date($format, $timestamp);
}
}
/**
* @param bool $long Whether to display the datetime in a 'long' format. Only used if $no_smart_dt is true.
*/
static function make_local_datetime(?string $timestamp, bool $long = false, ?int $owner_uid = null,
bool $no_smart_dt = false, bool $eta_min = false): string {
if (!$owner_uid) $owner_uid = $_SESSION['uid'];
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
if (!$timestamp) $timestamp = '1970-01-01 0:00';
global $utc_tz;
global $user_tz;
if (!$utc_tz) $utc_tz = new DateTimeZone('UTC');
$timestamp = substr($timestamp, 0, 19);
# We store date in UTC internally
$dt = new DateTime($timestamp, $utc_tz);
$user_tz_string = Prefs::get(Prefs::USER_TIMEZONE, $owner_uid);
if ($user_tz_string != 'Automatic') {
try {
if (!$user_tz) $user_tz = new DateTimeZone($user_tz_string);
} catch (Exception $e) {
$user_tz = $utc_tz;
}
$tz_offset = $user_tz->getOffset($dt);
} else {
$tz_offset = (int) -($_SESSION["clientTzOffset"] ?? 0);
}
$user_timestamp = $dt->format('U') + $tz_offset;
if (!$no_smart_dt) {
return self::smart_date_time($user_timestamp,
$tz_offset, $owner_uid, $eta_min);
} else {
if ($long)
$format = Prefs::get(Prefs::LONG_DATE_FORMAT, $owner_uid, $profile);
else
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
return date($format, $user_timestamp);
}
}
static function convert_timestamp(int $timestamp, string $source_tz, string $dest_tz): int {
try {
$source_tz = new DateTimeZone($source_tz);
} catch (Exception $e) {
$source_tz = new DateTimeZone('UTC');
}
try {
$dest_tz = new DateTimeZone($dest_tz);
} catch (Exception $e) {
$dest_tz = new DateTimeZone('UTC');
}
$dt = new DateTime(date('Y-m-d H:i:s', $timestamp), $source_tz);
return (int)$dt->format('U') + $dest_tz->getOffset($dt);
}
}

View File

@@ -1,485 +0,0 @@
<?php
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
class UrlHelper {
const EXTRA_HREF_SCHEMES = [
"magnet",
"mailto",
"tel"
];
const EXTRA_SCHEMES_BY_CONTENT_TYPE = [
"application/x-bittorrent" => [ "magnet" ],
];
static string $fetch_last_error;
static int $fetch_last_error_code;
static string $fetch_last_error_content;
static string $fetch_last_content_type;
static string $fetch_last_modified;
static string $fetch_effective_url;
static string $fetch_effective_ip_addr;
public static ?GuzzleHttp\ClientInterface $client = null;
private static function get_client(): GuzzleHttp\ClientInterface {
if (self::$client == null) {
self::$client = new GuzzleHttp\Client([
GuzzleHttp\RequestOptions::COOKIES => false,
GuzzleHttp\RequestOptions::PROXY => Config::get(Config::HTTP_PROXY) ?: null,
]);
}
return self::$client;
}
/**
* @param array<string, string|int> $parts
*/
static function build_url(array $parts): string {
$tmp = $parts['scheme'] . "://" . $parts['host'];
if (isset($parts['path'])) $tmp .= $parts['path'];
if (isset($parts['query'])) $tmp .= '?' . $parts['query'];
if (isset($parts['fragment'])) $tmp .= '#' . $parts['fragment'];
return $tmp;
}
/**
* Converts a (possibly) relative URL to a absolute one, using provided base URL.
* Provides some exceptions for additional schemes like data: if called with owning element/attribute.
*
* @param string $base_url Base URL (i.e. from where the document is)
* @param string $rel_url Possibly relative URL in the document
* @param string $owner_element Owner element tag name (i.e. "a") (optional)
* @param string $owner_attribute Owner attribute (i.e. "href") (optional)
* @param string $content_type URL content type as specified by enclosures, etc.
*
* @return false|string Absolute URL or false on failure (either during URL parsing or validation)
*/
public static function rewrite_relative($base_url,
$rel_url,
string $owner_element = "",
string $owner_attribute = "",
string $content_type = ""): false|string {
$rel_parts = parse_url($rel_url);
if (!$rel_url) return $base_url;
/**
* If parse_url failed to parse $rel_url return false to match the current "invalid thing" behavior
* of UrlHelper::validate().
*
* TODO: There are many places where a string return value is assumed. We should either update those
* to account for the possibility of failure, or look into updating this function's return values.
*/
if ($rel_parts === false) {
return false;
}
if (!empty($rel_parts['host']) && !empty($rel_parts['scheme'])) {
return self::validate($rel_url);
// protocol-relative URL (rare but they exist)
} else if (str_starts_with($rel_url, "//")) {
return self::validate("https:" . $rel_url);
// allow some extra schemes for A href
} else if (in_array($rel_parts["scheme"] ?? "", self::EXTRA_HREF_SCHEMES, true) &&
$owner_element == "a" &&
$owner_attribute == "href") {
return $rel_url;
// allow some extra schemes for links with feed-specified content type i.e. enclosures
} else if ($content_type &&
isset(self::EXTRA_SCHEMES_BY_CONTENT_TYPE[$content_type]) &&
in_array($rel_parts["scheme"], self::EXTRA_SCHEMES_BY_CONTENT_TYPE[$content_type])) {
return $rel_url;
// allow limited subset of inline base64-encoded images for IMG elements
} else if (($rel_parts["scheme"] ?? "") == "data" &&
preg_match('%^image/(webp|gif|jpg|png|svg);base64,%', $rel_parts["path"]) &&
$owner_element == "img" &&
$owner_attribute == "src") {
return $rel_url;
} else {
$base_parts = parse_url($base_url);
$rel_parts['host'] = $base_parts['host'] ?? "";
$rel_parts['scheme'] = $base_parts['scheme'] ?? "";
if ($rel_parts['path'] ?? "") {
// we append dirname() of base path to relative URL path as per RFC 3986 section 5.2.2
$base_path = with_trailing_slash(dirname($base_parts['path'] ?? ""));
// 1. absolute relative path (/test.html) = no-op, proceed as is
// 2. dotslash relative URI (./test.html) - strip "./", append base path
if (str_starts_with($rel_parts['path'], './')) {
$rel_parts['path'] = $base_path . substr($rel_parts['path'], 2);
// 3. anything else relative (test.html) - append dirname() of base path
} else if (!str_starts_with($rel_parts['path'], '/')) {
$rel_parts['path'] = $base_path . $rel_parts['path'];
}
//$rel_parts['path'] = str_replace("/./", "/", $rel_parts['path']);
//$rel_parts['path'] = str_replace("//", "/", $rel_parts['path']);
}
return self::validate(self::build_url($rel_parts));
}
}
/** extended filtering involves validation for safe ports and loopback
* @return false|string false if something went wrong, otherwise the URL string
*/
static function validate(string $url, bool $extended_filtering = false): false|string {
$url = clean($url);
# fix protocol-relative URLs
if (str_starts_with($url, "//"))
$url = "https:" . $url;
$tokens = parse_url($url);
// this isn't really necessary because filter_var(... FILTER_VALIDATE_URL) requires host and scheme
// as per https://php.watch/versions/7.3/filter-var-flag-deprecation but it might save time
if (empty($tokens['host']))
return false;
if (!in_array(strtolower($tokens['scheme']), ['http', 'https']))
return false;
//convert IDNA hostname to punycode if possible
if (function_exists("idn_to_ascii")) {
if (mb_detect_encoding($tokens['host']) != 'ASCII') {
if (defined('IDNA_NONTRANSITIONAL_TO_ASCII') && defined('INTL_IDNA_VARIANT_UTS46')) {
$tokens['host'] = idn_to_ascii($tokens['host'], IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
} else {
$tokens['host'] = idn_to_ascii($tokens['host']);
}
// if `idn_to_ascii` failed
if ($tokens['host'] === false) {
return false;
}
}
}
// separate set of tokens with urlencoded 'path' because filter_var() rightfully fails on non-latin characters
// (used for validation only, we actually request the original URL, in case of urlencode breaking it)
$tokens_filter_var = $tokens;
if ($tokens['path'] ?? false) {
$tokens_filter_var['path'] = implode("/",
array_map("rawurlencode",
array_map("rawurldecode",
explode("/", $tokens['path']))));
}
$url = self::build_url($tokens);
$url_filter_var = self::build_url($tokens_filter_var);
if (filter_var($url_filter_var, FILTER_VALIDATE_URL) === false)
return false;
if ($extended_filtering) {
if (!in_array($tokens['port'] ?? '', [80, 443, '']))
return false;
if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1'
|| str_starts_with($tokens['host'], '127.'))
return false;
}
return $url;
}
static function resolve_redirects(string $url, int $timeout): false|string {
$client = self::get_client();
try {
$response = $client->request('HEAD', $url, [
GuzzleHttp\RequestOptions::CONNECT_TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT),
GuzzleHttp\RequestOptions::TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_TIMEOUT),
GuzzleHttp\RequestOptions::ALLOW_REDIRECTS => ['max' => 10, 'track_redirects' => true],
GuzzleHttp\RequestOptions::HTTP_ERRORS => false,
GuzzleHttp\RequestOptions::HEADERS => [
'User-Agent' => Config::get_user_agent(),
'Connection' => 'close',
],
]);
} catch (Exception $ex) {
return false;
}
// If a history header value doesn't exist there was no redirection and the original URL is fine.
$history_header = $response->getHeader(GuzzleHttp\RedirectMiddleware::HISTORY_HEADER);
return ($history_header ? end($history_header) : $url);
}
/**
* @param array<string, bool|int|string>|string $options
* @return false|string false if something went wrong, otherwise string contents
*/
// TODO: max_size currently only works for CURL transfers
// TODO: multiple-argument way is deprecated, first parameter is a hash now
public static function fetch(array|string $options /* previously: 0: $url , 1: $type = false, 2: $login = false, 3: $pass = false,
4: $post_query = false, 5: $timeout = false, 6: $timestamp = 0, 7: $useragent = false, 8: $encoding = false,
9: $auth_type = "basic" */): false|string {
self::$fetch_last_error = "";
self::$fetch_last_error_code = -1;
self::$fetch_last_error_content = "";
self::$fetch_last_content_type = "";
self::$fetch_last_modified = "";
self::$fetch_effective_url = "";
self::$fetch_effective_ip_addr = "";
if (!is_array($options)) {
// falling back on compatibility shim
$option_names = [ "url", "type", "login", "pass", "post_query", "timeout", "last_modified", "useragent", "encoding", "auth_type" ];
$tmp = [];
for ($i = 0; $i < func_num_args(); $i++) {
$tmp[$option_names[$i]] = func_get_arg($i);
}
$options = $tmp;
/*$options = array(
"url" => func_get_arg(0),
"type" => @func_get_arg(1),
"login" => @func_get_arg(2),
"pass" => @func_get_arg(3),
"post_query" => @func_get_arg(4),
"timeout" => @func_get_arg(5),
"timestamp" => @func_get_arg(6),
"useragent" => @func_get_arg(7),
"encoding" => @func_get_arg(8),
"auth_type" => @func_get_arg(9),
); */
}
$url = $options["url"];
$type = $options["type"] ?? false;
$login = $options["login"] ?? false;
$pass = $options["pass"] ?? false;
$auth_type = $options["auth_type"] ?? "basic";
$post_query = $options["post_query"] ?? false;
$timeout = $options["timeout"] ?? false;
$last_modified = $options["last_modified"] ?? "";
$useragent = $options["useragent"] ?? false;
$followlocation = $options["followlocation"] ?? true;
$max_size = $options["max_size"] ?? Config::get(Config::MAX_DOWNLOAD_FILE_SIZE); // in bytes
$http_accept = $options["http_accept"] ?? false;
$http_referrer = $options["http_referrer"] ?? false;
$encoding = $options["encoding"] ?? false;
$url = ltrim($url, ' ');
$url = str_replace(' ', '%20', $url);
Debug::log("[UrlHelper] fetching: $url", Debug::LOG_EXTENDED);
$url = self::validate($url, true);
if (!$url) {
self::$fetch_last_error = 'Requested URL failed extended validation.';
return false;
}
// this skip is needed for integration tests, please don't enable in production
if (!getenv('__URLHELPER_ALLOW_LOOPBACK')) {
$url_host = parse_url($url, PHP_URL_HOST);
$ip_addr = gethostbyname($url_host);
if (!$ip_addr || str_starts_with($ip_addr, '127.')) {
self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
return false;
}
}
$req_options = [
GuzzleHttp\RequestOptions::CONNECT_TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_CONNECT_TIMEOUT),
GuzzleHttp\RequestOptions::TIMEOUT => $timeout ?: Config::get(Config::FILE_FETCH_TIMEOUT),
GuzzleHttp\RequestOptions::HEADERS => [
'User-Agent' => $useragent ?: Config::get_user_agent(),
],
'curl' => [],
];
if ($followlocation) {
$req_options[GuzzleHttp\RequestOptions::ALLOW_REDIRECTS] = [
'max' => 20,
'track_redirects' => true,
'on_redirect' => function(RequestInterface $request, ResponseInterface $response, UriInterface $uri) {
if (!self::validate($uri, true)) {
self::$fetch_effective_url = (string) $uri;
throw new GuzzleHttp\Exception\RequestException('URL received during redirection failed extended validation.',
$request, $response);
}
},
];
} else {
$req_options[GuzzleHttp\RequestOptions::ALLOW_REDIRECTS] = false;
}
if ($last_modified && !$post_query)
$req_options[GuzzleHttp\RequestOptions::HEADERS]['If-Modified-Since'] = $last_modified;
if ($http_accept)
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Accept'] = $http_accept;
if ($encoding)
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Accept-Encoding'] = $encoding;
if ($http_referrer)
$req_options[GuzzleHttp\RequestOptions::HEADERS]['Referer'] = $http_referrer;
if ($login && $pass && in_array($auth_type, ['basic', 'digest', 'ntlm'])) {
// Let Guzzle handle the details for auth types it supports
$req_options[GuzzleHttp\RequestOptions::AUTH] = [$login, $pass, $auth_type];
} elseif ($auth_type === 'any') {
// https://docs.guzzlephp.org/en/stable/faq.html#how-can-i-add-custom-curl-options
$req_options['curl'][\CURLOPT_HTTPAUTH] = \CURLAUTH_ANY;
if ($login && $pass)
$req_options['curl'][\CURLOPT_USERPWD] = "$login:$pass";
}
if ($post_query)
$req_options[GuzzleHttp\RequestOptions::FORM_PARAMS] = $post_query;
if ($max_size) {
$req_options[GuzzleHttp\RequestOptions::PROGRESS] = function($download_size, $downloaded, $upload_size, $uploaded) use(&$max_size, $url) {
//Debug::log("[curl progressfunction] $downloaded $max_size", Debug::$LOG_EXTENDED);
if ($downloaded > $max_size) {
Debug::log("[UrlHelper] fetch error: max size of $max_size bytes exceeded when downloading $url . Aborting.", Debug::LOG_VERBOSE);
throw new \LengthException("Download exceeded size limit");
}
};
# Alternative/supplement to `progress` checking
$req_options[GuzzleHttp\RequestOptions::ON_HEADERS] = function(ResponseInterface $response) use(&$max_size, $url) {
$content_length = $response->getHeaderLine('Content-Length');
if ($content_length > $max_size) {
Debug::log("[UrlHelper] fetch error: server indicated (via 'Content-Length: {$content_length}') max size of $max_size bytes " .
"would be exceeded when downloading $url . Aborting.", Debug::LOG_VERBOSE);
throw new \LengthException("Server sent 'Content-Length' exceeding download limit");
}
};
}
$client = self::get_client();
try {
$response = $client->request($post_query ? 'POST' : 'GET', $url, $req_options);
} catch (\LengthException $ex) {
// Either 'Content-Length' indicated the download limit would be exceeded, or the transfer actually exceeded the download limit.
self::$fetch_last_error = $ex->getMessage();
return false;
} catch (GuzzleHttp\Exception\GuzzleException $ex) {
self::$fetch_last_error = $ex->getMessage();
if ($ex instanceof GuzzleHttp\Exception\RequestException) {
if ($ex instanceof GuzzleHttp\Exception\BadResponseException) {
// 4xx or 5xx
self::$fetch_last_error_code = $ex->getResponse()->getStatusCode();
// If credentials were provided and we got a 403 back, retry once with auth type 'any'
// to attempt compatibility with unusual configurations.
if ($login && $pass && self::$fetch_last_error_code === 403 && $auth_type !== 'any') {
$options['auth_type'] = 'any';
return self::fetch($options);
}
self::$fetch_last_content_type = $ex->getResponse()->getHeaderLine('content-type');
if ($type && !str_contains(self::$fetch_last_content_type, "$type"))
self::$fetch_last_error_content = (string) $ex->getResponse()->getBody();
} elseif (array_key_exists('errno', $ex->getHandlerContext())) {
$errno = (int) $ex->getHandlerContext()['errno'];
// By default, all supported encoding types are sent via `Accept-Encoding` and decoding of
// responses with `Content-Encoding` is automatically attempted. If this fails, we do a
// single retry with `Accept-Encoding: none` to try and force an unencoded response.
if (($errno === \CURLE_WRITE_ERROR || $errno === \CURLE_BAD_CONTENT_ENCODING) &&
$ex->getRequest()->getHeaderLine('accept-encoding') !== 'none') {
$options['encoding'] = 'none';
return self::fetch($options);
}
}
}
return false;
}
// Keep setting expected 'fetch_last_error_code' and 'fetch_last_error' values
self::$fetch_last_error_code = $response->getStatusCode();
self::$fetch_last_error = "HTTP/{$response->getProtocolVersion()} {$response->getStatusCode()} {$response->getReasonPhrase()}";
self::$fetch_last_modified = $response->getHeaderLine('last-modified');
self::$fetch_last_content_type = $response->getHeaderLine('content-type');
// If a history header value doesn't exist there was no redirection and the original URL is fine.
$history_header = $response->getHeader(GuzzleHttp\RedirectMiddleware::HISTORY_HEADER);
self::$fetch_effective_url = $history_header ? end($history_header) : $url;
// This shouldn't be necessary given the checks that occur during potential redirects, but we'll do it anyway.
if (!self::validate(self::$fetch_effective_url, true)) {
self::$fetch_last_error = "URL received after redirection failed extended validation.";
return false;
}
// @phpstan-ignore argument.type (prior validation ensures the host value exists)
self::$fetch_effective_ip_addr = gethostbyname(parse_url(self::$fetch_effective_url, PHP_URL_HOST));
// this skip is needed for integration tests, please don't enable in production
if (!getenv('__URLHELPER_ALLOW_LOOPBACK')) {
if (!self::$fetch_effective_ip_addr || str_starts_with(self::$fetch_effective_ip_addr, '127.')) {
self::$fetch_last_error = 'URL hostname received after redirection failed to resolve or resolved to a loopback address (' .
self::$fetch_effective_ip_addr . ')';
return false;
}
}
$body = (string) $response->getBody();
if (!$body) {
self::$fetch_last_error = 'Successful response, but no content was received.';
return false;
}
return $body;
}
/**
* @return false|string false if the provided URL didn't match expected patterns, otherwise the video ID string
*/
public static function url_to_youtube_vid(string $url): false|string {
$url = str_replace("youtube.com", "youtube-nocookie.com", $url);
$regexps = [
"/\/\/www\.youtube-nocookie\.com\/v\/([\w-]+)/",
"/\/\/www\.youtube-nocookie\.com\/embed\/([\w-]+)/",
"/\/\/www\.youtube-nocookie\.com\/watch\?v=([\w-]+)/",
"/\/\/youtu.be\/([\w-]+)/",
];
foreach ($regexps as $re) {
$matches = [];
if (preg_match($re, $url, $matches)) {
return $matches[1];
}
}
return false;
}
}

View File

@@ -1,500 +0,0 @@
<?php
use OTPHP\TOTP;
class UserHelper {
const HASH_ALGO_SSHA512 = 'SSHA-512';
const HASH_ALGO_SSHA256 = 'SSHA-256';
const HASH_ALGO_MODE2 = 'MODE2';
const HASH_ALGO_SHA1X = 'SHA1X';
const HASH_ALGO_SHA1 = 'SHA1';
const HASH_ALGOS = [
self::HASH_ALGO_SSHA512,
self::HASH_ALGO_SSHA256,
self::HASH_ALGO_MODE2,
self::HASH_ALGO_SHA1X,
self::HASH_ALGO_SHA1
];
const ACCESS_LEVELS = [
self::ACCESS_LEVEL_DISABLED,
self::ACCESS_LEVEL_READONLY,
self::ACCESS_LEVEL_USER,
self::ACCESS_LEVEL_POWERUSER,
self::ACCESS_LEVEL_ADMIN,
self::ACCESS_LEVEL_KEEP_CURRENT
];
/** forbidden to login */
const ACCESS_LEVEL_DISABLED = -2;
/** can't subscribe to new feeds, feeds are not updated */
const ACCESS_LEVEL_READONLY = -1;
/** no restrictions, regular user */
const ACCESS_LEVEL_USER = 0;
/** not used, same as regular user */
const ACCESS_LEVEL_POWERUSER = 5;
/** has administrator permissions */
const ACCESS_LEVEL_ADMIN = 10;
/** used by self::user_modify() to keep current access level */
const ACCESS_LEVEL_KEEP_CURRENT = -1024;
/**
* @param int $level integer loglevel value
* @return UserHelper::ACCESS_LEVEL_* if valid, warn and return ACCESS_LEVEL_KEEP_CURRENT otherwise
*/
public static function map_access_level(int $level) : int {
if (in_array($level, self::ACCESS_LEVELS)) {
/** @phpstan-ignore return.type (yes it is a UserHelper::ACCESS_LEVEL_* value) */
return $level;
} else {
user_error("Passed invalid user access level: $level", E_USER_WARNING);
return self::ACCESS_LEVEL_KEEP_CURRENT;
}
}
static function authenticate(?string $login = null, ?string $password = null, bool $check_only = false, ?string $service = null): bool {
if (!Config::get(Config::SINGLE_USER_MODE)) {
$user_id = false;
$auth_module = false;
$login = mb_strtolower($login);
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_AUTH_USER,
function ($result, $plugin) use (&$user_id, &$auth_module) {
if ($result) {
$user_id = (int)$result;
$auth_module = strtolower(get_class($plugin));
return true;
}
},
$login, $password, $service);
if ($user_id && !$check_only) {
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
session_regenerate_id(true);
$user = ORM::for_table('ttrss_users')->find_one($user_id);
if ($user && $user->access_level != self::ACCESS_LEVEL_DISABLED) {
self::set_session_for_user($user_id);
$_SESSION["auth_module"] = $auth_module;
$_SESSION["name"] = $user->login;
$_SESSION["access_level"] = $user->access_level;
$_SESSION["pwd_hash"] = $user->pwd_hash;
$user->last_login = Db::NOW();
$user->save();
return true;
}
return false;
}
if ($login && $password && !$user_id && !$check_only)
Logger::log(E_USER_WARNING, "Failed login attempt for $login (service: $service) from " . UserHelper::get_user_ip());
return false;
} else {
self::set_session_for_user(1);
$_SESSION["name"] = "admin";
$_SESSION["access_level"] = self::ACCESS_LEVEL_ADMIN;
$_SESSION["hide_hello"] = true;
$_SESSION["hide_logout"] = true;
$_SESSION["auth_module"] = false;
return true;
}
}
static function set_session_for_user(int $owner_uid): void {
$_SESSION["uid"] = $owner_uid;
$_SESSION["last_login_update"] = time();
$_SESSION["ip_address"] = UserHelper::get_user_ip();
if (empty($_SESSION["csrf_token"]))
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
}
static function load_user_plugins(int $owner_uid, ?PluginHost $pluginhost = null): void {
if (!$pluginhost) $pluginhost = PluginHost::getInstance();
if ($owner_uid && Config::get_schema_version() >= 100 && empty($_SESSION["safe_mode"])) {
$profile = isset($_SESSION['uid']) && $owner_uid == $_SESSION['uid'] && isset($_SESSION['profile']) ? $_SESSION['profile'] : null;
$plugins = Prefs::get(Prefs::_ENABLED_PLUGINS, $owner_uid, $profile);
$pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid);
/*if (get_schema_version() > 100) {
$pluginhost->load_data();
}*/
}
}
static function login_sequence(): void {
$pdo = Db::pdo();
if (Config::get(Config::SINGLE_USER_MODE)) {
if (session_status() != PHP_SESSION_ACTIVE)
session_start();
self::authenticate("admin", null);
startup_gettext();
self::load_user_plugins($_SESSION["uid"]);
} else {
if (!Sessions::validate_session())
$_SESSION["uid"] = null;
if (empty($_SESSION["uid"])) {
if (Config::get(Config::AUTH_AUTO_LOGIN) && self::authenticate(null, null)) {
$_SESSION["ref_schema_version"] = Config::get_schema_version();
} else {
self::authenticate(null, null, true);
}
if (empty($_SESSION["uid"])) {
UserHelper::logout();
Handler_Public::_render_login_form();
exit;
}
} else {
/* bump login timestamp */
$user = ORM::for_table('ttrss_users')->find_one($_SESSION["uid"]);
$user->last_login = Db::NOW();
$user->save();
$_SESSION["last_login_update"] = time();
}
startup_gettext();
self::load_user_plugins($_SESSION["uid"]);
}
}
static function print_user_stylesheet(): void {
$value = Prefs::get(Prefs::USER_STYLESHEET, $_SESSION['uid'], $_SESSION['profile'] ?? null);
if ($value) {
print "<style type='text/css' id='user_css_style'>";
print str_replace("<br/>", "\n", $value);
print "</style>";
}
}
static function get_user_ip(): ?string {
foreach (["HTTP_X_REAL_IP", "REMOTE_ADDR"] as $hdr) {
if (isset($_SERVER[$hdr]))
return $_SERVER[$hdr];
}
return null;
}
static function get_login_by_id(int $id): ?string {
$user = ORM::for_table('ttrss_users')
->find_one($id);
if ($user)
return $user->login;
else
return null;
}
static function find_user_by_login(string $login): ?int {
$user = ORM::for_table('ttrss_users')
->where_raw('LOWER(login) = LOWER(?)', [$login])
->find_one();
if ($user)
return $user->id;
else
return null;
}
static function logout(): void {
if (session_status() === PHP_SESSION_ACTIVE)
session_destroy();
if (isset($_COOKIE[session_name()])) {
setcookie(session_name(), '', time()-42000, '/');
}
session_commit();
}
static function get_salt(): string {
return substr(bin2hex(get_random_bytes(125)), 0, 250);
}
/** TODO: this should invoke UserHelper::user_modify() */
static function reset_password(int $uid, bool $format_output = false, string $new_password = ""): void {
$user = ORM::for_table('ttrss_users')->find_one($uid);
if ($user) {
$login = $user->login;
$new_salt = self::get_salt();
$tmp_user_pwd = $new_password ? $new_password : make_password();
$pwd_hash = self::hash_password($tmp_user_pwd, $new_salt, self::HASH_ALGOS[0]);
$user->pwd_hash = $pwd_hash;
$user->salt = $new_salt;
$user->save();
$message = T_sprintf("Changed password of user %s to %s", "<strong>$login</strong>", "<strong>$tmp_user_pwd</strong>");
} else {
$message = __("User not found");
}
if ($format_output)
print_notice($message);
else
print $message;
}
static function check_otp(int $owner_uid, int $otp_check) : bool {
$otp = TOTP::create(self::get_otp_secret($owner_uid, true));
return $otp->now() == $otp_check;
}
static function disable_otp(int $owner_uid) : bool {
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($user) {
$user->otp_enabled = false;
// force new OTP secret when next enabled
if (Config::get_schema_version() >= 143) {
$user->otp_secret = null;
}
$user->save();
return true;
} else {
return false;
}
}
static function enable_otp(int $owner_uid, int $otp_check) : bool {
$secret = self::get_otp_secret($owner_uid);
if ($secret) {
$otp = TOTP::create($secret);
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($otp->now() == $otp_check && $user) {
$user->otp_enabled = true;
$user->save();
return true;
}
}
return false;
}
static function is_otp_enabled(int $owner_uid) : bool {
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($user) {
return $user->otp_enabled;
} else {
return false;
}
}
static function get_otp_secret(int $owner_uid, bool $show_if_enabled = false): ?string {
$user = ORM::for_table('ttrss_users')->find_one($owner_uid);
if ($user) {
$salt_based_secret = mb_substr(sha1($user->salt), 0, 12);
if (Config::get_schema_version() >= 143) {
$secret = $user->otp_secret;
if (empty($secret)) {
/* migrate secret if OTP is already enabled, otherwise make a new one */
if ($user->otp_enabled) {
$user->otp_secret = $salt_based_secret;
} else {
$user->otp_secret = bin2hex(get_random_bytes(10));
}
$user->save();
$secret = $user->otp_secret;
}
} else {
$secret = $salt_based_secret;
}
if (!$user->otp_enabled || $show_if_enabled) {
return \ParagonIE\ConstantTime\Base32::encodeUpperUnpadded($secret);
}
}
return null;
}
/**
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
* @throws PDOException
* @throws Exception
*/
static function is_default_password(?int $owner_uid = null): bool {
return self::user_has_password($owner_uid, 'password');
}
/**
* @param string $algo should be one of UserHelper::HASH_ALGO_*
*
* @return false|string False if the password couldn't be hashed, otherwise the hash string.
*/
static function hash_password(string $pass, string $salt, string $algo = self::HASH_ALGOS[0]): false|string {
$pass_hash = match ($algo) {
self::HASH_ALGO_SHA1 => sha1($pass),
self::HASH_ALGO_SHA1X => sha1("$salt:$pass"),
self::HASH_ALGO_MODE2, self::HASH_ALGO_SSHA256 => hash('sha256', $salt . $pass),
self::HASH_ALGO_SSHA512 => hash('sha512', $salt . $pass),
default => null,
};
if ($pass_hash === null)
user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR);
return $pass_hash ? "$algo:$pass_hash" : false;
}
/**
* @param string $login Login for new user (case-insensitive)
* @param string $password Password for new user (may not be blank)
* @param UserHelper::ACCESS_LEVEL_* $access_level Access level for new user
* @return bool true if user has been created
*/
static function user_add(string $login, string $password, int $access_level) : bool {
$login = clean($login);
if ($login &&
$password &&
!self::find_user_by_login($login) &&
self::map_access_level((int)$access_level) != self::ACCESS_LEVEL_KEEP_CURRENT) {
$user = ORM::for_table('ttrss_users')->create();
$user->salt = self::get_salt();
$user->login = mb_strtolower($login);
$user->pwd_hash = self::hash_password($password, $user->salt);
$user->access_level = $access_level;
$user->created = Db::NOW();
return $user->save();
}
return false;
}
/**
* @param int $uid User ID to modify
* @param string $new_password set password to this value if its not blank
* @param UserHelper::ACCESS_LEVEL_* $access_level set user access level to this value if it is set (default ACCESS_LEVEL_KEEP_CURRENT)
* @return bool true if user record has been saved
*
* NOTE: $access_level is of mixed type because of intellephense
*/
static function user_modify(int $uid, string $new_password = '', $access_level = self::ACCESS_LEVEL_KEEP_CURRENT) : bool {
$user = ORM::for_table('ttrss_users')->find_one($uid);
if ($user) {
if ($new_password != '') {
$new_salt = self::get_salt();
$pwd_hash = self::hash_password($new_password, $new_salt, self::HASH_ALGOS[0]);
$user->pwd_hash = $pwd_hash;
$user->salt = $new_salt;
}
if ($access_level != self::ACCESS_LEVEL_KEEP_CURRENT) {
$user->access_level = (int)$access_level;
}
return $user->save();
}
return false;
}
/**
* @param int $uid user ID to delete (this won't delete built-in admin user with UID 1)
* @return bool true if user has been deleted
*/
static function user_delete(int $uid) : bool {
if ($uid != 1) {
$user = ORM::for_table('ttrss_users')->find_one($uid);
if ($user) {
// TODO: is it still necessary to split those queries?
ORM::for_table('ttrss_tags')
->where('owner_uid', $uid)
->delete_many();
ORM::for_table('ttrss_feeds')
->where('owner_uid', $uid)
->delete_many();
return $user->delete();
}
}
return false;
}
/**
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
* @param string $password password to compare hash against
*/
static function user_has_password(?int $owner_uid, string $password) : bool {
if ($owner_uid) {
$authenticator = new Auth_Internal();
return $authenticator->check_password($owner_uid, $password);
} else {
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
if ($authenticator &&
method_exists($authenticator, "check_password") &&
$authenticator->check_password($_SESSION["uid"], $password)) {
return true;
}
}
return false;
}
}

888
classes/api.php Executable file
View File

@@ -0,0 +1,888 @@
<?php
class API extends Handler {
const API_LEVEL = 14;
const STATUS_OK = 0;
const STATUS_ERR = 1;
private $seq;
static function param_to_bool($p) {
return $p && ($p !== "f" && $p !== "false");
}
function before($method) {
if (parent::before($method)) {
header("Content-Type: text/json");
if (!$_SESSION["uid"] && $method != "login" && $method != "isloggedin") {
$this->wrap(self::STATUS_ERR, array("error" => 'NOT_LOGGED_IN'));
return false;
}
if ($_SESSION["uid"] && $method != "logout" && !get_pref('ENABLE_API_ACCESS')) {
$this->wrap(self::STATUS_ERR, array("error" => 'API_DISABLED'));
return false;
}
$this->seq = (int) clean($_REQUEST['seq']);
return true;
}
return false;
}
function wrap($status, $reply) {
print json_encode(array("seq" => $this->seq,
"status" => $status,
"content" => $reply));
}
function getVersion() {
$rv = array("version" => VERSION);
$this->wrap(self::STATUS_OK, $rv);
}
function getApiLevel() {
$rv = array("level" => self::API_LEVEL);
$this->wrap(self::STATUS_OK, $rv);
}
function login() {
@session_destroy();
@session_start();
$login = clean($_REQUEST["user"]);
$password = clean($_REQUEST["password"]);
$password_base64 = base64_decode(clean($_REQUEST["password"]));
if (SINGLE_USER_MODE) $login = "admin";
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE login = ?");
$sth->execute([$login]);
if ($row = $sth->fetch()) {
$uid = $row["id"];
} else {
$uid = 0;
}
if (!$uid) {
$this->wrap(self::STATUS_ERR, array("error" => "LOGIN_ERROR"));
return;
}
if (get_pref("ENABLE_API_ACCESS", $uid)) {
if (authenticate_user($login, $password)) { // try login with normal password
$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
"api_level" => self::API_LEVEL));
} else if (authenticate_user($login, $password_base64)) { // else try with base64_decoded password
$this->wrap(self::STATUS_OK, array("session_id" => session_id(),
"api_level" => self::API_LEVEL));
} else { // else we are not logged in
user_error("Failed login attempt for $login from {$_SERVER['REMOTE_ADDR']}", E_USER_WARNING);
$this->wrap(self::STATUS_ERR, array("error" => "LOGIN_ERROR"));
}
} else {
$this->wrap(self::STATUS_ERR, array("error" => "API_DISABLED"));
}
}
function logout() {
logout_user();
$this->wrap(self::STATUS_OK, array("status" => "OK"));
}
function isLoggedIn() {
$this->wrap(self::STATUS_OK, array("status" => $_SESSION["uid"] != ''));
}
function getUnread() {
$feed_id = clean($_REQUEST["feed_id"]);
$is_cat = clean($_REQUEST["is_cat"]);
if ($feed_id) {
$this->wrap(self::STATUS_OK, array("unread" => getFeedUnread($feed_id, $is_cat)));
} else {
$this->wrap(self::STATUS_OK, array("unread" => Feeds::getGlobalUnread()));
}
}
/* Method added for ttrss-reader for Android */
function getCounters() {
$this->wrap(self::STATUS_OK, Counters::getAllCounters());
}
function getFeeds() {
$cat_id = clean($_REQUEST["cat_id"]);
$unread_only = API::param_to_bool(clean($_REQUEST["unread_only"]));
$limit = (int) clean($_REQUEST["limit"]);
$offset = (int) clean($_REQUEST["offset"]);
$include_nested = API::param_to_bool(clean($_REQUEST["include_nested"]));
$feeds = $this->api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested);
$this->wrap(self::STATUS_OK, $feeds);
}
function getCategories() {
$unread_only = API::param_to_bool(clean($_REQUEST["unread_only"]));
$enable_nested = API::param_to_bool(clean($_REQUEST["enable_nested"]));
$include_empty = API::param_to_bool(clean($_REQUEST['include_empty']));
// TODO do not return empty categories, return Uncategorized and standard virtual cats
if ($enable_nested)
$nested_qpart = "parent_cat IS NULL";
else
$nested_qpart = "true";
$sth = $this->pdo->prepare("SELECT
id, title, order_id, (SELECT COUNT(id) FROM
ttrss_feeds WHERE
ttrss_feed_categories.id IS NOT NULL AND cat_id = ttrss_feed_categories.id) AS num_feeds,
(SELECT COUNT(id) FROM
ttrss_feed_categories AS c2 WHERE
c2.parent_cat = ttrss_feed_categories.id) AS num_cats
FROM ttrss_feed_categories
WHERE $nested_qpart AND owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$cats = array();
while ($line = $sth->fetch()) {
if ($include_empty || $line["num_feeds"] > 0 || $line["num_cats"] > 0) {
$unread = getFeedUnread($line["id"], true);
if ($enable_nested)
$unread += Feeds::getCategoryChildrenUnread($line["id"]);
if ($unread || !$unread_only) {
array_push($cats, array("id" => $line["id"],
"title" => $line["title"],
"unread" => $unread,
"order_id" => (int) $line["order_id"],
));
}
}
}
foreach (array(-2,-1,0) as $cat_id) {
if ($include_empty || !$this->isCategoryEmpty($cat_id)) {
$unread = getFeedUnread($cat_id, true);
if ($unread || !$unread_only) {
array_push($cats, array("id" => $cat_id,
"title" => Feeds::getCategoryTitle($cat_id),
"unread" => $unread));
}
}
}
$this->wrap(self::STATUS_OK, $cats);
}
function getHeadlines() {
$feed_id = clean($_REQUEST["feed_id"]);
if ($feed_id !== "") {
if (is_numeric($feed_id)) $feed_id = (int) $feed_id;
$limit = (int)clean($_REQUEST["limit"]);
if (!$limit || $limit >= 200) $limit = 200;
$offset = (int)clean($_REQUEST["skip"]);
$filter = clean($_REQUEST["filter"]);
$is_cat = API::param_to_bool(clean($_REQUEST["is_cat"]));
$show_excerpt = API::param_to_bool(clean($_REQUEST["show_excerpt"]));
$show_content = API::param_to_bool(clean($_REQUEST["show_content"]));
/* all_articles, unread, adaptive, marked, updated */
$view_mode = clean($_REQUEST["view_mode"]);
$include_attachments = API::param_to_bool(clean($_REQUEST["include_attachments"]));
$since_id = (int)clean($_REQUEST["since_id"]);
$include_nested = API::param_to_bool(clean($_REQUEST["include_nested"]));
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
API::param_to_bool($_REQUEST["sanitize"]);
$force_update = API::param_to_bool(clean($_REQUEST["force_update"]));
$has_sandbox = API::param_to_bool(clean($_REQUEST["has_sandbox"]));
$excerpt_length = (int)clean($_REQUEST["excerpt_length"]);
$check_first_id = (int)clean($_REQUEST["check_first_id"]);
$include_header = API::param_to_bool(clean($_REQUEST["include_header"]));
$_SESSION['hasSandbox'] = $has_sandbox;
$skip_first_id_check = false;
$override_order = false;
switch (clean($_REQUEST["order_by"])) {
case "title":
$override_order = "ttrss_entries.title, date_entered, updated";
break;
case "date_reverse":
$override_order = "score DESC, date_entered, updated";
$skip_first_id_check = true;
break;
case "feed_dates":
$override_order = "updated DESC";
break;
}
/* do not rely on params below */
$search = clean($_REQUEST["search"]);
list($headlines, $headlines_header) = $this->api_get_headlines($feed_id, $limit, $offset,
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $override_order,
$include_attachments, $since_id, $search,
$include_nested, $sanitize_content, $force_update, $excerpt_length, $check_first_id, $skip_first_id_check);
if ($include_header) {
$this->wrap(self::STATUS_OK, array($headlines_header, $headlines));
} else {
$this->wrap(self::STATUS_OK, $headlines);
}
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
}
}
function updateArticle() {
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
$mode = (int) clean($_REQUEST["mode"]);
$data = clean($_REQUEST["data"]);
$field_raw = (int)clean($_REQUEST["field"]);
$field = "";
$set_to = "";
switch ($field_raw) {
case 0:
$field = "marked";
$additional_fields = ",last_marked = NOW()";
break;
case 1:
$field = "published";
$additional_fields = ",last_published = NOW()";
break;
case 2:
$field = "unread";
$additional_fields = ",last_read = NOW()";
break;
case 3:
$field = "note";
};
switch ($mode) {
case 1:
$set_to = "true";
break;
case 0:
$set_to = "false";
break;
case 2:
$set_to = "NOT $field";
break;
}
if ($field == "note") $set_to = $this->pdo->quote($data);
if ($field && $set_to && count($article_ids) > 0) {
$article_qmarks = arr_qmarks($article_ids);
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
$field = $set_to $additional_fields
WHERE ref_id IN ($article_qmarks) AND owner_uid = ?");
$sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
$num_updated = $sth->rowCount();
if ($num_updated > 0 && $field == "unread") {
$sth = $this->pdo->prepare("SELECT DISTINCT feed_id FROM ttrss_user_entries
WHERE ref_id IN ($article_qmarks)");
$sth->execute($article_ids);
while ($line = $sth->fetch()) {
CCache::update($line["feed_id"], $_SESSION["uid"]);
}
}
$this->wrap(self::STATUS_OK, array("status" => "OK",
"updated" => $num_updated));
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
}
}
function getArticle() {
$article_ids = explode(",", clean($_REQUEST["article_id"]));
$sanitize_content = !isset($_REQUEST["sanitize"]) ||
API::param_to_bool($_REQUEST["sanitize"]);
if ($article_ids) {
$article_qmarks = arr_qmarks($article_ids);
$sth = $this->pdo->prepare("SELECT id,guid,title,link,content,feed_id,comments,int_id,
marked,unread,published,score,note,lang,
".SUBSTRING_FOR_DATE."(updated,1,16) as updated,
author,(SELECT title FROM ttrss_feeds WHERE id = feed_id) AS feed_title,
(SELECT site_url FROM ttrss_feeds WHERE id = feed_id) AS site_url,
(SELECT hide_images FROM ttrss_feeds WHERE id = feed_id) AS hide_images
FROM ttrss_entries,ttrss_user_entries
WHERE id IN ($article_qmarks) AND ref_id = id AND owner_uid = ?");
$sth->execute(array_merge($article_ids, [$_SESSION['uid']]));
$articles = array();
while ($line = $sth->fetch()) {
$attachments = Article::get_article_enclosures($line['id']);
$article = array(
"id" => $line["id"],
"guid" => $line["guid"],
"title" => $line["title"],
"link" => $line["link"],
"labels" => Article::get_article_labels($line['id']),
"unread" => API::param_to_bool($line["unread"]),
"marked" => API::param_to_bool($line["marked"]),
"published" => API::param_to_bool($line["published"]),
"comments" => $line["comments"],
"author" => $line["author"],
"updated" => (int) strtotime($line["updated"]),
"feed_id" => $line["feed_id"],
"attachments" => $attachments,
"score" => (int)$line["score"],
"feed_title" => $line["feed_title"],
"note" => $line["note"],
"lang" => $line["lang"]
);
if ($sanitize_content) {
$article["content"] = sanitize(
$line["content"],
API::param_to_bool($line['hide_images']),
false, $line["site_url"], false, $line["id"]);
} else {
$article["content"] = $line["content"];
}
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ARTICLE_API) as $p) {
$article = $p->hook_render_article_api(array("article" => $article));
}
$article['content'] = rewrite_cached_urls($article['content']);
array_push($articles, $article);
}
$this->wrap(self::STATUS_OK, $articles);
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
}
}
function getConfig() {
$config = array(
"icons_dir" => ICONS_DIR,
"icons_url" => ICONS_URL);
$config["daemon_is_running"] = file_is_locked("update_daemon.lock");
$sth = $this->pdo->prepare("SELECT COUNT(*) AS cf FROM
ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
$config["num_feeds"] = $row["cf"];
$this->wrap(self::STATUS_OK, $config);
}
function updateFeed() {
$feed_id = (int) clean($_REQUEST["feed_id"]);
if (!ini_get("open_basedir")) {
RSSUtils::update_rss_feed($feed_id);
}
$this->wrap(self::STATUS_OK, array("status" => "OK"));
}
function catchupFeed() {
$feed_id = clean($_REQUEST["feed_id"]);
$is_cat = clean($_REQUEST["is_cat"]);
Feeds::catchup_feed($feed_id, $is_cat);
$this->wrap(self::STATUS_OK, array("status" => "OK"));
}
function getPref() {
$pref_name = clean($_REQUEST["pref_name"]);
$this->wrap(self::STATUS_OK, array("value" => get_pref($pref_name)));
}
function getLabels() {
$article_id = (int)clean($_REQUEST['article_id']);
$rv = array();
$sth = $this->pdo->prepare("SELECT id, caption, fg_color, bg_color
FROM ttrss_labels2
WHERE owner_uid = ? ORDER BY caption");
$sth->execute([$_SESSION['uid']]);
if ($article_id)
$article_labels = Article::get_article_labels($article_id);
else
$article_labels = array();
while ($line = $sth->fetch()) {
$checked = false;
foreach ($article_labels as $al) {
if (Labels::feed_to_label_id($al[0]) == $line['id']) {
$checked = true;
break;
}
}
array_push($rv, array(
"id" => (int)Labels::label_to_feed_id($line['id']),
"caption" => $line['caption'],
"fg_color" => $line['fg_color'],
"bg_color" => $line['bg_color'],
"checked" => $checked));
}
$this->wrap(self::STATUS_OK, $rv);
}
function setArticleLabel() {
$article_ids = explode(",", clean($_REQUEST["article_ids"]));
$label_id = (int) clean($_REQUEST['label_id']);
$assign = API::param_to_bool(clean($_REQUEST['assign']));
$label = Labels::find_caption(Labels::feed_to_label_id($label_id), $_SESSION["uid"]);
$num_updated = 0;
if ($label) {
foreach ($article_ids as $id) {
if ($assign)
Labels::add_article($id, $label, $_SESSION["uid"]);
else
Labels::remove_article($id, $label, $_SESSION["uid"]);
++$num_updated;
}
}
$this->wrap(self::STATUS_OK, array("status" => "OK",
"updated" => $num_updated));
}
function index($method) {
$plugin = PluginHost::getInstance()->get_api_method(strtolower($method));
if ($plugin && method_exists($plugin, $method)) {
$reply = $plugin->$method();
$this->wrap($reply[0], $reply[1]);
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'UNKNOWN_METHOD', "method" => $method));
}
}
function shareToPublished() {
$title = strip_tags(clean($_REQUEST["title"]));
$url = strip_tags(clean($_REQUEST["url"]));
$content = strip_tags(clean($_REQUEST["content"]));
if (Article::create_published_article($title, $url, $content, "", $_SESSION["uid"])) {
$this->wrap(self::STATUS_OK, array("status" => 'OK'));
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'Publishing failed'));
}
}
static function api_get_feeds($cat_id, $unread_only, $limit, $offset, $include_nested = false) {
$feeds = array();
$pdo = Db::pdo();
$limit = (int) $limit;
$offset = (int) $offset;
$cat_id = (int) $cat_id;
/* Labels */
if ($cat_id == -4 || $cat_id == -2) {
$counters = Counters::getLabelCounters(true);
foreach (array_values($counters) as $cv) {
$unread = $cv["counter"];
if ($unread || !$unread_only) {
$row = array(
"id" => (int) $cv["id"],
"title" => $cv["description"],
"unread" => $cv["counter"],
"cat_id" => -2,
);
array_push($feeds, $row);
}
}
}
/* Virtual feeds */
if ($cat_id == -4 || $cat_id == -1) {
foreach (array(-1, -2, -3, -4, -6, 0) as $i) {
$unread = getFeedUnread($i);
if ($unread || !$unread_only) {
$title = Feeds::getFeedTitle($i);
$row = array(
"id" => $i,
"title" => $title,
"unread" => $unread,
"cat_id" => -1,
);
array_push($feeds, $row);
}
}
}
/* Child cats */
if ($include_nested && $cat_id) {
$sth = $pdo->prepare("SELECT
id, title, order_id FROM ttrss_feed_categories
WHERE parent_cat = ? AND owner_uid = ? ORDER BY id, title");
$sth->execute([$cat_id, $_SESSION['uid']]);
while ($line = $sth->fetch()) {
$unread = getFeedUnread($line["id"], true) +
Feeds::getCategoryChildrenUnread($line["id"]);
if ($unread || !$unread_only) {
$row = array(
"id" => (int) $line["id"],
"title" => $line["title"],
"unread" => $unread,
"is_cat" => true,
"order_id" => (int) $line["order_id"]
);
array_push($feeds, $row);
}
}
}
/* Real feeds */
if ($limit) {
$limit_qpart = "LIMIT $limit OFFSET $offset";
} else {
$limit_qpart = "";
}
if ($cat_id == -4 || $cat_id == -3) {
$sth = $pdo->prepare("SELECT
id, feed_url, cat_id, title, order_id, ".
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE owner_uid = ?
ORDER BY cat_id, title " . $limit_qpart);
$sth->execute([$_SESSION['uid']]);
} else {
$sth = $pdo->prepare("SELECT
id, feed_url, cat_id, title, order_id, ".
SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL))
AND owner_uid = :uid
ORDER BY cat_id, title " . $limit_qpart);
$sth->execute([":uid" => $_SESSION['uid'], ":cat" => $cat_id]);
}
while ($line = $sth->fetch()) {
$unread = getFeedUnread($line["id"]);
$has_icon = Feeds::feedHasIcon($line['id']);
if ($unread || !$unread_only) {
$row = array(
"feed_url" => $line["feed_url"],
"title" => $line["title"],
"id" => (int)$line["id"],
"unread" => (int)$unread,
"has_icon" => $has_icon,
"cat_id" => (int)$line["cat_id"],
"last_updated" => (int) strtotime($line["last_updated"]),
"order_id" => (int) $line["order_id"],
);
array_push($feeds, $row);
}
}
return $feeds;
}
/**
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
static function api_get_headlines($feed_id, $limit, $offset,
$filter, $is_cat, $show_excerpt, $show_content, $view_mode, $order,
$include_attachments, $since_id,
$search = "", $include_nested = false, $sanitize_content = true,
$force_update = false, $excerpt_length = 100, $check_first_id = false, $skip_first_id_check = false) {
$pdo = Db::pdo();
if ($force_update && $feed_id > 0 && is_numeric($feed_id)) {
// Update the feed if required with some basic flood control
$sth = $pdo->prepare(
"SELECT cache_images,".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM ttrss_feeds WHERE id = ?");
$sth->execute([$feed_id]);
if ($row = $sth->fetch()) {
$last_updated = strtotime($row["last_updated"]);
$cache_images = API::param_to_bool($row["cache_images"]);
if (!$cache_images && time() - $last_updated > 120) {
RSSUtils::update_rss_feed($feed_id, true);
} else {
$sth = $pdo->prepare("UPDATE ttrss_feeds SET last_updated = '1970-01-01', last_update_started = '1970-01-01'
WHERE id = ?");
$sth->execute([$feed_id]);
}
}
}
$params = array(
"feed" => $feed_id,
"limit" => $limit,
"view_mode" => $view_mode,
"cat_view" => $is_cat,
"search" => $search,
"override_order" => $order,
"offset" => $offset,
"since_id" => $since_id,
"include_children" => $include_nested,
"check_first_id" => $check_first_id,
"skip_first_id_check" => $skip_first_id_check
);
$qfh_ret = Feeds::queryFeedHeadlines($params);
$result = $qfh_ret[0];
$feed_title = $qfh_ret[1];
$first_id = $qfh_ret[6];
$headlines = array();
$headlines_header = array(
'id' => $feed_id,
'first_id' => $first_id,
'is_cat' => $is_cat);
if (!is_numeric($result)) {
while ($line = $result->fetch()) {
$line["content_preview"] = truncate_string(strip_tags($line["content"]), $excerpt_length);
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_QUERY_HEADLINES) as $p) {
$line = $p->hook_query_headlines($line, $excerpt_length, true);
}
$is_updated = ($line["last_read"] == "" &&
($line["unread"] != "t" && $line["unread"] != "1"));
$tags = explode(",", $line["tag_cache"]);
$label_cache = $line["label_cache"];
$labels = false;
if ($label_cache) {
$label_cache = json_decode($label_cache, true);
if ($label_cache) {
if ($label_cache["no-labels"] == 1)
$labels = array();
else
$labels = $label_cache;
}
}
if (!is_array($labels)) $labels = Article::get_article_labels($line["id"]);
$headline_row = array(
"id" => (int)$line["id"],
"guid" => $line["guid"],
"unread" => API::param_to_bool($line["unread"]),
"marked" => API::param_to_bool($line["marked"]),
"published" => API::param_to_bool($line["published"]),
"updated" => (int)strtotime($line["updated"]),
"is_updated" => $is_updated,
"title" => $line["title"],
"link" => $line["link"],
"feed_id" => $line["feed_id"] ? $line['feed_id'] : 0,
"tags" => $tags,
);
if ($include_attachments)
$headline_row['attachments'] = Article::get_article_enclosures(
$line['id']);
if ($show_excerpt)
$headline_row["excerpt"] = $line["content_preview"];
if ($show_content) {
if ($sanitize_content) {
$headline_row["content"] = sanitize(
$line["content"],
API::param_to_bool($line['hide_images']),
false, $line["site_url"], false, $line["id"]);
} else {
$headline_row["content"] = $line["content"];
}
}
// unify label output to ease parsing
if ($labels["no-labels"] == 1) $labels = array();
$headline_row["labels"] = $labels;
$headline_row["feed_title"] = $line["feed_title"] ? $line["feed_title"] :
$feed_title;
$headline_row["comments_count"] = (int)$line["num_comments"];
$headline_row["comments_link"] = $line["comments"];
$headline_row["always_display_attachments"] = API::param_to_bool($line["always_display_enclosures"]);
$headline_row["author"] = $line["author"];
$headline_row["score"] = (int)$line["score"];
$headline_row["note"] = $line["note"];
$headline_row["lang"] = $line["lang"];
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ARTICLE_API) as $p) {
$headline_row = $p->hook_render_article_api(array("headline" => $headline_row));
}
$headline_row['content'] = rewrite_cached_urls($headline_row['content']);
array_push($headlines, $headline_row);
}
} else if (is_numeric($result) && $result == -1) {
$headlines_header['first_id_changed'] = true;
}
return array($headlines, $headlines_header);
}
function unsubscribeFeed() {
$feed_id = (int) clean($_REQUEST["feed_id"]);
$sth = $this->pdo->prepare("SELECT id FROM ttrss_feeds WHERE
id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
Pref_Feeds::remove_feed($feed_id, $_SESSION["uid"]);
$this->wrap(self::STATUS_OK, array("status" => "OK"));
} else {
$this->wrap(self::STATUS_ERR, array("error" => "FEED_NOT_FOUND"));
}
}
function subscribeToFeed() {
$feed_url = clean($_REQUEST["feed_url"]);
$category_id = (int) clean($_REQUEST["category_id"]);
$login = clean($_REQUEST["login"]);
$password = clean($_REQUEST["password"]);
if ($feed_url) {
$rc = Feeds::subscribe_to_feed($feed_url, $category_id, $login, $password);
$this->wrap(self::STATUS_OK, array("status" => $rc));
} else {
$this->wrap(self::STATUS_ERR, array("error" => 'INCORRECT_USAGE'));
}
}
function getFeedTree() {
$include_empty = API::param_to_bool(clean($_REQUEST['include_empty']));
$pf = new Pref_Feeds($_REQUEST);
$_REQUEST['mode'] = 2;
$_REQUEST['force_show_empty'] = $include_empty;
if ($pf){
$data = $pf->makefeedtree();
$this->wrap(self::STATUS_OK, array("categories" => $data));
} else {
$this->wrap(self::STATUS_ERR, array("error" =>
'UNABLE_TO_INSTANTIATE_OBJECT'));
}
}
// only works for labels or uncategorized for the time being
private function isCategoryEmpty($id) {
if ($id == -2) {
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_labels2
WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
return $row["count"] == 0;
} else if ($id == 0) {
$sth = $this->pdo->prepare("SELECT COUNT(id) AS count FROM ttrss_feeds
WHERE cat_id IS NULL AND owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
return $row["count"] == 0;
}
return false;
}
}

824
classes/article.php Executable file
View File

@@ -0,0 +1,824 @@
<?php
class Article extends Handler_Protected {
function csrf_ignore($method) {
$csrf_ignored = array("redirect", "editarticletags");
return array_search($method, $csrf_ignored) !== false;
}
function redirect() {
$id = clean($_REQUEST['id']);
$sth = $this->pdo->prepare("SELECT link FROM ttrss_entries, ttrss_user_entries
WHERE id = ? AND id = ref_id AND owner_uid = ?
LIMIT 1");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$article_url = $row['link'];
$article_url = str_replace("\n", "", $article_url);
header("Location: $article_url");
return;
} else {
print_error(__("Article not found."));
}
}
/*
function view() {
$id = clean($_REQUEST["id"]);
$cids = explode(",", clean($_REQUEST["cids"]));
$mode = clean($_REQUEST["mode"]);
// in prefetch mode we only output requested cids, main article
// just gets marked as read (it already exists in client cache)
$articles = array();
if ($mode == "") {
array_push($articles, $this->format_article($id, false));
} else if ($mode == "zoom") {
array_push($articles, $this->format_article($id, true, true));
} else if ($mode == "raw") {
if (isset($_REQUEST['html'])) {
header("Content-Type: text/html");
print '<link rel="stylesheet" type="text/css" href="css/default.css"/>';
}
$article = $this->format_article($id, false, isset($_REQUEST["zoom"]));
print $article['content'];
return;
}
$this->catchupArticleById($id, 0);
if (!$_SESSION["bw_limit"]) {
foreach ($cids as $cid) {
if ($cid) {
array_push($articles, $this->format_article($cid, false, false));
}
}
}
print json_encode($articles);
} */
/*
private function catchupArticleById($id, $cmode) {
if ($cmode == 0) {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
unread = false,last_read = NOW()
WHERE ref_id = ? AND owner_uid = ?");
} else if ($cmode == 1) {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
unread = true
WHERE ref_id = ? AND owner_uid = ?");
} else {
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
unread = NOT unread,last_read = NOW()
WHERE ref_id = ? AND owner_uid = ?");
}
$sth->execute([$id, $_SESSION['uid']]);
$feed_id = $this->getArticleFeed($id);
CCache::update($feed_id, $_SESSION["uid"]);
}
*/
static function create_published_article($title, $url, $content, $labels_str,
$owner_uid) {
$guid = 'SHA1:' . sha1("ttshared:" . $url . $owner_uid); // include owner_uid to prevent global GUID clash
if (!$content) {
$pluginhost = new PluginHost();
$pluginhost->load_all(PluginHost::KIND_ALL, $owner_uid);
$pluginhost->load_data();
foreach ($pluginhost->get_hooks(PluginHost::HOOK_GET_FULL_TEXT) as $p) {
$extracted_content = $p->hook_get_full_text($url);
if ($extracted_content) {
$content = $extracted_content;
break;
}
}
}
$content_hash = sha1($content);
if ($labels_str != "") {
$labels = explode(",", $labels_str);
} else {
$labels = array();
}
$rc = false;
if (!$title) $title = $url;
if (!$title && !$url) return false;
if (filter_var($url, FILTER_VALIDATE_URL) === FALSE) return false;
$pdo = Db::pdo();
$pdo->beginTransaction();
// only check for our user data here, others might have shared this with different content etc
$sth = $pdo->prepare("SELECT id FROM ttrss_entries, ttrss_user_entries WHERE
guid = ? AND ref_id = id AND owner_uid = ? LIMIT 1");
$sth->execute([$guid, $owner_uid]);
if ($row = $sth->fetch()) {
$ref_id = $row['id'];
$sth = $pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
ref_id = ? AND owner_uid = ? LIMIT 1");
$sth->execute([$ref_id, $owner_uid]);
if ($row = $sth->fetch()) {
$int_id = $row['int_id'];
$sth = $pdo->prepare("UPDATE ttrss_entries SET
content = ?, content_hash = ? WHERE id = ?");
$sth->execute([$content, $content_hash, $ref_id]);
if (DB_TYPE == "pgsql"){
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
$params = [
":ts_content" => mb_substr(strip_tags($content ), 0, 900000),
":id" => $ref_id];
$sth->execute($params);
}
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET published = true,
last_published = NOW() WHERE
int_id = ? AND owner_uid = ?");
$sth->execute([$int_id, $owner_uid]);
} else {
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
last_read, note, unread, last_published)
VALUES
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
$sth->execute([$ref_id, $owner_uid]);
}
if (count($labels) != 0) {
foreach ($labels as $label) {
Labels::add_article($ref_id, trim($label), $owner_uid);
}
}
$rc = true;
} else {
$sth = $pdo->prepare("INSERT INTO ttrss_entries
(title, guid, link, updated, content, content_hash, date_entered, date_updated)
VALUES
(?, ?, ?, NOW(), ?, ?, NOW(), NOW())");
$sth->execute([$title, $guid, $url, $content, $content_hash]);
$sth = $pdo->prepare("SELECT id FROM ttrss_entries WHERE guid = ?");
$sth->execute([$guid]);
if ($row = $sth->fetch()) {
$ref_id = $row["id"];
if (DB_TYPE == "pgsql"){
$sth = $pdo->prepare("UPDATE ttrss_entries
SET tsvector_combined = to_tsvector( :ts_content)
WHERE id = :id");
$params = [
":ts_content" => mb_substr(strip_tags($content ), 0, 900000),
":id" => $ref_id];
$sth->execute($params);
}
$sth = $pdo->prepare("INSERT INTO ttrss_user_entries
(ref_id, uuid, feed_id, orig_feed_id, owner_uid, published, tag_cache, label_cache,
last_read, note, unread, last_published)
VALUES
(?, '', NULL, NULL, ?, true, '', '', NOW(), '', false, NOW())");
$sth->execute([$ref_id, $owner_uid]);
if (count($labels) != 0) {
foreach ($labels as $label) {
Labels::add_article($ref_id, trim($label), $owner_uid);
}
}
$rc = true;
}
}
$pdo->commit();
return $rc;
}
function editArticleTags() {
$param = clean($_REQUEST['param']);
$tags = Article::get_article_tags($param);
$tags_str = join(", ", $tags);
print_hidden("id", "$param");
print_hidden("op", "article");
print_hidden("method", "setArticleTags");
print "<header class='horizontal'>" . __("Tags for this article (separated by commas):")."</header>";
print "<section>";
print "<textarea dojoType='dijit.form.SimpleTextarea' rows='4'
style='height : 100px; font-size : 12px; width : 98%' id='tags_str'
name='tags_str'>$tags_str</textarea>
<div class='autocomplete' id='tags_choices'
style='display:none'></div>";
print "</section>";
print "<footer>";
print "<button dojoType='dijit.form.Button'
type='submit' class='alt-primary' onclick=\"dijit.byId('editTagsDlg').execute()\">".__('Save')."</button> ";
print "<button dojoType='dijit.form.Button'
onclick=\"dijit.byId('editTagsDlg').hide()\">".__('Cancel')."</button>";
print "</footer>";
}
function setScore() {
$ids = explode(",", clean($_REQUEST['id']));
$score = (int)clean($_REQUEST['score']);
$ids_qmarks = arr_qmarks($ids);
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries SET
score = ? WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
$sth->execute(array_merge([$score], $ids, [$_SESSION['uid']]));
print json_encode(["id" => $ids, "score" => (int)$score]);
}
function getScore() {
$id = clean($_REQUEST['id']);
$sth = $this->pdo->prepare("SELECT score FROM ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
$row = $sth->fetch();
$score = $row['score'];
print json_encode(["id" => $id, "score" => (int)$score]);
}
function setArticleTags() {
$id = clean($_REQUEST["id"]);
$tags_str = clean($_REQUEST["tags_str"]);
$tags = array_unique(trim_array(explode(",", $tags_str)));
$this->pdo->beginTransaction();
$sth = $this->pdo->prepare("SELECT int_id FROM ttrss_user_entries WHERE
ref_id = ? AND owner_uid = ? LIMIT 1");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
$tags_to_cache = array();
$int_id = $row['int_id'];
$sth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE
post_int_id = ? AND owner_uid = ?");
$sth->execute([$int_id, $_SESSION['uid']]);
foreach ($tags as $tag) {
$tag = Article::sanitize_tag($tag);
if (!Article::tag_is_valid($tag)) {
continue;
}
if (preg_match("/^[0-9]*$/", $tag)) {
continue;
}
// print "<!-- $id : $int_id : $tag -->";
if ($tag != '') {
$sth = $this->pdo->prepare("INSERT INTO ttrss_tags
(post_int_id, owner_uid, tag_name)
VALUES (?, ?, ?)");
$sth->execute([$int_id, $_SESSION['uid'], $tag]);
}
array_push($tags_to_cache, $tag);
}
/* update tag cache */
sort($tags_to_cache);
$tags_str = join(",", $tags_to_cache);
$sth = $this->pdo->prepare("UPDATE ttrss_user_entries
SET tag_cache = ? WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$tags_str, $id, $_SESSION['uid']]);
}
$this->pdo->commit();
$tags = Article::get_article_tags($id);
$tags_str = $this->format_tags_string($tags, $id);
$tags_str_full = join(", ", $tags);
if (!$tags_str_full) $tags_str_full = __("no tags");
print json_encode(array("id" => (int)$id,
"content" => $tags_str, "content_full" => $tags_str_full));
}
function completeTags() {
$search = clean($_REQUEST["search"]);
$sth = $this->pdo->prepare("SELECT DISTINCT tag_name FROM ttrss_tags
WHERE owner_uid = ? AND
tag_name LIKE ? ORDER BY tag_name
LIMIT 10");
$sth->execute([$_SESSION['uid'], "$search%"]);
print "<ul>";
while ($line = $sth->fetch()) {
print "<li>" . $line["tag_name"] . "</li>";
}
print "</ul>";
}
function assigntolabel() {
return $this->labelops(true);
}
function removefromlabel() {
return $this->labelops(false);
}
private function labelops($assign) {
$reply = array();
$ids = explode(",", clean($_REQUEST["ids"]));
$label_id = clean($_REQUEST["lid"]);
$label = Labels::find_caption($label_id, $_SESSION["uid"]);
$reply["info-for-headlines"] = array();
if ($label) {
foreach ($ids as $id) {
if ($assign)
Labels::add_article($id, $label, $_SESSION["uid"]);
else
Labels::remove_article($id, $label, $_SESSION["uid"]);
$labels = $this->get_article_labels($id, $_SESSION["uid"]);
array_push($reply["info-for-headlines"],
array("id" => $id, "labels" => $this->format_article_labels($labels)));
}
}
$reply["message"] = "UPDATE_COUNTERS";
print json_encode($reply);
}
function getArticleFeed($id) {
$sth = $this->pdo->prepare("SELECT feed_id FROM ttrss_user_entries
WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$id, $_SESSION['uid']]);
if ($row = $sth->fetch()) {
return $row["feed_id"];
} else {
return 0;
}
}
static function format_article_enclosures($id, $always_display_enclosures,
$article_content, $hide_images = false) {
$result = Article::get_article_enclosures($id);
$rv = '';
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_FORMAT_ENCLOSURES) as $plugin) {
$retval = $plugin->hook_format_enclosures($rv, $result, $id, $always_display_enclosures, $article_content, $hide_images);
if (is_array($retval)) {
$rv = $retval[0];
$result = $retval[1];
} else {
$rv = $retval;
}
}
unset($retval); // Unset to prevent breaking render if there are no HOOK_RENDER_ENCLOSURE hooks below.
if ($rv === '' && !empty($result)) {
$entries_html = array();
$entries = array();
$entries_inline = array();
foreach ($result as $line) {
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_ENCLOSURE_ENTRY) as $plugin) {
$line = $plugin->hook_enclosure_entry($line);
}
$url = $line["content_url"];
$ctype = $line["content_type"];
$title = $line["title"];
$width = $line["width"];
$height = $line["height"];
if (!$ctype) $ctype = __("unknown type");
//$filename = substr($url, strrpos($url, "/")+1);
$filename = basename($url);
$player = format_inline_player($url, $ctype);
if ($player) array_push($entries_inline, $player);
# $entry .= " <a target=\"_blank\" href=\"" . htmlspecialchars($url) . "\" rel=\"noopener noreferrer\">" .
# $filename . " (" . $ctype . ")" . "</a>";
$entry = "<div onclick=\"popupOpenUrl('".htmlspecialchars($url)."')\"
dojoType=\"dijit.MenuItem\">$filename ($ctype)</div>";
array_push($entries_html, $entry);
$entry = array();
$entry["type"] = $ctype;
$entry["filename"] = $filename;
$entry["url"] = $url;
$entry["title"] = $title;
$entry["width"] = $width;
$entry["height"] = $height;
array_push($entries, $entry);
}
if ($_SESSION['uid'] && !get_pref("STRIP_IMAGES") && !$_SESSION["bw_limit"]) {
if ($always_display_enclosures ||
!preg_match("/<img/i", $article_content)) {
foreach ($entries as $entry) {
foreach (PluginHost::getInstance()->get_hooks(PluginHost::HOOK_RENDER_ENCLOSURE) as $plugin)
$retval = $plugin->hook_render_enclosure($entry, $hide_images);
if ($retval) {
$rv .= $retval;
} else {
if (preg_match("/image/", $entry["type"])) {
if (!$hide_images) {
$encsize = '';
if ($entry['height'] > 0)
$encsize .= ' height="' . intval($entry['height']) . '"';
if ($entry['width'] > 0)
$encsize .= ' width="' . intval($entry['width']) . '"';
$rv .= "<p><img
alt=\"".htmlspecialchars($entry["filename"])."\"
src=\"" .htmlspecialchars($entry["url"]) . "\"
" . $encsize . " /></p>";
} else {
$rv .= "<p><a target=\"_blank\" rel=\"noopener noreferrer\"
href=\"".htmlspecialchars($entry["url"])."\"
>" .htmlspecialchars($entry["url"]) . "</a></p>";
}
if ($entry['title']) {
$rv.= "<div class=\"enclosure_title\">${entry['title']}</div>";
}
}
}
}
}
}
if (count($entries_inline) > 0) {
//$rv .= "<hr clear='both'/>";
foreach ($entries_inline as $entry) { $rv .= $entry; };
$rv .= "<br clear='both'/>";
}
$rv .= "<div class=\"attachments\" dojoType=\"fox.form.DropDownButton\">".
"<span>" . __('Attachments')."</span>";
$rv .= "<div dojoType=\"dijit.Menu\" style=\"display: none;\">";
foreach ($entries as $entry) {
if ($entry["title"])
$title = " &mdash; " . truncate_string($entry["title"], 30);
else
$title = "";
if ($entry["filename"])
$filename = truncate_middle(htmlspecialchars($entry["filename"]), 60);
else
$filename = "";
$rv .= "<div onclick='popupOpenUrl(\"".htmlspecialchars($entry["url"])."\")'
dojoType=\"dijit.MenuItem\">".$filename . $title."</div>";
};
$rv .= "</div>";
$rv .= "</div>";
}
return $rv;
}
static function get_article_tags($id, $owner_uid = 0, $tag_cache = false) {
$a_id = $id;
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT DISTINCT tag_name,
owner_uid as owner FROM ttrss_tags
WHERE post_int_id = (SELECT int_id FROM ttrss_user_entries WHERE
ref_id = ? AND owner_uid = ? LIMIT 1) ORDER BY tag_name");
$tags = array();
/* check cache first */
if ($tag_cache === false) {
$csth = $pdo->prepare("SELECT tag_cache FROM ttrss_user_entries
WHERE ref_id = ? AND owner_uid = ?");
$csth->execute([$id, $owner_uid]);
if ($row = $csth->fetch()) $tag_cache = $row["tag_cache"];
}
if ($tag_cache) {
$tags = explode(",", $tag_cache);
} else {
/* do it the hard way */
$sth->execute([$a_id, $owner_uid]);
while ($tmp_line = $sth->fetch()) {
array_push($tags, $tmp_line["tag_name"]);
}
/* update the cache */
$tags_str = join(",", $tags);
$sth = $pdo->prepare("UPDATE ttrss_user_entries
SET tag_cache = ? WHERE ref_id = ?
AND owner_uid = ?");
$sth->execute([$tags_str, $id, $owner_uid]);
}
return $tags;
}
static function format_tags_string($tags) {
if (!is_array($tags) || count($tags) == 0) {
return __("no tags");
} else {
$maxtags = min(5, count($tags));
$tags_str = "";
for ($i = 0; $i < $maxtags; $i++) {
$tags_str .= "<a class=\"tag\" href=\"#\" onclick=\"Feeds.open({feed:'".$tags[$i]."'})\">" . $tags[$i] . "</a>, ";
}
$tags_str = mb_substr($tags_str, 0, mb_strlen($tags_str)-2);
if (count($tags) > $maxtags)
$tags_str .= ", &hellip;";
return $tags_str;
}
}
static function format_article_labels($labels) {
if (!is_array($labels)) return '';
$labels_str = "";
foreach ($labels as $l) {
$labels_str .= sprintf("<div class='label'
style='color : %s; background-color : %s'>%s</div>",
$l[2], $l[3], $l[1]);
}
return $labels_str;
}
static function format_article_note($id, $note, $allow_edit = true) {
if ($allow_edit) {
$onclick = "onclick='Plugins.Note.edit($id)'";
$note_class = 'editable';
} else {
$onclick = '';
$note_class = '';
}
return "<div class='article-note $note_class'>
<i class='material-icons'>note</i>
<div $onclick class='body'>$note</div>
</div>";
return $str;
}
static function get_article_enclosures($id) {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT * FROM ttrss_enclosures
WHERE post_id = ? AND content_url != ''");
$sth->execute([$id]);
$rv = array();
while ($line = $sth->fetch()) {
if (file_exists(CACHE_DIR . '/images/' . sha1($line["content_url"]))) {
$line["content_url"] = get_self_url_prefix() . '/public.php?op=cached_url&hash=' . sha1($line["content_url"]);
}
array_push($rv, $line);
}
return $rv;
}
static function purge_orphans() {
// purge orphaned posts in main content table
if (DB_TYPE == "mysql")
$limit_qpart = "LIMIT 5000";
else
$limit_qpart = "";
$pdo = Db::pdo();
$res = $pdo->query("DELETE FROM ttrss_entries WHERE
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id) $limit_qpart");
if (Debug::enabled()) {
$rows = $res->rowCount();
Debug::log("Purged $rows orphaned posts.");
}
}
static function catchupArticlesById($ids, $cmode, $owner_uid = false) {
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
$ids_qmarks = arr_qmarks($ids);
if ($cmode == 1) {
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
unread = true
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
} else if ($cmode == 2) {
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
unread = NOT unread,last_read = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
} else {
$sth = $pdo->prepare("UPDATE ttrss_user_entries SET
unread = false,last_read = NOW()
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
}
$sth->execute(array_merge($ids, [$owner_uid]));
/* update ccache */
$sth = $pdo->prepare("SELECT DISTINCT feed_id FROM ttrss_user_entries
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
$sth->execute(array_merge($ids, [$owner_uid]));
while ($line = $sth->fetch()) {
CCache::update($line["feed_id"], $owner_uid);
}
}
static function getLastArticleId() {
$pdo = DB::pdo();
$sth = $pdo->prepare("SELECT ref_id AS id FROM ttrss_user_entries
WHERE owner_uid = ? ORDER BY ref_id DESC LIMIT 1");
$sth->execute([$_SESSION['uid']]);
if ($row = $sth->fetch()) {
return $row['id'];
} else {
return -1;
}
}
static function get_article_labels($id, $owner_uid = false) {
$rv = array();
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT label_cache FROM
ttrss_user_entries WHERE ref_id = ? AND owner_uid = ?");
$sth->execute([$id, $owner_uid]);
if ($row = $sth->fetch()) {
$label_cache = $row["label_cache"];
if ($label_cache) {
$tmp = json_decode($label_cache, true);
if (!$tmp || $tmp["no-labels"] == 1)
return $rv;
else
return $tmp;
}
}
$sth = $pdo->prepare("SELECT DISTINCT label_id,caption,fg_color,bg_color
FROM ttrss_labels2, ttrss_user_labels2
WHERE id = label_id
AND article_id = ?
AND owner_uid = ?
ORDER BY caption");
$sth->execute([$id, $owner_uid]);
while ($line = $sth->fetch()) {
$rk = array(Labels::label_to_feed_id($line["label_id"]),
$line["caption"], $line["fg_color"],
$line["bg_color"]);
array_push($rv, $rk);
}
if (count($rv) > 0)
Labels::update_cache($owner_uid, $id, $rv);
else
Labels::update_cache($owner_uid, $id, array("no-labels" => 1));
return $rv;
}
static function sanitize_tag($tag) {
$tag = trim($tag);
$tag = mb_strtolower($tag, 'utf-8');
$tag = preg_replace('/[,\'\"\+\>\<]/', "", $tag);
if (DB_TYPE == "mysql") {
$tag = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $tag);
}
return $tag;
}
static function tag_is_valid($tag) {
if (!$tag || is_numeric($tag) || mb_strlen($tag) > 250)
return false;
return true;
}
}

62
classes/auth/base.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
class Auth_Base {
private $pdo;
function __construct() {
$this->pdo = Db::pdo();
}
/**
* @SuppressWarnings(unused)
*/
function check_password($owner_uid, $password) {
return false;
}
/**
* @SuppressWarnings(unused)
*/
function authenticate($login, $password) {
return false;
}
// Auto-creates specified user if allowed by system configuration
// Can be used instead of find_user_by_login() by external auth modules
function auto_create_user($login, $password = false) {
if ($login && defined('AUTH_AUTO_CREATE') && AUTH_AUTO_CREATE) {
$user_id = $this->find_user_by_login($login);
if (!$password) $password = make_password();
if (!$user_id) {
$salt = substr(bin2hex(get_random_bytes(125)), 0, 250);
$pwd_hash = encrypt_password($password, $salt, true);
$sth = $this->pdo->prepare("INSERT INTO ttrss_users
(login,access_level,last_login,created,pwd_hash,salt)
VALUES (?, 0, null, NOW(), ?,?)");
$sth->execute([$login, $pwd_hash, $salt]);
return $this->find_user_by_login($login);
} else {
return $user_id;
}
}
return $this->find_user_by_login($login);
}
function find_user_by_login($login) {
$sth = $this->pdo->prepare("SELECT id FROM ttrss_users WHERE
login = ?");
$sth->execute([$login]);
if ($row = $sth->fetch()) {
return $row["id"];
} else {
return false;
}
}
}

163
classes/backend.php Normal file
View File

@@ -0,0 +1,163 @@
<?php
class Backend extends Handler {
function loading() {
header("Content-type: text/html");
print __("Loading, please wait...") . " " .
"<img src='images/indicator_tiny.gif'>";
}
function digestTest() {
if (isset($_SESSION['uid'])) {
header("Content-type: text/html");
$rv = Digest::prepare_headlines_digest($_SESSION['uid'], 1, 1000);
print "<h1>HTML</h1>";
print $rv[0];
print "<h1>Plain text</h1>";
print "<pre>".$rv[3]."</pre>";
} else {
print error_json(6);
}
}
private function display_main_help() {
$info = get_hotkeys_info();
$imap = get_hotkeys_map();
$omap = array();
foreach ($imap[1] as $sequence => $action) {
if (!isset($omap[$action])) $omap[$action] = array();
array_push($omap[$action], $sequence);
}
print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>";
print "<h2>" . __("Keyboard Shortcuts") . "</h2>";
foreach ($info as $section => $hotkeys) {
print "<li><hr></li>";
print "<li><h3>" . $section . "</h3></li>";
foreach ($hotkeys as $action => $description) {
if (is_array($omap[$action])) {
foreach ($omap[$action] as $sequence) {
if (strpos($sequence, "|") !== FALSE) {
$sequence = substr($sequence,
strpos($sequence, "|")+1,
strlen($sequence));
} else {
$keys = explode(" ", $sequence);
for ($i = 0; $i < count($keys); $i++) {
if (strlen($keys[$i]) > 1) {
$tmp = '';
foreach (str_split($keys[$i]) as $c) {
switch ($c) {
case '*':
$tmp .= __('Shift') . '+';
break;
case '^':
$tmp .= __('Ctrl') . '+';
break;
default:
$tmp .= $c;
}
}
$keys[$i] = $tmp;
}
}
$sequence = join(" ", $keys);
}
print "<li>";
print "<div class='hk'><code>$sequence</code></div>";
print "<div class='desc'>$description</div>";
print "</li>";
}
}
}
}
print "</ul>";
}
function help() {
$topic = basename(clean($_REQUEST["topic"])); // only one for now
if ($topic == "main") {
$info = get_hotkeys_info();
$imap = get_hotkeys_map();
$omap = array();
foreach ($imap[1] as $sequence => $action) {
if (!isset($omap[$action])) $omap[$action] = array();
array_push($omap[$action], $sequence);
}
print "<ul class='panel panel-scrollable hotkeys-help' style='height : 300px'>";
$cur_section = "";
foreach ($info as $section => $hotkeys) {
if ($cur_section) print "<li>&nbsp;</li>";
print "<li><h3>" . $section . "</h3></li>";
$cur_section = $section;
foreach ($hotkeys as $action => $description) {
if (is_array($omap[$action])) {
foreach ($omap[$action] as $sequence) {
if (strpos($sequence, "|") !== FALSE) {
$sequence = substr($sequence,
strpos($sequence, "|")+1,
strlen($sequence));
} else {
$keys = explode(" ", $sequence);
for ($i = 0; $i < count($keys); $i++) {
if (strlen($keys[$i]) > 1) {
$tmp = '';
foreach (str_split($keys[$i]) as $c) {
switch ($c) {
case '*':
$tmp .= __('Shift') . '+';
break;
case '^':
$tmp .= __('Ctrl') . '+';
break;
default:
$tmp .= $c;
}
}
$keys[$i] = $tmp;
}
}
$sequence = join(" ", $keys);
}
print "<li>";
print "<div class='hk'><code>$sequence</code></div>";
print "<div class='desc'>$description</div>";
print "</li>";
}
}
}
}
print "</ul>";
}
print "<footer class='text-center'>";
print "<button dojoType='dijit.form.Button'
onclick=\"return dijit.byId('helpDlg').hide()\">".__('Close this window')."</button>";
print "</footer>";
}
}

211
classes/ccache.php Normal file
View File

@@ -0,0 +1,211 @@
<?php
class CCache {
static function zero_all($owner_uid) {
$pdo = Db::pdo();
$sth = $pdo->prepare("UPDATE ttrss_counters_cache SET
value = 0 WHERE owner_uid = ?");
$sth->execute([$owner_uid]);
$sth = $pdo->prepare("UPDATE ttrss_cat_counters_cache SET
value = 0 WHERE owner_uid = ?");
$sth->execute([$owner_uid]);
}
static function remove($feed_id, $owner_uid, $is_cat = false) {
$feed_id = (int) $feed_id;
if (!$is_cat) {
$table = "ttrss_counters_cache";
} else {
$table = "ttrss_cat_counters_cache";
}
$pdo = Db::pdo();
$sth = $pdo->prepare("DELETE FROM $table WHERE
feed_id = ? AND owner_uid = ?");
$sth->execute([$feed_id, $owner_uid]);
}
static function update_all($owner_uid) {
$pdo = Db::pdo();
if (get_pref('ENABLE_FEED_CATS', $owner_uid)) {
$sth = $pdo->prepare("SELECT feed_id FROM ttrss_cat_counters_cache
WHERE feed_id > 0 AND owner_uid = ?");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch()) {
CCache::update($line["feed_id"], $owner_uid, true);
}
/* We have to manually include category 0 */
CCache::update(0, $owner_uid, true);
} else {
$sth = $pdo->prepare("SELECT feed_id FROM ttrss_counters_cache
WHERE feed_id > 0 AND owner_uid = ?");
$sth->execute([$owner_uid]);
while ($line = $sth->fetch()) {
print CCache::update($line["feed_id"], $owner_uid);
}
}
}
static function find($feed_id, $owner_uid, $is_cat = false,
$no_update = false) {
// "" (null) is valid and should be cast to 0 (uncategorized)
// everything else i.e. tags are not
if (!is_numeric($feed_id) && $feed_id)
return;
$feed_id = (int) $feed_id;
if (!$is_cat) {
$table = "ttrss_counters_cache";
} else {
$table = "ttrss_cat_counters_cache";
}
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT value FROM $table
WHERE owner_uid = ? AND feed_id = ?
LIMIT 1");
$sth->execute([$owner_uid, $feed_id]);
if ($row = $sth->fetch()) {
return $row["value"];
} else {
if ($no_update) {
return -1;
} else {
return CCache::update($feed_id, $owner_uid, $is_cat);
}
}
}
static function update($feed_id, $owner_uid, $is_cat = false,
$update_pcat = true, $pcat_fast = false) {
// "" (null) is valid and should be cast to 0 (uncategorized)
// everything else i.e. tags are not
if (!is_numeric($feed_id) && $feed_id)
return;
$feed_id = (int) $feed_id;
$prev_unread = CCache::find($feed_id, $owner_uid, $is_cat, true);
/* When updating a label, all we need to do is recalculate feed counters
* because labels are not cached */
if ($feed_id < 0) {
CCache::update_all($owner_uid);
return;
}
if (!$is_cat) {
$table = "ttrss_counters_cache";
} else {
$table = "ttrss_cat_counters_cache";
}
$pdo = Db::pdo();
if ($is_cat && $feed_id >= 0) {
/* Recalculate counters for child feeds */
if (!$pcat_fast) {
$sth = $pdo->prepare("SELECT id FROM ttrss_feeds
WHERE owner_uid = :uid AND
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL))");
$sth->execute([":uid" => $owner_uid, ":cat" => $feed_id]);
while ($line = $sth->fetch()) {
CCache::update((int)$line["id"], $owner_uid, false, false);
}
}
$sth = $pdo->prepare("SELECT SUM(value) AS sv
FROM ttrss_counters_cache, ttrss_feeds
WHERE ttrss_feeds.id = feed_id AND
(cat_id = :cat OR (:cat = 0 AND cat_id IS NULL)) AND
ttrss_counters_cache.owner_uid = :uid AND
ttrss_feeds.owner_uid = :uid");
$sth->execute([":uid" => $owner_uid, ":cat" => $feed_id]);
$row = $sth->fetch();
$unread = (int) $row["sv"];
} else {
$unread = (int) Feeds::getFeedArticles($feed_id, $is_cat, true, $owner_uid);
}
$tr_in_progress = false;
try {
$pdo->beginTransaction();
} catch (Exception $e) {
$tr_in_progress = true;
}
$sth = $pdo->prepare("SELECT feed_id FROM $table
WHERE owner_uid = ? AND feed_id = ? LIMIT 1");
$sth->execute([$owner_uid, $feed_id]);
if ($sth->fetch()) {
$sth = $pdo->prepare("UPDATE $table SET
value = ?, updated = NOW() WHERE
feed_id = ? AND owner_uid = ?");
$sth->execute([$unread, $feed_id, $owner_uid]);
} else {
$sth = $pdo->prepare("INSERT INTO $table
(feed_id, value, owner_uid, updated)
VALUES
(?, ?, ?, NOW())");
$sth->execute([$feed_id, $unread, $owner_uid]);
}
if (!$tr_in_progress) $pdo->commit();
if ($feed_id > 0 && $prev_unread != $unread) {
if (!$is_cat) {
/* Update parent category */
if ($update_pcat) {
$sth = $pdo->prepare("SELECT cat_id FROM ttrss_feeds
WHERE owner_uid = ? AND id = ?");
$sth->execute([$owner_uid, $feed_id]);
if ($row = $sth->fetch()) {
CCache::update((int)$row["cat_id"], $owner_uid, true, true, true);
}
}
}
} else if ($feed_id < 0) {
CCache::update_all($owner_uid);
}
return $unread;
}
}

216
classes/counters.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
class Counters {
static function getAllCounters() {
$data = Counters::getGlobalCounters();
$data = array_merge($data, Counters::getVirtCounters());
$data = array_merge($data, Counters::getLabelCounters());
$data = array_merge($data, Counters::getFeedCounters());
$data = array_merge($data, Counters::getCategoryCounters());
return $data;
}
static function getCategoryCounters() {
$ret_arr = array();
/* Labels category */
$cv = array("id" => -2, "kind" => "cat",
"counter" => Feeds::getCategoryUnread(-2));
array_push($ret_arr, $cv);
$pdo = DB::pdo();
$sth = $pdo->prepare("SELECT ttrss_feed_categories.id AS cat_id, value AS unread,
(SELECT COUNT(id) FROM ttrss_feed_categories AS c2
WHERE c2.parent_cat = ttrss_feed_categories.id) AS num_children
FROM ttrss_feed_categories, ttrss_cat_counters_cache
WHERE ttrss_cat_counters_cache.feed_id = ttrss_feed_categories.id AND
ttrss_cat_counters_cache.owner_uid = ttrss_feed_categories.owner_uid AND
ttrss_feed_categories.owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
while ($line = $sth->fetch()) {
$line["cat_id"] = (int) $line["cat_id"];
if ($line["num_children"] > 0) {
$child_counter = Feeds::getCategoryChildrenUnread($line["cat_id"], $_SESSION["uid"]);
} else {
$child_counter = 0;
}
$cv = array("id" => $line["cat_id"], "kind" => "cat",
"counter" => $line["unread"] + $child_counter);
array_push($ret_arr, $cv);
}
/* Special case: NULL category doesn't actually exist in the DB */
$cv = array("id" => 0, "kind" => "cat",
"counter" => (int) CCache::find(0, $_SESSION["uid"], true));
array_push($ret_arr, $cv);
return $ret_arr;
}
static function getGlobalCounters($global_unread = -1) {
$ret_arr = array();
if ($global_unread == -1) {
$global_unread = Feeds::getGlobalUnread();
}
$cv = array("id" => "global-unread",
"counter" => (int) $global_unread);
array_push($ret_arr, $cv);
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT COUNT(id) AS fn FROM
ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
$row = $sth->fetch();
$subscribed_feeds = $row["fn"];
$cv = array("id" => "subscribed-feeds",
"counter" => (int) $subscribed_feeds);
array_push($ret_arr, $cv);
return $ret_arr;
}
static function getVirtCounters() {
$ret_arr = array();
for ($i = 0; $i >= -4; $i--) {
$count = getFeedUnread($i);
if ($i == 0 || $i == -1 || $i == -2)
$auxctr = Feeds::getFeedArticles($i, false);
else
$auxctr = 0;
$cv = array("id" => $i,
"counter" => (int) $count,
"auxcounter" => (int) $auxctr);
// if (get_pref('EXTENDED_FEEDLIST'))
// $cv["xmsg"] = getFeedArticles($i)." ".__("total");
array_push($ret_arr, $cv);
}
$feeds = PluginHost::getInstance()->get_feeds(-1);
if (is_array($feeds)) {
foreach ($feeds as $feed) {
$cv = array("id" => PluginHost::pfeed_to_feed_id($feed['id']),
"counter" => $feed['sender']->get_unread($feed['id']));
if (method_exists($feed['sender'], 'get_total'))
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
array_push($ret_arr, $cv);
}
}
return $ret_arr;
}
static function getLabelCounters($descriptions = false) {
$ret_arr = array();
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT id,caption,SUM(CASE WHEN u1.unread = true THEN 1 ELSE 0 END) AS unread, COUNT(u1.unread) AS total
FROM ttrss_labels2 LEFT JOIN ttrss_user_labels2 ON
(ttrss_labels2.id = label_id)
LEFT JOIN ttrss_user_entries AS u1 ON u1.ref_id = article_id
WHERE ttrss_labels2.owner_uid = :uid AND u1.owner_uid = :uid
GROUP BY ttrss_labels2.id,
ttrss_labels2.caption");
$sth->execute([":uid" => $_SESSION['uid']]);
while ($line = $sth->fetch()) {
$id = Labels::label_to_feed_id($line["id"]);
$cv = array("id" => $id,
"counter" => (int) $line["unread"],
"auxcounter" => (int) $line["total"]);
if ($descriptions)
$cv["description"] = $line["caption"];
array_push($ret_arr, $cv);
}
return $ret_arr;
}
static function getFeedCounters($active_feed = false) {
$ret_arr = array();
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT ttrss_feeds.id,
ttrss_feeds.title,
".SUBSTRING_FOR_DATE."(ttrss_feeds.last_updated,1,19) AS last_updated,
last_error, value AS count
FROM ttrss_feeds, ttrss_counters_cache
WHERE ttrss_feeds.owner_uid = ?
AND ttrss_counters_cache.owner_uid = ttrss_feeds.owner_uid
AND ttrss_counters_cache.feed_id = ttrss_feeds.id");
$sth->execute([$_SESSION['uid']]);
while ($line = $sth->fetch()) {
$id = $line["id"];
$count = $line["count"];
$last_error = htmlspecialchars($line["last_error"]);
$last_updated = make_local_datetime($line['last_updated'], false);
if (Feeds::feedHasIcon($id)) {
$has_img = filemtime(Feeds::getIconFile($id));
} else {
$has_img = false;
}
if (date('Y') - date('Y', strtotime($line['last_updated'])) > 2)
$last_updated = '';
$cv = array("id" => $id,
"updated" => $last_updated,
"counter" => (int) $count,
"has_img" => (int) $has_img);
if ($last_error)
$cv["error"] = $last_error;
// if (get_pref('EXTENDED_FEEDLIST'))
// $cv["xmsg"] = getFeedArticles($id)." ".__("total");
if ($active_feed && $id == $active_feed)
$cv["title"] = truncate_string($line["title"], 30);
array_push($ret_arr, $cv);
}
return $ret_arr;
}
}

116
classes/db.php Executable file
View File

@@ -0,0 +1,116 @@
<?php
class Db
{
/* @var Db $instance */
private static $instance;
/* @var IDb $adapter */
private $adapter;
private $link;
/* @var PDO $pdo */
private $pdo;
private function __clone() {
//
}
private function legacy_connect() {
user_error("Legacy connect requested to " . DB_TYPE, E_USER_NOTICE);
$er = error_reporting(E_ALL);
switch (DB_TYPE) {
case "mysql":
$this->adapter = new Db_Mysqli();
break;
case "pgsql":
$this->adapter = new Db_Pgsql();
break;
default:
die("Unknown DB_TYPE: " . DB_TYPE);
}
if (!$this->adapter) {
print("Error initializing database adapter for " . DB_TYPE);
exit(100);
}
$this->link = $this->adapter->connect(DB_HOST, DB_USER, DB_PASS, DB_NAME, defined('DB_PORT') ? DB_PORT : "");
if (!$this->link) {
print("Error connecting through adapter: " . $this->adapter->last_error());
exit(101);
}
error_reporting($er);
}
// this really shouldn't be used unless a separate PDO connection is needed
// normal usage is Db::pdo()->prepare(...) etc
public function pdo_connect() {
$db_port = defined('DB_PORT') && DB_PORT ? ';port=' . DB_PORT : '';
$db_host = defined('DB_HOST') && DB_HOST ? ';host=' . DB_HOST : '';
try {
$pdo = new PDO(DB_TYPE . ':dbname=' . DB_NAME . $db_host . $db_port,
DB_USER,
DB_PASS);
} catch (Exception $e) {
print "<pre>Exception while creating PDO object:" . $e->getMessage() . "</pre>";
exit(101);
}
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
if (DB_TYPE == "pgsql") {
$pdo->query("set client_encoding = 'UTF-8'");
$pdo->query("set datestyle = 'ISO, european'");
$pdo->query("set TIME ZONE 0");
$pdo->query("set cpu_tuple_cost = 0.5");
} else if (DB_TYPE == "mysql") {
$pdo->query("SET time_zone = '+0:0'");
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
$pdo->query("SET NAMES " . MYSQL_CHARSET);
}
}
return $pdo;
}
public static function instance() {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
public static function get() {
if (self::$instance == null)
self::$instance = new self();
if (!self::$instance->adapter) {
self::$instance->legacy_connect();
}
return self::$instance->adapter;
}
public static function pdo() {
if (self::$instance == null)
self::$instance = new self();
if (!self::$instance->pdo) {
self::$instance->pdo = self::$instance->pdo_connect();
}
return self::$instance->pdo;
}
}

85
classes/db/mysqli.php Normal file
View File

@@ -0,0 +1,85 @@
<?php
class Db_Mysqli implements IDb {
private $link;
private $last_error;
function connect($host, $user, $pass, $db, $port) {
if ($port)
$this->link = mysqli_connect($host, $user, $pass, $db, $port);
else
$this->link = mysqli_connect($host, $user, $pass, $db);
if ($this->link) {
$this->init();
return $this->link;
} else {
print("Unable to connect to database (as $user to $host, database $db): " . mysqli_connect_error());
exit(102);
}
}
function escape_string($s, $strip_tags = true) {
if ($strip_tags) $s = strip_tags($s);
return mysqli_real_escape_string($this->link, $s);
}
function query($query, $die_on_error = true) {
$result = @mysqli_query($this->link, $query);
if (!$result) {
$this->last_error = @mysqli_error($this->link);
@mysqli_query($this->link, "ROLLBACK");
user_error("Query $query failed: " . ($this->link ? $this->last_error : "No connection"),
$die_on_error ? E_USER_ERROR : E_USER_WARNING);
}
return $result;
}
function fetch_assoc($result) {
return mysqli_fetch_assoc($result);
}
function num_rows($result) {
return mysqli_num_rows($result);
}
function fetch_result($result, $row, $param) {
if (mysqli_data_seek($result, $row)) {
$line = mysqli_fetch_assoc($result);
return $line[$param];
} else {
return false;
}
}
function close() {
return mysqli_close($this->link);
}
function affected_rows($result) {
return mysqli_affected_rows($this->link);
}
function last_error() {
return mysqli_error($this->link);
}
function last_query_error() {
return $this->last_error;
}
function init() {
$this->query("SET time_zone = '+0:0'");
if (defined('MYSQL_CHARSET') && MYSQL_CHARSET) {
mysqli_set_charset($this->link, MYSQL_CHARSET);
}
return true;
}
}

Some files were not shown because too many files have changed in this diff Show More