1 Commits

Author SHA1 Message Date
Andrew Dolgov
b300efd439 add rootless app (fpm) container 2024-09-23 10:13:14 +03:00
1587 changed files with 209145 additions and 93189 deletions

View File

@@ -0,0 +1,101 @@
ARG PROXY_REGISTRY
FROM ${PROXY_REGISTRY}alpine:3.20
EXPOSE 9000/tcp
ARG ALPINE_MIRROR
ENV SCRIPT_ROOT=/opt/tt-rss
ENV SRC_DIR=/src/tt-rss/
# overriding those without rebuilding image won't do much
ENV OWNER_UID=1000
ENV OWNER_GID=1000
RUN [ ! -z ${ALPINE_MIRROR} ] && \
sed -i.bak "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#" /etc/apk/repositories ; \
apk add --no-cache dcron php83 php83-fpm php83-phar php83-sockets php83-pecl-apcu \
php83-pdo php83-gd php83-pgsql php83-pdo_pgsql php83-xmlwriter php83-opcache \
php83-mbstring php83-intl php83-xml php83-curl php83-simplexml \
php83-session php83-tokenizer php83-dom php83-fileinfo php83-ctype \
php83-json php83-iconv php83-pcntl php83-posix php83-zip php83-exif \
php83-openssl git postgresql-client sudo php83-pecl-xdebug rsync tzdata && \
sed -i 's/\(memory_limit =\) 128M/\1 256M/' /etc/php83/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/php83/php-fpm.d/www.conf && \
mkdir -p /var/www ${SCRIPT_ROOT}/config.d && \
addgroup -g $OWNER_GID app && \
adduser -D -h /var/www/html -G app -u $OWNER_UID app && \
update-ca-certificates && \
chown -R $OWNER_UID /etc/php83 /var/log/php83
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-rootless/startup.sh ${SCRIPT_ROOT}
ADD .docker/app-rootless/updater.sh ${SCRIPT_ROOT}
ADD .docker/app-rootless/index.php ${SCRIPT_ROOT}
ADD .docker/app-rootless/config.docker.php ${SCRIPT_ROOT}
COPY . ${SRC_DIR}
ARG ORIGIN_REPO_XACCEL=https://git.tt-rss.org/fox/ttrss-nginx-xaccel.git
RUN git clone --depth=1 ${ORIGIN_REPO_XACCEL} ${SRC_DIR}/plugins.local/nginx_xaccel
USER $OWNER_UID
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_TYPE="pgsql"
ENV TTRSS_DB_HOST="db"
ENV TTRSS_DB_PORT="5432"
ENV TTRSS_MYSQL_CHARSET="UTF8"
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php83"
ENV TTRSS_PLUGINS="auth_internal, note, nginx_xaccel"
CMD ${SCRIPT_ROOT}/startup.sh

View File

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

View File

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

View File

@@ -0,0 +1,155 @@
#!/bin/sh -e
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; 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
DST_DIR=/var/www/html/tt-rss
if [ ! -w $DST_DIR -a ! -w /var/www/html ]; then
echo please make sure both /var/www/html and $DST_DIR are writable to current user $(id)
exit 1
fi
[ -e $DST_DIR ] && rm -f $DST_DIR/.app_is_ready
export PGPASSWORD=$TTRSS_DB_PASS
[ ! -e /var/www/html/index.php ] && cp ${SCRIPT_ROOT}/index.php /var/www/html
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
if [ ! -d $DST_DIR ]; then
mkdir -p $DST_DIR
rsync -a --no-owner \
$SRC_DIR/ $DST_DIR/
else
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/
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
mkdir -p $DST_DIR/$d
done
for d in cache lock feed-icons; do
chmod 777 $DST_DIR/$d
find $DST_DIR/$d -type f -exec chmod 666 {} \;
done
cp ${SCRIPT_ROOT}/config.docker.php $DST_DIR/config.php
chmod 644 $DST_DIR/config.php
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 && \
git config core.filemode false && \
git config pull.rebase false && \
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 -U $TTRSS_DB_USER $TTRSS_DB_NAME"
$PSQL -c "create extension if not exists pg_trgm"
RESTORE_SCHEMA=${SCRIPT_ROOT}/restore-schema.sql.gz
if [ -r $RESTORE_SCHEMA ]; then
$PSQL -c "drop schema public cascade; create schema public;"
zcat $RESTORE_SCHEMA | $PSQL
fi
# 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/php83/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/php83/php.ini
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
/etc/php83/php-fpm.d/www.conf
php83 $DST_DIR/update.php --update-schema=force-yes
if [ ! -z "$ADMIN_USER_PASS" ]; then
php83 $DST_DIR/update.php --user-set-password "admin:$ADMIN_USER_PASS"
else
if php83 $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 "*****************************************************************************"
php83 $DST_DIR/update.php --user-set-password "admin:$RANDOM_PASS"
fi
fi
if [ ! -z "$ADMIN_USER_ACCESS_LEVEL" ]; then
php83 $DST_DIR/update.php --user-set-access-level "admin:$ADMIN_USER_ACCESS_LEVEL"
fi
if [ ! -z "$AUTO_CREATE_USER" ]; then
/bin/sh -c "php83 $DST_DIR/update.php --user-exists $AUTO_CREATE_USER ||
php83 $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
/bin/sh -c "php83 $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
touch $DST_DIR/.app_is_ready
exec /usr/sbin/php-fpm83 --nodaemonize --force-stderr

View File

@@ -0,0 +1,33 @@
#!/bin/sh -e
# 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 /var/www/html -G app -u $OWNER_UID app
fi
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; do
echo waiting until $TTRSS_DB_HOST is ready...
sleep 3
done
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
/etc/php83/php.ini
DST_DIR=/var/www/html/tt-rss
while [ ! -s $DST_DIR/config.php -a -e $DST_DIR/.app_is_ready ]; do
echo waiting for app container...
sleep 3
done
sudo -E -u app /usr/bin/php83 /var/www/html/tt-rss/update_daemon2.php "$@"

View File

@@ -1,6 +1,6 @@
ARG PROXY_REGISTRY
FROM ${PROXY_REGISTRY}alpine:3.22
FROM ${PROXY_REGISTRY}alpine:3.20
EXPOSE 9000/tcp
ARG ALPINE_MIRROR
@@ -8,22 +8,15 @@ 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.bak "s#dl-cdn.alpinelinux.org#${ALPINE_MIRROR}#" /etc/apk/repositories ; \
apk add --no-cache dcron php83 php83-fpm php83-phar php83-sockets php83-pecl-apcu \
php83-pdo php83-gd php83-pgsql php83-pdo_pgsql php83-xmlwriter php83-opcache \
php83-mbstring php83-intl php83-xml php83-curl php83-simplexml \
php83-session php83-tokenizer php83-dom php83-fileinfo php83-ctype \
php83-json php83-iconv php83-pcntl php83-posix php83-zip php83-exif \
php83-openssl git postgresql-client sudo php83-pecl-xdebug rsync tzdata && \
sed -i 's/\(memory_limit =\) 128M/\1 256M/' /etc/php83/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' \
@@ -31,8 +24,8 @@ RUN [ ! -z ${ALPINE_MIRROR} ] && \
-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
/etc/php83/php-fpm.d/www.conf && \
mkdir -p /var/www ${SCRIPT_ROOT}/config.d
ARG CI_COMMIT_BRANCH
ENV CI_COMMIT_BRANCH=${CI_COMMIT_BRANCH}
@@ -47,7 +40,6 @@ 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
@@ -59,7 +51,7 @@ 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
ARG ORIGIN_REPO_XACCEL=https://git.tt-rss.org/fox/ttrss-nginx-xaccel.git
RUN git clone --depth=1 ${ORIGIN_REPO_XACCEL} ${SRC_DIR}/plugins.local/nginx_xaccel
@@ -95,10 +87,12 @@ ENV TTRSS_XDEBUG_ENABLED=""
ENV TTRSS_XDEBUG_HOST=""
ENV TTRSS_XDEBUG_PORT="9000"
ENV TTRSS_DB_TYPE="pgsql"
ENV TTRSS_DB_HOST="db"
ENV TTRSS_DB_PORT="5432"
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php${PHP_SUFFIX}"
ENV TTRSS_MYSQL_CHARSET="UTF8"
ENV TTRSS_PHP_EXECUTABLE="/usr/bin/php83"
ENV TTRSS_PLUGINS="auth_internal, note, nginx_xaccel"
CMD ${SCRIPT_ROOT}/startup.sh

View File

@@ -2,14 +2,14 @@
DST_DIR=/backups
KEEP_DAYS=28
APP_ROOT=$APP_INSTALL_BASE_DIR/tt-rss
APP_ROOT=/var/www/html/tt-rss
if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; then
if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; 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
export PGPASSWORD=$TTRSS_DB_PASS
pg_dump --clean -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME | gzip > $DST_DIR/$DST_FILE
@@ -18,7 +18,7 @@ if pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER -p $TTRSS_DB_PORT; then
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/feed-icons/ \
$APP_ROOT/config.php
echo cleaning up...

View File

@@ -1,10 +1,6 @@
#!/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
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; do
echo waiting until $TTRSS_DB_HOST is ready...
sleep 3
done
@@ -15,18 +11,18 @@ 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
adduser -D -h /var/www/html -G app -u $OWNER_UID app
fi
update-ca-certificates || true
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
DST_DIR=/var/www/html/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
[ ! -e /var/www/html/index.php ] && cp ${SCRIPT_ROOT}/index.php /var/www/html
if [ -z $SKIP_RSYNC_ON_STARTUP ]; then
if [ ! -d $DST_DIR ]; then
@@ -60,21 +56,16 @@ for d in cache lock feed-icons plugins.local themes.local templates.local cache/
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
chmod 777 $DST_DIR/$d
find $DST_DIR/$d -type f -exec chmod 666 {} \;
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}
/var/log/php83
if [ -z "$TTRSS_NO_STARTUP_PLUGIN_UPDATES" ]; then
echo updating all local plugins...
@@ -86,17 +77,24 @@ if [ -z "$TTRSS_NO_STARTUP_PLUGIN_UPDATES" ]; then
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.
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="psql -q -h $TTRSS_DB_HOST -U $TTRSS_DB_USER $TTRSS_DB_NAME"
$PSQL -c "create extension if not exists pg_trgm"
RESTORE_SCHEMA=${SCRIPT_ROOT}/restore-schema.sql.gz
if [ -r $RESTORE_SCHEMA ]; then
$PSQL -c "drop schema public cascade; create schema public;"
zcat $RESTORE_SCHEMA | $PSQL
fi
# this was previously generated
rm -f $DST_DIR/config.php.bak
@@ -106,7 +104,7 @@ if [ ! -z "${TTRSS_XDEBUG_ENABLED}" ]; then
fi
echo enabling xdebug with the following parameters:
env | grep TTRSS_XDEBUG
cat > /etc/php${PHP_SUFFIX}/conf.d/50_xdebug.ini <<EOF
cat > /etc/php83/conf.d/50_xdebug.ini <<EOF
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request = yes
@@ -116,17 +114,17 @@ EOF
fi
sed -i.bak "s/^\(memory_limit\) = \(.*\)/\1 = ${PHP_WORKER_MEMORY_LIMIT}/" \
/etc/php${PHP_SUFFIX}/php.ini
/etc/php83/php.ini
sed -i.bak "s/^\(pm.max_children\) = \(.*\)/\1 = ${PHP_WORKER_MAX_CHILDREN}/" \
/etc/php${PHP_SUFFIX}/php-fpm.d/www.conf
/etc/php83/php-fpm.d/www.conf
sudo -Eu app php${PHP_SUFFIX} $DST_DIR/update.php --update-schema=force-yes
sudo -Eu app php83 $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"
sudo -Eu app php83 $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
if sudo -Eu app php83 $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 "*****************************************************************************"
@@ -134,21 +132,21 @@ else
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"
sudo -Eu app php83 $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"
sudo -Eu app php83 $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\""
sudo -Eu app /bin/sh -c "php83 $DST_DIR/update.php --user-exists $AUTO_CREATE_USER ||
php83 $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
sudo -Eu app /bin/sh -c "php83 $DST_DIR/update.php --user-enable-api \"$AUTO_CREATE_USER:$AUTO_CREATE_USER_ENABLE_API\"" || true
fi
fi
@@ -160,11 +158,6 @@ rm -f /tmp/error.log && mkfifo /tmp/error.log && chown app:app /tmp/error.log
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
exec /usr/sbin/php-fpm83 --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,7 +1,4 @@
#!/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
@@ -15,30 +12,22 @@ sleep 30
if ! id app; then
addgroup -g $OWNER_GID app
adduser -D -h $APP_INSTALL_BASE_DIR -G app -u $OWNER_UID app
adduser -D -h /var/www/html -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
while ! pg_isready -h $TTRSS_DB_HOST -U $TTRSS_DB_USER; 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
/etc/php83/php.ini
DST_DIR=$APP_INSTALL_BASE_DIR/tt-rss
DST_DIR=/var/www/html/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 "$@"
sudo -E -u app /usr/bin/php83 /var/www/html/tt-rss/update_daemon2.php "$@"

View File

@@ -2,4 +2,3 @@ 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

@@ -8,7 +8,6 @@ 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}

View File

@@ -47,7 +47,7 @@ http {
set $backend "${APP_UPSTREAM}:9000";
fastcgi_pass ${APP_FASTCGI_PASS};
fastcgi_pass $backend;
}
# Allow PATH_INFO for PHP files in plugins.local directories with an /api/ sub directory to allow plugins to leverage when desired
@@ -68,9 +68,9 @@ http {
set $backend "${APP_UPSTREAM}:9000";
fastcgi_pass ${APP_FASTCGI_PASS};
fastcgi_pass $backend;
}
location / {
try_files $uri $uri/ =404;
}

View File

@@ -7,7 +7,7 @@ module.exports = {
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2020
"ecmaVersion": 2018
},
"rules": {
"accessor-pairs": "error",

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

View File

@@ -21,6 +21,9 @@ include:
- project: 'ci/ci-templates'
ref: master
file: .ci-lint-common.yml
- project: 'ci/ci-templates'
ref: master
file: .ci-integration-test.yml
- project: 'ci/ci-templates'
ref: master
file: .ci-update-helm-imagetag.yml
@@ -42,8 +45,15 @@ ttrss-fpm-pgsql-static:build:
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
ttrss-fpm-pgsql-static:push-master-commit-only:
extends: .crane-image-registry-push-master-commit-only
variables:
IMAGE_TAR: ${IMAGE_TAR_FPM}
needs:
- job: ttrss-fpm-pgsql-static:build
ttrss-fpm-pgsql-static:push-branch:
extends: .crane-image-registry-push-branch
variables:
IMAGE_TAR: ${IMAGE_TAR_FPM}
needs:
@@ -55,8 +65,15 @@ ttrss-web-nginx:build:
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
ttrss-web-nginx:push-master-commit-only:
extends: .crane-image-registry-push-master-commit-only
variables:
IMAGE_TAR: ${IMAGE_TAR_WEB}
needs:
- job: ttrss-web-nginx:build
ttrss-web-nginx:push-branch:
extends: .crane-image-registry-push-branch
variables:
IMAGE_TAR: ${IMAGE_TAR_WEB}
needs:
@@ -68,7 +85,7 @@ phpdoc:build:
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- php84 /phpDocumentor.phar -d classes -d include -t phpdoc --visibility=public
- php83 /phpDocumentor.phar -d classes -d include -t phpdoc --visibility=public
artifacts:
paths:
- phpdoc
@@ -88,51 +105,16 @@ phpdoc:publish:
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
TEST_HELM_REPO: oci://registry.fakecake.org/infra/helm-charts/tt-rss
extends: .integration-test
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
- export K8S_NAMESPACE=$(kubectl get pods -o=custom-columns=NS:.metadata.namespace | tail -1)
- export API_URL="http://tt-rss-${CI_COMMIT_SHORT_SHA}-app.$K8S_NAMESPACE.svc.cluster.local/tt-rss/api/"
- export TTRSS_DB_HOST=tt-rss-${CI_COMMIT_SHORT_SHA}-app.$K8S_NAMESPACE.svc.cluster.local
- export TTRSS_DB_USER=postgres
- export TTRSS_DB_NAME=postgres
- export TTRSS_DB_PASS=password
- php83 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:
@@ -142,32 +124,22 @@ phpunit-integration:
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
TEST_HELM_REPO: oci://registry.fakecake.org/infra/helm-charts/tt-rss
SELENIUM_GRID_ENDPOINT: http://selenium-hub.selenium-grid.svc.cluster.local:4444/wd/hub
extends: .integration-test
script:
- export K8S_NAMESPACE=$(kubectl get pods -o=custom-columns=NS:.metadata.namespace | tail -1)
- |
for i in `seq 1 10`; do
for i in `seq 1 3`; do
echo attempt $i...
python3 tests/integration/selenium_test.py && break
sleep 10
sleep 3
done
needs:
- job: phpunit-integration
artifacts:
when: always
reports:
@@ -252,15 +224,3 @@ update-prod:
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

2
.vscode/tasks.json vendored
View File

@@ -38,7 +38,7 @@
"label": "gulp: default",
"options": {
"env": {
"PATH": "${workspaceRoot}/node_modules/.bin:$PATH"
"PATH": "${env:PATH}:/usr/lib/sdk/node16/bin/"
}
}
}

View File

@@ -1,5 +1,39 @@
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.
TLDR: it works *almost* like Github.
Due to spam, new Gitlab users are set to [external](https://docs.gitlab.com/ee/user/admin_area/external_users.html). In order to do anything, you'll need to ask for your account to be promoted. Sorry for the inconvenience.
1. Register on the [Gitlab](https://gitlab.tt-rss.org);
2. Post on the forums asking for your account to be promoted;
3. Fork the repository you're interested in;
4. Do the needful;
6. File a PR against master branch and verify that CI pipeline (especially, PHPStan) passes;
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).
Please don't inline patches in forum posts, attach files instead (``.patch`` or ``.diff`` file extensions should work).
### FAQ
#### How do I push or pull without SSH?
You can't use SSH directly because tt-rss Gitlab is behind Cloudflare. You can use HTTPS with personal access tokens instead.
Create a personal access token in [Gitlab preferences](https://gitlab.tt-rss.org/-/user_settings/personal_access_tokens);
Optionally, configure Git to transparently work with tt-rss Gitlab repositories using HTTPS:
```
git config --global \
--add url."https://gitlab-token:your-personal-access-token@gitlab.tt-rss.org/".insteadOf \
"git@gitlab.tt-rss.org:"
```
Alternatively, checkout over HTTPS while adding the token manually:
```
git clone https://gitlab-token:your-personal-access-token@gitlab.tt-rss.org/tt-rss/tt-rss.git tt-rss
```
That's it.

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

View File

@@ -31,7 +31,7 @@
if (!empty($_SESSION["uid"])) {
if (!Sessions::validate_session()) {
header("Content-Type: application/json");
header("Content-Type: text/json");
print json_encode([
"seq" => -1,
@@ -55,14 +55,10 @@
} 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();

View File

@@ -14,7 +14,7 @@
/* Public calls compatibility shim */
$public_calls = array("rss", "getUnread", "getProfiles", "share");
$public_calls = array("globalUpdateFeeds", "rss", "getUnread", "getProfiles", "share");
if (array_search($op, $public_calls) !== false) {
header("Location: public.php?" . $_SERVER['QUERY_STRING']);
@@ -35,7 +35,9 @@
return;
}
header("Content-Type: application/json; charset=utf-8");
$span = OpenTelemetry\API\Trace\Span::getCurrent();
header("Content-Type: text/json; charset=utf-8");
if (Config::get(Config::SINGLE_USER_MODE)) {
UserHelper::authenticate("admin", null);
@@ -43,9 +45,10 @@
if (!empty($_SESSION["uid"])) {
if (!Sessions::validate_session()) {
header("Content-Type: application/json");
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
return;
}
UserHelper::load_user_plugins($_SESSION["uid"]);
@@ -54,6 +57,7 @@
if (Config::is_migration_needed()) {
print Errors::to_json(Errors::E_SCHEMA_MISMATCH);
$span->setAttribute('error', Errors::E_SCHEMA_MISMATCH);
return;
}
@@ -96,7 +100,7 @@
];
// shortcut syntax for plugin methods (?op=plugin--pmethod&...params)
/* if (str_contains($op, PluginHost::PUBLIC_METHOD_DELIMITER)) {
/* if (strpos($op, PluginHost::PUBLIC_METHOD_DELIMITER) !== false) {
list ($plugin, $pmethod) = explode(PluginHost::PUBLIC_METHOD_DELIMITER, $op, 2);
// TODO: better implementation that won't modify $_REQUEST
@@ -111,15 +115,17 @@
if (class_exists($op) || $override) {
if (str_starts_with($method, "_")) {
if (strpos($method, "_") === 0) {
user_error("Refusing to invoke method $method of handler $op which starts with underscore.", E_USER_WARNING);
header("Content-Type: application/json");
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
return;
}
if ($override) {
/** @var Plugin|IHandler|ICatchall $handler */
$handler = $override;
} else {
$reflection = new ReflectionClass($op);
@@ -127,14 +133,16 @@
}
if (implements_interface($handler, 'IHandler')) {
$span->addEvent("construct/$op");
$handler->__construct($_REQUEST);
if (validate_csrf($csrf_token) || $handler->csrf_ignore($method)) {
$span->addEvent("before/$method");
$before = $handler->before($method);
if ($before) {
$span->addEvent("method/$method");
if ($method && method_exists($handler, $method)) {
$reflection = new ReflectionMethod($handler, $method);
@@ -142,38 +150,44 @@
$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");
header("Content-Type: text/json");
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
} else {
if (method_exists($handler, "catchall")) {
$handler->catchall($method);
} else {
header("Content-Type: application/json");
header("Content-Type: text/json");
$span->setAttribute('error', Errors::E_UNKNOWN_METHOD);
print Errors::to_json(Errors::E_UNKNOWN_METHOD, ["info" => get_class($handler) . "->$method"]);
}
}
$span->addEvent("after/$method");
$handler->after();
return;
} else {
header("Content-Type: application/json");
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
return;
}
} else {
user_error("Refusing to invoke method $method of handler $op with invalid CSRF token.", E_USER_WARNING);
header("Content-Type: application/json");
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
$span->setAttribute('error', Errors::E_UNAUTHORIZED);
return;
}
}
}
header("Content-Type: application/json");
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNKNOWN_METHOD, [ "info" => (isset($handler) ? get_class($handler) : "UNKNOWN:".$op) . "->$method"]);
$span->setAttribute('error', Errors::E_UNKNOWN_METHOD);

View File

@@ -1,7 +1,7 @@
<?php
class API extends Handler {
const API_LEVEL = 23;
const API_LEVEL = 21;
const STATUS_OK = 0;
const STATUS_ERR = 1;
@@ -14,7 +14,8 @@ class API extends Handler {
const E_OPERATION_FAILED = "E_OPERATION_FAILED";
const E_NOT_FOUND = "E_NOT_FOUND";
private ?int $seq = null;
/** @var int|null */
private $seq;
/**
* @param array<int|string, mixed> $reply
@@ -30,14 +31,14 @@ class API extends Handler {
function before(string $method): bool {
if (parent::before($method)) {
header("Content-Type: application/json");
header("Content-Type: text/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"])) {
if (!empty($_SESSION["uid"]) && $method != "logout" && !get_pref(Prefs::ENABLE_API_ACCESS)) {
$this->_wrap(self::STATUS_ERR, array("error" => self::E_API_DISABLED));
return false;
}
@@ -73,7 +74,7 @@ class API extends Handler {
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 (get_pref(Prefs::ENABLE_API_ACCESS, $uid)) {
if (UserHelper::authenticate($login, $password, false, Auth_Base::AUTH_SERVICE_API)) {
// needed for _get_config()
@@ -175,7 +176,7 @@ class API extends Handler {
if ($unread || !$unread_only) {
array_push($cats, [
'id' => $cat_id,
'title' => Feeds::_get_cat_title($cat_id, $_SESSION['uid']),
'title' => Feeds::_get_cat_title($cat_id),
'unread' => (int) $unread,
]);
}
@@ -234,12 +235,13 @@ class API extends Handler {
}
function updateArticle(): bool {
$article_ids = array_filter(explode(",", clean($_REQUEST["article_ids"] ?? "")));
$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 = "";
$additional_fields = "";
switch ($field_raw) {
@@ -263,17 +265,20 @@ class API extends Handler {
break;
};
$set_to = match ($mode) {
0 => 'false',
1 => 'true',
2 => "NOT $field",
default => null,
};
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);
elseif ($field == 'score')
$set_to = (int) $data;
if ($field == "note") $set_to = $this->pdo->quote($data);
if ($field == "score") $set_to = (int) $data;
if ($field && $set_to && count($article_ids) > 0) {
@@ -284,12 +289,6 @@ class API extends Handler {
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",
@@ -301,16 +300,17 @@ class API extends Handler {
}
function getArticle(): bool {
$article_ids = array_filter(explode(',', clean($_REQUEST['article_id'] ?? '')));
$article_ids = explode(',', clean($_REQUEST['article_id'] ?? ''));
$sanitize_content = self::_param_to_bool($_REQUEST['sanitize'] ?? true);
// @phpstan-ignore-next-line
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)',
'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)',
@@ -372,19 +372,22 @@ class API extends Handler {
}
/**
* @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(),
$config = [
"icons_dir" => Config::get(Config::ICONS_DIR),
"icons_url" => Config::get(Config::ICONS_URL)
];
$config["daemon_is_running"] = file_is_locked("update_daemon.lock");
$config["custom_sort_types"] = $this->_get_custom_sort_types();
$config["num_feeds"] = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
->count();
return $config;
}
function getConfig(): bool {
@@ -407,13 +410,11 @@ class API extends Handler {
$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]);
Feeds::_catchup($feed_id, $is_cat, $_SESSION["uid"], $mode);
return $this->_wrap(self::STATUS_OK, array("status" => "OK"));
}
@@ -421,7 +422,7 @@ class API extends Handler {
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)));
return $this->_wrap(self::STATUS_OK, array("value" => get_pref($pref_name)));
}
function getLabels(): bool {
@@ -549,22 +550,26 @@ class API extends Handler {
/* Virtual feeds */
foreach (PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL) as $feed) {
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
$vfeeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL);
/** @var IVirtualFeed $feed['sender'] */
$unread = $feed['sender']->get_unread($feed['id']);
if (is_array($vfeeds)) {
foreach ($vfeeds as $feed) {
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
if ($unread || !$unread_only) {
$row = [
'id' => PluginHost::pfeed_to_feed_id($feed['id']),
'title' => $feed['title'],
'unread' => $unread,
'cat_id' => Feeds::CATEGORY_SPECIAL,
];
/** @var IVirtualFeed $feed['sender'] */
$unread = $feed['sender']->get_unread($feed['id']);
array_push($feeds, $row);
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);
}
}
}
@@ -574,7 +579,7 @@ class API extends Handler {
$unread = Feeds::_get_counters($i, false, true);
if ($unread || !$unread_only) {
$title = Feeds::_get_title($i, $_SESSION['uid']);
$title = Feeds::_get_title($i);
$row = [
'id' => $i,
@@ -618,8 +623,8 @@ class API extends Handler {
/* 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')
->select_many('id', 'feed_url', 'cat_id', 'title', 'order_id')
->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');
@@ -645,8 +650,6 @@ class API extends Handler {
'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);
@@ -657,9 +660,10 @@ class API extends Handler {
}
/**
* @param string|int $feed_id
* @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,
private static function _api_get_headlines($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,
@@ -670,7 +674,7 @@ class API extends Handler {
$feed = ORM::for_table('ttrss_feeds')
->select_many('id', 'cache_images')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->find_one($feed_id);
if ($feed) {
@@ -692,6 +696,7 @@ class API extends Handler {
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);
/** @var IVirtualFeed|false $handler */
$handler = PluginHost::getInstance()->get_feed_handler($pfeed_id);
if ($handler) {
@@ -853,7 +858,7 @@ class API extends Handler {
array_push($headlines, $headline_row);
}
} else if ($result == -1) {
} else if (is_numeric($result) && $result == -1) {
$headlines_header['first_id_changed'] = true;
}

View File

@@ -85,21 +85,21 @@ class Article extends Handler_Protected {
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);
if (Config::get(Config::DB_TYPE) == "pgsql") {
$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
@@ -108,8 +108,6 @@ class Article extends Handler_Protected {
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) {
@@ -124,20 +122,23 @@ class Article extends Handler_Protected {
$sth = $pdo->prepare("INSERT INTO ttrss_entries
(title, guid, link, updated, content, content_hash, date_entered, date_updated)
VALUES
(?, ?, ?, NOW(), ?, ?, NOW(), NOW()) RETURNING id");
(?, ?, ?, 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"];
$sth = $pdo->prepare("UPDATE ttrss_entries
if (Config::get(Config::DB_TYPE) == "pgsql"){
$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);
$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)
@@ -145,8 +146,6 @@ class Article extends Handler_Protected {
(?, '', 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);
@@ -299,6 +298,8 @@ class Article extends Handler_Protected {
* @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 {
$span = Tracer::start(__METHOD__);
$enclosures = self::_get_enclosures($id);
$enclosures_formatted = "";
@@ -325,6 +326,7 @@ class Article extends Handler_Protected {
$enclosures_formatted, $enclosures, $id, $always_display_enclosures, $article_content, $hide_images);
if (!empty($enclosures_formatted)) {
$span->end();
return [
'formatted' => $enclosures_formatted,
'entries' => []
@@ -338,7 +340,7 @@ class Article extends Handler_Protected {
$rv['can_inline'] = isset($_SESSION["uid"]) &&
empty($_SESSION["bw_limit"]) &&
!Prefs::get(Prefs::STRIP_IMAGES, $_SESSION["uid"], $_SESSION["profile"] ?? null) &&
!get_pref(Prefs::STRIP_IMAGES) &&
($always_display_enclosures || !preg_match("/<img/i", $article_content));
$rv['inline_text_only'] = $hide_images && $rv['can_inline'];
@@ -368,6 +370,7 @@ class Article extends Handler_Protected {
}
}
$span->end();
return $rv;
}
@@ -375,6 +378,8 @@ class Article extends Handler_Protected {
* @return array<int, string>
*/
static function _get_tags(int $id, int $owner_uid = 0, ?string $tag_cache = null): array {
$span = Tracer::start(__METHOD__);
$a_id = $id;
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@@ -422,6 +427,7 @@ class Article extends Handler_Protected {
$sth->execute([$tags_str, $id, $owner_uid]);
}
$span->end();
return $tags;
}
@@ -466,9 +472,16 @@ class Article extends Handler_Protected {
static function _purge_orphans(): void {
// purge orphaned posts in main content table
if (Config::get(Config::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)");
NOT EXISTS (SELECT ref_id FROM ttrss_user_entries WHERE ref_id = id) $limit_qpart");
if (Debug::enabled()) {
$rows = $res->rowCount();
@@ -509,6 +522,8 @@ class Article extends Handler_Protected {
* @return array<int, array<int, int|string>>
*/
static function _get_labels(int $id, ?int $owner_uid = null): array {
$span = Tracer::start(__METHOD__);
$rv = array();
if (!$owner_uid) $owner_uid = $_SESSION["uid"];
@@ -554,6 +569,8 @@ class Article extends Handler_Protected {
else
Labels::update_cache($owner_uid, $id, array("no-labels" => 1));
$span->end();
return $rv;
}
@@ -563,7 +580,9 @@ class Article extends Handler_Protected {
*
* @return array<int, Article::ARTICLE_KIND_*|string>
*/
static function _get_image(array $enclosures, string $content, string $site_url, array $headline): array {
static function _get_image(array $enclosures, string $content, string $site_url, array $headline) {
$span = Tracer::start(__METHOD__);
$article_image = "";
$article_stream = "";
$article_kind = 0;
@@ -584,7 +603,6 @@ class Article extends Handler_Protected {
$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 = [];
@@ -617,7 +635,7 @@ class Article extends Handler_Protected {
if (!$article_image)
foreach ($enclosures as $enc) {
if (str_contains($enc["content_type"], "image/")) {
if (strpos($enc["content_type"], "image/") !== false) {
$article_image = $enc["content_url"];
break;
}
@@ -642,6 +660,8 @@ class Article extends Handler_Protected {
if ($article_stream && $cache->exists(sha1($article_stream)))
$article_stream = $cache->get_url(sha1($article_stream));
$span->end();
return [$article_image, $article_stream, $article_kind];
}
@@ -655,12 +675,12 @@ class Article extends Handler_Protected {
if (count($article_ids) == 0)
return [];
$span = Tracer::start(__METHOD__);
$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'])
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
->where_in('id', $article_ids)
->find_many();
$rv = [];
@@ -676,6 +696,8 @@ class Article extends Handler_Protected {
}
}
$span->end();
return array_unique($rv);
}
@@ -687,12 +709,12 @@ class Article extends Handler_Protected {
if (count($article_ids) == 0)
return [];
$span = Tracer::start(__METHOD__);
$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'])
->join('ttrss_user_entries', ['ref_id', '=', 'id'], 'ue')
->where_in('id', $article_ids)
->find_many();
$rv = [];
@@ -701,6 +723,8 @@ class Article extends Handler_Protected {
array_push($rv, $entry->feed_id);
}
$span->end();
return array_unique($rv);
}
}

View File

@@ -15,11 +15,12 @@ abstract class Auth_Base extends Plugin implements IAuthModule {
/** 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
* @param string|false $password
* @return null|int
* @throws Exception
* @throws PDOException
*/
function auto_create_user(string $login, null|false|string $password = false): ?int {
function auto_create_user(string $login, $password = false) {
if ($login && Config::get(Config::AUTH_AUTO_CREATE)) {
$user_id = UserHelper::find_user_by_login($login);
@@ -48,9 +49,11 @@ abstract class Auth_Base extends Plugin implements IAuthModule {
/** replaced with UserHelper::find_user_by_login()
* @param string $login
* @return null|int
* @deprecated
*/
function find_user_by_login(string $login): ?int {
function find_user_by_login(string $login) {
return UserHelper::find_user_by_login($login);
}
}

View File

@@ -6,7 +6,7 @@ class Config {
const T_STRING = 2;
const T_INT = 3;
const SCHEMA_VERSION = 151;
const SCHEMA_VERSION = 147;
/** override default values, defined below in _DEFAULTS[], prefixing with _ENVVAR_PREFIX:
*
@@ -18,16 +18,13 @@ class Config {
*
* or config.php:
*
* putenv('TTRSS_DB_HOST=my-patroni.example.com');
* putenv('TTRSS_DB_TYPE=pgsql');
*
* 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
*/
/** database type: pgsql or mysql */
const DB_TYPE = "DB_TYPE";
/** database server hostname */
@@ -45,8 +42,9 @@ class Config {
/** database server port */
const DB_PORT = "DB_PORT";
/** PostgreSQL SSL mode (prefer, require, disabled) */
const DB_SSLMODE = "DB_SSLMODE";
/** connection charset for MySQL. if you have a legacy database and/or experience
* garbage unicode characters with this option, try setting it to a blank string. */
const MYSQL_CHARSET = "MYSQL_CHARSET";
/** 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";
@@ -56,6 +54,13 @@ class Config {
* your tt-rss directory protected by other means (e.g. http auth). */
const SINGLE_USER_MODE = "SINGLE_USER_MODE";
/** enables fallback update mode where tt-rss tries to update feeds in
* background while tt-rss is open in your browser.
* if you don't have a lot of feeds and don't want to or can't run
* background processes while not running tt-rss, this method is generally
* viable to keep your feeds up to date. */
const SIMPLE_UPDATE_MODE = "SIMPLE_UPDATE_MODE";
/** use this PHP CLI executable to start various tasks */
const PHP_EXECUTABLE = "PHP_EXECUTABLE";
@@ -65,6 +70,12 @@ class Config {
/** base directory for local cache (must be writable) */
const CACHE_DIR = "CACHE_DIR";
/** directory for feed favicons (directory must be writable) */
const ICONS_DIR = "ICONS_DIR";
/** URL for feed favicons */
const ICONS_URL = "ICONS_URL";
/** auto create users authenticated via external modules */
const AUTH_AUTO_CREATE = "AUTH_AUTO_CREATE";
@@ -181,44 +192,11 @@ class Config {
/** 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";
/** host running Jaeger collector to receive traces (disabled if empty) */
const OPENTELEMETRY_ENDPOINT = "OPENTELEMETRY_ENDPOINT";
/** 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";
/** Jaeger service name */
const OPENTELEMETRY_SERVICE = "OPENTELEMETRY_SERVICE";
/** default values for all global configuration options */
private const _DEFAULTS = [
@@ -228,12 +206,15 @@ class Config {
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::MYSQL_CHARSET => [ "UTF8", Config::T_STRING ],
Config::SELF_URL_PATH => [ "https://example.com/tt-rss", Config::T_STRING ],
Config::SINGLE_USER_MODE => [ "", Config::T_BOOL ],
Config::SIMPLE_UPDATE_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::ICONS_DIR => [ "feed-icons", Config::T_STRING ],
Config::ICONS_URL => [ "feed-icons", 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 ],
@@ -272,33 +253,24 @@ class Config {
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::HTTP_USER_AGENT => [ 'Tiny Tiny RSS/%s (https://tt-rss.org/)',
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],
Config::OPENTELEMETRY_ENDPOINT => [ "", Config::T_STRING ],
Config::OPENTELEMETRY_SERVICE => [ "tt-rss", Config::T_STRING ],
];
private static ?Config $instance = null;
/** @var Config|null */
private static $instance;
/** @var array<string, array<bool|int|string>> */
private array $params = [];
private $params = [];
/** @var array<string, mixed> */
private array $version = [];
private $version = [];
private Db_Migrations $migrations;
/** @var Db_Migrations|null $migrations */
private $migrations;
public static function get_instance() : Config {
if (self::$instance == null)
@@ -332,7 +304,7 @@ class Config {
* 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 {
static function get_version(bool $as_string = true) {
return self::get_instance()->_get_version($as_string);
}
@@ -350,7 +322,7 @@ class Config {
/**
* @return array<string, mixed>|string
*/
private function _get_version(bool $as_string = true): array|string {
private function _get_version(bool $as_string = true) {
$root_dir = self::get_self_dir();
if (empty($this->version)) {
@@ -459,15 +431,24 @@ class Config {
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,
};
/**
* @return bool|int|string
*/
static function cast_to(string $value, int $type_hint) {
switch ($type_hint) {
case self::T_BOOL:
return sql_bool_to_bool($value);
case self::T_INT:
return (int) $value;
default:
return $value;
}
}
private function _get(string $param): bool|int|string {
/**
* @return bool|int|string
*/
private function _get(string $param) {
list ($value, $type_hint) = $this->params[$param];
return $this->cast_to($value, $type_hint);
@@ -485,7 +466,10 @@ class Config {
$instance->_add($param, $default, $type_hint);
}
static function get(string $param): bool|int|string {
/**
* @return bool|int|string
*/
static function get(string $param) {
$instance = self::get_instance();
return $instance->_get($param);
@@ -507,13 +491,32 @@ class Config {
$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);
$self_url_path = preg_replace("/(\/plugins(.local))\/.{1,}$/", "", $self_url_path);
return rtrim($self_url_path, "/");
}
}
/* sanity check stuff */
/** checks for mysql tables not using InnoDB (tt-rss is incompatible with MyISAM)
* @return array<int, array<string, string>> A list of entries identifying tt-rss tables with bad config
*/
private static function check_mysql_tables() {
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT engine, table_name FROM information_schema.tables WHERE
table_schema = ? AND table_name LIKE 'ttrss_%' AND engine != 'InnoDB'");
$sth->execute([self::get(Config::DB_NAME)]);
$bad_tables = [];
while ($line = $sth->fetch()) {
array_push($bad_tables, $line);
}
return $bad_tables;
}
static function sanity_check(): void {
/*
@@ -527,7 +530,7 @@ class Config {
$errors = [];
if (!str_contains(self::get(Config::PLUGINS), "auth_")) {
if (strpos(self::get(Config::PLUGINS), "auth_") === false) {
array_push($errors, "Please enable at least one authentication module via PLUGINS");
}
@@ -539,8 +542,8 @@ class Config {
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 (version_compare(PHP_VERSION, '7.4.0', '<')) {
array_push($errors, "PHP version 7.4.0 or newer required. You're using " . PHP_VERSION . ".");
}
if (!class_exists("UConverter")) {
@@ -596,6 +599,10 @@ class Config {
array_push($errors, "Data export cache is not writable (chmod -R 777 ".self::get(Config::CACHE_DIR)."/export)");
}
if (!is_writable(self::get(Config::ICONS_DIR))) {
array_push($errors, "ICONS_DIR defined in config.php is not writable (chmod -R 777 ".self::get(Config::ICONS_DIR).").\n");
}
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");
}
@@ -614,6 +621,29 @@ class Config {
}
}
if (self::get(Config::DB_TYPE) == "mysql") {
$bad_tables = self::check_mysql_tables();
if (count($bad_tables) > 0) {
$bad_tables_fmt = [];
foreach ($bad_tables as $bt) {
array_push($bad_tables_fmt, sprintf("%s (%s)", $bt['table_name'], $bt['engine']));
}
$msg = "<p>The following tables use an unsupported MySQL engine: <b>" .
implode(", ", $bad_tables_fmt) . "</b>.</p>";
$msg .= "<p>The only supported engine on MySQL is InnoDB. MyISAM lacks functionality to run
tt-rss.
Please backup your data (via OPML) and re-import the schema before continuing.</p>
<p><b>WARNING: importing the schema would mean LOSS OF ALL YOUR DATA.</b></p>";
array_push($errors, $msg);
}
}
if (count($errors) > 0 && php_sapi_name() != "cli") {
http_response_code(503); ?>
@@ -632,9 +662,9 @@ class Config {
<?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>
<p>You might want to check the tt-rss <a target="_blank" href="https://tt-rss.org/wiki/InstallationNotes/">wiki</a> or
<a target="_blank" href="https://community.tt-rss.org/">forums</a> for more information. Please search the forums before creating a new topic
for your question.</p>
</div>
</body>
</html>

View File

@@ -35,7 +35,6 @@ class Counters {
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)
@@ -43,14 +42,13 @@ class Counters {
->find_many();
foreach ($cats as $cat) {
list ($tmp_unread, $tmp_marked, $tmp_published) = self::get_cat_children($cat->id, $owner_uid);
list ($tmp_unread, $tmp_marked) = 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];
return [$unread, $marked];
}
/**
@@ -77,9 +75,8 @@ class Counters {
$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
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(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)
@@ -89,9 +86,8 @@ class Counters {
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
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
@@ -102,9 +98,8 @@ class Counters {
} 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
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
(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)
@@ -114,9 +109,8 @@ class Counters {
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
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked,
0
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.cat_id IS NULL AND
ue.feed_id = f.id AND
@@ -127,18 +121,16 @@ class Counters {
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"]);
list ($child_counter, $child_marked_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
];
@@ -153,40 +145,75 @@ class Counters {
* @return array<int, array<string, int|string>>
*/
private static function get_feeds(?array $feed_ids = null): array {
$span = Tracer::start(__METHOD__);
$ret = [];
if (is_array($feed_ids) && count($feed_ids) === 0)
return $ret;
$pdo = Db::pdo();
$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)) {
if (count($feed_ids) == 0)
return [];
if (is_array($feed_ids))
$feeds->where_in('f.id', $feed_ids);
$feed_ids_qmarks = arr_qmarks($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,
];
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = ? AND f.id IN ($feed_ids_qmarks)
GROUP BY f.id");
$sth->execute([$_SESSION['uid'], ...$feed_ids]);
} else {
$sth = $pdo->prepare("SELECT f.id,
f.title,
".SUBSTRING_FOR_DATE."(f.last_updated,1,19) AS last_updated,
f.last_error,
SUM(CASE WHEN unread THEN 1 ELSE 0 END) AS count,
SUM(CASE WHEN marked THEN 1 ELSE 0 END) AS count_marked
FROM ttrss_feeds f, ttrss_user_entries ue
WHERE f.id = ue.feed_id AND ue.owner_uid = :uid
GROUP BY f.id");
$sth->execute(["uid" => $_SESSION['uid']]);
}
while ($line = $sth->fetch()) {
$id = $line["id"];
$last_updated = TimeHelper::make_local_datetime($line['last_updated'], false);
if (Feeds::_has_icon($id)) {
$ts = filemtime(Feeds::_get_icon_file($id));
} else {
$ts = 0;
}
// hide default un-updated timestamp i.e. 1970-01-01 (?) -fox
if ((int)date('Y') - (int)date('Y', strtotime($line['last_updated'] ?? '')) > 2)
$last_updated = '';
$cv = [
"id" => $id,
"updated" => $last_updated,
"counter" => (int) $line["count"],
"markedcounter" => (int) $line["count_marked"],
"ts" => (int) $ts
];
$cv["error"] = $line["last_error"];
$cv["title"] = truncate_string($line["title"], 30);
array_push($ret, $cv);
}
$span->end();
return $ret;
}
@@ -194,6 +221,8 @@ class Counters {
* @return array<int, array<string, int|string>>
*/
private static function get_global(): array {
$span = Tracer::start(__METHOD__);
$ret = [
[
"id" => "global-unread",
@@ -210,6 +239,8 @@ class Counters {
"counter" => $subcribed_feeds
]);
$span->end();
return $ret;
}
@@ -217,6 +248,8 @@ class Counters {
* @return array<int, array<string, int|string>>
*/
private static function get_virt(): array {
$span = Tracer::start(__METHOD__);
$ret = [];
foreach ([Feeds::FEED_ARCHIVED, Feeds::FEED_STARRED, Feeds::FEED_PUBLISHED,
@@ -238,29 +271,31 @@ class Counters {
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;
$feeds = PluginHost::getInstance()->get_feeds(Feeds::CATEGORY_SPECIAL);
/** @var Plugin&IVirtualFeed $feed['sender'] */
if (is_array($feeds)) {
foreach ($feeds as $feed) {
/** @var IVirtualFeed $feed['sender'] */
$cv = [
"id" => PluginHost::pfeed_to_feed_id($feed['id']),
"counter" => $feed['sender']->get_unread($feed['id'])
];
if (!implements_interface($feed['sender'], 'IVirtualFeed'))
continue;
if (method_exists($feed['sender'], 'get_total'))
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
$cv = [
"id" => PluginHost::pfeed_to_feed_id($feed['id']),
"counter" => $feed['sender']->get_unread($feed['id'])
];
array_push($ret, $cv);
if (method_exists($feed['sender'], 'get_total'))
$cv["auxcounter"] = $feed['sender']->get_total($feed['id']);
array_push($ret, $cv);
}
}
$span->end();
return $ret;
}
@@ -269,6 +304,8 @@ class Counters {
* @return array<int, array<string, int|string>>
*/
static function get_labels(?array $label_ids = null): array {
$span = Tracer::start(__METHOD__);
$ret = [];
$pdo = Db::pdo();
@@ -283,7 +320,6 @@ class Counters {
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)
@@ -296,7 +332,6 @@ class Counters {
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)
@@ -315,13 +350,13 @@ class Counters {
"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);
}
$span->end();
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,14 +1,20 @@
<?php
class Db {
private static ?Db $instance = null;
class Db
{
/** @var Db $instance */
private static $instance;
private ?PDO $pdo = null;
/** @var PDO|null $pdo */
private $pdo;
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);
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) {
ORM::configure('driver_options', array(PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . Config::get(Config::MYSQL_CHARSET)));
}
}
/**
@@ -26,10 +32,13 @@ class Db {
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);
if (Config::get(Config::DB_TYPE) == "mysql" && Config::get(Config::MYSQL_CHARSET)) {
$db_charset = ';charset=' . Config::get(Config::MYSQL_CHARSET);
} else {
$db_charset = '';
}
return 'pgsql:dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port .
";sslmode=$db_sslmode";
return Config::get(Config::DB_TYPE) . ':dbname=' . Config::get(Config::DB_NAME) . $db_host . $db_port . $db_charset;
}
// this really shouldn't be used unless a separate PDO connection is needed
@@ -47,10 +56,20 @@ class Db {
$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");
if (Config::get(Config::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 (Config::get(Config::DB_TYPE) == "mysql") {
$pdo->query("SET time_zone = '+0:0'");
if (Config::get(Config::MYSQL_CHARSET)) {
$pdo->query("SET NAMES " . Config::get(Config::MYSQL_CHARSET));
}
}
return $pdo;
}
@@ -73,8 +92,11 @@ class Db {
return self::$instance->pdo;
}
/** @deprecated usages should be replaced with `RANDOM()` */
public static function sql_random_function(): string {
if (Config::get(Config::DB_TYPE) == "mysql") {
return "RAND()";
}
return "RANDOM()";
}
}

View File

@@ -23,7 +23,7 @@ class Db_Migrations {
}
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->base_path = "$root_path/" . Config::get(Config::DB_TYPE);
$this->migrations_path = $this->base_path . "/migrations";
$this->migrations_table = $migrations_table;
$this->base_is_latest = $base_is_latest;
@@ -88,7 +88,9 @@ class Db_Migrations {
$lines = $this->get_lines($version);
if (count($lines) > 0) {
$this->pdo->beginTransaction();
// mysql doesn't support transactions for DDL statements
if (Config::get(Config::DB_TYPE) != "mysql")
$this->pdo->beginTransaction();
foreach ($lines as $line) {
Debug::log($line, Debug::LOG_EXTENDED);
@@ -105,7 +107,8 @@ class Db_Migrations {
else
$this->set_version($version);
$this->pdo->commit();
if (Config::get(Config::DB_TYPE) != "mysql")
$this->pdo->commit();
Debug::log("Migration finished, current version: " . $this->get_version(), Debug::LOG_VERBOSE);
@@ -187,7 +190,7 @@ class Db_Migrations {
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, "--"));
fn($line) => strlen(trim($line)) > 0 && strpos($line, "--") !== 0);
return array_filter(explode(";", implode("", $lines)),
fn($line) => strlen(trim($line)) > 0 && !in_array(strtolower($line), ["begin", "commit"]));

View File

@@ -2,11 +2,17 @@
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);
/**
* @return bool|int|null|string
*/
function read(string $pref_name, ?int $user_id = null, bool $die_on_error = false) {
return get_pref($pref_name, $user_id);
}
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);
/**
* @param mixed $value
*/
function write(string $pref_name, $value, ?int $user_id = null, bool $strip_tags = true): bool {
return set_pref($pref_name, $value, $user_id, $strip_tags);
}
}

View File

@@ -77,7 +77,7 @@ class Debug {
*/
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) */
/** @phpstan-ignore-next-line */
return $level;
} else {
user_error("Passed invalid debug log level: $level", E_USER_WARNING);

View File

@@ -2,20 +2,28 @@
class Digest
{
static function send_headlines_digests(): void {
$span = Tracer::start(__METHOD__);
$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");
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "last_digest_sent < NOW() - INTERVAL '1 days'";
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_qpart = "last_digest_sent < DATE_SUB(NOW(), INTERVAL 1 DAY)";
}
$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')");
WHERE email != '' AND (last_digest_sent IS NULL OR $interval_qpart)");
while ($line = $res->fetch()) {
if (Prefs::get(Prefs::DIGEST_ENABLE, $line['id'])) {
$preferred_ts = strtotime(Prefs::get(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? '');
if (get_pref(Prefs::DIGEST_ENABLE, $line['id'])) {
$preferred_ts = strtotime(get_pref(Prefs::DIGEST_PREFERRED_TIME, $line['id']) ?? '');
// try to send digests within 2 hours of preferred time
if ($preferred_ts && time() >= $preferred_ts &&
@@ -24,7 +32,7 @@ class Digest
Debug::log("Sending digest for UID:" . $line['id'] . " - " . $line["email"]);
$do_catchup = Prefs::get(Prefs::DIGEST_CATCHUP, $line['id']);
$do_catchup = get_pref(Prefs::DIGEST_CATCHUP, $line['id']);
global $tz_offset;
@@ -69,6 +77,7 @@ class Digest
}
}
$span->end();
Debug::log("All done.");
}
@@ -100,6 +109,13 @@ class Digest
$tpl_t->setVariable('TTRSS_HOST', Config::get_self_url());
$affected_ids = array();
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "ttrss_entries.date_updated > NOW() - INTERVAL '$days days'";
} else /* if (Config::get(Config::DB_TYPE) == "mysql") */ {
$interval_qpart = "ttrss_entries.date_updated > DATE_SUB(NOW(), INTERVAL $days DAY)";
}
$pdo = Db::pdo();
$sth = $pdo->prepare("SELECT ttrss_entries.title,
@@ -110,7 +126,7 @@ class Digest
link,
score,
content,
SUBSTRING_FOR_DATE(last_updated,1,19) AS last_updated
".SUBSTRING_FOR_DATE."(last_updated,1,19) AS last_updated
FROM
ttrss_user_entries,ttrss_entries,ttrss_feeds
LEFT JOIN
@@ -118,7 +134,7 @@ class Digest
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 $interval_qpart
AND ttrss_user_entries.owner_uid = :user_id
AND unread = true
AND score >= :min_score
@@ -140,16 +156,17 @@ class Digest
array_push($affected_ids, $line["ref_id"]);
$updated = TimeHelper::make_local_datetime($line['last_updated'], owner_uid: $user_id);
$updated = TimeHelper::make_local_datetime($line['last_updated'], false,
$user_id);
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $user_id)) {
if (get_pref(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) {
if (is_array($article_labels) && count($article_labels) > 0) {
$article_labels_formatted = implode(", ", array_map(fn($a) => $a[1], $article_labels));
}

View File

@@ -1,9 +1,10 @@
<?php
class DiskCache implements Cache_Adapter {
private Cache_Adapter $adapter;
/** @var Cache_Adapter $adapter */
private $adapter;
/** @var array<string, DiskCache> $instances */
private static array $instances = [];
private static $instances = [];
/**
* https://stackoverflow.com/a/53662733
@@ -220,7 +221,11 @@ class DiskCache implements Cache_Adapter {
}
public function remove(string $filename): bool {
$span = Tracer::start(__METHOD__);
$span->setAttribute('file.name', $filename);
$rc = $this->adapter->remove($filename);
$span->end();
return $rc;
}
@@ -246,6 +251,9 @@ class DiskCache implements Cache_Adapter {
}
public function exists(string $filename): bool {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent("DiskCache::exists: $filename");
$rc = $this->adapter->exists(basename($filename));
return $rc;
@@ -255,7 +263,11 @@ class DiskCache implements Cache_Adapter {
* @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) {
$span = Tracer::start(__METHOD__);
$span->setAttribute('file.name', $filename);
$rc = $this->adapter->get_size(basename($filename));
$span->end();
return $rc;
}
@@ -266,7 +278,11 @@ class DiskCache implements Cache_Adapter {
* @return int|false Bytes written or false if an error occurred.
*/
public function put(string $filename, $data) {
return $this->adapter->put(basename($filename), $data);
$span = Tracer::start(__METHOD__);
$rc = $this->adapter->put(basename($filename), $data);
$span->end();
return $rc;
}
/** @deprecated we can't assume cached files are local, and other storages
@@ -300,11 +316,8 @@ class DiskCache implements Cache_Adapter {
if ($this->exists($local_filename) && !$force)
return true;
$data = UrlHelper::fetch([
'url' => $url,
'max_size' => Config::get(Config::MAX_CACHE_FILE_SIZE),
...$options,
]);
$data = UrlHelper::fetch(array_merge(["url" => $url,
"max_size" => Config::get(Config::MAX_CACHE_FILE_SIZE)], $options));
if ($data)
return $this->put($local_filename, $data) > 0;
@@ -313,12 +326,17 @@ class DiskCache implements Cache_Adapter {
}
public function send(string $filename) {
$span = Tracer::start(__METHOD__);
$span->setAttribute('file.name', $filename);
$filename = basename($filename);
if (!$this->exists($filename)) {
header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found");
echo "File not found.";
$span->setAttribute('error', '404 not found');
$span->end();
return false;
}
@@ -328,6 +346,8 @@ class DiskCache implements Cache_Adapter {
if (($_SERVER['HTTP_IF_MODIFIED_SINCE'] ?? '') == $gmt_modified || ($_SERVER['HTTP_IF_NONE_MATCH'] ?? '') == $file_mtime) {
header('HTTP/1.1 304 Not Modified');
$span->setAttribute('error', '304 not modified');
$span->end();
return false;
}
@@ -345,6 +365,9 @@ class DiskCache implements Cache_Adapter {
header("Content-type: text/plain");
print "Stored file has disallowed content type ($mimetype)";
$span->setAttribute('error', '400 disallowed content type');
$span->end();
return false;
}
@@ -366,7 +389,13 @@ class DiskCache implements Cache_Adapter {
header_remove("Pragma");
return $this->adapter->send($filename);
$span->setAttribute('mimetype', $mimetype);
$rc = $this->adapter->send($filename);
$span->end();
return $rc;
}
public function get_full_path(string $filename): string {
@@ -395,9 +424,13 @@ class DiskCache implements Cache_Adapter {
// 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 {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent("DiskCache::rewrite_urls");
$res = trim($str);
if (!$res) {
$span->end();
return '';
}
@@ -406,12 +439,13 @@ class DiskCache implements Cache_Adapter {
$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 */
$need_saving = false;
foreach ($entries as $entry) {
$span->addEvent("entry: " . $entry->tagName);
foreach (array('src', 'poster') as $attr) {
if ($entry->hasAttribute($attr)) {
$url = $entry->getAttribute($attr);

View File

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

View File

@@ -3,7 +3,7 @@ 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_date();
abstract function get_link(): string;
abstract function get_title(): string;

View File

@@ -15,7 +15,7 @@ class FeedItem_Atom extends FeedItem_Common {
/**
* @return int|false a timestamp on success, false otherwise
*/
function get_date(): false|int {
function get_date() {
$updated = $this->elem->getElementsByTagName("updated")->item(0);
if ($updated) {
@@ -43,6 +43,7 @@ class FeedItem_Atom extends FeedItem_Common {
$links = $this->elem->getElementsByTagName("link");
foreach ($links as $link) {
/** @phpstan-ignore-next-line */
if ($link->hasAttribute("href") &&
(!$link->hasAttribute("rel")
|| $link->getAttribute("rel") == "alternate"
@@ -80,7 +81,6 @@ class FeedItem_Atom extends FeedItem_Common {
$elems = $tmpxpath->query("(//*[@href]|//*[@src])");
/** @var DOMElement $elem */
foreach ($elems as $elem) {
if ($elem->hasAttribute("href")) {
$elem->setAttribute("href",
@@ -181,14 +181,16 @@ class FeedItem_Atom extends FeedItem_Common {
$encs = [];
foreach ($links as $link) {
/** @phpstan-ignore-next-line */
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'));
$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);
@@ -211,7 +213,6 @@ class FeedItem_Atom extends FeedItem_Common {
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"));

View File

@@ -1,13 +1,18 @@
<?php
abstract class FeedItem_Common extends FeedItem {
protected readonly DOMElement $elem;
protected readonly DOMDocument $doc;
protected readonly DOMXPath $xpath;
/** @var DOMElement */
protected $elem;
/** @var DOMDocument */
protected $doc;
/** @var DOMXPath */
protected $xpath;
function __construct(DOMElement $elem, DOMDocument $doc, DOMXPath $xpath) {
$this->elem = $elem;
$this->doc = $doc;
$this->xpath = $xpath;
$this->doc = $doc;
try {
$source = $elem->getElementsByTagName("source")->item(0);
@@ -84,7 +89,6 @@ abstract class FeedItem_Common extends FeedItem {
/**
* 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 {
@@ -92,14 +96,14 @@ abstract class FeedItem_Common extends FeedItem {
$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'));
$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) {
@@ -115,16 +119,17 @@ abstract class FeedItem_Common extends FeedItem {
$enclosures = $this->xpath->query("media:group", $this->elem);
foreach ($enclosures as $enclosure) {
$enc = new FeedEnclosure();
/** @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'));
$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) {
@@ -143,15 +148,15 @@ abstract class FeedItem_Common extends FeedItem {
}
}
$enclosures = $this->xpath->query("(.|media:content|media:group|media:group/media:content)/media:thumbnail", $this->elem);
$enclosures = $this->xpath->query("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'));
$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);
}
@@ -166,7 +171,7 @@ abstract class FeedItem_Common extends FeedItem {
/**
* @return false|string false on failure, otherwise string contents
*/
function subtree_or_text(DOMElement $node): false|string {
function subtree_or_text(DOMElement $node) {
if ($this->count_children($node) == 0) {
return $node->nodeValue;
} else {
@@ -196,6 +201,10 @@ abstract class FeedItem_Common extends FeedItem {
$cat = preg_replace('/[,\'\"]/', "", $cat);
if (Config::get(Config::DB_TYPE) == "mysql") {
$cat = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $cat);
}
if (mb_strlen($cat) > 250)
$cat = mb_substr($cat, 0, 250);

View File

@@ -13,7 +13,7 @@ class FeedItem_RSS extends FeedItem_Common {
/**
* @return int|false a timestamp on success, false otherwise
*/
function get_date(): false|int {
function get_date() {
$pubDate = $this->elem->getElementsByTagName("pubDate")->item(0);
if ($pubDate) {
@@ -33,9 +33,8 @@ class FeedItem_RSS extends FeedItem_Common {
function get_link(): string {
$links = $this->xpath->query("atom:link", $this->elem);
/** @var DOMElement $link */
foreach ($links as $link) {
if ($link->hasAttribute("href") &&
if ($link && $link->hasAttribute("href") &&
(!$link->hasAttribute("rel")
|| $link->getAttribute("rel") == "alternate"
|| $link->getAttribute("rel") == "standout")) {
@@ -143,11 +142,12 @@ class FeedItem_RSS extends FeedItem_Common {
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'));
$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);
}

View File

@@ -1,25 +1,30 @@
<?php
class FeedParser {
private DOMDocument $doc;
private ?string $error = null;
/** @var DOMDocument */
private $doc;
/** @var string|null */
private $error = null;
/** @var array<string> */
private array $libxml_errors = [];
private $libxml_errors = [];
/** @var array<FeedItem> */
private array $items = [];
private $items = [];
private ?string $link = null;
/** @var string|null */
private $link;
private ?string $title = null;
/** @var string|null */
private $title;
/** @var FeedParser::FEED_* */
private int $type;
/** @var FeedParser::FEED_*|null */
private $type;
private ?DOMXPath $xpath = null;
/** @var DOMXPath|null */
private $xpath;
const FEED_UNKNOWN = -1;
const FEED_RDF = 0;
const FEED_RSS = 1;
const FEED_ATOM = 2;
@@ -27,9 +32,6 @@ class FeedParser {
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);
@@ -46,54 +48,75 @@ class FeedParser {
}
}
}
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;
function init() : void {
$xpath = new DOMXPath($this->doc);
$xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
$xpath->registerNamespace('atom03', 'http://purl.org/atom/ns#');
$xpath->registerNamespace('media', 'http://search.yahoo.com/mrss/');
$xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#');
$xpath->registerNamespace('slash', 'http://purl.org/rss/1.0/modules/slash/');
$xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/');
$xpath->registerNamespace('content', 'http://purl.org/rss/1.0/modules/content/');
$xpath->registerNamespace('thread', 'http://purl.org/syndication/thread/1.0');
$type = $this->get_type();
$this->xpath = $xpath;
if ($type === self::FEED_UNKNOWN)
return false;
$root_list = $xpath->query("(//atom03:feed|//atom:feed|//channel|//rdf:rdf|//rdf:RDF)");
$xpath = $this->xpath;
if (!empty($root_list) && $root_list->length > 0) {
switch ($type) {
/** @var DOMElement|null $root */
$root = $root_list->item(0);
if ($root) {
switch (mb_strtolower($root->tagName)) {
case "rdf:rdf":
$this->type = $this::FEED_RDF;
break;
case "channel":
$this->type = $this::FEED_RSS;
break;
case "feed":
case "atom:feed":
$this->type = $this::FEED_ATOM;
break;
default:
$this->error ??= "Unknown/unsupported feed type";
return;
}
}
switch ($this->type) {
case $this::FEED_ATOM:
$title = $xpath->query('//atom:feed/atom:title')->item(0)
?? $xpath->query('//atom03:feed/atom03:title')->item(0);
$title = $xpath->query("//atom:feed/atom:title")->item(0);
if (!$title)
$title = $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);
$link = $xpath->query("//atom:feed/atom:link[not(@rel)]")->item(0);
if ($link?->getAttribute('href'))
$this->link = $link->getAttribute('href');
if (!$link)
$link = $xpath->query("//atom:feed/atom:link[@rel='alternate']")->item(0);
if (!$link)
$link = $xpath->query("//atom03:feed/atom03:link[not(@rel)]")->item(0);
if (!$link)
$link = $xpath->query("//atom03:feed/atom03:link[@rel='alternate']")->item(0);
/** @var DOMElement|null $link */
if ($link && $link->hasAttributes()) {
$this->link = $link->getAttribute("href");
}
$articles = $xpath->query("//atom:entry");
@@ -151,15 +174,16 @@ class FeedParser {
}
break;
}
if ($this->title) $this->title = trim($this->title);
if ($this->link) $this->link = trim($this->link);
} else {
$this->error ??= "Unknown/unsupported feed type";
return;
}
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 */
@@ -177,33 +201,6 @@ class FeedParser {
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 ?? '');
}
@@ -225,7 +222,6 @@ class FeedParser {
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'))));
@@ -235,7 +231,6 @@ class FeedParser {
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'))));

File diff suppressed because it is too large Load Diff

View File

@@ -8,27 +8,23 @@ class Handler_Public extends Handler {
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 = "background-color : #fff7d5;
border-width : 1px; ".
"padding : 5px; border-style : dashed; border-color : #e7d796;".
"margin-bottom : 1em; color : #9a8c59;";
$note_style = 'color: #9a8c59; background-color: #fff7d5; '
. 'border: 1px dashed #e7d796; padding: 5px; margin-bottom: 1em;';
if (!$limit)
$limit = 60;
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',
};
$override_order = "date_entered DESC, updated DESC";
if ($feed == Feeds::FEED_PUBLISHED && !$is_cat) {
$override_order = "last_published DESC";
} else if ($feed == Feeds::FEED_STARRED && !$is_cat) {
$override_order = "last_marked DESC";
}
}
$params = array(
@@ -46,9 +42,8 @@ class Handler_Public extends Handler {
);
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);
$user_plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid);
$tmppluginhost = new PluginHost();
$tmppluginhost->load(Config::get(Config::PLUGINS), PluginHost::KIND_ALL);
@@ -59,6 +54,8 @@ class Handler_Public extends Handler {
PluginHost::feed_to_pfeed_id((int)$feed));
if ($handler) {
// 'get_headlines' is implemented by the plugin.
// @phpstan-ignore-next-line
$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);
@@ -79,8 +76,7 @@ class Handler_Public extends Handler {
"/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 (!$feed_site_url) $feed_site_url = Config::get_self_url();
if ($format == 'atom') {
$tpl = new Templator();
@@ -88,12 +84,10 @@ class Handler_Public extends Handler {
$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, '...'));
@@ -126,7 +120,8 @@ class Handler_Public extends Handler {
$content = DiskCache::rewrite_urls($content);
if ($line['note']) {
$content = "<div style=\"$note_style\">Article note: " . $line['note'] . "</div>" . $content;
$content = "<div style=\"$note_style\">Article note: " . $line['note'] . "</div>" .
$content;
$tpl->setVariable('ARTICLE_NOTE', htmlspecialchars($line['note']), true);
}
@@ -153,7 +148,7 @@ class Handler_Public extends Handler {
foreach ($enclosures as $e) {
$type = htmlspecialchars($e['content_type']);
$url = htmlspecialchars($e['content_url']);
$length = $e['duration'] ?: 1;
$length = $e['duration'] ? $e['duration'] : 1;
$tpl->setVariable('ARTICLE_ENCLOSURE_URL', $url, true);
$tpl->setVariable('ARTICLE_ENCLOSURE_TYPE', $type, true);
@@ -186,14 +181,14 @@ class Handler_Public extends Handler {
}
print $tmp;
} else { // $format == 'json'
} else if ($format == 'json') {
$feed = [
'title' => $feed_title,
'feed_url' => $feed_self_url,
'self_url' => Config::get_self_url(),
'articles' => [],
];
$feed = array();
$feed['title'] = $feed_title;
$feed['feed_url'] = $feed_self_url;
$feed['self_url'] = Config::get_self_url();
$feed['articles'] = [];
while ($line = $result->fetch()) {
@@ -212,26 +207,30 @@ class Handler_Public extends Handler {
},
$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,
],
$article = array();
$article['id'] = $line['link'];
$article['link'] = $line['link'];
$article['title'] = $line['title'];
$article['excerpt'] = $line["content_preview"];
$article['content'] = Sanitizer::sanitize($line["content"], false, $owner_uid, $feed_site_url, null, $line["id"]);
$article['updated'] = date('c', strtotime($line["updated"] ?? ''));
if (!empty($line['note'])) $article['note'] = $line['note'];
if (!empty($line['author'])) $article['author'] = $line['author'];
$article['source'] = [
'link' => $line['site_url'] ? $line["site_url"] : Config::get_self_url(),
'title' => $line['feed_title'] ?? $feed_title
];
if (!empty($line['note']))
$article['note'] = $line['note'];
if (count($line["tags"]) > 0) {
$article['tags'] = array();
if (!empty($line['author']))
$article['author'] = $line['author'];
if (count($line['tags']) > 0)
$article['tags'] = $line['tags'];
foreach ($line["tags"] as $tag) {
array_push($article['tags'], $tag);
}
}
$enclosures = Article::_get_enclosures($line["id"]);
@@ -239,20 +238,23 @@ class Handler_Public extends Handler {
$article['enclosures'] = array();
foreach ($enclosures as $e) {
$article['enclosures'][] = [
'url' => $e['content_url'],
'type' => $e['content_type'],
'length' => $e['duration'],
];
$type = $e['content_type'];
$url = $e['content_url'];
$length = $e['duration'];
array_push($article['enclosures'], array("url" => $url, "type" => $type, "length" => $length));
}
}
array_push($feed['articles'], $article);
}
header("Content-Type: application/json; charset=utf-8");
header("Content-Type: text/json; charset=utf-8");
print json_encode($feed);
} else {
header("Content-Type: text/plain; charset=utf-8");
print "Unknown format: $format.";
}
}
@@ -319,7 +321,7 @@ class Handler_Public extends Handler {
header("Location: " . $redirect_url);
} else {
header("Content-Type: application/json");
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNAUTHORIZED);
}
}
@@ -359,6 +361,20 @@ class Handler_Public extends Handler {
header('HTTP/1.1 403 Forbidden');
}
function updateTask(): void {
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK);
}
function housekeepingTask(): void {
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_HOUSE_KEEPING);
}
function globalUpdateFeeds(): void {
RPC::updaterandomfeed_real();
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_UPDATE_TASK);
}
function login(): void {
if (!Config::get(Config::SINGLE_USER_MODE)) {
@@ -416,13 +432,6 @@ class Handler_Public extends Handler {
}
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();
@@ -438,23 +447,15 @@ class Handler_Public extends Handler {
<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 stylesheet_tag("themes/light.css");
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'>
<body class='flat ttrss_utility'>
<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){
@@ -463,19 +464,6 @@ class Handler_Public extends Handler {
});
});
</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>";
@@ -775,11 +763,6 @@ class Handler_Public extends Handler {
$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");
@@ -826,17 +809,17 @@ class Handler_Public extends Handler {
$plugin->$method();
} else {
user_error("PluginHandler[PUBLIC]: Requested private method '$method' of plugin '$plugin_name'.", E_USER_WARNING);
header("Content-Type: application/json");
header("Content-Type: text/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");
header("Content-Type: text/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");
header("Content-Type: text/json");
print Errors::to_json(Errors::E_UNKNOWN_PLUGIN, ['plugin' => $plugin_name]);
}
}

View File

@@ -197,7 +197,7 @@ class Labels
/**
* @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 {
static function create(string $caption, ?string $fg_color = '', ?string $bg_color = '', ?int $owner_uid = null) {
if (!$owner_uid) $owner_uid = $_SESSION['uid'];

View File

@@ -1,8 +1,10 @@
<?php
class Logger {
private static ?Logger $instance = null;
/** @var Logger|null */
private static $instance;
private ?Logger_Adapter $adapter = null;
/** @var Logger_Adapter|null */
private $adapter;
const LOG_DEST_SQL = "sql";
const LOG_DEST_STDOUT = "stdout";
@@ -55,12 +57,19 @@ class Logger {
}
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,
};
switch (Config::get(Config::LOG_DESTINATION)) {
case self::LOG_DEST_SQL:
$this->adapter = new Logger_SQL();
break;
case self::LOG_DEST_SYSLOG:
$this->adapter = new Logger_Syslog();
break;
case self::LOG_DEST_STDOUT:
$this->adapter = new Logger_Stdout();
break;
default:
$this->adapter = 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);

View File

@@ -6,7 +6,7 @@ class Mailer {
* @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 {
function mail(array $params) {
$to_name = $params["to_name"] ?? "";
$to_address = $params["to_address"];

View File

@@ -8,9 +8,9 @@ class OPML extends Handler_Protected {
}
/**
* @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
* @return bool|int|void false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or void if $owner_uid is missing
*/
function export(): bool|int|null {
function export() {
$output_name = sprintf("tt-rss_%s_%s.opml", $_SESSION["name"], date("Y-m-d"));
$include_settings = $_REQUEST["include_settings"] == "1";
$owner_uid = $_SESSION["uid"];
@@ -20,6 +20,33 @@ class OPML extends Handler_Protected {
return $rc;
}
function import(): void {
$owner_uid = $_SESSION["uid"];
header('Content-Type: text/html; charset=utf-8');
print "<html>
<head>
".stylesheet_tag("themes/light.css")."
<title>".__("OPML Utility")."</title>
<meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\"/>
</head>
<body class='claro ttrss_utility'>
<h1>".__('OPML Utility')."</h1><div class='content'>";
Feeds::_add_cat("Imported feeds", $owner_uid);
$this->opml_notice(__("Importing OPML..."));
$this->opml_import($owner_uid);
print "<br><form method=\"GET\" action=\"prefs.php\">
<input type=\"submit\" value=\"".__("Return to preferences")."\">
</form>";
print "</div></body></html>";
}
// Export
private function opml_export_category(int $owner_uid, int $cat_id, bool $hide_private_feeds = false, bool $include_settings = true): string {
@@ -99,10 +126,10 @@ class OPML extends Handler_Protected {
}
/**
* @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
* @return bool|int|void false if writing the file failed, true if printing succeeded, int if bytes were written to a file, or void 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;
function opml_export(string $filename, int $owner_uid, bool $hide_private_feeds = false, bool $include_settings = true, bool $file_output = false) {
if (!$owner_uid) return;
if (!$file_output)
if (!isset($_REQUEST["debug"])) {
@@ -182,7 +209,6 @@ class OPML extends Handler_Protected {
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"] = "";
@@ -191,16 +217,16 @@ class OPML extends Handler_Protected {
$match = [];
foreach (json_decode($tmp_line["match_on"], true) as $feed_id) {
if (str_starts_with($feed_id, "CAT:")) {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
if ($feed_id) {
array_push($match, [Feeds::_get_cat_title($feed_id, $owner_uid), true, false]);
array_push($match, [Feeds::_get_cat_title($feed_id), 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]);
array_push($match, [Feeds::_get_title((int)$feed_id), false, false]);
} else {
array_push($match, [0, false, true]);
}
@@ -337,9 +363,6 @@ class OPML extends Handler_Protected {
}
}
/**
* @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;
@@ -350,7 +373,7 @@ class OPML extends Handler_Protected {
$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);
set_pref($pref_name, $pref_value, $owner_uid);
}
}
@@ -371,10 +394,14 @@ class OPML extends Handler_Protected {
//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");
VALUES (?, ?, ?, ?, ?)");
$sth->execute([$match_any_rule, $enabled, $inverse, $title, $owner_uid]);
$sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2 WHERE
owner_uid = ?");
$sth->execute([$owner_uid]);
$row = $sth->fetch();
$filter_id = $row['id'];
@@ -560,12 +587,19 @@ class OPML extends Handler_Protected {
$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),
};
switch ($cat_title) {
case "tt-rss-prefs":
$this->opml_import_preference($node, $owner_uid, $nest+1);
break;
case "tt-rss-labels":
$this->opml_import_label($node, $owner_uid, $nest+1);
break;
case "tt-rss-filters":
$this->opml_import_filter($node, $owner_uid, $nest+1);
break;
default:
$this->opml_import_feed($node, $dst_cat_id, $owner_uid, $nest+1);
}
}
}
}
@@ -573,10 +607,10 @@ class OPML extends Handler_Protected {
/** $filename is optional; assumes HTTP upload with $_FILES otherwise */
/**
* @return bool|null false on failure, true if successful, null if $owner_uid is missing
* @return bool|void false on failure, true if successful, void if $owner_uid is missing
*/
function opml_import(int $owner_uid, string $filename = ""): ?bool {
if (!$owner_uid) return null;
function opml_import(int $owner_uid, string $filename = "") {
if (!$owner_uid) return;
if (!$filename) {
if ($_FILES['opml_file']['error'] != 0) {
@@ -610,8 +644,16 @@ class OPML extends Handler_Protected {
$doc = new DOMDocument();
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(false);
}
$loaded = $doc->load($tmp_file);
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
}
// only remove temporary i.e. HTTP uploaded files
if (!$filename)
unlink($tmp_file);

View File

@@ -76,7 +76,7 @@ abstract class Plugin {
*
* @return string */
function __($msgid) {
// this is a strictly template-related hack
/** @var Plugin $this -- this is a strictly template-related hack */
return _dgettext(PluginHost::object_to_domain($this), $msgid);
}
@@ -87,7 +87,7 @@ abstract class Plugin {
*
* @return string */
function _ngettext($singular, $plural, $number) {
// this is a strictly template-related hack
/** @var Plugin $this -- this is a strictly template-related hack */
return _dngettext(PluginHost::object_to_domain($this), $singular, $plural, $number);
}
@@ -557,18 +557,18 @@ abstract class Plugin {
return -1;
}
/**
* Invoked when filtering is triggered on an article. May be used to implement logging for filters, etc.
/** Invoked when filter is triggered on an article, may be used to implement logging for filters
* NOTE: $article_filters should be renamed $filter_actions because that's what this is
* @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
* @param array<string,string> $article_filters
* @return void
* @see PluginHost::HOOK_FILTER_TRIGGERED
*/
function hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filter_actions) {
function hook_filter_triggered($feed_id, $owner_uid, $article, $matched_filters, $matched_rules, $article_filters) {
user_error("Dummy method invoked.", E_USER_ERROR);
}
@@ -713,26 +713,4 @@ abstract class Plugin {
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

@@ -23,8 +23,8 @@ class PluginHost {
/** @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<int, array<int, array{'id': int, 'title': string, 'sender': Plugin, 'icon': string}>> */
private array $feeds = [];
/** @var array<string, Plugin> API method name, Plugin sender */
private array $api_methods = [];
@@ -38,8 +38,6 @@ class PluginHost {
private static ?PluginHost $instance = null;
private ?Scheduler $scheduler = null;
const API_VERSION = 2;
const PUBLIC_METHOD_DELIMITER = "--";
@@ -145,6 +143,9 @@ class PluginHost {
/** @see Plugin::hook_format_article() */
const HOOK_FORMAT_ARTICLE = "hook_format_article";
/** @see Plugin::hook_format_article_cdm() */
const HOOK_FORMAT_ARTICLE_CDM = "hook_format_article_cdm";
/** @see Plugin::hook_feed_basic_info() */
const HOOK_FEED_BASIC_INFO = "hook_feed_basic_info";
@@ -201,12 +202,6 @@ class PluginHost {
/** @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;
@@ -217,7 +212,6 @@ class PluginHost {
function __construct() {
$this->pdo = Db::pdo();
$this->scheduler = new Scheduler('PluginHost Scheduler');
$this->storage = [];
}
@@ -348,8 +342,16 @@ class PluginHost {
*/
function chain_hooks_callback(string $hook, Closure $callback, &...$args): void {
$method = strtolower((string)$hook);
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent("chain_hooks_callback: $hook");
foreach ($this->get_hooks((string)$hook) as $plugin) {
//Debug::log("invoking: " . get_class($plugin) . "->$hook()", Debug::$LOG_VERBOSE);
//$p_span = Tracer::start("$hook - " . get_class($plugin));
$span->addEvent("$hook - " . get_class($plugin));
try {
if ($callback($plugin->$method(...$args), $plugin))
break;
@@ -358,7 +360,11 @@ class PluginHost {
} catch (Error $err) {
user_error($err, E_USER_WARNING);
}
//$p_span->end();
}
//$span->end();
}
/**
@@ -392,7 +398,7 @@ class PluginHost {
* @param PluginHost::HOOK_* $type
*/
function del_hook(string $type, Plugin $sender): void {
if (array_key_exists($type, $this->hooks)) {
if (is_array($this->hooks[$type])) {
foreach (array_keys($this->hooks[$type]) as $prio) {
$key = array_search($sender, $this->hooks[$type][$prio]);
@@ -424,6 +430,9 @@ class PluginHost {
* @param PluginHost::KIND_* $kind
*/
function load_all(int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
$span = Tracer::start(__METHOD__);
$span->setAttribute('func.args', json_encode(func_get_args()));
$plugins = [...(glob("plugins/*") ?: []), ...(glob("plugins.local/*") ?: [])];
$plugins = array_filter($plugins, "is_dir");
$plugins = array_map("basename", $plugins);
@@ -431,24 +440,27 @@ class PluginHost {
asort($plugins);
$this->load(join(",", $plugins), (int)$kind, $owner_uid, $skip_init);
$span->end();
}
/**
* @param PluginHost::KIND_* $kind
*/
function load(string $classlist, int $kind, ?int $owner_uid = null, bool $skip_init = false): void {
$span = Tracer::start(__METHOD__);
$span->setAttribute('func.args', json_encode(func_get_args()));
$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)));
$span->addEvent("$class_file: load");
// try system plugin directory first
$file = Config::get_self_dir() . "/plugins/$class_file/init.php";
@@ -473,6 +485,8 @@ class PluginHost {
}
$_SESSION["safe_mode"] = 1;
$span->setAttribute('error', 'plugin is blacklisted');
continue;
}
@@ -483,6 +497,8 @@ class PluginHost {
} catch (Error $err) {
user_error($err, E_USER_WARNING);
$span->setAttribute('error', $err);
continue;
}
@@ -492,6 +508,8 @@ class PluginHost {
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);
$span->setAttribute('error', 'plugin is not compatible with API version');
continue;
}
@@ -500,6 +518,8 @@ class PluginHost {
_bind_textdomain_codeset($class, "UTF-8");
}
$span->addEvent("$class_file: initialize");
try {
switch ($kind) {
case $this::KIND_SYSTEM:
@@ -529,6 +549,7 @@ class PluginHost {
}
$this->load_data();
$span->end();
}
function is_system(Plugin $plugin): bool {
@@ -560,7 +581,7 @@ class PluginHost {
/**
* @return false|Plugin false if the handler couldn't be found, otherwise the Plugin/handler
*/
function lookup_handler(string $handler, string $method): false|Plugin {
function lookup_handler(string $handler, string $method) {
$handler = str_replace("-", "_", strtolower($handler));
$method = strtolower($method);
@@ -589,7 +610,7 @@ class PluginHost {
/**
* @return false|Plugin false if the command couldn't be found, otherwise the registered Plugin
*/
function lookup_command(string $command): false|Plugin {
function lookup_command(string $command) {
$command = "-" . strtolower($command);
if (array_key_exists($command, $this->commands)) {
@@ -599,7 +620,7 @@ class PluginHost {
}
}
/** @return array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}> command type -> details array */
/** @return array<string, array{'description': string, 'suffix': string, 'arghelp': string, 'class': Plugin}>> command type -> details array */
function get_commands() {
return $this->commands;
}
@@ -617,12 +638,17 @@ class PluginHost {
}
private function load_data(): void {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent('load plugin data');
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()) {
$span->addEvent($line["name"] . ': unserialize');
$this->storage[$line["name"]] = unserialize($line["content"]);
}
@@ -632,6 +658,9 @@ class PluginHost {
private function save_data(string $plugin): void {
if ($this->owner_uid) {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent(__METHOD__ . ": $plugin");
if (!$this->pdo_data)
$this->pdo_data = Db::instance()->pdo_connect();
@@ -740,7 +769,7 @@ class PluginHost {
* @param array<int|string, mixed> $default_value
* @return array<int|string, mixed>
*/
function get_array(Plugin $sender, string $name, array $default_value = []): array {
function get_array(Plugin $sender, string $name, array $default_value = []) {
$tmp = $this->get($sender, $name);
if (!is_array($tmp)) $tmp = $default_value;
@@ -769,51 +798,36 @@ class PluginHost {
}
}
/**
* 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;
// Plugin feed functions are *EXPERIMENTAL*!
$id = count($this->special_feeds);
// cat_id: only -1 (Feeds::CATEGORY_SPECIAL) is supported (Special)
function add_feed(int $cat_id, string $title, string $icon, Plugin $sender): int {
$this->special_feeds[] = [
'id' => $id,
'title' => $title,
'sender' => $sender,
'icon' => $icon,
];
if (empty($this->feeds[$cat_id]))
$this->feeds[$cat_id] = [];
$id = count($this->feeds[$cat_id]);
array_push($this->feeds[$cat_id],
['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 : [];
return $this->feeds[$cat_id] ?? [];
}
/**
* 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
*/
// convert feed_id (e.g. -129) to pfeed_id first
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;
foreach ($this->feeds as $cat) {
foreach ($cat as $feed) {
if ($feed['id'] == $pfeed_id) {
return $feed['sender'];
}
}
}
return null;
@@ -867,12 +881,14 @@ class PluginHost {
*/
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,
]);
http_build_query(
array_merge(
[
"op" => "pluginhandler",
"plugin" => strtolower(get_class($sender)),
"method" => $method
],
$params));
}
// shortcut syntax (disabled for now)
@@ -894,10 +910,12 @@ class PluginHost {
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,
]);
http_build_query(
array_merge(
[
"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;
@@ -913,31 +931,4 @@ class PluginHost {
$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);
}
}

View File

@@ -15,8 +15,12 @@ class Pref_Feeds extends Handler_Protected {
* @return array<int, string>
*/
public static function get_ts_languages(): array {
return array_map('ucfirst',
array_column(ORM::for_table('pg_ts_config')->select('cfgname')->find_array(), 'cfgname'));
if (Config::get(Config::DB_TYPE) == 'pgsql') {
return array_map('ucfirst',
array_column(ORM::for_table('pg_ts_config')->select('cfgname')->find_array(), 'cfgname'));
}
return [];
}
function renameCat(): void {
@@ -72,15 +76,11 @@ class Pref_Feeds extends Handler_Protected {
}
$feeds_obj = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->left_outer_join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->select_expr('COUNT(ue.int_id) AS num_articles')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->where(['cat_id' => $cat_id, 'owner_uid' => $_SESSION['uid']])
->order_by_asc('order_id')
->order_by_asc('title')
->group_by('id');
->order_by_asc('title');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
@@ -96,10 +96,7 @@ class Pref_Feeds extends Handler_Protected {
'unread' => -1,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => T_sprintf(
_ngettext("(%d article / %s)", "(%d articles / %s)", $feed->num_articles),
$feed->num_articles,
TimeHelper::make_local_datetime($feed->last_updated)),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'updates_disabled' => (int)($feed->update_interval < 0),
]);
}
@@ -115,7 +112,6 @@ class Pref_Feeds extends Handler_Protected {
* @return array<string, array<int|string, mixed>|string>
*/
function _makefeedtree(): array {
$profile = $_SESSION['profile'] ?? null;
if (clean($_REQUEST['mode'] ?? 0) != 2)
$search = $_SESSION["prefs_feed_search"] ?? "";
@@ -129,7 +125,7 @@ class Pref_Feeds extends Handler_Protected {
$root['param'] = 0;
$root['type'] = 'category';
$enable_cats = Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $profile);
$enable_cats = get_pref(Prefs::ENABLE_FEED_CATS);
if (clean($_REQUEST['mode'] ?? 0) == 2) {
@@ -179,7 +175,7 @@ class Pref_Feeds extends Handler_Protected {
ttrss_labels2 WHERE owner_uid = ? ORDER by caption");
$sth->execute([$_SESSION['uid']]);
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $profile)) {
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
$cat = $this->feedlist_init_cat(Feeds::CATEGORY_LABELS);
} else {
$cat['items'] = [];
@@ -244,7 +240,8 @@ class Pref_Feeds extends Handler_Protected {
/**
* Uncategorized is a special case.
*
* @var array{id: string, bare_id: int, auxcounter: int, name: string, items: array<int, array<string, mixed>>, type: 'category', checkbox: bool, unread: int, child_unread: int}
* Define a minimal array shape to help PHPStan with the type of $cat['items']
* @var array{items: array<int, array<string, mixed>>} $cat
*/
$cat = [
'id' => 'CAT:0',
@@ -259,16 +256,12 @@ class Pref_Feeds extends Handler_Protected {
];
$feeds_obj = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->left_outer_join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->select_expr('COUNT(ue.int_id) AS num_articles')
->select_expr(SUBSTRING_FOR_DATE.'(last_updated,1,19)', 'last_updated')
->where('owner_uid', $_SESSION['uid'])
->where_null('cat_id')
->order_by_asc('order_id')
->order_by_asc('title')
->group_by('id');
->order_by_asc('title');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
@@ -283,10 +276,7 @@ class Pref_Feeds extends Handler_Protected {
'checkbox' => false,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => T_sprintf(
_ngettext("(%d article / %s)", "(%d articles / %s)", $feed->num_articles),
$feed->num_articles,
TimeHelper::make_local_datetime($feed->last_updated)),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'unread' => -1,
'type' => 'feed',
'updates_disabled' => (int)($feed->update_interval < 0),
@@ -303,15 +293,11 @@ class Pref_Feeds extends Handler_Protected {
} else {
$feeds_obj = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('id', 'title', 'last_error', 'update_interval')
->select_expr('SUBSTRING_FOR_DATE(last_updated,1,19)', 'last_updated')
->left_outer_join('ttrss_user_entries', [ 'ue.feed_id', '=', 'f.id'], 'ue')
->select_expr('COUNT(ue.int_id) AS num_articles')
->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')
->group_by('id');
->order_by_asc('title');
if ($search) {
$feeds_obj->where_raw('(LOWER(title) LIKE ? OR LOWER(feed_url) LIKE LOWER(?))', ["%$search%", "%$search%"]);
@@ -326,10 +312,7 @@ class Pref_Feeds extends Handler_Protected {
'checkbox' => false,
'error' => $feed->last_error,
'icon' => Feeds::_get_icon($feed->id),
'param' => T_sprintf(
_ngettext("(%d article / %s)", "(%d articles / %s)", $feed->num_articles),
$feed->num_articles,
TimeHelper::make_local_datetime($feed->last_updated)),
'param' => TimeHelper::make_local_datetime($feed->last_updated, true),
'unread' => -1,
'type' => 'feed',
'updates_disabled' => (int)($feed->update_interval < 0),
@@ -402,7 +385,7 @@ class Pref_Feeds extends Handler_Protected {
if ($item['_reference']) {
if (str_starts_with($id, "FEED")) {
if (strpos($id, "FEED") === 0) {
$feed = ORM::for_table('ttrss_feeds')
->where('owner_uid', $_SESSION['uid'])
@@ -413,7 +396,7 @@ class Pref_Feeds extends Handler_Protected {
$feed->cat_id = ($item_id != "root" && $bare_item_id) ? $bare_item_id : null;
$feed->save();
}
} else if (str_starts_with($id, "CAT:")) {
} else if (strpos($id, "CAT:") === 0) {
$this->process_category_order($data_map, $item['_reference'], $item_id,
$nest_level+1);
@@ -540,8 +523,6 @@ class Pref_Feeds extends Handler_Protected {
global $purge_intervals;
global $update_intervals;
$profile = $_SESSION['profile'] ?? null;
$feed_id = (int)clean($_REQUEST["id"]);
$row = ORM::for_table('ttrss_feeds')
@@ -556,14 +537,13 @@ class Pref_Feeds extends Handler_Protected {
ob_end_clean();
$row["icon"] = Feeds::_get_icon($feed_id);
$row["auth_pass"] = Feeds::decrypt_feed_pass($row["auth_pass"]);
$local_update_intervals = $update_intervals;
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[Prefs::get(Prefs::DEFAULT_UPDATE_INTERVAL, $_SESSION['uid'])]);
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[get_pref(Prefs::DEFAULT_UPDATE_INTERVAL)]);
if (Config::get(Config::FORCE_ARTICLE_PURGE) == 0) {
$local_purge_intervals = $purge_intervals;
$default_purge_interval = Prefs::get(Prefs::PURGE_OLD_DAYS, $_SESSION['uid']);
$default_purge_interval = get_pref(Prefs::PURGE_OLD_DAYS);
if ($default_purge_interval > 0)
$local_purge_intervals[0] .= " " . T_nsprintf('(%d day)', '(%d days)', $default_purge_interval, $default_purge_interval);
@@ -580,7 +560,7 @@ class Pref_Feeds extends Handler_Protected {
print json_encode([
"feed" => $row,
"cats" => [
"enabled" => Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $profile),
"enabled" => get_pref(Prefs::ENABLE_FEED_CATS),
"select" => \Controls\select_feeds_cats("cat_id", $row["cat_id"]),
],
"plugin_data" => $plugin_data,
@@ -593,8 +573,8 @@ class Pref_Feeds extends Handler_Protected {
"access_level" => $user->access_level
],
"lang" => [
"enabled" => true,
"default" => Prefs::get(Prefs::DEFAULT_SEARCH_LANGUAGE, $_SESSION['uid'], $profile),
"enabled" => Config::get(Config::DB_TYPE) == "pgsql",
"default" => get_pref(Prefs::DEFAULT_SEARCH_LANGUAGE),
"all" => $this::get_ts_languages(),
]
]);
@@ -613,10 +593,10 @@ class Pref_Feeds extends Handler_Protected {
$feed_ids = clean($_REQUEST["ids"]);
$local_update_intervals = $update_intervals;
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[Prefs::get(Prefs::DEFAULT_UPDATE_INTERVAL, $_SESSION['uid'])]);
$local_update_intervals[0] .= sprintf(" (%s)", $update_intervals[get_pref(Prefs::DEFAULT_UPDATE_INTERVAL)]);
$local_purge_intervals = $purge_intervals;
$default_purge_interval = Prefs::get(Prefs::PURGE_OLD_DAYS, $_SESSION['uid']);
$default_purge_interval = get_pref(Prefs::PURGE_OLD_DAYS);
if ($default_purge_interval > 0)
$local_purge_intervals[0] .= " " . T_sprintf("(%d days)", $default_purge_interval);
@@ -641,7 +621,7 @@ class Pref_Feeds extends Handler_Protected {
<div dojoType="dijit.layout.TabContainer" style="height : 450px">
<div dojoType="dijit.layout.ContentPane" title="<?= __('General') ?>">
<section>
<?php if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null)) { ?>
<?php if (get_pref(Prefs::ENABLE_FEED_CATS)) { ?>
<fieldset>
<label><?= __('Place in category:') ?></label>
<?= \Controls\select_feeds_cats("cat_id", null, ['disabled' => '1']) ?>
@@ -649,11 +629,13 @@ class Pref_Feeds extends Handler_Protected {
</fieldset>
<?php } ?>
<?php if (Config::get(Config::DB_TYPE) == "pgsql") { ?>
<fieldset>
<label><?= __('Language:') ?></label>
<?= \Controls\select_tag("feed_language", "", $this::get_ts_languages(), ["disabled"=> 1]) ?>
<?= $this->_batch_toggle_checkbox("feed_language") ?>
</fieldset>
<?php } ?>
</section>
<hr/>
@@ -741,11 +723,6 @@ class Pref_Feeds extends Handler_Protected {
$feed_language = clean($_POST["feed_language"] ?? "");
$key = Config::get(Config::ENCRYPTION_KEY);
if ($key && $auth_pass)
$auth_pass = base64_encode(serialize(Crypt::encrypt_string($auth_pass)));
if (!$batch) {
/* $sth = $this->pdo->prepare("SELECT feed_url FROM ttrss_feeds WHERE id = ?");
@@ -851,7 +828,7 @@ class Pref_Feeds extends Handler_Protected {
break;
case "cat_id":
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null)) {
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
$qpart = "cat_id = ?";
$qparams = $cat_id ? [$cat_id] : [null];
}
@@ -961,7 +938,7 @@ class Pref_Feeds extends Handler_Protected {
</div>
</div>
<?php if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null)) { ?>
<?php if (get_pref(Prefs::ENABLE_FEED_CATS)) { ?>
<div dojoType="fox.form.DropDownButton">
<span><?= __('Categories') ?></span>
<div dojoType="dijit.Menu" style="display: none">
@@ -1100,10 +1077,13 @@ class Pref_Feeds extends Handler_Protected {
* @return array<string, mixed>
*/
private function feedlist_init_cat(int $cat_id): array {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent(__METHOD__ . ": $cat_id");
return [
'id' => 'CAT:' . $cat_id,
'items' => array(),
'name' => Feeds::_get_cat_title($cat_id, $_SESSION['uid']),
'name' => Feeds::_get_cat_title($cat_id),
'type' => 'category',
'unread' => -1, //(int) Feeds::_get_cat_unread($cat_id);
'bare_id' => $cat_id,
@@ -1114,8 +1094,11 @@ class Pref_Feeds extends Handler_Protected {
* @return array<string, mixed>
*/
private function feedlist_init_feed(int $feed_id, ?string $title = null, bool $unread = false, string $error = '', string $updated = ''): array {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent(__METHOD__ . ": $feed_id");
if (!$title)
$title = Feeds::_get_title($feed_id, $_SESSION['uid']);
$title = Feeds::_get_title($feed_id, false);
if ($unread === false)
$unread = Feeds::_get_counters($feed_id, false, true);
@@ -1135,6 +1118,12 @@ class Pref_Feeds extends Handler_Protected {
function inactiveFeeds(): void {
if (Config::get(Config::DB_TYPE) == "pgsql") {
$interval_qpart = "NOW() - INTERVAL '3 months'";
} else {
$interval_qpart = "DATE_SUB(NOW(), INTERVAL 3 MONTH)";
}
$inactive_feeds = ORM::for_table('ttrss_feeds')
->table_alias('f')
->select_many('f.id', 'f.title', 'f.site_url', 'f.feed_url')
@@ -1146,7 +1135,7 @@ class Pref_Feeds extends Handler_Protected {
"(SELECT MAX(ttrss_entries.updated)
FROM ttrss_entries
JOIN ttrss_user_entries ON ttrss_entries.id = ttrss_user_entries.ref_id
WHERE ttrss_user_entries.feed_id = f.id) < NOW() - INTERVAL '3 months'")
WHERE ttrss_user_entries.feed_id = f.id) < $interval_qpart")
->group_by('f.title')
->group_by('f.id')
->group_by('f.site_url')
@@ -1155,7 +1144,7 @@ class Pref_Feeds extends Handler_Protected {
->find_array();
foreach ($inactive_feeds as $inactive_feed) {
$inactive_feed['last_article'] = TimeHelper::make_local_datetime($inactive_feed['last_article']);
$inactive_feed['last_article'] = TimeHelper::make_local_datetime($inactive_feed['last_article'], false);
}
print json_encode($inactive_feeds);
@@ -1214,7 +1203,7 @@ class Pref_Feeds extends Handler_Protected {
function batchSubscribe(): void {
print json_encode([
"enable_cats" => (int)Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null),
"enable_cats" => (int)get_pref(Prefs::ENABLE_FEED_CATS),
"cat_select" => \Controls\select_feeds_cats("cat")
]);
}
@@ -1284,7 +1273,7 @@ class Pref_Feeds extends Handler_Protected {
]);
print json_encode([
"title" => Feeds::_get_title($feed_id, $_SESSION['uid'], $is_cat),
"title" => Feeds::_get_title($feed_id, $is_cat),
"link" => $link
]);
}

View File

@@ -10,22 +10,6 @@ class Pref_Filters extends Handler_Protected {
const PARAM_ACTIONS = [self::ACTION_TAG, self::ACTION_SCORE,
self::ACTION_LABEL, self::ACTION_PLUGIN, self::ACTION_REMOVE_TAG];
const MAX_ACTIONS_TO_DISPLAY = 3;
/** @var array<int,array<mixed>> $action_descriptions */
private array $action_descriptions = [];
function before(string $method) : bool {
$descriptions = ORM::for_table("ttrss_filter_actions")->find_array();
foreach ($descriptions as $desc) {
$this->action_descriptions[$desc['id']] = $desc;
}
return parent::before($method);
}
function csrf_ignore(string $method): bool {
$csrf_ignored = array("index", "getfiltertree", "savefilterorder");
@@ -70,173 +54,122 @@ class Pref_Filters extends Handler_Protected {
$offset = (int) clean($_REQUEST["offset"]);
$limit = (int) clean($_REQUEST["limit"]);
// catchall fake filter which includes all rules
$filter = [
'enabled' => true,
'match_any_rule' => checkbox_to_sql_bool($_REQUEST['match_any_rule'] ?? false),
'inverse' => checkbox_to_sql_bool($_REQUEST['inverse'] ?? false),
'rules' => [],
'actions' => ['dummy-action'],
];
$filter = array();
$filter["enabled"] = true;
$filter["match_any_rule"] = checkbox_to_sql_bool($_REQUEST["match_any_rule"] ?? false);
$filter["inverse"] = checkbox_to_sql_bool($_REQUEST["inverse"] ?? false);
$filter["rules"] = array();
$filter["actions"] = array("dummy-action");
$res = $this->pdo->query("SELECT id,name FROM ttrss_filter_types");
/** @var array<int, string> */
$filter_types = [];
foreach (ORM::for_table('ttrss_filter_types')->find_many() as $filter_type) {
$filter_types[$filter_type->id] = $filter_type->name;
while ($line = $res->fetch()) {
$filter_types[$line["id"]] = $line["name"];
}
$scope_qparts = [];
$scope_qparts = array();
/** @var string $rule_json */
foreach (clean($_REQUEST['rule']) as $rule_json) {
/** @var array{reg_exp: string, filter_type: int, feed_id: array<int, int|string>, name: string, inverse?: bool}|null */
$rule = json_decode($rule_json, true);
$rctr = 0;
if (is_array($rule)) {
$rule['type'] = $filter_types[$rule['filter_type']];
$rule['inverse'] ??= false;
array_push($filter['rules'], $rule);
/** @var string $r */
foreach (clean($_REQUEST["rule"]) AS $r) {
/** @var array{'reg_exp': string, 'filter_type': int, 'feed_id': array<int, int|string>, 'name': string}|null */
$rule = json_decode($r, true);
if ($rule && $rctr < 5) {
$rule["type"] = $filter_types[$rule["filter_type"]];
unset($rule["filter_type"]);
$scope_inner_qparts = [];
/** @var int|string $feed_id may be a category string (e.g. 'CAT:7') or feed ID int */
foreach ($rule["feed_id"] as $feed_id) {
if (str_starts_with("$feed_id", "CAT:")) {
if (strpos("$feed_id", "CAT:") === 0) {
$cat_id = (int) substr("$feed_id", 4);
if ($cat_id > 0)
array_push($scope_inner_qparts, "cat_id = " . $cat_id);
else
array_push($scope_inner_qparts, "cat_id IS NULL");
array_push($scope_inner_qparts, "cat_id = " . $cat_id);
} else if (is_numeric($feed_id) && $feed_id > 0) {
array_push($scope_inner_qparts, "feed_id = " . (int)$feed_id);
}
}
if (count($scope_inner_qparts) > 0)
array_push($scope_qparts, '(' . implode(' OR ', $scope_inner_qparts) . ')');
if (count($scope_inner_qparts) > 0) {
array_push($scope_qparts, "(" . implode(" OR ", $scope_inner_qparts) . ")");
}
array_push($filter["rules"], $rule);
++$rctr;
} else {
break;
}
}
$query = ORM::for_table('ttrss_entries')
->table_alias('e')
->select_many('e.title', 'e.content', 'e.date_entered', 'e.link', 'e.author', 'ue.tag_cache',
['feed_id' => 'f.id', 'feed_title' => 'f.title', 'cat_id' => 'fc.id'])
->join('ttrss_user_entries', [ 'ue.ref_id', '=', 'e.id'], 'ue')
->left_outer_join('ttrss_feeds', ['f.id', '=', 'ue.feed_id'], 'f')
->left_outer_join('ttrss_feed_categories', ['fc.id', '=', 'f.cat_id'], 'fc')
->where('ue.owner_uid', $_SESSION['uid'])
->order_by_desc('e.date_entered')
->limit($limit)
->offset($offset);
if (count($scope_qparts) == 0) $scope_qparts = ["true"];
if (count($scope_qparts) > 0)
$query->where_raw(join($filter['match_any_rule'] ? ' OR ' : ' AND ', $scope_qparts));
$glue = $filter['match_any_rule'] ? " OR " : " AND ";
$scope_qpart = join($glue, $scope_qparts);
$entries = $query->find_array();
/** @phpstan-ignore-next-line */
if (!$scope_qpart) $scope_qpart = "true";
$rv = [
'pre_filtering_count' => count($entries),
'items' => [],
];
$rv = array();
foreach ($entries as $entry) {
//while ($found < $limit && $offset < $limit * 1000 && time() - $started < ini_get("max_execution_time") * 0.7) {
// temporary filter which will be used to compare against returned article
$feed_filter = $filter;
$feed_filter['rules'] = [];
$sth = $this->pdo->prepare("SELECT ttrss_entries.id,
ttrss_entries.title,
ttrss_feeds.id AS feed_id,
ttrss_feeds.title AS feed_title,
ttrss_feed_categories.id AS cat_id,
content,
date_entered,
link,
author,
tag_cache
FROM
ttrss_entries, ttrss_user_entries
LEFT JOIN ttrss_feeds ON (feed_id = ttrss_feeds.id)
LEFT JOIN ttrss_feed_categories ON (ttrss_feeds.cat_id = ttrss_feed_categories.id)
WHERE
ref_id = ttrss_entries.id AND
($scope_qpart) AND
ttrss_user_entries.owner_uid = ?
ORDER BY date_entered DESC LIMIT $limit OFFSET $offset");
// only add rules which match result from specific feed or category ID or rules matching all feeds
// @phpstan-ignore foreach.emptyArray
foreach ($filter['rules'] as $rule) {
foreach ($rule['feed_id'] as $rule_feed) {
if (($rule_feed === 'CAT:0' && $entry['cat_id'] === null) || // rule matches Uncategorized
$rule_feed === 'CAT:' . $entry['cat_id'] || // rule matches category
(int)$rule_feed === $entry['feed_id'] || // rule matches feed
$rule_feed === '0') { // rule matches all feeds
$sth->execute([$_SESSION['uid']]);
$feed_filter['rules'][] = $rule;
}
}
}
while ($line = $sth->fetch()) {
$matched_rules = [];
$rc = RSSUtils::get_article_filters(array($filter), $line['title'], $line['content'], $line['link'],
$line['author'], explode(",", $line['tag_cache']));
$entry_tags = explode(",", $entry['tag_cache']);
if (count($rc) > 0) {
$article_filter_actions = RSSUtils::eval_article_filters([$feed_filter], $entry['title'], $entry['content'], $entry['link'],
$entry['author'], $entry_tags, $matched_rules);
$line["content_preview"] = truncate_string(strip_tags($line["content"]), 200, '&hellip;');
if (count($article_filter_actions) > 0) {
$content_preview = "";
$excerpt_length = 100;
$matches = [];
$rules = [];
PluginHost::getInstance()->chain_hooks_callback(PluginHost::HOOK_QUERY_HEADLINES,
function ($result) use (&$line) {
$line = $result;
},
$line, $excerpt_length);
$entry_title = $entry["title"];
$content_preview = $line["content_preview"];
// technically only one rule may match *here* because we're testing a single (fake) filter defined above
// let's keep this forward-compatible in case we'll want to return multiple rules for whatever reason
foreach ($matched_rules as $rule) {
$can_highlight_content = false;
$can_highlight_title = false;
$tmp = "<li><span class='title'>" . $line["title"] . "</span><br/>" .
"<span class='feed'>" . $line['feed_title'] . "</span>, <span class='date'>" . mb_substr($line["date_entered"], 0, 16) . "</span>" .
"<div class='preview text-muted'>" . $content_preview . "</div>" .
"</li>";
$rule_regexp_match = mb_substr(strip_tags($rule['regexp_matches'][0]), 0, 200);
array_push($rv, $tmp);
$matches[] = $rule_regexp_match;
$rules[] = self::_get_rule_name($rule, '');
if (in_array($rule['type'], ['content', 'both'])) {
// also stripping [\r\n\t] to match what's done for content in RSSUtils#eval_article_filters()
$entry_content_text = strip_tags(preg_replace("/[\r\n\t]/", "", $entry["content"]));
$match_index = mb_strpos($entry_content_text, $rule_regexp_match);
$content_preview = truncate_string(mb_substr($entry_content_text, $match_index), 200);
if ($match_index > 0)
$content_preview = '&hellip;' . $content_preview;
} else if ($rule['type'] == 'link') {
$content_preview = $entry['link'];
} else if ($rule['type'] == 'author') {
$content_preview = $entry['author'];
} else if ($rule['type'] == 'tag') {
$content_preview = '<i class="material-icons">label_outline</i> ' . implode(', ', $entry_tags);
} else {
$content_preview = "&mdash;";
}
switch ($rule['type']) {
case "both":
$can_highlight_title = true;
$can_highlight_content = true;
break;
case "title":
$can_highlight_title = true;
break;
case "content":
case "link":
case "author":
case "tag":
$can_highlight_content = true;
break;
}
if ($can_highlight_content)
$content_preview = Sanitizer::highlight_words_str($content_preview, $matches);
if ($can_highlight_title)
$entry_title = Sanitizer::highlight_words_str($entry_title, $matches);
}
$rv['items'][] = [
'title' => $entry_title,
'feed_title' => $entry['feed_title'],
'date' => mb_substr($entry['date_entered'], 0, 16),
'content_preview' => $content_preview,
'rules' => $rules
];
}
}
@@ -244,108 +177,148 @@ class Pref_Filters extends Handler_Protected {
}
private function _get_rules_list(int $filter_id): string {
$rules = ORM::for_table('ttrss_filters2_rules')
->table_alias('r')
->join('ttrss_filter_types', ['r.filter_type', '=', 't.id'], 't')
->where('filter_id', $filter_id)
->select_many(['r.*', 'field' => 't.description'])
->find_many();
$sth = $this->pdo->prepare("SELECT reg_exp,
inverse,
match_on,
feed_id,
cat_id,
cat_filter,
ttrss_filter_types.description AS field
FROM
ttrss_filters2_rules, ttrss_filter_types
WHERE
filter_id = ? AND filter_type = ttrss_filter_types.id
ORDER BY reg_exp");
$sth->execute([$filter_id]);
$rv = "";
foreach ($rules as $rule) {
if ($rule->match_on) {
$feeds = json_decode($rule->match_on, true);
$feeds_fmt = [];
while ($line = $sth->fetch()) {
foreach ($feeds as $feed_id) {
if ($line["match_on"]) {
$feeds = json_decode($line["match_on"], true);
$feeds_fmt = [];
if (str_starts_with($feed_id, "CAT:")) {
$feed_id = (int)substr($feed_id, 4);
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id, $_SESSION['uid']));
} else {
if ($feed_id)
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id, $_SESSION['uid']));
else
array_push($feeds_fmt, __("All feeds"));
}
}
foreach ($feeds as $feed_id) {
$where = implode(", ", $feeds_fmt);
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id));
} else {
if ($feed_id)
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id));
else
array_push($feeds_fmt, __("All feeds"));
}
}
} else {
$where = $rule->cat_filter ?
Feeds::_get_cat_title($rule->cat_id ?? 0, $_SESSION['uid']) :
($rule->feed_id ?
Feeds::_get_title($rule->feed_id, $_SESSION['uid']) : __("All feeds"));
}
$where = implode(", ", $feeds_fmt);
$inverse_class = $rule->inverse ? "inverse" : "";
} else {
$rv .= "<li class='$inverse_class'>" . T_sprintf("%s on %s in %s %s",
htmlspecialchars($rule->reg_exp),
$rule->field,
$where = $line["cat_filter"] ?
Feeds::_get_cat_title($line["cat_id"] ?? 0) :
($line["feed_id"] ?
Feeds::_get_title($line["feed_id"]) : __("All feeds"));
}
# $where = $line["cat_id"] . "/" . $line["feed_id"];
$inverse = $line["inverse"] ? "inverse" : "";
$rv .= "<li class='$inverse'>" . T_sprintf("%s on %s in %s %s",
htmlspecialchars($line["reg_exp"]),
$line["field"],
$where,
$rule->inverse ? __("(inverse)") : "") . "</li>";
$line["inverse"] ? __("(inverse)") : "") . "</li>";
}
return $rv;
}
function getfiltertree(): void {
$root = [
'id' => 'root',
'name' => __('Filters'),
'enabled' => true,
'items' => []
];
$root = array();
$root['id'] = 'root';
$root['name'] = __('Filters');
$root['enabled'] = true;
$root['items'] = array();
$filter_search = ($_SESSION["prefs_filter_search"] ?? "");
$filters = ORM::for_table('ttrss_filters2')
->where('owner_uid', $_SESSION['uid'])
->order_by_asc(['order_id', 'title'])
->find_many();
$sth = $this->pdo->prepare("SELECT *,
(SELECT action_param FROM ttrss_filters2_actions
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS action_param,
(SELECT action_id FROM ttrss_filters2_actions
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS action_id,
(SELECT description FROM ttrss_filter_actions
WHERE id = (SELECT action_id FROM ttrss_filters2_actions
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1)) AS action_name,
(SELECT reg_exp FROM ttrss_filters2_rules
WHERE filter_id = ttrss_filters2.id ORDER BY id LIMIT 1) AS reg_exp
FROM ttrss_filters2 WHERE
owner_uid = ? ORDER BY order_id, title");
$sth->execute([$_SESSION['uid']]);
$folder = [
'items' => []
];
$folder = array();
$folder['items'] = array();
foreach ($filters as $filter) {
$details = $this->_get_details($filter->id);
while ($line = $sth->fetch()) {
if ($filter_search &&
mb_stripos($filter->title, $filter_search) === false &&
!ORM::for_table('ttrss_filters2_rules')
->where('filter_id', $filter->id)
->where_raw('LOWER(reg_exp) LIKE LOWER(?)', ["%$filter_search%"])
->find_one()) {
$name = $this->_get_name($line["id"]);
continue;
$match_ok = false;
if ($filter_search) {
if (mb_strpos($line['title'], $filter_search) !== false) {
$match_ok = true;
}
$rules_sth = $this->pdo->prepare("SELECT reg_exp
FROM ttrss_filters2_rules WHERE filter_id = ?");
$rules_sth->execute([$line['id']]);
while ($rule_line = $rules_sth->fetch()) {
if (mb_strpos($rule_line['reg_exp'], $filter_search) !== false) {
$match_ok = true;
break;
}
}
}
$item = [
'id' => 'FILTER:' . $filter->id,
'bare_id' => $filter->id,
'bare_name' => $details['title'],
'name' => $details['title_summary'],
'param' => $details['actions_summary'],
'checkbox' => false,
'last_triggered' => $filter->last_triggered ? TimeHelper::make_local_datetime($filter->last_triggered) : null,
'enabled' => sql_bool_to_bool($filter->enabled),
'rules' => $this->_get_rules_list($filter->id)
];
if ($line['action_id'] == self::ACTION_LABEL) {
$label_sth = $this->pdo->prepare("SELECT fg_color, bg_color
FROM ttrss_labels2 WHERE caption = ? AND
owner_uid = ?");
$label_sth->execute([$line['action_param'], $_SESSION['uid']]);
array_push($folder['items'], $item);
if ($label_row = $label_sth->fetch()) {
//$fg_color = $label_row["fg_color"];
$bg_color = $label_row["bg_color"];
$name[1] = "<i class=\"material-icons\" style='color : $bg_color; margin-right : 4px'>label</i>" . $name[1];
}
}
$filter = array();
$filter['id'] = 'FILTER:' . $line['id'];
$filter['bare_id'] = $line['id'];
$filter['name'] = $name[0];
$filter['param'] = $name[1];
$filter['checkbox'] = false;
$filter['last_triggered'] = $line["last_triggered"] ? TimeHelper::make_local_datetime($line["last_triggered"], false) : null;
$filter['enabled'] = sql_bool_to_bool($line["enabled"]);
$filter['rules'] = $this->_get_rules_list($line['id']);
if (!$filter_search || $match_ok) {
array_push($folder['items'], $filter);
}
}
$root['items'] = $folder['items'];
$fl = [
'identifier' => 'id',
'label' => 'name',
'items' => [$root]
];
$fl = array();
$fl['identifier'] = 'id';
$fl['label'] = 'name';
$fl['items'] = array($root);
print json_encode($fl);
}
@@ -449,7 +422,7 @@ class Pref_Filters extends Handler_Protected {
/**
* @param array<string, mixed>|null $rule
*/
private function _get_rule_name(?array $rule = null, string $format = 'html'): string {
private function _get_rule_name(?array $rule = null): string {
if (!$rule) $rule = json_decode(clean($_REQUEST["rule"]), true);
$feeds = $rule["feed_id"];
@@ -459,12 +432,12 @@ class Pref_Filters extends Handler_Protected {
foreach ($feeds as $feed_id) {
if (str_starts_with($feed_id, "CAT:")) {
if (strpos($feed_id, "CAT:") === 0) {
$feed_id = (int)substr($feed_id, 4);
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id, $_SESSION['uid']));
array_push($feeds_fmt, Feeds::_get_cat_title($feed_id));
} else {
if ($feed_id)
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id, $_SESSION['uid']));
array_push($feeds_fmt, Feeds::_get_title((int)$feed_id));
else
array_push($feeds_fmt, __("All feeds"));
}
@@ -484,45 +457,49 @@ class Pref_Filters extends Handler_Protected {
$inverse = isset($rule["inverse"]) ? "inverse" : "";
if ($format === 'html')
return "<span class='filterRule $inverse'>" .
T_sprintf("%s on %s in %s %s", htmlspecialchars($rule["reg_exp"]),
"<span class='field'>$filter_type</span>", "<span class='feed'>$feed</span>", isset($rule["inverse"]) ? __("(inverse)") : "") . "</span>";
else
return T_sprintf("%s on %s in %s %s", $rule["reg_exp"],
$filter_type, $feed, isset($rule["inverse"]) ? __("(inverse)") : "");
}
return "<span class='filterRule $inverse'>" .
T_sprintf("%s on %s in %s %s", htmlspecialchars($rule["reg_exp"]),
"<span class='field'>$filter_type</span>", "<span class='feed'>$feed</span>", isset($rule["inverse"]) ? __("(inverse)") : "") . "</span>";
}
function printRuleName(): void {
print $this->_get_rule_name(json_decode(clean($_REQUEST["rule"]), true));
}
/**
* @param array<string,mixed>|ArrayAccess<string, mixed>|null $action
* @param array<string, mixed>|null $action
*/
private function _get_action_name(array|ArrayAccess|null $action = null): string {
private function _get_action_name(?array $action = null): string {
if (!$action) {
return "";
}
$title = __($this->action_descriptions[$action['action_id']]['description']) ??
T_sprintf('Unknown action: %d', $action['action_id']);
$sth = $this->pdo->prepare("SELECT description FROM
ttrss_filter_actions WHERE id = ?");
$sth->execute([(int)$action["action_id"]]);
if ($action["action_id"] == self::ACTION_PLUGIN) {
list ($pfclass, $pfaction) = explode(":", $action["action_param"]);
$title = "";
$filter_actions = PluginHost::getInstance()->get_filter_actions();
if ($row = $sth->fetch()) {
foreach ($filter_actions as $fclass => $factions) {
foreach ($factions as $faction) {
if ($pfaction == $faction["action"] && $pfclass == $fclass) {
$title .= ": " . $fclass . ": " . $faction["description"];
break;
$title = __($row["description"]);
if ($action["action_id"] == self::ACTION_PLUGIN) {
list ($pfclass, $pfaction) = explode(":", $action["action_param"]);
$filter_actions = PluginHost::getInstance()->get_filter_actions();
foreach ($filter_actions as $fclass => $factions) {
foreach ($factions as $faction) {
if ($pfaction == $faction["action"] && $pfclass == $fclass) {
$title .= ": " . $fclass . ": " . $faction["description"];
break;
}
}
}
} else if (in_array($action["action_id"], self::PARAM_ACTIONS)) {
$title .= ": " . $action["action_param"];
}
} else if (in_array($action["action_id"], self::PARAM_ACTIONS)) {
$title .= ": " . $action["action_param"];
}
return $title;
@@ -564,25 +541,6 @@ class Pref_Filters extends Handler_Protected {
$sth->execute([...$ids, $_SESSION['uid']]);
}
private function _clone_rules_and_actions(int $filter_id, ?int $src_filter_id = null): bool {
$sth = $this->pdo->prepare('INSERT INTO ttrss_filters2_rules
(filter_id, reg_exp, inverse, filter_type, feed_id, cat_id, match_on, cat_filter)
SELECT :filter_id, reg_exp, inverse, filter_type, feed_id, cat_id, match_on, cat_filter
FROM ttrss_filters2_rules
WHERE filter_id = :src_filter_id');
if (!$sth->execute(['filter_id' => $filter_id, 'src_filter_id' => $src_filter_id]))
return false;
$sth = $this->pdo->prepare('INSERT INTO ttrss_filters2_actions
(filter_id, action_id, action_param)
SELECT :filter_id, action_id, action_param
FROM ttrss_filters2_actions
WHERE filter_id = :src_filter_id');
return $sth->execute(['filter_id' => $filter_id, 'src_filter_id' => $src_filter_id]);
}
private function _save_rules_and_actions(int $filter_id): void {
$sth = $this->pdo->prepare("DELETE FROM ttrss_filters2_rules WHERE filter_id = ?");
@@ -665,24 +623,11 @@ class Pref_Filters extends Handler_Protected {
}
}
/**
* @param null|array{'src_filter_id': int, 'title': string, 'enabled': 0|1, 'match_any_rule': 0|1, 'inverse': 0|1} $props
*/
function add(?array $props = null): void {
if ($props === null) {
$src_filter_id = null;
$title = clean($_REQUEST['title']);
$enabled = checkbox_to_sql_bool($_REQUEST['enabled'] ?? false);
$match_any_rule = checkbox_to_sql_bool($_REQUEST['match_any_rule'] ?? false);
$inverse = checkbox_to_sql_bool($_REQUEST['inverse'] ?? false);
} else {
// see checkbox_to_sql_bool() for 0 vs false justification
$src_filter_id = $props['src_filter_id'];
$title = clean($props['title']);
$enabled = $props['enabled'];
$match_any_rule = $props['match_any_rule'];
$inverse = $props['inverse'];
}
function add(): void {
$enabled = checkbox_to_sql_bool($_REQUEST["enabled"] ?? false);
$match_any_rule = checkbox_to_sql_bool($_REQUEST["match_any_rule"] ?? false);
$title = clean($_REQUEST["title"]);
$inverse = checkbox_to_sql_bool($_REQUEST["inverse"] ?? false);
$this->pdo->beginTransaction();
@@ -690,44 +635,22 @@ class Pref_Filters extends Handler_Protected {
$sth = $this->pdo->prepare("INSERT INTO ttrss_filters2
(owner_uid, match_any_rule, enabled, title, inverse) VALUES
(?, ?, ?, ?, ?) RETURNING id");
(?, ?, ?, ?, ?)");
$sth->execute([$_SESSION['uid'], $match_any_rule, $enabled, $title, $inverse]);
$sth = $this->pdo->prepare("SELECT MAX(id) AS id FROM ttrss_filters2
WHERE owner_uid = ?");
$sth->execute([$_SESSION['uid']]);
if ($row = $sth->fetch()) {
$filter_id = $row['id'];
if ($src_filter_id === null)
$this->_save_rules_and_actions($filter_id);
else
$this->_clone_rules_and_actions($filter_id, $src_filter_id);
$this->_save_rules_and_actions($filter_id);
}
$this->pdo->commit();
}
function clone(): void {
/** @var array<int, int> */
$src_filter_ids = array_map('intval', array_filter(explode(',', clean($_REQUEST['ids'] ?? ''))));
$new_filter_title = count($src_filter_ids) === 1 ? clean($_REQUEST['new_filter_title'] ?? null) : null;
$src_filters = ORM::for_table('ttrss_filters2')
->where('owner_uid', $_SESSION['uid'])
->where_id_in($src_filter_ids)
->find_many();
foreach ($src_filters as $src_filter) {
// see checkbox_to_sql_bool() for 0+1 justification
$this->add([
'src_filter_id' => $src_filter->id,
'title' => $new_filter_title ?? sprintf(__('Clone of %s'), $src_filter->title),
'enabled' => 0,
'match_any_rule' => $src_filter->match_any_rule ? 1 : 0,
'inverse' => $src_filter->inverse ? 1 : 0,
]);
}
}
function index(): void {
if (array_key_exists("search", $_REQUEST)) {
$filter_search = clean($_REQUEST["search"]);
@@ -762,8 +685,6 @@ class Pref_Filters extends Handler_Protected {
<button dojoType="dijit.form.Button" onclick="return Filters.edit()">
<?= __('Create filter') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').cloneSelectedFilters()">
<?= __('Clone') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').joinSelectedFilters()">
<?= __('Combine') ?></button>
<button dojoType="dijit.form.Button" onclick="return dijit.byId('filterTree').removeSelectedFilters()">
@@ -793,6 +714,7 @@ class Pref_Filters extends Handler_Protected {
}
function editrule(): void {
/** @var array<int, int|string> */
$feed_ids = explode(",", clean($_REQUEST["ids"]));
print json_encode([
@@ -801,86 +723,58 @@ class Pref_Filters extends Handler_Protected {
}
/**
* @return array{'title': string, 'title_summary': string, 'actions_summary': string}
* @return array<int, string>
*/
private function _get_details(int $id): array {
private function _get_name(int $id): array {
$filter = ORM::for_table("ttrss_filters2")
->table_alias('f')
->select('f.title')
->select('f.match_any_rule')
->select('f.inverse')
->select_expr('COUNT(DISTINCT r.id)', 'num_rules')
->select_expr('COUNT(DISTINCT a.id)', 'num_actions')
->left_outer_join('ttrss_filters2_rules', ['r.filter_id', '=', 'f.id'], 'r')
->left_outer_join('ttrss_filters2_actions', ['a.filter_id', '=', 'f.id'], 'a')
->where('f.id', $id)
->group_by_expr('f.title, f.match_any_rule, f.inverse')
->find_one();
$sth = $this->pdo->prepare(
"SELECT title,match_any_rule,f.inverse AS inverse,COUNT(DISTINCT r.id) AS num_rules,COUNT(DISTINCT a.id) AS num_actions
FROM ttrss_filters2 AS f LEFT JOIN ttrss_filters2_rules AS r
ON (r.filter_id = f.id)
LEFT JOIN ttrss_filters2_actions AS a
ON (a.filter_id = f.id) WHERE f.id = ? GROUP BY f.title, f.match_any_rule, f.inverse");
$sth->execute([$id]);
$title = $filter->title ?: __('[No caption]');
$title_summary = [
sprintf(
_ngettext("%s (%d rule)", "%s (%d rules)", (int) $filter->num_rules),
$title,
$filter->num_rules)];
if ($row = $sth->fetch()) {
if ($filter->match_any_rule) array_push($title_summary, __("matches any rule"));
if ($filter->inverse) array_push($title_summary, __("inverse"));
$title = $row["title"];
$num_rules = $row["num_rules"];
$num_actions = $row["num_actions"];
$match_any_rule = $row["match_any_rule"];
$inverse = $row["inverse"];
$actions = ORM::for_table("ttrss_filters2_actions")
->where("filter_id", $id)
->order_by_asc('id')
->find_many();
if (!$title) $title = __("[No caption]");
/** @var array<string> $actions_summary */
$actions_summary = [];
$cumulative_score = 0;
$title = sprintf(_ngettext("%s (%d rule)", "%s (%d rules)", (int) $num_rules), $title, $num_rules);
// we're going to show a summary adjustment so we skip individual score action descriptions here
foreach ($actions as $action) {
if ($action->action_id == self::ACTION_SCORE) {
$cumulative_score += (int) $action->action_param;
continue;
$sth = $this->pdo->prepare("SELECT * FROM ttrss_filters2_actions
WHERE filter_id = ? ORDER BY id LIMIT 1");
$sth->execute([$id]);
$actions = "";
if ($line = $sth->fetch()) {
$actions = $this->_get_action_name($line);
$num_actions -= 1;
}
array_push($actions_summary, "<li>" . self::_get_action_name($action) . "</li>");
if ($match_any_rule) $title .= " (" . __("matches any rule") . ")";
if ($inverse) $title .= " (" . __("inverse") . ")";
if ($num_actions > 0)
$actions = sprintf(_ngettext("%s (+%d action)", "%s (+%d actions)", (int) $num_actions), $actions, $num_actions);
return [$title, $actions];
}
// inject a fake action description using cumulative filter score
if ($cumulative_score != 0) {
array_unshift($actions_summary,
"<li>" . self::_get_action_name(["action_id" => self::ACTION_SCORE, "action_param" => $cumulative_score]) . "</li>");
}
if (count($actions_summary) > self::MAX_ACTIONS_TO_DISPLAY) {
$actions_not_shown = count($actions_summary) - self::MAX_ACTIONS_TO_DISPLAY;
$actions_summary = array_slice($actions_summary, 0, self::MAX_ACTIONS_TO_DISPLAY);
array_push($actions_summary,
"<li class='text-muted'><em>" . sprintf(_ngettext("(+%d action)", "(+%d actions)", $actions_not_shown), $actions_not_shown)) . "</em></li>";
}
return [
'title' => $title,
'title_summary' => implode(', ', $title_summary),
'actions_summary' => implode('', $actions_summary),
];
return [];
}
function join(): void {
/** @var array<int, int> */
$ids = array_map("intval", explode(",", clean($_REQUEST["ids"])));
// fail early if any provided filter IDs aren't owned by the current user
$unowned_filter_count = ORM::for_table('ttrss_filters2')
->where_in('id', $ids)
->where_not_equal('owner_uid', $_SESSION['uid'])
->count();
if ($unowned_filter_count)
return;
if (count($ids) > 1) {
$base_id = array_shift($ids);
$ids_qmarks = arr_qmarks($ids);
@@ -983,7 +877,7 @@ class Pref_Filters extends Handler_Protected {
}
}
if (Prefs::get(Prefs::ENABLE_FEED_CATS, $_SESSION['uid'], $_SESSION['profile'] ?? null)) {
if (get_pref(Prefs::ENABLE_FEED_CATS)) {
if (!$root_id) $root_id = null;

View File

@@ -1,4 +1,6 @@
<?php
use chillerlan\QRCode;
class Pref_Prefs extends Handler_Protected {
/** @var array<Prefs::*, array<int, string>> */
private array $pref_help = [];
@@ -175,7 +177,7 @@ class Pref_Prefs extends Handler_Protected {
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
if (implements_interface($authenticator, "IAuthModule2")) {
/** @var Plugin&IAuthModule2 $authenticator */
/** @var IAuthModule2 $authenticator */
print format_notice($authenticator->change_password($_SESSION["uid"], $old_pw, $new_pw));
} else {
print "ERROR: ".format_error("Function not supported by authentication module.");
@@ -183,8 +185,6 @@ class Pref_Prefs extends Handler_Protected {
}
function saveconfig(): void {
$profile = $_SESSION['profile'] ?? null;
$boolean_prefs = explode(",", clean($_POST["boolean_prefs"]));
foreach ($boolean_prefs as $pref) {
@@ -199,7 +199,7 @@ class Pref_Prefs extends Handler_Protected {
switch ($pref_name) {
case Prefs::DIGEST_PREFERRED_TIME:
if (Prefs::get(Prefs::DIGEST_PREFERRED_TIME, $_SESSION['uid']) != $value) {
if (get_pref(Prefs::DIGEST_PREFERRED_TIME) != $value) {
$sth = $this->pdo->prepare("UPDATE ttrss_users SET
last_digest_sent = NULL WHERE id = ?");
@@ -207,10 +207,14 @@ class Pref_Prefs extends Handler_Protected {
}
break;
case Prefs::USER_CSS_THEME:
case Prefs::USER_LANGUAGE:
if (!$need_reload) $need_reload = Prefs::get($pref_name, $_SESSION['uid'], $profile) != $value;
if (!$need_reload) $need_reload = $_SESSION["language"] != $value;
break;
case Prefs::USER_CSS_THEME:
if (!$need_reload) $need_reload = get_pref($pref_name) != $value;
break;
case Prefs::BLACKLISTED_TAGS:
$cats = FeedItem_Common::normalize_categories(explode(",", $value));
asort($cats);
@@ -219,7 +223,7 @@ class Pref_Prefs extends Handler_Protected {
}
if (Prefs::is_valid($pref_name)) {
Prefs::set($pref_name, $value, $_SESSION['uid'], $profile);
Prefs::set($pref_name, $value, $_SESSION["uid"], $_SESSION["profile"] ?? null);
}
}
@@ -461,7 +465,7 @@ class Pref_Prefs extends Handler_Protected {
} else {
print "<img src='{$this->_get_otp_qrcode_img()}' style='width: 25%'>";
print "<img src=".($this->_get_otp_qrcode_img()).">";
print_notice("You will need to generate app passwords for API clients if you enable OTP.");
@@ -591,6 +595,10 @@ class Pref_Prefs extends Handler_Protected {
continue;
}
if ($pref_name == Prefs::DEFAULT_SEARCH_LANGUAGE && Config::get(Config::DB_TYPE) != "pgsql") {
continue;
}
if (isset($prefs_available[$pref_name])) {
$item = $prefs_available[$pref_name];
@@ -647,7 +655,7 @@ class Pref_Prefs extends Handler_Protected {
<?= \Controls\button_tag(\Controls\icon("palette") . " " . __("Customize"), "",
["onclick" => "Helpers.Prefs.customizeCSS()"]) ?>
<?= \Controls\button_tag(\Controls\icon("open_in_new") . " " . __("More themes..."), "",
["class" => "alt-info", "onclick" => "window.open(\"https://github.com/tt-rss/tt-rss/wiki/Themes\")"]) ?>
["class" => "alt-info", "onclick" => "window.open(\"https://tt-rss.org/Themes/\")"]) ?>
<?php
@@ -713,7 +721,7 @@ class Pref_Prefs extends Handler_Protected {
print \Controls\button_tag(\Controls\icon("help") . " " . __("More info..."), "", [
"class" => "alt-info",
"onclick" => "window.open('https://github.com/tt-rss/tt-rss/wiki/SSL-Certificate-Authentication')"]);
"onclick" => "window.open('https://tt-rss.org/wiki/SSL%20Certificate%20Authentication')"]);
} else if ($pref_name == Prefs::DIGEST_PREFERRED_TIME) {
print "<input dojoType=\"dijit.form.ValidationTextBox\"
@@ -794,7 +802,7 @@ class Pref_Prefs extends Handler_Protected {
function getPluginsList(): void {
$system_enabled = array_map("trim", explode(",", (string)Config::get(Config::PLUGINS)));
$user_enabled = array_map('trim', explode(',', Prefs::get(Prefs::_ENABLED_PLUGINS, $_SESSION['uid'], $_SESSION['profile'] ?? null)));
$user_enabled = array_map("trim", explode(",", get_pref(Prefs::_ENABLED_PLUGINS)));
$tmppluginhost = new PluginHost();
$tmppluginhost->load_all($tmppluginhost::KIND_ALL, $_SESSION["uid"], true);
@@ -878,7 +886,7 @@ class Pref_Prefs extends Handler_Protected {
print_error(
T_sprintf("The following plugins use per-feed content hooks. This may cause excessive data usage and origin server load resulting in a ban of your instance: <b>%s</b>" ,
implode(", ", array_map(fn($plugin) => get_class($plugin), $feed_handlers))
) . " (<a href='https://github.com/tt-rss/tt-rss/wiki/Feed-Handler-Plugins' target='_blank'>".__("More info...")."</a>)"
) . " (<a href='https://tt-rss.org/wiki/FeedHandlerPlugins' target='_blank'>".__("More info...")."</a>)"
);
}
?> -->
@@ -890,7 +898,7 @@ class Pref_Prefs extends Handler_Protected {
</div>
<div dojoType="dijit.layout.ContentPane" region="bottom">
<button dojoType='dijit.form.Button' class="alt-info pull-right" onclick='window.open("https://github.com/tt-rss/tt-rss/wiki/Plugins")'>
<button dojoType='dijit.form.Button' class="alt-info pull-right" onclick='window.open("https://tt-rss.org/Plugins/")'>
<i class='material-icons'>help</i>
<?= __("More info") ?>
</button>
@@ -968,7 +976,7 @@ class Pref_Prefs extends Handler_Protected {
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
/** @var Auth_Internal|null $authenticator -- this is only here to make check_password() visible to static analyzer */
/** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */
if ($authenticator->check_password($_SESSION["uid"], $password)) {
if (UserHelper::enable_otp($_SESSION["uid"], $otp_check)) {
print "OK";
@@ -983,7 +991,7 @@ class Pref_Prefs extends Handler_Protected {
function otpdisable(): void {
$password = clean($_REQUEST["password"]);
/** @var Auth_Internal|null $authenticator -- this is only here to make check_password() visible to static analyzer */
/** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
if ($authenticator->check_password($_SESSION["uid"], $password)) {
@@ -1023,7 +1031,7 @@ class Pref_Prefs extends Handler_Protected {
function setplugins(): void {
$plugins = array_filter($_REQUEST["plugins"] ?? [], 'clean');
Prefs::set(Prefs::_ENABLED_PLUGINS, implode(',', $plugins), $_SESSION['uid'], $_SESSION['profile'] ?? null);
set_pref(Prefs::_ENABLED_PLUGINS, implode(",", $plugins));
}
function _get_plugin_version(Plugin $plugin): string {
@@ -1087,19 +1095,9 @@ class Pref_Prefs extends Handler_Protected {
2 => ["pipe", "w"], // STDERR
];
// TODO: clean up handling main+master
$proc = proc_open("git fetch -q origin -a && git log HEAD..origin/main --oneline", $descriptorspec, $pipes, $plugin_dir);
$proc = proc_open("git fetch -q origin -a && git log HEAD..origin/master --oneline", $descriptorspec, $pipes, $plugin_dir);
if (is_resource($proc)) {
$rv = [
"stdout" => stream_get_contents($pipes[1]),
"stderr" => stream_get_contents($pipes[2]),
"git_status" => proc_close($proc),
];
$rv["need_update"] = !empty($rv["stdout"]);
} else {
$proc = proc_open("git fetch -q origin -a && git log HEAD..origin/master --oneline", $descriptorspec, $pipes, $plugin_dir);
$rv = [
"stdout" => stream_get_contents($pipes[1]),
"stderr" => stream_get_contents($pipes[2]),
@@ -1129,25 +1127,12 @@ class Pref_Prefs extends Handler_Protected {
2 => ["pipe", "w"], // STDERR
];
// TODO: clean up handling main+master
$proc = proc_open("git fetch origin -a && git log HEAD..origin/main --oneline && git pull --ff-only origin main", $descriptorspec, $pipes, $plugin_dir);
$proc = proc_open("git fetch origin -a && git log HEAD..origin/master --oneline && git pull --ff-only origin master", $descriptorspec, $pipes, $plugin_dir);
if (is_resource($proc)) {
$rv = [
'stdout' => stream_get_contents($pipes[1]),
'stderr' => stream_get_contents($pipes[2]),
'git_status' => proc_close($proc),
];
} else {
$proc = proc_open("git fetch origin -a && git log HEAD..origin/master --oneline && git pull --ff-only origin master", $descriptorspec, $pipes, $plugin_dir);
if (is_resource($proc)) {
$rv = [
'stdout' => stream_get_contents($pipes[1]),
'stderr' => stream_get_contents($pipes[2]),
'git_status' => proc_close($proc),
];
}
$rv["stdout"] = stream_get_contents($pipes[1]);
$rv["stderr"] = stream_get_contents($pipes[2]);
$rv["git_status"] = proc_close($proc);
}
}
@@ -1297,12 +1282,9 @@ class Pref_Prefs extends Handler_Protected {
* @return array<int, array{'name': string, 'description': string, 'topics': array<int, string>, 'html_url': string, 'clone_url': string, 'last_update': string}>
*/
private function _get_available_plugins(): array {
// TODO: Get this working again. https://tt-rss.org/plugins.json won't exist after 2025-11-01 (probably).
if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) {
// $content = json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true);
$content = false;
$content = json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true);
/** @phpstan-ignore if.alwaysFalse (intentionally disabling for now) */
if ($content) {
return $content;
}
@@ -1334,8 +1316,14 @@ class Pref_Prefs extends Handler_Protected {
function updateLocalPlugins(): void {
if ($_SESSION["access_level"] >= UserHelper::ACCESS_LEVEL_ADMIN) {
$plugins = array_filter(explode(",", $_REQUEST["plugins"] ?? ""), "strlen");
$plugins = explode(",", $_REQUEST["plugins"] ?? "");
if ($plugins !== false) {
$plugins = array_filter($plugins, 'strlen');
}
$root_dir = Config::get_self_dir();
$rv = [];
if ($plugins) {
@@ -1368,7 +1356,7 @@ class Pref_Prefs extends Handler_Protected {
}
function customizeCSS(): void {
$value = Prefs::get(Prefs::USER_STYLESHEET, $_SESSION['uid'], $_SESSION['profile'] ?? null);
$value = get_pref(Prefs::USER_STYLESHEET);
$value = str_replace("<br/>", "\n", $value);
print json_encode(["value" => $value]);
@@ -1538,10 +1526,10 @@ class Pref_Prefs extends Handler_Protected {
<?= htmlspecialchars($pass["title"]) ?>
</td>
<td class='text-muted'>
<?= TimeHelper::make_local_datetime($pass['created']) ?>
<?= TimeHelper::make_local_datetime($pass['created'], false) ?>
</td>
<td class='text-muted'>
<?= TimeHelper::make_local_datetime($pass['last_used']) ?>
<?= TimeHelper::make_local_datetime($pass['last_used'], false) ?>
</td>
</tr>
<?php } ?>

View File

@@ -28,41 +28,6 @@ class Pref_System extends Handler_Administrative {
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();
@@ -73,11 +38,16 @@ class Pref_System extends Handler_Administrative {
}
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 => [],
};
$errno_values = [];
switch ($severity) {
case E_USER_ERROR:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR ];
break;
case E_USER_WARNING:
$errno_values = [ E_ERROR, E_USER_ERROR, E_PARSE, E_COMPILE_ERROR, E_WARNING, E_USER_WARNING, E_DEPRECATED, E_USER_DEPRECATED ];
break;
}
if (count($errno_values) > 0) {
$errno_qmarks = arr_qmarks($errno_values);
@@ -178,7 +148,7 @@ class Pref_System extends Handler_Administrative {
<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']) ?>
<?= TimeHelper::make_local_datetime($line["created_at"], false) ?>
</td>
</tr>
<?php } ?>
@@ -240,17 +210,6 @@ class Pref_System extends Handler_Administrative {
</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'))

View File

@@ -6,7 +6,7 @@ class Pref_Users extends Handler_Administrative {
function edit(): void {
$user = ORM::for_table('ttrss_users')
->select_many('id', 'login', 'access_level', 'email', 'full_name', 'otp_enabled')
->select_expr("id,login,access_level,email,full_name,otp_enabled")
->find_one((int)$_REQUEST["id"])
->as_array();
@@ -23,25 +23,32 @@ class Pref_Users extends Handler_Administrative {
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);
$sth = $this->pdo->prepare("SELECT login,
".SUBSTRING_FOR_DATE."(last_login,1,16) AS last_login,
access_level,
(SELECT COUNT(int_id) FROM ttrss_user_entries
WHERE owner_uid = id) AS stored_articles,
".SUBSTRING_FOR_DATE."(created,1,16) AS created
FROM ttrss_users
WHERE id = ?");
$sth->execute([$id]);
if ($user) {
$created = TimeHelper::make_local_datetime($user->created);
$last_login = TimeHelper::make_local_datetime($user->last_login);
if ($row = $sth->fetch()) {
$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();
$last_login = TimeHelper::make_local_datetime(
$row["last_login"], true);
$created = TimeHelper::make_local_datetime(
$row["created"], true);
$stored_articles = $row["stored_articles"];
$sth = $this->pdo->prepare("SELECT COUNT(id) as num_feeds FROM ttrss_feeds
WHERE owner_uid = ?");
$sth->execute([$id]);
$row = $sth->fetch();
$num_feeds = $row["num_feeds"];
?>
@@ -57,25 +64,31 @@ class Pref_Users extends Handler_Administrative {
<fieldset>
<label><?= __('Subscribed feeds') ?>:</label>
<?= count($user_owned_feeds) ?>
<?= $num_feeds ?>
</fieldset>
<fieldset>
<label><?= __('Stored articles') ?>:</label>
<?= $user->stored_articles ?>
<?= $stored_articles ?>
</fieldset>
<?php
$sth = $this->pdo->prepare("SELECT id,title,site_url FROM ttrss_feeds
WHERE owner_uid = ? ORDER BY title");
$sth->execute([$id]);
?>
<ul class="panel panel-scrollable list list-unstyled">
<?php foreach ($user_owned_feeds as $feed) { ?>
<?php while ($row = $sth->fetch()) { ?>
<li>
<?php
$icon_url = Feeds::_get_icon_url($feed->id, 'images/blank_icon.gif');
$icon_url = Feeds::_get_icon_url($row['id'], 'images/blank_icon.gif');
?>
<img class="icon" src="<?= htmlspecialchars($icon_url) ?>">
<a target="_blank" href="<?= htmlspecialchars($feed->site_url) ?>">
<?= htmlspecialchars($feed->title) ?>
<a target="_blank" href="<?= htmlspecialchars($row["site_url"]) ?>">
<?= htmlspecialchars($row["title"]) ?>
</a>
</li>
<?php } ?>
@@ -123,9 +136,14 @@ class Pref_Users extends Handler_Administrative {
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();
$sth = $this->pdo->prepare("DELETE FROM ttrss_tags WHERE owner_uid = ?");
$sth->execute([$id]);
$sth = $this->pdo->prepare("DELETE FROM ttrss_feeds WHERE owner_uid = ?");
$sth->execute([$id]);
$sth = $this->pdo->prepare("DELETE FROM ttrss_users WHERE id = ?");
$sth->execute([$id]);
}
}
}
@@ -264,8 +282,8 @@ class Pref_Users extends Handler_Administrative {
</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>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user["created"], false) ?></td>
<td class='text-muted'><?= TimeHelper::make_local_datetime($user["last_login"], false) ?></td>
</tr>
<?php } ?>
</table>

View File

@@ -144,12 +144,14 @@ class Prefs {
Prefs::_PREFS_MIGRATED
];
private static ?Prefs $instance = null;
/** @var Prefs|null */
private static $instance;
/** @var array<string, bool|int|string> */
private array $cache = [];
private $cache = [];
private ?PDO $pdo = null;
/** @var PDO */
private $pdo;
public static function get_instance() : Prefs {
if (self::$instance == null)
@@ -162,7 +164,10 @@ class Prefs {
return isset(self::_DEFAULTS[$pref_name]);
}
static function get_default(string $pref_name): bool|int|null|string {
/**
* @return bool|int|null|string
*/
static function get_default(string $pref_name) {
if (self::is_valid($pref_name))
return self::_DEFAULTS[$pref_name][0];
else
@@ -188,14 +193,14 @@ class Prefs {
/**
* @return array<int, array<string, bool|int|null|string>>
*/
static function get_all(int $owner_uid, ?int $profile_id = null): array {
static function get_all(int $owner_uid, ?int $profile_id = null) {
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 {
private function _get_all(int $owner_uid, ?int $profile_id = null) {
$rv = [];
$ref = new ReflectionClass(get_class($this));
@@ -242,11 +247,17 @@ class Prefs {
}
}
static function get(string $pref_name, int $owner_uid, ?int $profile_id = null): bool|int|null|string {
/**
* @return bool|int|null|string
*/
static function get(string $pref_name, int $owner_uid, ?int $profile_id = null) {
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 {
/**
* @return bool|int|null|string
*/
private function _get(string $pref_name, int $owner_uid, ?int $profile_id) {
if (isset(self::_DEFAULTS[$pref_name])) {
if (!$profile_id || in_array($pref_name, self::_PROFILE_BLACKLIST)) $profile_id = null;
@@ -287,22 +298,34 @@ class Prefs {
return isset($this->cache[$cache_key]);
}
private function _get_cache(string $pref_name, int $owner_uid, ?int $profile_id): bool|int|null|string {
/**
* @return bool|int|null|string
*/
private function _get_cache(string $pref_name, int $owner_uid, ?int $profile_id) {
$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 {
/**
* @param bool|int|string $value
*/
private function _set_cache(string $pref_name, $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 {
/**
* @param bool|int|string $value
*/
static function set(string $pref_name, $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 {
/**
* @param bool|int|string $value
*/
private function _set(string $pref_name, $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))

View File

@@ -23,7 +23,7 @@ class RPC extends Handler_Protected {
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
if(strpos($orig, "\000") !== false) { // Plural forms
$key = explode(chr(0), $orig);
$rv[$key[0]] = _ngettext($key[0], $key[1], 1); // Singular
@@ -42,9 +42,8 @@ class RPC extends Handler_Protected {
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);
set_pref($key, !get_pref($key));
$value = get_pref($key);
print json_encode(array("param" =>$key, "value" => $value));
}
@@ -54,7 +53,7 @@ class RPC extends Handler_Protected {
$key = clean($_REQUEST['key']);
$value = $_REQUEST['value'];
Prefs::set($key, $value, $_SESSION['uid'], $_SESSION['profile'] ?? null, $key != 'USER_STYLESHEET');
set_pref($key, $value, $_SESSION["uid"], $key != 'USER_STYLESHEET');
print json_encode(array("param" =>$key, "value" => $value));
}
@@ -69,8 +68,6 @@ class RPC extends Handler_Protected {
$sth->execute([$mark, $id, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, [$id]);
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
@@ -82,6 +79,8 @@ class RPC extends Handler_Protected {
WHERE ref_id IN ($ids_qmarks) AND owner_uid = ?");
$sth->execute([...$ids, $_SESSION['uid']]);
Article::_purge_orphans();
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
@@ -95,8 +94,6 @@ class RPC extends Handler_Protected {
$sth->execute([$pub, $id, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, [$id]);
print json_encode(array("message" => "UPDATE_COUNTERS"));
}
@@ -109,6 +106,8 @@ class RPC extends Handler_Protected {
}
function getAllCounters(): void {
$span = Tracer::start(__METHOD__);
@$seq = (int) $_REQUEST['seq'];
$feed_id_count = (int) ($_REQUEST["feed_id_count"] ?? -1);
@@ -127,8 +126,7 @@ class RPC extends Handler_Protected {
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 = is_array($feed_ids) && !get_pref(Prefs::DISABLE_CONDITIONAL_COUNTERS) ?
Counters::get_conditional($feed_ids, $label_ids) : Counters::get_all();
$reply = [
@@ -136,6 +134,7 @@ class RPC extends Handler_Protected {
'seq' => $seq
];
$span->end();
print json_encode($reply);
}
@@ -177,6 +176,8 @@ class RPC extends Handler_Protected {
}
function sanityCheck(): void {
$span = Tracer::start(__METHOD__);
$_SESSION["hasSandbox"] = self::_param_to_bool($_REQUEST["hasSandbox"] ?? false);
$_SESSION["clientTzOffset"] = clean($_REQUEST["clientTzOffset"]);
@@ -208,6 +209,8 @@ class RPC extends Handler_Protected {
} else {
print Errors::to_json($error, $error_params);
}
$span->end();
}
/*function completeLabels() {
@@ -245,11 +248,110 @@ class RPC extends Handler_Protected {
function setWidescreen(): void {
$wide = (int) clean($_REQUEST["wide"]);
Prefs::set(Prefs::WIDESCREEN_MODE, $wide, $_SESSION['uid'], $_SESSION['profile'] ?? null);
set_pref(Prefs::WIDESCREEN_MODE, $wide);
print json_encode(["wide" => $wide]);
}
static function updaterandomfeed_real(): void {
$span = Tracer::start(__METHOD__);
$default_interval = (int) Prefs::get_default(Prefs::DEFAULT_UPDATE_INTERVAL);
// Test if the feed need a update (update interval exceded).
if (Config::get(Config::DB_TYPE) == "pgsql") {
$update_limit_qpart = "AND ((
update_interval = 0
AND (p.value IS NULL OR p.value != '-1')
AND last_updated < NOW() - CAST((COALESCE(p.value, '$default_interval') || ' minutes') AS INTERVAL)
) OR (
update_interval > 0
AND last_updated < NOW() - CAST((update_interval || ' minutes') AS INTERVAL)
) OR (
update_interval >= 0
AND (p.value IS NULL OR p.value != '-1')
AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
))";
} else {
$update_limit_qpart = "AND ((
update_interval = 0
AND (p.value IS NULL OR p.value != '-1')
AND last_updated < DATE_SUB(NOW(), INTERVAL CONVERT(COALESCE(p.value, '$default_interval'), SIGNED INTEGER) MINUTE)
) OR (
update_interval > 0
AND last_updated < DATE_SUB(NOW(), INTERVAL update_interval MINUTE)
) OR (
update_interval >= 0
AND (p.value IS NULL OR p.value != '-1')
AND (last_updated = '1970-01-01 00:00:00' OR last_updated IS NULL)
))";
}
// Test if feed is currently being updated by another process.
if (Config::get(Config::DB_TYPE) == "pgsql") {
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < NOW() - INTERVAL '5 minutes')";
} else {
$updstart_thresh_qpart = "AND (last_update_started IS NULL OR last_update_started < DATE_SUB(NOW(), INTERVAL 5 MINUTE))";
}
$random_qpart = Db::sql_random_function();
$pdo = Db::pdo();
// we could be invoked from public.php with no active session
if (!empty($_SESSION["uid"])) {
$owner_check_qpart = "AND f.owner_uid = ".$pdo->quote($_SESSION["uid"]);
} else {
$owner_check_qpart = "";
}
$query = "SELECT f.feed_url,f.id
FROM
ttrss_feeds f, ttrss_users u LEFT JOIN ttrss_user_prefs2 p ON
(p.owner_uid = u.id AND profile IS NULL AND pref_name = 'DEFAULT_UPDATE_INTERVAL')
WHERE
f.owner_uid = u.id AND
u.access_level NOT IN (".sprintf("%d, %d", UserHelper::ACCESS_LEVEL_DISABLED, UserHelper::ACCESS_LEVEL_READONLY).")
$owner_check_qpart
$update_limit_qpart
$updstart_thresh_qpart
ORDER BY $random_qpart LIMIT 30";
$res = $pdo->query($query);
$num_updated = 0;
$tstart = time();
while ($line = $res->fetch()) {
$feed_id = $line["id"];
if (time() - $tstart < ini_get("max_execution_time") * 0.7) {
RSSUtils::update_rss_feed($feed_id, true);
++$num_updated;
} else {
break;
}
}
// Purge orphans and cleanup tags
Article::_purge_orphans();
//cleanup_tags(14, 50000);
if ($num_updated > 0) {
print json_encode(array("message" => "UPDATE_COUNTERS",
"num_updated" => $num_updated));
} else {
print json_encode(array("message" => "NOTHING_TO_UPDATE"));
}
$span->end();
}
function updaterandomfeed(): void {
self::updaterandomfeed_real();
}
/**
* @param array<int, int> $ids
*/
@@ -272,8 +374,6 @@ class RPC extends Handler_Protected {
}
$sth->execute([...$ids, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_MARK_TOGGLED, $ids);
}
/**
@@ -298,11 +398,11 @@ class RPC extends Handler_Protected {
}
$sth->execute([...$ids, $_SESSION['uid']]);
PluginHost::getInstance()->run_hooks(PluginHost::HOOK_ARTICLES_PUBLISH_TOGGLED, $ids);
}
function log(): void {
$span = Tracer::start(__METHOD__);
$msg = clean($_REQUEST['msg'] ?? "");
$file = basename(clean($_REQUEST['file'] ?? ""));
$line = (int) clean($_REQUEST['line'] ?? 0);
@@ -314,9 +414,13 @@ class RPC extends Handler_Protected {
echo json_encode(array("message" => "HOST_ERROR_LOGGED"));
}
$span->end();
}
function checkforupdates(): void {
$span = Tracer::start(__METHOD__);
$rv = ["changeset" => [], "plugins" => []];
$version = Config::get_version(false);
@@ -324,12 +428,9 @@ class RPC extends Handler_Protected {
$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;
$content = @UrlHelper::fetch(["url" => "https://tt-rss.org/version.json"]);
/** @phpstan-ignore if.alwaysFalse (intentionally disabling for now) */
if ($content) {
$content = json_decode($content, true);
@@ -345,6 +446,8 @@ class RPC extends Handler_Protected {
$rv["plugins"] = Pref_Prefs::_get_updated_plugins();
}
$span->end();
print json_encode($rv);
}
@@ -352,7 +455,8 @@ class RPC extends Handler_Protected {
* @return array<string, mixed>
*/
private function _make_init_params(): array {
$profile = $_SESSION['profile'] ?? null;
$span = Tracer::start(__METHOD__);
$params = array();
foreach ([Prefs::ON_CATCHUP_SHOW_NEXT_FEED, Prefs::HIDE_READ_FEEDS,
@@ -361,21 +465,21 @@ class RPC extends Handler_Protected {
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[strtolower($param)] = (int) get_pref($param);
}
$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["default_view_mode"] = get_pref(Prefs::_DEFAULT_VIEW_MODE);
$params["default_view_limit"] = (int) get_pref(Prefs::_DEFAULT_VIEW_LIMIT);
$params["default_view_order_by"] = get_pref(Prefs::_DEFAULT_VIEW_ORDER_BY);
$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);
$theme = get_pref(Prefs::USER_CSS_THEME);
$params["theme"] = theme_exists($theme) ? $theme : "";
$params["plugins"] = implode(", ", PluginHost::getInstance()->get_plugin_names());
@@ -397,13 +501,16 @@ class RPC extends Handler_Protected {
$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["widescreen"] = (int) get_pref(Prefs::WIDESCREEN_MODE);
$params['simple_update'] = Config::get(Config::SIMPLE_UPDATE_MODE);
$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"]);
$span->end();
return $params;
}
@@ -423,6 +530,8 @@ class RPC extends Handler_Protected {
* @return array<string, mixed>
*/
static function _make_runtime_info(): array {
$span = Tracer::start(__METHOD__);
$data = array();
$pdo = Db::pdo();
@@ -437,16 +546,21 @@ class RPC extends Handler_Protected {
$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['cdm_expanded'] = get_pref(Prefs::CDM_EXPANDED);
$data["labels"] = Labels::get_all($_SESSION["uid"]);
if (Config::get(Config::LOG_DESTINATION) == 'sql' && $_SESSION['access_level'] >= UserHelper::ACCESS_LEVEL_ADMIN) {
if (Config::get(Config::DB_TYPE) == 'pgsql') {
$log_interval = "created_at > NOW() - interval '1 hour'";
} else {
$log_interval = "created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR)";
}
$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
$log_interval 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();
@@ -483,6 +597,8 @@ class RPC extends Handler_Protected {
}
}
$span->end();
return $data;
}
@@ -682,7 +798,7 @@ class RPC extends Handler_Protected {
if (!empty($omap[$action])) {
foreach ($omap[$action] as $sequence) {
if (str_contains($sequence, "|")) {
if (strpos($sequence, "|") !== false) {
$sequence = substr($sequence,
strpos($sequence, "|")+1,
strlen($sequence));
@@ -693,11 +809,16 @@ class RPC extends Handler_Protected {
if (strlen($keys[$i]) > 1) {
$tmp = '';
foreach (str_split($keys[$i]) as $c) {
$tmp .= match ($c) {
'*' => __('Shift') . '+',
'^' => __('Ctrl') . '+',
default => $c,
};
switch ($c) {
case '*':
$tmp .= __('Shift') . '+';
break;
case '^':
$tmp .= __('Ctrl') . '+';
break;
default:
$tmp .= $c;
}
}
$keys[$i] = $tmp;
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,6 @@ class Sanitizer {
$entries = $xpath->query('//*');
foreach ($entries as $entry) {
/** @var DOMElement $entry */
if (!in_array($entry->nodeName, $allowed_elements)) {
$entry->parentNode->removeChild($entry);
}
@@ -20,11 +18,11 @@ class Sanitizer {
foreach ($entry->attributes as $attr) {
if (str_starts_with($attr->nodeName, 'on')) {
if (strpos($attr->nodeName, 'on') === 0) {
array_push($attrs_to_remove, $attr);
}
if (str_starts_with($attr->nodeName, 'data-')) {
if (strpos($attr->nodeName, "data-") === 0) {
array_push($attrs_to_remove, $attr);
}
@@ -59,76 +57,18 @@ class Sanitizer {
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 {
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) {
$span = OpenTelemetry\API\Trace\Span::getCurrent();
$span->addEvent("Sanitizer::sanitize");
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();
@@ -141,7 +81,6 @@ class Sanitizer {
$entries = $xpath->query('(//a[@href]|//img[@src]|//source[@srcset|@src]|//video[@poster])');
/** @var DOMElement $entry */
foreach ($entries as $entry) {
if ($entry->hasAttribute('href')) {
@@ -178,7 +117,8 @@ class Sanitizer {
}
if ($entry->hasAttribute('src') &&
($owner && Prefs::get(Prefs::STRIP_IMAGES, $owner, $profile)) || $force_remove_images || ($_SESSION['bw_limit'] ?? false)) {
($owner && get_pref(Prefs::STRIP_IMAGES, $owner)) || $force_remove_images || ($_SESSION["bw_limit"] ?? false)) {
$p = $doc->createElement('p');
$a = $doc->createElement('a');
@@ -203,8 +143,6 @@ class Sanitizer {
}
$entries = $xpath->query('//iframe');
/** @var DOMElement $entry */
foreach ($entries as $entry) {
if (!self::iframe_whitelisted($entry)) {
$entry->setAttribute('sandbox', 'allow-scripts');
@@ -256,8 +194,34 @@ class Sanitizer {
$div->appendChild($entry);
}
if (is_array($highlight_words))
self::highlight_words($doc, $xpath, $highlight_words);
if (is_array($highlight_words)) {
foreach ($highlight_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);
}
}
}
$res = $doc->saveHTML();

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

@@ -9,7 +9,7 @@ class Sessions implements \SessionHandlerInterface {
private string $session_name;
public function __construct() {
$this->session_expire = min(2147483647 - time() - 1, Config::get(Config::SESSION_COOKIE_LIFETIME));
$this->session_expire = min(2147483647 - time() - 1, max(Config::get(Config::SESSION_COOKIE_LIFETIME), 86400));
$this->session_name = Config::get(Config::SESSION_NAME);
}
@@ -29,11 +29,11 @@ class Sessions implements \SessionHandlerInterface {
}
/**
* Extend the validity of the PHP session cookie (if it exists) and is persistent (expire > 0)
* Extend the validity of the PHP session cookie (if it exists)
* @return bool Whether the new cookie was set successfully
*/
public function extend_session(): bool {
if (isset($_COOKIE[$this->session_name]) && $this->session_expire > 0) {
if (isset($_COOKIE[$this->session_name])) {
return setcookie($this->session_name,
$_COOKIE[$this->session_name],
time() + $this->session_expire,
@@ -53,22 +53,17 @@ class Sessions implements \SessionHandlerInterface {
return true;
}
public function read(string $id): false|string {
/**
* @todo set return type to string|false, and remove ReturnTypeWillChange, when min supported is PHP 8
* @return string|false
*/
#[\ReturnTypeWillChange]
public function read(string $id) {
$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;
return base64_decode($row['data']);
}
$expire = time() + $this->session_expire;
@@ -79,12 +74,7 @@ class Sessions implements \SessionHandlerInterface {
}
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=?');
@@ -105,9 +95,11 @@ class Sessions implements \SessionHandlerInterface {
}
/**
* @todo set return type to int|false, and remove ReturnTypeWillChange, when min supported is PHP 8
* @return int|false the number of deleted sessions on success, or false on failure
*/
public function gc(int $max_lifetime): false|int {
#[\ReturnTypeWillChange]
public function gc(int $max_lifetime) {
$result = Db::pdo()->query('DELETE FROM ttrss_sessions WHERE expire < ' . time());
return $result === false ? false : $result->rowCount();
}

View File

@@ -5,7 +5,7 @@ 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, "/")) {
if (strpos($fileName, "/") === false) {
$fileName = basename($fileName);

View File

@@ -2,39 +2,29 @@
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"))
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
if (strpos((strtolower($format)), "a") === false)
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);
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
return date($format, $timestamp);
} else {
$format = Prefs::get(Prefs::LONG_DATE_FORMAT, $owner_uid, $profile);
$format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid);
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,
static function make_local_datetime(?string $timestamp, bool $long, ?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;
@@ -47,7 +37,7 @@ class TimeHelper {
# We store date in UTC internally
$dt = new DateTime($timestamp, $utc_tz);
$user_tz_string = Prefs::get(Prefs::USER_TIMEZONE, $owner_uid);
$user_tz_string = get_pref(Prefs::USER_TIMEZONE, $owner_uid);
if ($user_tz_string != 'Automatic') {
@@ -69,9 +59,9 @@ class TimeHelper {
$tz_offset, $owner_uid, $eta_min);
} else {
if ($long)
$format = Prefs::get(Prefs::LONG_DATE_FORMAT, $owner_uid, $profile);
$format = get_pref(Prefs::LONG_DATE_FORMAT, $owner_uid);
else
$format = Prefs::get(Prefs::SHORT_DATE_FORMAT, $owner_uid, $profile);
$format = get_pref(Prefs::SHORT_DATE_FORMAT, $owner_uid);
return date($format, $user_timestamp);
}

216
classes/Tracer.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
use OpenTelemetry\API\Trace\Propagation\TraceContextPropagator;
use OpenTelemetry\API\Trace\SpanContextInterface;
use OpenTelemetry\API\Trace\SpanInterface;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\TraceFlags;
use OpenTelemetry\API\Trace\TraceStateInterface;
use OpenTelemetry\Context\ContextInterface;
use OpenTelemetry\Context\ContextKey;
use OpenTelemetry\Context\ContextKeyInterface;
use OpenTelemetry\Context\ImplicitContextKeyedInterface;
use OpenTelemetry\Context\ScopeInterface;
use OpenTelemetry\Contrib\Otlp\OtlpHttpTransportFactory;
use OpenTelemetry\Contrib\Otlp\SpanExporter;
use OpenTelemetry\SDK\Common\Attribute\Attributes;
use OpenTelemetry\SDK\Resource\ResourceInfo;
use OpenTelemetry\SDK\Resource\ResourceInfoFactory;
use OpenTelemetry\SDK\Trace\Sampler\AlwaysOnSampler;
use OpenTelemetry\SDK\Trace\Sampler\ParentBased;
use OpenTelemetry\SDK\Trace\SpanProcessor\SimpleSpanProcessor;
use OpenTelemetry\SDK\Trace\TracerProvider;
use OpenTelemetry\SemConv\ResourceAttributes;
class DummyContextInterface implements ContextInterface {
/** @var DummyContextInterface */
private static $instance;
public function __construct() {
self::$instance = $this;
}
/** @phpstan-ignore-next-line */
public static function createKey(string $key): ContextKeyInterface { return new ContextKey(); }
public static function getCurrent(): ContextInterface { return self::$instance; }
public function activate(): ScopeInterface { return new DummyScopeInterface(); }
public function with(ContextKeyInterface $key, $value): ContextInterface { return $this; }
public function withContextValue(ImplicitContextKeyedInterface $value): ContextInterface { return $this; }
public function get(ContextKeyInterface $key) { return new ContextKey(); }
}
class DummySpanContextInterface implements SpanContextInterface {
/** @var DummySpanContextInterface $instance */
private static $instance;
public function __construct() {
self::$instance = $this;
}
public static function createFromRemoteParent(string $traceId, string $spanId, int $traceFlags = TraceFlags::DEFAULT, ?TraceStateInterface $traceState = null): SpanContextInterface { return self::$instance; }
public static function getInvalid(): SpanContextInterface { return self::$instance; }
public static function create(string $traceId, string $spanId, int $traceFlags = TraceFlags::DEFAULT, ?TraceStateInterface $traceState = null): SpanContextInterface { return self::$instance; }
public function getTraceId(): string { return ""; }
public function getTraceIdBinary(): string { return ""; }
public function getSpanId(): string { return ""; }
public function getSpanIdBinary(): string { return ""; }
public function getTraceFlags(): int { return 0; }
public function getTraceState(): ?TraceStateInterface { return null; }
public function isValid(): bool { return false; }
public function isRemote(): bool { return false; }
public function isSampled(): bool { return false; }
}
class DummyScopeInterface implements ScopeInterface {
public function detach(): int { return 0; }
}
class DummySpanInterface implements SpanInterface {
/** @var DummySpanInterface $instance */
private static $instance;
public function __construct() {
self::$instance = $this;
}
public static function fromContext(ContextInterface $context): SpanInterface { return self::$instance; }
public static function getCurrent(): SpanInterface { return self::$instance; }
public static function getInvalid(): SpanInterface { return self::$instance; }
public static function wrap(SpanContextInterface $spanContext): SpanInterface { return self::$instance; }
public function getContext(): SpanContextInterface { return new DummySpanContextInterface(); }
public function isRecording(): bool { return false; }
/** @phpstan-ignore-next-line */
public function setAttribute(string $key, $value): SpanInterface { return self::$instance; }
/** @phpstan-ignore-next-line */
public function setAttributes(iterable $attributes): SpanInterface { return self::$instance; }
/** @phpstan-ignore-next-line */
public function addEvent(string $name, iterable $attributes = [], ?int $timestamp = null): SpanInterface { return $this; }
/** @phpstan-ignore-next-line */
public function recordException(Throwable $exception, iterable $attributes = []): SpanInterface { return $this; }
public function updateName(string $name): SpanInterface { return $this; }
public function setStatus(string $code, ?string $description = null): SpanInterface { return $this; }
public function end(?int $endEpochNanos = null): void { }
public function activate(): ScopeInterface { return new DummyScopeInterface(); }
public function storeInContext(ContextInterface $context): ContextInterface { return new DummyContextInterface(); }
}
class Tracer {
/** @var Tracer $instance */
private static $instance = null;
/** @var OpenTelemetry\SDK\Trace\TracerProviderInterface $tracerProvider */
private $tracerProvider = null;
/** @var OpenTelemetry\API\Trace\TracerInterface $tracer */
private $tracer = null;
public function __construct() {
$OPENTELEMETRY_ENDPOINT = Config::get(Config::OPENTELEMETRY_ENDPOINT);
if ($OPENTELEMETRY_ENDPOINT) {
$transport = (new OtlpHttpTransportFactory())->create($OPENTELEMETRY_ENDPOINT, 'application/x-protobuf');
$exporter = new SpanExporter($transport);
$resource = ResourceInfoFactory::emptyResource()->merge(
ResourceInfo::create(Attributes::create(
[ResourceAttributes::SERVICE_NAME => Config::get(Config::OPENTELEMETRY_SERVICE)]
), ResourceAttributes::SCHEMA_URL),
);
$this->tracerProvider = TracerProvider::builder()
->addSpanProcessor(new SimpleSpanProcessor($exporter))
->setResource($resource)
->setSampler(new ParentBased(new AlwaysOnSampler()))
->build();
$this->tracer = $this->tracerProvider->getTracer('io.opentelemetry.contrib.php');
$context = TraceContextPropagator::getInstance()->extract(getallheaders());
$span = $this->tracer->spanBuilder($_SESSION['name'] ?? 'not logged in')
->setParent($context)
->setSpanKind(SpanKind::KIND_SERVER)
->setAttribute('php.request', json_encode($_REQUEST))
->setAttribute('php.server', json_encode($_SERVER))
->setAttribute('php.session', json_encode($_SESSION ?? []))
->startSpan();
$scope = $span->activate();
register_shutdown_function(function() use ($span, $scope) {
$span->end();
$scope->detach();
$this->tracerProvider->shutdown();
});
}
}
/**
* @param string $name
* @return OpenTelemetry\API\Trace\SpanInterface
*/
private function _start(string $name) {
if ($this->tracer != null) {
$span = $this->tracer
->spanBuilder($name)
->setSpanKind(SpanKind::KIND_SERVER)
->startSpan();
$span->activate();
} else {
$span = new DummySpanInterface();
}
return $span;
}
/**
* @param string $name
* @return OpenTelemetry\API\Trace\SpanInterface
*/
public static function start(string $name) {
return self::get_instance()->_start($name);
}
public static function get_instance() : Tracer {
if (self::$instance == null)
self::$instance = new self();
return self::$instance;
}
}

View File

@@ -65,7 +65,7 @@ class UrlHelper {
$rel_url,
string $owner_element = "",
string $owner_attribute = "",
string $content_type = ""): false|string {
string $content_type = "") {
$rel_parts = parse_url($rel_url);
@@ -86,7 +86,7 @@ class UrlHelper {
return self::validate($rel_url);
// protocol-relative URL (rare but they exist)
} else if (str_starts_with($rel_url, "//")) {
} else if (strpos($rel_url, "//") === 0) {
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) &&
@@ -118,10 +118,10 @@ class UrlHelper {
// 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'], './')) {
if (strpos($rel_parts['path'], './') === 0) {
$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'], '/')) {
} else if (strpos($rel_parts['path'], '/') !== 0) {
$rel_parts['path'] = $base_path . $rel_parts['path'];
}
@@ -136,12 +136,12 @@ class UrlHelper {
/** 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 {
static function validate(string $url, bool $extended_filtering = false) {
$url = clean($url);
# fix protocol-relative URLs
if (str_starts_with($url, "//"))
if (strpos($url, "//") === 0)
$url = "https:" . $url;
$tokens = parse_url($url);
@@ -191,15 +191,19 @@ class UrlHelper {
if (!in_array($tokens['port'] ?? '', [80, 443, '']))
return false;
if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1'
|| str_starts_with($tokens['host'], '127.'))
if (strtolower($tokens['host']) == 'localhost' || $tokens['host'] == '::1' || strpos($tokens['host'], '127.') === 0)
return false;
}
return $url;
}
static function resolve_redirects(string $url, int $timeout): false|string {
/**
* @return false|string
*/
static function resolve_redirects(string $url, int $timeout) {
$span = Tracer::start(__METHOD__);
$span->setAttribute('func.args', json_encode(func_get_args()));
$client = self::get_client();
try {
@@ -214,11 +218,14 @@ class UrlHelper {
],
]);
} catch (Exception $ex) {
$span->setAttribute('error', (string) $ex);
$span->end();
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);
$span->end();
return ($history_header ? end($history_header) : $url);
}
@@ -228,9 +235,11 @@ class UrlHelper {
*/
// 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,
public static function fetch($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 {
9: $auth_type = "basic" */) {
$span = Tracer::start(__METHOD__);
$span->setAttribute('func.args', json_encode(func_get_args()));
self::$fetch_last_error = "";
self::$fetch_last_error_code = -1;
@@ -290,18 +299,19 @@ class UrlHelper {
if (!$url) {
self::$fetch_last_error = 'Requested URL failed extended validation.';
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
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);
$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;
}
if (!$ip_addr || strpos($ip_addr, '127.') === 0) {
self::$fetch_last_error = "URL hostname failed to resolve or resolved to a loopback address ($ip_addr)";
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
}
$req_options = [
@@ -382,6 +392,8 @@ class UrlHelper {
} 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();
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
} catch (GuzzleHttp\Exception\GuzzleException $ex) {
self::$fetch_last_error = $ex->getMessage();
@@ -395,12 +407,13 @@ class UrlHelper {
// to attempt compatibility with unusual configurations.
if ($login && $pass && self::$fetch_last_error_code === 403 && $auth_type !== 'any') {
$options['auth_type'] = 'any';
$span->end();
return self::fetch($options);
}
self::$fetch_last_content_type = $ex->getResponse()->getHeaderLine('content-type');
if ($type && !str_contains(self::$fetch_last_content_type, "$type"))
if ($type && strpos(self::$fetch_last_content_type, "$type") === false)
self::$fetch_last_error_content = (string) $ex->getResponse()->getBody();
} elseif (array_key_exists('errno', $ex->getHandlerContext())) {
$errno = (int) $ex->getHandlerContext()['errno'];
@@ -411,11 +424,15 @@ class UrlHelper {
if (($errno === \CURLE_WRITE_ERROR || $errno === \CURLE_BAD_CONTENT_ENCODING) &&
$ex->getRequest()->getHeaderLine('accept-encoding') !== 'none') {
$options['encoding'] = 'none';
$span->end();
return self::fetch($options);
}
}
}
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
}
@@ -432,41 +449,44 @@ class UrlHelper {
// 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.";
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
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;
}
if (!self::$fetch_effective_ip_addr || strpos(self::$fetch_effective_ip_addr, '127.') === 0) {
self::$fetch_last_error = 'URL hostname received after redirection failed to resolve or resolved to a loopback address (' .
self::$fetch_effective_ip_addr . ')';
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
}
$body = (string) $response->getBody();
if (!$body) {
self::$fetch_last_error = 'Successful response, but no content was received.';
$span->setAttribute('error', self::$fetch_last_error);
$span->end();
return false;
}
$span->end();
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 {
public static function url_to_youtube_vid(string $url) {
$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-]+)/",
"/\/\/www\.youtube-nocookie\.com\/watch?v=([\w-]+)/",
"/\/\/youtu.be\/([\w-]+)/",
];

View File

@@ -50,7 +50,7 @@ class UserHelper {
*/
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) */
/** @phpstan-ignore-next-line */
return $level;
} else {
user_error("Passed invalid user access level: $level", E_USER_WARNING);
@@ -125,6 +125,10 @@ class UserHelper {
if (empty($_SESSION["csrf_token"]))
$_SESSION["csrf_token"] = bin2hex(get_random_bytes(16));
if (Config::get_schema_version() >= 120) {
$_SESSION["language"] = get_pref(Prefs::USER_LANGUAGE, $owner_uid);
}
}
static function load_user_plugins(int $owner_uid, ?PluginHost $pluginhost = null): void {
@@ -132,8 +136,7 @@ class UserHelper {
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);
$plugins = get_pref(Prefs::_ENABLED_PLUGINS, $owner_uid);
$pluginhost->load((string)$plugins, PluginHost::KIND_USER, $owner_uid);
@@ -181,13 +184,15 @@ class UserHelper {
$_SESSION["last_login_update"] = time();
}
startup_gettext();
self::load_user_plugins($_SESSION["uid"]);
if ($_SESSION["uid"]) {
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);
$value = get_pref(Prefs::USER_STYLESHEET);
if ($value) {
print "<style type='text/css' id='user_css_style'>";
@@ -362,6 +367,7 @@ class UserHelper {
/**
* @param null|int $owner_uid if null, checks current user via session-specific auth module, if set works on internal database only
* @return bool
* @throws PDOException
* @throws Exception
*/
@@ -374,19 +380,31 @@ class UserHelper {
*
* @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,
};
static function hash_password(string $pass, string $salt, string $algo = self::HASH_ALGOS[0]) {
$pass_hash = "";
if ($pass_hash === null)
user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR);
switch ($algo) {
case self::HASH_ALGO_SHA1:
$pass_hash = sha1($pass);
break;
case self::HASH_ALGO_SHA1X:
$pass_hash = sha1("$salt:$pass");
break;
case self::HASH_ALGO_MODE2:
case self::HASH_ALGO_SSHA256:
$pass_hash = hash('sha256', $salt . $pass);
break;
case self::HASH_ALGO_SSHA512:
$pass_hash = hash('sha512', $salt . $pass);
break;
default:
user_error("hash_password: unknown hash algo: $algo", E_USER_ERROR);
}
return $pass_hash ? "$algo:$pass_hash" : false;
if ($pass_hash)
return "$algo:$pass_hash";
else
return false;
}
/**
@@ -477,6 +495,7 @@ class UserHelper {
/**
* @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
* @return bool
*/
static function user_has_password(?int $owner_uid, string $password) : bool {
if ($owner_uid) {
@@ -484,6 +503,7 @@ class UserHelper {
return $authenticator->check_password($owner_uid, $password);
} else {
/** @var Auth_Internal|false $authenticator -- this is only here to make check_password() visible to static analyzer */
$authenticator = PluginHost::getInstance()->get_plugin($_SESSION["auth_module"]);
if ($authenticator &&

View File

@@ -9,7 +9,7 @@
{
"name": "j4mie/idiorm",
"type": "vcs",
"url": "https://github.com/tt-rss/tt-rss-idiorm.git"
"url": "https://dev.tt-rss.org/fox/idiorm.git"
}
],
"autoload": {
@@ -21,16 +21,17 @@
]
},
"require": {
"spomky-labs/otphp": "^11.3",
"chillerlan/php-qrcode": "^5.0.3",
"spomky-labs/otphp": "^10.0",
"chillerlan/php-qrcode": "^4.3.3",
"mervick/material-design-icons": "^2.2",
"j4mie/idiorm": "dev-master",
"open-telemetry/exporter-otlp": "^1.0",
"php-http/guzzle7-adapter": "^1.0",
"soundasleep/html2text": "^2.1",
"guzzlehttp/guzzle": "^7.0",
"dragonmantank/cron-expression": "^3.4"
"guzzlehttp/guzzle": "^7.0"
},
"require-dev": {
"phpstan/phpstan": "2.1.30",
"phpstan/phpstan": "1.10.3",
"phpunit/phpunit": "9.5.16",
"phpunit/php-code-coverage": "^9.2"
}

1624
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,6 @@
etc.
See this page for more information: https://github.com/tt-rss/tt-rss/wiki/Global-Config
See this page for more information: https://tt-rss.org/wiki/GlobalConfig
*/

View File

@@ -221,7 +221,7 @@ function _color_hue2rgb(float $m1, float $m2, float $h): int {
* @return array{0: int, 1: int, 2: int}
*/
function _color_unpack(string $hex, bool $normalize = false): array {
$hex = str_starts_with($hex, '#') ? substr($hex, 1) : _resolve_htmlcolor($hex);
$hex = strpos($hex, '#') !== 0 ? _resolve_htmlcolor($hex) : substr($hex, 1);
if (strlen($hex) == 4) {
$hex = $hex[1] . $hex[1] . $hex[2] . $hex[2] . $hex[3] . $hex[3];

View File

@@ -46,7 +46,7 @@
*/
function input_tag(string $name, string $value, string $type = "text", array $attributes = [], string $id = ""): string {
$attributes_str = attributes_to_string($attributes);
$dojo_type = str_contains($attributes_str, "dojoType") ? "" : "dojoType='dijit.form.TextBox'";
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='dijit.form.TextBox'" : "";
return "<input name=\"".htmlspecialchars($name)."\" $dojo_type ".attributes_to_string($attributes)." id=\"".htmlspecialchars($id)."\"
type=\"$type\" value=\"".htmlspecialchars($value)."\">";
@@ -56,21 +56,21 @@
* @param array<string, mixed> $attributes
*/
function number_spinner_tag(string $name, string $value, array $attributes = [], string $id = ""): string {
return input_tag($name, $value, 'text', ['dojoType' => 'dijit.form.NumberSpinner', ...$attributes], $id);
return input_tag($name, $value, "text", array_merge(["dojoType" => "dijit.form.NumberSpinner"], $attributes), $id);
}
/**
* @param array<string, mixed> $attributes
*/
function submit_tag(string $value, array $attributes = []): string {
return button_tag($value, 'submit', ['class' => 'alt-primary', ...$attributes]);
return button_tag($value, "submit", array_merge(["class" => "alt-primary"], $attributes));
}
/**
* @param array<string, mixed> $attributes
*/
function cancel_dialog_tag(string $value, array $attributes = []): string {
return button_tag($value, '', ['onclick' => 'App.dialogOf(this).hide()', ...$attributes]);
return button_tag($value, "", array_merge(["onclick" => "App.dialogOf(this).hide()"], $attributes));
}
/**
@@ -81,12 +81,13 @@
}
/**
* @param mixed $value
* @param array<int|string, string> $values
* @param array<string, mixed> $attributes
*/
function select_tag(string $name, mixed $value, array $values, array $attributes = [], string $id = ""): string {
function select_tag(string $name, $value, array $values, array $attributes = [], string $id = ""): string {
$attributes_str = attributes_to_string($attributes);
$dojo_type = str_contains($attributes_str, "dojoType") ? "" : "dojoType='fox.form.Select'";
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='fox.form.Select'" : "";
$rv = "<select $dojo_type name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\" name=\"".htmlspecialchars($name)."\" $attributes_str>";
@@ -115,7 +116,7 @@
*/
function select_hash(string $name, $value, array $values, array $attributes = [], string $id = ""): string {
$attributes_str = attributes_to_string($attributes);
$dojo_type = str_contains($attributes_str, "dojoType") ? "" : "dojoType='fox.form.Select'";
$dojo_type = strpos($attributes_str, "dojoType") === false ? "dojoType='fox.form.Select'" : "";
$rv = "<select $dojo_type name=\"".htmlspecialchars($name)."\"
id=\"".htmlspecialchars($id)."\" name=\"".htmlspecialchars($name)."\" $attributes_str>";

View File

@@ -5,13 +5,15 @@
*/
function stylesheet_tag(string $filename, array $attributes = []): string {
$attributes_str = \Controls\attributes_to_string([
'href' => "$filename?" . filemtime($filename),
'rel' => 'stylesheet',
'type' => 'text/css',
'data-orig-href' => $filename,
...$attributes,
]);
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"href" => "$filename?" . filemtime($filename),
"rel" => "stylesheet",
"type" => "text/css",
"data-orig-href" => $filename
],
$attributes));
return "<link $attributes_str/>\n";
}
@@ -20,12 +22,14 @@ function stylesheet_tag(string $filename, array $attributes = []): string {
* @param array<string, mixed> $attributes
*/
function javascript_tag(string $filename, array $attributes = []): string {
$attributes_str = \Controls\attributes_to_string([
'src' => "$filename?" . filemtime($filename),
'type' => 'text/javascript',
'charset' => 'utf-8',
...$attributes,
]);
$attributes_str = \Controls\attributes_to_string(
array_merge(
[
"src" => "$filename?" . filemtime($filename),
"type" => "text/javascript",
"charset" => "utf-8"
],
$attributes));
return "<script $attributes_str></script>\n";
}

View File

@@ -2,38 +2,40 @@
/**
* @param array<int, array<string, mixed>> $trace
*/
function format_backtrace(array $trace): string {
function format_backtrace($trace): string {
$rv = "";
$idx = 1;
foreach ($trace as $e) {
if (isset($e["file"]) && isset($e["line"])) {
$fmt_args = [];
if (is_array($trace)) {
foreach ($trace as $e) {
if (isset($e["file"]) && isset($e["line"])) {
$fmt_args = [];
if (is_array($e["args"] ?? false)) {
foreach ($e["args"] as $a) {
if (is_object($a)) {
array_push($fmt_args, "{" . get_class($a) . "}");
} else if (is_array($a)) {
array_push($fmt_args, "[" . truncate_string(json_encode($a), 256, "...")) . "]";
} else if (is_resource($a)) {
array_push($fmt_args, truncate_string(get_resource_type($a), 256, "..."));
} else if (is_string($a)) {
array_push($fmt_args, truncate_string($a, 256, "..."));
if (is_array($e["args"] ?? false)) {
foreach ($e["args"] as $a) {
if (is_object($a)) {
array_push($fmt_args, "{" . get_class($a) . "}");
} else if (is_array($a)) {
array_push($fmt_args, "[" . truncate_string(json_encode($a), 256, "...")) . "]";
} else if (is_resource($a)) {
array_push($fmt_args, truncate_string(get_resource_type($a), 256, "..."));
} else if (is_string($a)) {
array_push($fmt_args, truncate_string($a, 256, "..."));
}
}
}
$filename = str_replace(dirname(__DIR__) . "/", "", $e["file"]);
$rv .= sprintf("%d. %s(%s): %s(%s)\n",
$idx,
$filename,
$e["line"],
$e["function"],
implode(", ", $fmt_args));
$idx++;
}
$filename = str_replace(dirname(__DIR__) . "/", "", $e["file"]);
$rv .= sprintf("%d. %s(%s): %s(%s)\n",
$idx,
$filename,
$e["line"],
$e["function"],
implode(", ", $fmt_args));
$idx++;
}
}
@@ -41,19 +43,18 @@ function format_backtrace(array $trace): string {
}
function ttrss_error_handler(int $errno, string $errstr, string $file, int $line): bool {
// return true in order to avoid default error handling by PHP
if (!(error_reporting() & $errno)) return true;
// return true in order to avoid default error handling by PHP
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
if (error_reporting() == 0 || !$errno) return true;
} else {
if (!(error_reporting() & $errno)) return true;
}
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
$context = format_backtrace(debug_backtrace());
$errstr = truncate_middle($errstr, 16384, " (...) ");
if (php_sapi_name() == 'cli' && class_exists("Debug")) {
Debug::log("!! Exception: $errstr ($file:$line)");
Debug::log($context);
}
if (class_exists("Logger"))
return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
else
@@ -75,16 +76,8 @@ function ttrss_fatal_handler(): bool {
$file = substr(str_replace(dirname(__DIR__), "", $file), 1);
if (php_sapi_name() == 'cli' && class_exists("Debug")) {
Debug::log("!! Fatal error: $errstr ($file:$line)");
Debug::log($context);
}
if (class_exists("Logger"))
Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
if (php_sapi_name() == 'cli')
exit(1);
return Logger::log_error((int)$errno, $errstr, $file, (int)$line, $context);
}
return false;

View File

@@ -5,14 +5,21 @@
/** @deprecated by Config::SCHEMA_VERSION */
define('SCHEMA_VERSION', Config::SCHEMA_VERSION);
if (version_compare(PHP_VERSION, '8.0.0', '<')) {
libxml_disable_entity_loader(true);
}
libxml_use_internal_errors(true);
// separate test because this is included before sanity checks
if (function_exists("mb_internal_encoding")) mb_internal_encoding("UTF-8");
date_default_timezone_set('UTC');
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
if (defined('E_DEPRECATED')) {
error_reporting(E_ALL & ~E_NOTICE & ~E_DEPRECATED);
} else {
error_reporting(E_ALL & ~E_NOTICE);
}
ini_set('display_errors', "false");
ini_set('display_startup_errors', "false");
@@ -23,20 +30,27 @@
require_once "autoload.php";
/** @deprecated use the 'SUBSTRING_FOR_DATE' string directly */
const SUBSTRING_FOR_DATE = 'SUBSTRING_FOR_DATE';
if (Config::get(Config::DB_TYPE) == "pgsql") {
define('SUBSTRING_FOR_DATE', 'SUBSTRING_FOR_DATE');
} else {
define('SUBSTRING_FOR_DATE', 'SUBSTRING');
}
/**
* @return bool|int|null|string
*
* @deprecated by Prefs::get()
*/
function get_pref(string $pref_name, ?int $owner_uid = null): bool|int|null|string {
function get_pref(string $pref_name, ?int $owner_uid = null) {
return Prefs::get($pref_name, $owner_uid ? $owner_uid : $_SESSION["uid"], $_SESSION["profile"] ?? null);
}
/**
* @param bool|int|string $value
*
* @deprecated by Prefs::set()
*/
function set_pref(string $pref_name, bool|int|string $value, ?int $owner_uid = null, bool $strip_tags = true): bool {
function set_pref(string $pref_name, $value, ?int $owner_uid = null, bool $strip_tags = true): bool {
return Prefs::set($pref_name, $value, $owner_uid ? $owner_uid : $_SESSION["uid"], $_SESSION["profile"] ?? null, $strip_tags);
}
@@ -74,7 +88,6 @@
"uk_UA" => "Українська",
"sv_SE" => "Svenska",
"fi_FI" => "Suomi",
"ta" => "Tamil",
"tr_TR" => "Türkçe");
return $t;
@@ -110,29 +123,31 @@
// create a list like "en" => 0.8
$langs = array_combine($lang_parse[1], $lang_parse[4]);
// set default to 1 for any without q factor
foreach ($langs as $lang => $val) {
if ($val === '') $langs[$lang] = 1;
}
if (is_array($langs)) {
// set default to 1 for any without q factor
foreach ($langs as $lang => $val) {
if ($val === '') $langs[$lang] = 1;
}
// sort list based on value
arsort($langs, SORT_NUMERIC);
// sort list based on value
arsort($langs, SORT_NUMERIC);
foreach (array_keys($langs) as $lang) {
$lang = strtolower($lang);
foreach (array_keys($langs) as $lang) {
$lang = strtolower($lang);
foreach ($valid_langs as $vlang => $vlocale) {
if ($vlang == $lang) {
$selected_locale = $vlocale;
break 2;
foreach ($valid_langs as $vlang => $vlocale) {
if ($vlang == $lang) {
$selected_locale = $vlocale;
break 2;
}
}
}
}
}
}
if (!empty($_SESSION["uid"]) && Config::get_schema_version() >= 120) {
$pref_locale = Prefs::get(Prefs::USER_LANGUAGE, $_SESSION["uid"], $_SESSION["profile"] ?? null);
if (!empty($_SESSION["uid"]) && get_schema_version() >= 120) {
$pref_locale = get_pref(Prefs::USER_LANGUAGE, $_SESSION["uid"]);
if (!empty($pref_locale) && $pref_locale != 'auto') {
$selected_locale = $pref_locale;
@@ -164,7 +179,7 @@
*
* @return array<string, mixed>|string
*/
function get_version(): array|string {
function get_version() {
return Config::get_version();
}
@@ -185,7 +200,7 @@
* @return int
* @throws PDOException
*/
function getFeedUnread(int|string $feed, bool $is_cat = false): int {
function getFeedUnread($feed, bool $is_cat = false): int {
return Feeds::_get_counters($feed, $is_cat, true, $_SESSION["uid"]);
}
@@ -196,7 +211,7 @@
*
* @return false|string The HTML, or false if an error occurred.
*/
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 {
function sanitize(string $str, bool $force_remove_images = false, ?int $owner = null, ?string $site_url = null, ?array $highlight_words = null, ?int $article_id = null) {
return Sanitizer::sanitize($str, $force_remove_images, $owner, $site_url, $highlight_words, $article_id);
}
@@ -204,9 +219,9 @@
* @deprecated by UrlHelper::fetch()
*
* @param array<string, bool|int|string>|string $params
* @return false|string false if something went wrong, otherwise string contents
* @return bool|string false if something went wrong, otherwise string contents
*/
function fetch_file_contents(array|string $params): false|string {
function fetch_file_contents($params) {
return UrlHelper::fetch($params);
}
@@ -228,9 +243,9 @@
/**
* @deprecated by UrlHelper::validate()
*
* @return false|string false if something went wrong, otherwise the URL string
* @return bool|string false if something went wrong, otherwise the URL string
*/
function validate_url(string $url): false|string {
function validate_url(string $url) {
return UrlHelper::validate($url);
}
@@ -245,7 +260,7 @@
}
/** @deprecated by TimeHelper::make_local_datetime() */
function make_local_datetime(string $timestamp, bool $long = false, ?int $owner_uid = null, bool $no_smart_dt = false, bool $eta_min = false): string {
function make_local_datetime(string $timestamp, bool $long, ?int $owner_uid = null, bool $no_smart_dt = false, bool $eta_min = false): string {
return TimeHelper::make_local_datetime($timestamp, $long, $owner_uid, $no_smart_dt, $eta_min);
}
@@ -259,8 +274,12 @@
/**
* This is used for user http parameters unless HTML code is actually needed.
*
* @param mixed $param
*
* @return mixed|null
*/
function clean(mixed $param): mixed {
function clean($param) {
if (is_array($param)) {
return array_map("trim", array_map("strip_tags", $param));
} else if (is_string($param)) {
@@ -271,7 +290,11 @@
}
function with_trailing_slash(string $str) : string {
return str_ends_with($str, '/') ? $str : "$str/";
if (substr($str, -1) === "/") {
return $str;
} else {
return "$str/";
}
}
function make_password(int $length = 12): string {
@@ -329,8 +352,9 @@
}
/** Convert values accepted by tt-rss as true/false to PHP booleans
* @see https://github.com/tt-rss/tt-rss/wiki/API-Reference#boolean-values
* @see https://tt-rss.org/ApiReference/#boolean-values
* @param null|string $s null values are considered false
* @return bool
*/
function sql_bool_to_bool(?string $s): bool {
return $s && ($s !== "f" && $s !== "false"); //no-op for PDO, backwards compat for legacy layer
@@ -422,7 +446,7 @@
/**
* @return false|string The decoded string or false if an error occurred.
*/
function gzdecode(string $string): false|string { // no support for 2nd argument
function gzdecode(string $string) { // no support for 2nd argument
return file_get_contents('compress.zlib://data:who/cares;base64,'.
base64_encode($string));
}
@@ -455,7 +479,10 @@
return null;
}
function implements_interface(object|string $class, string $interface): bool {
/**
* @param object|string $class
*/
function implements_interface($class, string $interface): bool {
$class_implemented_interfaces = class_implements($class);
if ($class_implemented_interfaces) {
@@ -464,14 +491,14 @@
return false;
}
function get_theme_path(string $theme, string $default = ""): string {
function get_theme_path(string $theme): string {
$check = "themes/$theme";
if (file_exists($check)) return $check;
$check = "themes.local/$theme";
if (file_exists($check)) return $check;
return $default;
return "";
}
function theme_exists(string $theme): bool {
@@ -496,3 +523,4 @@
return $ts;
}

View File

@@ -33,25 +33,22 @@
require({cache:{}});
</script>
<script type="text/javascript">
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">
/* exported Plugins */
const Plugins = {};
<?php
foreach (PluginHost::getInstance()->get_plugins() as $n => $p) {
$script = $p->get_login_js();
if (method_exists($p, "get_login_js")) {
$script = $p->get_login_js();
if ($script) {
echo "try {
$script
} catch (e) {
console.warn('failed to initialize plugin JS: $n', e);
}";
if ($script) {
echo "try {
$script
} catch (e) {
console.warn('failed to initialize plugin JS: $n', e);
}";
}
}
}
?>
@@ -126,84 +123,79 @@
<fieldset>
<label><?= __("Login:") ?></label>
<input name="login" id="login" dojoType="dijit.form.TextBox" type="text"
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
<?= Config::get(Config::DISABLE_LOGIN_FORM) ? 'disabled="disabled"' : '' ?>
required="1" value="<?= $_SESSION["fake_login"] ?? "" ?>" />
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
required="1" value="<?= $_SESSION["fake_login"] ?? "" ?>" />
</fieldset>
<fieldset>
<label><?= __("Password:") ?></label>
<input type="password" name="password" required="1"
dojoType="dijit.form.TextBox"
class="input input-text"
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
<?= Config::get(Config::DISABLE_LOGIN_FORM) ? 'disabled="disabled"' : '' ?>
value="<?= $_SESSION["fake_password"] ?? "" ?>"/>
dojoType="dijit.form.TextBox"
class="input input-text"
onchange="UtilityApp.fetchProfiles()"
onfocus="UtilityApp.fetchProfiles()"
onblur="UtilityApp.fetchProfiles()"
value="<?= $_SESSION["fake_password"] ?? "" ?>"/>
</fieldset>
<?php if (!Config::get(Config::DISABLE_LOGIN_FORM) && str_contains(Config::get(Config::PLUGINS), "auth_internal")) { ?>
<?php if (strpos(Config::get(Config::PLUGINS), "auth_internal") !== false) { ?>
<fieldset class="align-right">
<a href="public.php?op=forgotpass"><?= __("I forgot my password") ?></a>
</fieldset>
<?php } ?>
<?php if (!Config::get(Config::DISABLE_LOGIN_FORM)) { ?>
<fieldset>
<label><?= __("Profile:") ?></label>
<fieldset>
<label><?= __("Profile:") ?></label>
<select disabled='disabled' name="profile" id="profile" dojoType='dijit.form.Select'>
<option><?= __("Default profile") ?></option>
</select>
</fieldset>
<select disabled='disabled' name="profile" id="profile" dojoType='dijit.form.Select'>
<option><?= __("Default profile") ?></option>
</select>
</fieldset>
<fieldset class="narrow">
<label> </label>
<label id="bw_limit_label">
<?= \Controls\checkbox_tag("bw_limit", false, "",
["onchange" => 'UtilityApp.bwLimitChange(this)'], 'bw_limit') ?>
<?= __("Use less traffic") ?></label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="bw_limit_label" position="below" style="display:none">
<?= __("Does not display images in articles, reduces automatic refreshes."); ?>
</div>
<fieldset class="narrow">
<label> </label>
<label id="safe_mode_label">
<?= \Controls\checkbox_tag("safe_mode") ?>
<?= __("Safe mode") ?>
</label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="safe_mode_label" position="below" style="display:none">
<?= __("Uses default theme and prevents all plugins from loading."); ?>
</div>
<?php if (Config::get(Config::SESSION_COOKIE_LIFETIME) > 0) { ?>
<fieldset class="narrow">
<label> </label>
<label id="bw_limit_label">
<?= \Controls\checkbox_tag("bw_limit", false, "",
["onchange" => 'UtilityApp.bwLimitChange(this)'], 'bw_limit') ?>
<?= __("Use less traffic") ?></label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="bw_limit_label" position="below" style="display:none">
<?= __("Does not display images in articles, reduces automatic refreshes."); ?>
</div>
<fieldset class="narrow">
<label> </label>
<label id="safe_mode_label">
<?= \Controls\checkbox_tag("safe_mode") ?>
<?= __("Safe mode") ?>
<label>
<?= \Controls\checkbox_tag("remember_me") ?>
<?= __("Remember me") ?>
</label>
</fieldset>
<div dojoType="dijit.Tooltip" connectId="safe_mode_label" position="below" style="display:none">
<?= __("Uses default theme and prevents all plugins from loading."); ?>
</div>
<?php if (Config::get(Config::SESSION_COOKIE_LIFETIME) > 0) { ?>
<fieldset class="narrow">
<label> </label>
<label>
<?= \Controls\checkbox_tag("remember_me") ?>
<?= __("Remember me") ?>
</label>
</fieldset>
<?php } ?>
<?php } ?>
<hr/>
<fieldset class="align-right">
<label> </label>
<?php if (!Config::get(Config::DISABLE_LOGIN_FORM)) { ?>
<?= \Controls\submit_tag(__('Log in')) ?>
<?php } ?>
<?php PluginHost::getInstance()->run_hooks(PluginHost::HOOK_LOGINFORM_ADDITIONAL_BUTTONS) ?>
</fieldset>
@@ -211,7 +203,8 @@
</div>
<div class="footer">
<a href="https://github.com/tt-rss/tt-rss">Tiny Tiny RSS</a>
<a href="https://tt-rss.org/">Tiny Tiny RSS</a>
&copy; 2005&ndash;<?= date('Y') ?> <a href="https://fakecake.org/">Andrew Dolgov</a>
</div>
</div>

View File

@@ -19,7 +19,7 @@
<meta name="viewport" content="initial-scale=1,width=device-width" />
<?php if ($_SESSION["uid"] && empty($_SESSION["safe_mode"])) {
$theme = Prefs::get(Prefs::USER_CSS_THEME, $_SESSION['uid'], $_SESSION['profile'] ?? null);
$theme = get_pref(Prefs::USER_CSS_THEME);
if ($theme && theme_exists("$theme")) {
echo stylesheet_tag(get_theme_path($theme), ['id' => 'theme_css']);
}
@@ -29,9 +29,6 @@
<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>
<?php UserHelper::print_user_stylesheet() ?>
@@ -39,9 +36,8 @@
<style type="text/css">
<?php
foreach (PluginHost::getInstance()->get_plugins() as $p) {
$css = $p->get_css();
if ($css) {
echo $css;
if (method_exists($p, "get_css")) {
echo $p->get_css();
}
}
?>
@@ -77,14 +73,16 @@
<script type="text/javascript">
<?php
foreach (PluginHost::getInstance()->get_plugins() as $n => $p) {
$script = $p->get_js();
if (method_exists($p, "get_js")) {
$script = $p->get_js();
if ($script) {
echo "try {
$script
} catch (e) {
console.warn('failed to initialize plugin JS: $n', e);
}";
if ($script) {
echo "try {
$script
} catch (e) {
console.warn('failed to initialize plugin JS: $n', e);
}";
}
}
}
?>

View File

@@ -14,7 +14,6 @@ const App = {
global_unread: -1,
_widescreen_mode: false,
_loading_progress: 0,
_night_mode_retry_timeout: false,
hotkey_actions: {},
is_prefs: false,
LABEL_BASE_INDEX: -1024,
@@ -168,25 +167,12 @@ const App = {
setInitParam: function(k, v) {
this._initParams[k] = v;
},
nightModeChanged: function(is_night, link, retry = 0) {
console.log("nightModeChanged: night mode changed to", is_night, "retry", retry);
nightModeChanged: function(is_night, link) {
console.log("night mode changed to", is_night);
if (link) {
if (retry < 15) {
window.clearTimeout(this._night_mode_retry_timeout);
this._night_mode_retry_timeout = window.setTimeout(
() => this.nightModeChanged(is_night, link, ++retry),
3000);
}
xhr.post("backend.php", {op: "RPC", method: "getRuntimeInfo"}, () => {
const css_override = is_night ? App.getInitParam("default_dark_theme") : App.getInitParam("default_light_theme");
link.setAttribute("href", css_override + "?" + Date.now());
window.clearTimeout(this._night_mode_retry_timeout);
});
const css_override = is_night ? "themes/night.css" : "themes/light.css";
link.setAttribute("href", css_override + "?" + Date.now());
}
},
setupNightModeDetection: function(callback) {
@@ -704,8 +690,6 @@ const App = {
window.onerror = this.Error.onWindowError;
this.setInitParam("csrf_token", __csrf_token);
this.setInitParam("default_light_theme", __default_light_theme);
this.setInitParam("default_dark_theme", __default_dark_theme);
this.setupNightModeDetection(() => {
parser.parse();
@@ -845,6 +829,11 @@ const App = {
Headlines.initScrollHandler();
if (this.getInitParam("simple_update")) {
console.log("scheduling simple feed updater...");
window.setInterval(() => { Feeds.updateRandom() }, 30 * 1000);
}
if (this.getInitParam('check_for_updates')) {
window.setInterval(() => {
this.checkForUpdates();
@@ -1169,20 +1158,8 @@ const App = {
};
this.hotkey_actions["feed_debug_viewfeed"] = () => {
let query = {
...{op: "Feeds", method: "view", feed: Feeds.getActive(), timestamps: 1,
debug: 1, cat: Feeds.activeIsCat(), csrf_token: __csrf_token},
...dojo.formToObject("toolbar-main")
};
if (Feeds._search_query) {
query = Object.assign(query, Feeds._search_query);
}
console.log('debug_viewfeed', query);
App.postOpenWindow("backend.php", query);
App.postOpenWindow("backend.php", {op: "Feeds", method: "view",
feed: Feeds.getActive(), timestamps: 1, debug: 1, cat: Feeds.activeIsCat(), csrf_token: __csrf_token});
};
this.hotkey_actions["feed_edit"] = () => {

View File

@@ -123,12 +123,15 @@ const Article = {
Article.setActive(0);
},
displayUrl: function (id) {
const hl = Headlines.objectById(id);
const query = {op: "Article", method: "getmetadatabyid", id: id};
if (hl?.link)
prompt(__("Article URL:"), hl.link);
else
alert(__("No URL could be displayed for this article."));
xhr.json("backend.php", query, (reply) => {
if (reply && reply.link) {
prompt(__("Article URL:"), reply.link);
} else {
alert(__("No URL could be displayed for this article."));
}
});
},
openInNewWindow: function (id) {
/* global __csrf_token */

View File

@@ -58,15 +58,10 @@ const CommonDialogs = {
${App.getInitParam('enable_feed_cats') ?
`
<fieldset>
<label>${__('Place in category:')}</label>
<label class='inline'>${__('Place in category:')}</label>
${reply.cat_select}
</fieldset>
` : ''}
<fieldset>
<label>${__("Update interval:")}</label>
${App.FormFields.select_hash("update_interval", 0, reply.intervals.update)}
</fieldset>
</section>
<div id="feedDlg_feedsContainer" style="display : none">
@@ -118,10 +113,10 @@ const CommonDialogs = {
</footer>
</form>
`,
show_error: function (msg, additional_info) {
show_error: function (msg) {
const elem = App.byId("fadd_error_message");
elem.innerHTML = `${msg}${additional_info ? `<br><br><h4>${__('Additional information')}</h4>${additional_info}` : ''}`;
elem.innerHTML = msg;
Element.show(elem);
},
@@ -168,7 +163,7 @@ const CommonDialogs = {
dialog.show_error(__("Specified URL seems to be invalid."));
break;
case 3:
dialog.show_error(__("Specified URL doesn't seem to contain any feeds."), App.escapeHtml(rc['message']));
dialog.show_error(__("Specified URL doesn't seem to contain any feeds."));
break;
case 4:
{
@@ -193,10 +188,10 @@ const CommonDialogs = {
}
break;
case 5:
dialog.show_error(__("Couldn't download the specified URL."), App.escapeHtml(rc['message']));
dialog.show_error(__("Couldn't download the specified URL: %s").replace("%s", rc['message']));
break;
case 6:
dialog.show_error(__("Invalid content."), App.escapeHtml(rc['message']));
dialog.show_error(__("XML validation failed: %s").replace("%s", rc['message']));
break;
case 7:
dialog.show_error(__("Error while creating feed database entry."));
@@ -690,7 +685,7 @@ const CommonDialogs = {
</section>
<footer>
<button dojoType='dijit.form.Button' style='float : left' class='alt-info'
onclick='window.open("https://github.com/tt-rss/tt-rss/wiki/Generated-Feeds")'>
onclick='window.open("https://tt-rss.org/wiki/GeneratedFeeds")'>
<i class='material-icons'>help</i> ${__("More info...")}</button>
<button dojoType='dijit.form.Button' onclick="return App.dialogOf(this).regenFeedKey('${feed}', '${is_cat}')">
${App.FormFields.icon("refresh")}

View File

@@ -30,45 +30,52 @@ const Filters = {
params.offset = offset;
params.limit = test_dialog.limit;
console.log("getTestResults:" + offset);
xhr.json("backend.php", params, (result) => {
try {
if (result && test_dialog && test_dialog.open) {
test_dialog.results += result.length;
console.log("got results:" + result.length);
const loading_message = test_dialog.domNode.querySelector(".loading-message");
const results_list = test_dialog.domNode.querySelector(".filter-results-list");
if (result.pre_filtering_count > 0) {
test_dialog.results += result.items.length;
loading_message.innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", test_dialog.results)
.replace("%d", offset);
loading_message.innerHTML = __("Looking for articles (%d processed, %f found)...")
.replace("%f", test_dialog.results)
.replace("%d", offset);
console.log(offset + " " + test_dialog.max_offset);
results_list.innerHTML += result.items.reduce((current, item) => current + `<li title="${App.escapeHtml(item.rules.join('\n'))}"><span class='title'>${item.title}</span>
&mdash; <span class='feed'>${item.feed_title}</span>, <span class='date'>${item.date}</span>
<div class='preview text-muted'>${item.content_preview}</div></li>`, '');
for (let i = 0; i < result.length; i++) {
const tmp = dojo.create("div", { innerHTML: result[i]});
// get the next batch if there may be more available and testing limits haven't been reached
if (result.pre_filtering_count === test_dialog.limit &&
test_dialog.results < 30 && offset < test_dialog.max_offset) {
window.setTimeout(function () {
test_dialog.getTestResults(params, offset + test_dialog.limit);
}, 0);
return;
}
results_list.innerHTML += tmp.innerHTML;
}
// all done-- either the backend found no more pre-filtering entries, or test limits were reached
test_dialog.domNode.querySelector(".loading-indicator").hide();
if (test_dialog.results < 30 && offset < test_dialog.max_offset) {
if (test_dialog.results == 0) {
results_list.innerHTML = `<li class="text-center text-muted">
${__('No recent articles matching this filter have been found.')}</li>`;
// get the next batch
window.setTimeout(function () {
test_dialog.getTestResults(params, offset + test_dialog.limit);
}, 0);
loading_message.innerHTML = __("Articles matching this filter:");
} else {
loading_message.innerHTML = __("Found at least %d articles matching this filter:")
.replace("%d", test_dialog.results);
// all done
test_dialog.domNode.querySelector(".loading-indicator").hide();
if (test_dialog.results == 0) {
results_list.innerHTML = `<li class="text-center text-muted">
${__('No recent articles matching this filter have been found.')}</li>`;
loading_message.innerHTML = __("Articles matching this filter:");
} else {
loading_message.innerHTML = __("Found %d articles matching this filter:")
.replace("%d", test_dialog.results);
}
}
} else if (!result) {
@@ -226,7 +233,7 @@ const Filters = {
<footer>
${App.FormFields.button_tag(App.FormFields.icon("help") + " " + __("More info"), "", {class: 'pull-left alt-info',
onclick: "window.open('https://github.com/tt-rss/tt-rss/wiki/Content-Filters')"})}
onclick: "window.open('https://tt-rss.org/wiki/ContentFilters')"})}
${App.FormFields.submit_tag(App.FormFields.icon("save") + " " + __("Save"), {onclick: "App.dialogOf(this).execute()"})}
${App.FormFields.cancel_dialog_tag(__("Cancel"))}
</footer>
@@ -317,9 +324,7 @@ const Filters = {
</form>
`);
const actionSelect = dijit.byId("filterDlg_actionSelect").attr('value', action.action_id);
edit_action_dialog.toggleParam(actionSelect);
dijit.byId("filterDlg_actionSelect").attr('value', action.action_id);
/*xhr.post("backend.php", {op: 'Pref_Filters', method: 'newaction', action: actionStr}, (reply) => {
edit_action_dialog.attr('content', reply);

View File

@@ -173,9 +173,6 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
tnode.markedCounterNode = dojo.create('span', { className: 'counterNode marked', innerHTML: args.item.markedcounter });
domConstruct.place(tnode.markedCounterNode, tnode.rowNode, 'first');
tnode.publishedCounterNode = dojo.create('span', { className: 'counterNode published', innerHTML: args.item.publishedcounter });
domConstruct.place(tnode.publishedCounterNode, tnode.rowNode, 'first');
tnode.auxCounterNode = dojo.create('span', { className: 'counterNode aux', innerHTML: args.item.auxcounter });
domConstruct.place(tnode.auxCounterNode, tnode.rowNode, 'first');
@@ -191,7 +188,7 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
updateCounter: function (item) {
const tree = this;
// console.log("updateCounter: " + item.id[0] + " U: " + item.unread + " P: " + item.publishedcounter + " M: " + item.markedcounter);
//console.log("updateCounter: " + item.id[0] + " " + item.unread + " " + tree);
let treeNode = tree._itemNodesMap[item.id];
@@ -201,7 +198,6 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
treeNode.unreadCounterNode.innerHTML = item.unread;
treeNode.auxCounterNode.innerHTML = item.auxcounter;
treeNode.markedCounterNode.innerHTML = item.markedcounter;
treeNode.publishedCounterNode.innerHTML = item.publishedcounter;
}
},
getTooltip: function (item) {
@@ -228,7 +224,6 @@ define(["dojo/_base/declare", "dojo/dom-construct", "dojo/_base/array", "dojo/co
if (item.unread > 0) rc += " Unread";
if (item.auxcounter > 0) rc += " Has_Aux";
if (item.markedcounter > 0) rc += " Has_Marked";
if (item.publishedcounter > 0) rc += " Has_Published";
if (item.updates_disabled > 0) rc += " UpdatesDisabled";
if (item.bare_id >= App.LABEL_BASE_INDEX && item.bare_id < 0 && !is_cat || item.bare_id == Feeds.FEED_ARCHIVED && !is_cat) rc += " Special";
if (item.bare_id == Feeds.CATEGORY_SPECIAL && is_cat) rc += " AlwaysVisible";

View File

@@ -23,7 +23,7 @@ const Feeds = {
infscroll_disabled: 0,
_infscroll_timeout: false,
_filter_query: false, // TODO: figure out the UI for this
_search_query: null,
_search_query: false,
last_search_query: [],
_viewfeed_wait_timeout: false,
_feeds_holder_observer: new IntersectionObserver(
@@ -105,7 +105,6 @@ const Feeds = {
this.setUnread(id, (kind == "cat"), ctr);
this.setValue(id, (kind == "cat"), 'auxcounter', parseInt(elems[l].auxcounter));
this.setValue(id, (kind == "cat"), 'markedcounter', parseInt(elems[l].markedcounter));
this.setValue(id, (kind == "cat"), 'publishedcounter', parseInt(elems[l].publishedcounter));
if (kind != "cat") {
this.setValue(id, false, 'error', error);
@@ -160,7 +159,7 @@ const Feeds = {
this.reload();
},
cancelSearch: function() {
this._search_query = null;
this._search_query = "";
this.reloadCurrent();
},
// null = get all data, [] would give empty response for specific type
@@ -271,9 +270,6 @@ const Feeds = {
console.log('got hash', hash);
if (hash.query)
this._search_query = {query: hash.query, search_language: hash.search_language};
if (hash.f != undefined) {
this.open({feed: parseInt(hash.f), is_cat: parseInt(hash.c)});
} else {
@@ -333,12 +329,7 @@ const Feeds = {
console.log('setActive', id, is_cat);
window.requestIdleCallback(() => {
App.Hash.set({
f: id,
c: is_cat ? 1 : 0,
query: Feeds._search_query?.query,
search_language: Feeds._search_query?.search_language,
});
App.Hash.set({f: id, c: is_cat ? 1 : 0});
});
this._active_feed_id = id;
@@ -658,7 +649,7 @@ const Feeds = {
<footer>
${reply.show_syntax_help ?
`${App.FormFields.button_tag(App.FormFields.icon("help") + " " + __("Search syntax"), "",
{class: 'alt-info pull-left', onclick: "window.open('https://github.com/tt-rss/tt-rss/wiki/Search-Syntax')"})}
{class: 'alt-info pull-left', onclick: "window.open('https://tt-rss.org/wiki/SearchSyntax')"})}
` : ''}
${App.FormFields.submit_tag(App.FormFields.icon("search") + " " + __('Search'), {onclick: "App.dialogOf(this).execute()"})}
@@ -673,7 +664,7 @@ const Feeds = {
// disallow empty queries
if (!Feeds._search_query.query)
Feeds._search_query = null;
Feeds._search_query = false;
this.hide();
Feeds.reloadCurrent();
@@ -744,6 +735,13 @@ const Feeds = {
dialog.show();
},
updateRandom: function() {
console.log("in update_random_feed");
xhr.json("backend.php", {op: "RPC", method: "updaterandomfeed"}, () => {
//
});
},
renderIcon: function(feed_id, exists) {
const icon_url = App.getInitParam("icons_url") + '?' + dojo.objectToQuery({op: 'feed_icon', id: feed_id});

View File

@@ -28,8 +28,8 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
const rules = this.model.store.getValue(args.item, 'rules');
if (param) {
param = dojo.doc.createElement('ul');
param.className = (enabled != false) ? 'actions_summary' : 'actions_summary filterDisabled';
param = dojo.doc.createElement('span');
param.className = (enabled != false) ? 'labelParam' : 'labelParam filterDisabled';
param.innerHTML = args.item.param[0];
domConstruct.place(param, tnode.rowNode, 'first');
}
@@ -165,39 +165,6 @@ define(["dojo/_base/declare", "dojo/dom-construct", "lib/CheckBoxTree"], functio
alert(__("No filters selected."));
}
return false;
},
cloneSelectedFilters: function() {
const sel_rows = this.getSelectedFilters();
if (sel_rows.length > 0) {
const query = {op: "Pref_Filters", method: "clone", ids: sel_rows.toString()};
let proceed = false;
if (sel_rows.length === 1) {
const selected_filter = this.model.getCheckedItems()[0];
const new_filter_title = prompt(__("Name for new filter:"),
__("Clone of %s").replace("%s", this.model.store.getValue(selected_filter, "bare_name")));
if (new_filter_title) {
query.new_filter_title = new_filter_title;
proceed = true;
}
} else if (sel_rows.length > 1) {
proceed = confirm(__("Clone selected filters?"));
}
if (proceed) {
Notify.progress(__("Cloning selected filters..."));
xhr.post("backend.php", query, () => {
this.reload();
});
}
} else {
alert(__("No filters selected."));
}
return false;
},
});

View File

@@ -7,11 +7,8 @@ window.addEventListener("load", function() {
apply_night_mode: function (is_night, link) {
console.log("night mode changed to", is_night);
const light_theme = typeof __default_light_theme != 'undefined' ? __default_light_theme : 'themes/light.css';
const dark_theme = typeof __default_dark_theme != 'undefined' ? __default_dark_theme : 'themes/night.css';
if (link) {
const css_override = is_night ? dark_theme : light_theme;
const css_override = is_night ? "themes/night.css" : "themes/light.css";
link.setAttribute("href", css_override + "?" + Date.now());
}

File diff suppressed because one or more lines are too long

View File

@@ -15,7 +15,7 @@
"url": "https://github.com/dojo/dijit.git"
},
"dependencies": {
"dojo": "1.17.3"
"dojo": "1.16.5"
},
"devDependencies": {
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,12 +1,12 @@
{
"name": "dijit",
"version": "1.17.3",
"version": "1.16.5",
"directories": {
"lib": "."
},
"main": "main",
"dependencies": {
"dojo": "1.17.3"
"dojo": "1.16.5"
},
"description": "Dijit provides a complete collection of user interface controls based on Dojo, giving you the power to create web applications that are highly optimized for usability, performance, internationalization, accessibility, but above all deliver an incredible user experience.",
"license" : "BSD-3-Clause OR AFL-2.1",

View File

@@ -5,40 +5,40 @@
# It will automatically replace previous build of Dojo in ../dojo
# Dojo requires Java runtime to build. Further information on rebuilding Dojo
# is available here: https://dojotoolkit.org/reference-guide/build/index.html
# is available here: http://dojotoolkit.org/reference-guide/build/index.html
VERSION=1.17.3
VERSION=1.16.5
# Download and extract dojo src code if it doesn't already exist
if [ ! -d "dojo" ]; then
TARBALL=dojo-release-$VERSION-src.tar.gz
if [ ! -f $TARBALL ]; then
wget https://download.dojotoolkit.org/release-$VERSION/$TARBALL
fi
tar -zxf $TARBALL
mv dojo-release-$VERSION-src/* .
rm -rf dojo-release-$VERSION-src
TARBALL=dojo-release-$VERSION-src.tar.gz
if [ ! -f $TARBALL ]; then
wget -q http://download.dojotoolkit.org/release-$VERSION/$TARBALL
fi
tar -zxf $TARBALL
mv dojo-release-$VERSION-src/* .
rm -rf dojo-release-$VERSION-src
fi
if [ -d util/buildscripts/ ]; then
rm -rf release/dojo
rm -rf release/dojo
pushd util/buildscripts
./build.sh profile=../../tt-rss action=release optimize=shrinksafe cssOptimize=comments
popd
pushd util/buildscripts
./build.sh profile=../../tt-rss action=release optimize=shrinksafe cssOptimize=comments
popd
if [ -d release/dojo ]; then
rm -rf ../dojo ../dijit
cp -r release/dojo/dojo ..
cp -r release/dojo/dijit ..
if [ -d release/dojo ]; then
rm -rf ../dojo ../dijit
cp -r release/dojo/dojo ..
cp -r release/dojo/dijit ..
cd ..
cd ..
find dojo -name '*uncompressed*' -exec rm -- {} \;
find dijit -name '*uncompressed*' -exec rm -- {} \;
else
echo $0: ERROR: Dojo build seems to have failed.
fi
find dojo -name '*uncompressed*' -exec rm -- {} \;
find dijit -name '*uncompressed*' -exec rm -- {} \;
else
echo $0: ERROR: Dojo build seems to have failed.
fi
else
echo $0: ERROR: Please unpack Dojo source release into current directory.
echo $0: ERROR: Please unpack Dojo source release into current directory.
fi

View File

@@ -5,4 +5,4 @@
*/
//>>built
define("dojo/_base/array",["./kernel","../has","./lang"],function(_1,_2,_3){var _4={},u;var _5;if(!_2("csp-restrictions")){_5=function(fn){return _4[fn]=new Function("item","index","array",fn);};}function _6(_7){var _8=!_7;return function(a,fn,o){var i=0,l=a&&a.length||0,_9;if(l&&typeof a=="string"){a=a.split("");}if(typeof fn=="string"){if(_2("csp-restrictions")){throw new TypeError("callback must be a function");}else{fn=_4[fn]||_5(fn);}}if(o){for(;i<l;++i){_9=!fn.call(o,a[i],i,a);if(_7^_9){return !_9;}}}else{for(;i<l;++i){_9=!fn(a[i],i,a);if(_7^_9){return !_9;}}}return _8;};};function _a(up){var _b=1,_c=0,_d=0;if(!up){_b=_c=_d=-1;}return function(a,x,_e,_f){if(_f&&_b>0){return _10.lastIndexOf(a,x,_e);}var l=a&&a.length||0,end=up?l+_d:_c,i;if(_e===u){i=up?_c:l+_d;}else{if(_e<0){i=l+_e;if(i<0){i=_c;}}else{i=_e>=l?l+_d:_e;}}if(l&&typeof a=="string"){a=a.split("");}for(;i!=end;i+=_b){if(a[i]==x){return i;}}return -1;};};var _10={every:_6(false),some:_6(true),indexOf:_a(true),lastIndexOf:_a(false),forEach:function(arr,_11,_12){var i=0,l=arr&&arr.length||0;if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _11=="string"){if(_2("csp-restrictions")){throw new TypeError("callback must be a function");}else{_11=_4[_11]||_5(_11);}}if(_12){for(;i<l;++i){_11.call(_12,arr[i],i,arr);}}else{for(;i<l;++i){_11(arr[i],i,arr);}}},map:function(arr,_13,_14,Ctr){var i=0,l=arr&&arr.length||0,out=new (Ctr||Array)(l);if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _13=="string"){if(_2("csp-restrictions")){throw new TypeError("callback must be a function");}else{_13=_4[_13]||_5(_13);}}if(_14){for(;i<l;++i){out[i]=_13.call(_14,arr[i],i,arr);}}else{for(;i<l;++i){out[i]=_13(arr[i],i,arr);}}return out;},filter:function(arr,_15,_16){var i=0,l=arr&&arr.length||0,out=[],_17;if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _15=="string"){if(_2("csp-restrictions")){throw new TypeError("callback must be a function");}else{_15=_4[_15]||_5(_15);}}if(_16){for(;i<l;++i){_17=arr[i];if(_15.call(_16,_17,i,arr)){out.push(_17);}}}else{for(;i<l;++i){_17=arr[i];if(_15(_17,i,arr)){out.push(_17);}}}return out;},clearCache:function(){_4={};}};1&&_3.mixin(_1,_10);return _10;});
define("dojo/_base/array",["./kernel","../has","./lang"],function(_1,_2,_3){var _4={},u;function _5(fn){return _4[fn]=new Function("item","index","array",fn);};function _6(_7){var _8=!_7;return function(a,fn,o){var i=0,l=a&&a.length||0,_9;if(l&&typeof a=="string"){a=a.split("");}if(typeof fn=="string"){fn=_4[fn]||_5(fn);}if(o){for(;i<l;++i){_9=!fn.call(o,a[i],i,a);if(_7^_9){return !_9;}}}else{for(;i<l;++i){_9=!fn(a[i],i,a);if(_7^_9){return !_9;}}}return _8;};};function _a(up){var _b=1,_c=0,_d=0;if(!up){_b=_c=_d=-1;}return function(a,x,_e,_f){if(_f&&_b>0){return _10.lastIndexOf(a,x,_e);}var l=a&&a.length||0,end=up?l+_d:_c,i;if(_e===u){i=up?_c:l+_d;}else{if(_e<0){i=l+_e;if(i<0){i=_c;}}else{i=_e>=l?l+_d:_e;}}if(l&&typeof a=="string"){a=a.split("");}for(;i!=end;i+=_b){if(a[i]==x){return i;}}return -1;};};var _10={every:_6(false),some:_6(true),indexOf:_a(true),lastIndexOf:_a(false),forEach:function(arr,_11,_12){var i=0,l=arr&&arr.length||0;if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _11=="string"){_11=_4[_11]||_5(_11);}if(_12){for(;i<l;++i){_11.call(_12,arr[i],i,arr);}}else{for(;i<l;++i){_11(arr[i],i,arr);}}},map:function(arr,_13,_14,Ctr){var i=0,l=arr&&arr.length||0,out=new (Ctr||Array)(l);if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _13=="string"){_13=_4[_13]||_5(_13);}if(_14){for(;i<l;++i){out[i]=_13.call(_14,arr[i],i,arr);}}else{for(;i<l;++i){out[i]=_13(arr[i],i,arr);}}return out;},filter:function(arr,_15,_16){var i=0,l=arr&&arr.length||0,out=[],_17;if(l&&typeof arr=="string"){arr=arr.split("");}if(typeof _15=="string"){_15=_4[_15]||_5(_15);}if(_16){for(;i<l;++i){_17=arr[i];if(_15.call(_16,_17,i,arr)){out.push(_17);}}}else{for(;i<l;++i){_17=arr[i];if(_15(_17,i,arr)){out.push(_17);}}}return out;},clearCache:function(){_4={};}};1&&_3.mixin(_1,_10);return _10;});

File diff suppressed because one or more lines are too long

View File

@@ -5,4 +5,4 @@
*/
//>>built
define("dojo/_base/kernel",["../global","../has","./config","require","module"],function(_1,_2,_3,_4,_5){var i,p,_6={},_7={},_8={config:_3,global:_1,dijit:_6,dojox:_7};var _9={dojo:["dojo",_8],dijit:["dijit",_6],dojox:["dojox",_7]},_a=(_4.map&&_4.map[_5.id.match(/[^\/]+/)[0]]),_b;for(p in _a){if(_9[p]){_9[p][0]=_a[p];}else{_9[p]=[_a[p],{}];}}for(p in _9){_b=_9[p];_b[1]._scopeName=_b[0];if(!_3.noGlobals){_1[_b[0]]=_b[1];}}_8.scopeMap=_9;_8.baseUrl=_8.config.baseUrl=_4.baseUrl;_8.isAsync=!1||_4.async;_8.locale=_3.locale;var _c="$Rev:$".match(/[0-9a-f]{7,}/);_8.version={major:1,minor:17,patch:3,flag:"",revision:_c?_c[0]:NaN,toString:function(){var v=_8.version;return v.major+"."+v.minor+"."+v.patch+v.flag+" ("+v.revision+")";}};1||_2.add("extend-dojo",1);if(!_2("csp-restrictions")){(Function("d","d.eval = function(){return d.global.eval ? d.global.eval(arguments[0]) : eval(arguments[0]);}"))(_8);}if(0){_8.exit=function(_d){quit(_d);};}else{_8.exit=function(){};}if(!_2("host-webworker")){1||_2.add("dojo-guarantee-console",1);}if(1){_2.add("console-as-object",function(){return Function.prototype.bind&&console&&typeof console.log==="object";});typeof console!="undefined"||(console={});var cn=["assert","count","debug","dir","dirxml","error","group","groupEnd","info","profile","profileEnd","time","timeEnd","trace","warn","log"];var tn;i=0;while((tn=cn[i++])){if(!console[tn]){(function(){var _e=tn+"";console[_e]=("log" in console)?function(){var a=Array.prototype.slice.call(arguments);a.unshift(_e+":");console["log"](a.join(" "));}:function(){};console[_e]._fake=true;})();}else{if(_2("console-as-object")){console[tn]=Function.prototype.bind.call(console[tn],console);}}}}_2.add("dojo-debug-messages",!!_3.isDebug);_8.deprecated=_8.experimental=function(){};if(_2("dojo-debug-messages")){_8.deprecated=function(_f,_10,_11){var _12="DEPRECATED: "+_f;if(_10){_12+=" "+_10;}if(_11){_12+=" -- will be removed in version: "+_11;}console.warn(_12);};_8.experimental=function(_13,_14){var _15="EXPERIMENTAL: "+_13+" -- APIs subject to change without notice.";if(_14){_15+=" "+_14;}console.warn(_15);};}1||_2.add("dojo-modulePaths",1);if(1){if(_3.modulePaths){_8.deprecated("dojo.modulePaths","use paths configuration");var _16={};for(p in _3.modulePaths){_16[p.replace(/\./g,"/")]=_3.modulePaths[p];}_4({paths:_16});}}1||_2.add("dojo-moduleUrl",1);if(1){_8.moduleUrl=function(_17,url){_8.deprecated("dojo.moduleUrl()","use require.toUrl","2.0");var _18=null;if(_17){_18=_4.toUrl(_17.replace(/\./g,"/")+(url?("/"+url):"")+"/*.*").replace(/\/\*\.\*/,"")+(url?"":"/");}return _18;};}_8._hasResource={};return _8;});
define("dojo/_base/kernel",["../global","../has","./config","require","module"],function(_1,_2,_3,_4,_5){var i,p,_6={},_7={},_8={config:_3,global:_1,dijit:_6,dojox:_7};var _9={dojo:["dojo",_8],dijit:["dijit",_6],dojox:["dojox",_7]},_a=(_4.map&&_4.map[_5.id.match(/[^\/]+/)[0]]),_b;for(p in _a){if(_9[p]){_9[p][0]=_a[p];}else{_9[p]=[_a[p],{}];}}for(p in _9){_b=_9[p];_b[1]._scopeName=_b[0];if(!_3.noGlobals){_1[_b[0]]=_b[1];}}_8.scopeMap=_9;_8.baseUrl=_8.config.baseUrl=_4.baseUrl;_8.isAsync=!1||_4.async;_8.locale=_3.locale;var _c="$Rev:$".match(/[0-9a-f]{7,}/);_8.version={major:1,minor:16,patch:5,flag:"",revision:_c?_c[0]:NaN,toString:function(){var v=_8.version;return v.major+"."+v.minor+"."+v.patch+v.flag+" ("+v.revision+")";}};1||_2.add("extend-dojo",1);if(!_2("csp-restrictions")){(Function("d","d.eval = function(){return d.global.eval ? d.global.eval(arguments[0]) : eval(arguments[0]);}"))(_8);}if(0){_8.exit=function(_d){quit(_d);};}else{_8.exit=function(){};}if(!_2("host-webworker")){1||_2.add("dojo-guarantee-console",1);}if(1){_2.add("console-as-object",function(){return Function.prototype.bind&&console&&typeof console.log==="object";});typeof console!="undefined"||(console={});var cn=["assert","count","debug","dir","dirxml","error","group","groupEnd","info","profile","profileEnd","time","timeEnd","trace","warn","log"];var tn;i=0;while((tn=cn[i++])){if(!console[tn]){(function(){var _e=tn+"";console[_e]=("log" in console)?function(){var a=Array.prototype.slice.call(arguments);a.unshift(_e+":");console["log"](a.join(" "));}:function(){};console[_e]._fake=true;})();}else{if(_2("console-as-object")){console[tn]=Function.prototype.bind.call(console[tn],console);}}}}_2.add("dojo-debug-messages",!!_3.isDebug);_8.deprecated=_8.experimental=function(){};if(_2("dojo-debug-messages")){_8.deprecated=function(_f,_10,_11){var _12="DEPRECATED: "+_f;if(_10){_12+=" "+_10;}if(_11){_12+=" -- will be removed in version: "+_11;}console.warn(_12);};_8.experimental=function(_13,_14){var _15="EXPERIMENTAL: "+_13+" -- APIs subject to change without notice.";if(_14){_15+=" "+_14;}console.warn(_15);};}1||_2.add("dojo-modulePaths",1);if(1){if(_3.modulePaths){_8.deprecated("dojo.modulePaths","use paths configuration");var _16={};for(p in _3.modulePaths){_16[p.replace(/\./g,"/")]=_3.modulePaths[p];}_4({paths:_16});}}1||_2.add("dojo-moduleUrl",1);if(1){_8.moduleUrl=function(_17,url){_8.deprecated("dojo.moduleUrl()","use require.toUrl","2.0");var _18=null;if(_17){_18=_4.toUrl(_17.replace(/\./g,"/")+(url?("/"+url):"")+"/*.*").replace(/\/\*\.\*/,"")+(url?"":"/");}return _18;};}_8._hasResource={};return _8;});

2
lib/dojo/dojo.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +0,0 @@
/*
Copyright (c) 2004-2016, The JS Foundation All Rights Reserved.
Available via Academic Free License >= 2.1 OR the modified BSD license.
see: http://dojotoolkit.org/license for details
*/
//>>built
define("dojo/json5",["./json5/parse"],function(_1){return {parse:_1};});

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