2016-10-02 21:07:17 +00:00
< ? php
2017-11-17 01:23:18 +00:00
/** @ license MIT
* Copyright 2017 J . King , Dustin Wilson et al .
* See LICENSE and AUTHORS files for details */
2016-10-06 02:08:43 +00:00
declare ( strict_types = 1 );
2017-03-28 04:12:12 +00:00
namespace JKingWeb\Arsse ;
2017-08-29 14:50:31 +00:00
2017-09-16 23:57:33 +00:00
use JKingWeb\DrUUID\UUID ;
2018-11-28 22:18:33 +00:00
use JKingWeb\Arsse\Db\Statement ;
2017-06-18 14:23:37 +00:00
use JKingWeb\Arsse\Misc\Query ;
2019-02-26 03:41:12 +00:00
use JKingWeb\Arsse\Context\Context ;
2017-07-17 11:47:57 +00:00
use JKingWeb\Arsse\Misc\Date ;
2017-09-26 20:45:41 +00:00
use JKingWeb\Arsse\Misc\ValueInfo ;
2019-09-03 23:04:23 +00:00
use JKingWeb\Arsse\Misc\URL ;
2016-10-02 21:07:17 +00:00
2019-02-13 17:37:41 +00:00
/** The high - level interface with the database
2019-05-02 02:52:20 +00:00
*
2019-02-13 17:37:41 +00:00
* The database stores information on the following things :
2019-05-02 02:52:20 +00:00
*
2019-02-13 17:37:41 +00:00
* - Users
* - Subscriptions to feeds , which belong to users
* - Folders , which belong to users and contain subscriptions
2019-03-07 03:15:41 +00:00
* - Tags , which belong to users and can be assigned to multiple subscriptions
2019-02-13 17:37:41 +00:00
* - Feeds to which users are subscribed
* - Articles , which belong to feeds and for which users can only affect metadata
* - Editions , identifying authorial modifications to articles
* - Labels , which belong to users and can be assigned to multiple articles
* - Sessions , used by some protocols to identify users across periods of time
2019-03-09 21:23:56 +00:00
* - Tokens , similar to sessions , but with more control over their properties
2019-02-13 17:37:41 +00:00
* - Metadata , used internally by the server
2019-05-02 02:52:20 +00:00
*
2019-02-13 17:37:41 +00:00
* The various methods of this class perform operations on these things , with
2019-05-02 02:52:20 +00:00
* each public method prefixed with the thing it concerns e . g . userRemove ()
2019-02-13 17:37:41 +00:00
* deletes a user from the database , and labelArticlesSet () changes a label ' s
* associations with articles . There has been an effort to keep public method
* names consistent throughout , but protected methods , having different
* concerns , will typicsally follow different conventions .
*/
2016-10-02 21:07:17 +00:00
class Database {
2019-02-03 17:25:07 +00:00
/** The version number of the latest schema the interface is aware of */
2019-09-13 01:19:26 +00:00
const SCHEMA_VERSION = 6 ;
2019-03-02 03:36:25 +00:00
/** The size of a set of values beyond which the set will be embedded into the query text */
const LIMIT_SET_SIZE = 25 ;
/** The length of a string in an embedded set beyond which a parameter placeholder will be used for the string */
const LIMIT_SET_STRING_LENGTH = 200 ;
2019-04-27 22:32:15 +00:00
/** Makes tag/label association change operations remove members */
const ASSOC_REMOVE = 0 ;
/** Makes tag/label association change operations add members */
const ASSOC_ADD = 1 ;
/** Makes tag/label association change operations replace members */
const ASSOC_REPLACE = 2 ;
2019-02-03 17:25:07 +00:00
/** A map database driver short-names and their associated class names */
2019-01-21 03:40:49 +00:00
const DRIVER_NAMES = [
'sqlite3' => \JKingWeb\Arsse\Db\SQLite3\Driver :: class ,
'postgresql' => \JKingWeb\Arsse\Db\PostgreSQL\Driver :: class ,
'mysql' => \JKingWeb\Arsse\Db\MySQL\Driver :: class ,
];
2018-10-26 18:58:04 +00:00
2017-07-17 11:47:57 +00:00
/** @var Db\Driver */
2017-08-29 14:50:31 +00:00
public $db ;
2016-10-15 13:45:23 +00:00
2019-02-03 17:25:07 +00:00
/** Constructs the database interface
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param boolean $initialize Whether to attempt to upgrade the databse schema when constructing
*/
2017-07-22 19:29:12 +00:00
public function __construct ( $initialize = true ) {
2017-07-17 11:47:57 +00:00
$driver = Arsse :: $conf -> dbDriver ;
2017-12-18 23:29:32 +00:00
$this -> db = $driver :: create ();
2017-05-04 00:00:29 +00:00
$ver = $this -> db -> schemaVersion ();
2017-08-29 14:50:31 +00:00
if ( $initialize && $ver < self :: SCHEMA_VERSION ) {
2017-05-04 00:00:29 +00:00
$this -> db -> schemaUpdate ( self :: SCHEMA_VERSION );
2017-02-16 20:29:42 +00:00
}
}
2016-10-02 21:07:17 +00:00
2019-02-03 17:25:07 +00:00
/** Returns the bare name of the calling context's calling method, when __FUNCTION__ is not appropriate */
2017-05-18 17:21:17 +00:00
protected function caller () : string {
return debug_backtrace ( DEBUG_BACKTRACE_IGNORE_ARGS , 3 )[ 2 ][ 'function' ];
}
2018-10-26 18:58:04 +00:00
2019-05-02 02:52:20 +00:00
/** Lists the available database drivers , as an associative array with
2019-02-03 17:25:07 +00:00
* fully - qualified class names as keys , and human - readable descriptions as values
*/
2017-08-29 14:50:31 +00:00
public static function driverList () : array {
2017-02-16 20:29:42 +00:00
$sep = \DIRECTORY_SEPARATOR ;
$path = __DIR__ . $sep . " Db " . $sep ;
$classes = [];
2017-08-29 14:50:31 +00:00
foreach ( glob ( $path . " * " . $sep . " Driver.php " ) as $file ) {
2017-03-07 23:01:13 +00:00
$name = basename ( dirname ( $file ));
$class = NS_BASE . " Db \\ $name\\Driver " ;
$classes [ $class ] = $class :: driverName ();
2017-02-16 20:29:42 +00:00
}
return $classes ;
}
2016-10-06 02:08:43 +00:00
2019-02-03 17:25:07 +00:00
/** Returns the current (actual) schema version of the database; compared against self::SCHEMA_VERSION to know when an upgrade is required */
2017-07-18 20:38:23 +00:00
public function driverSchemaVersion () : int {
2017-02-16 20:29:42 +00:00
return $this -> db -> schemaVersion ();
}
2016-10-15 13:45:23 +00:00
2019-02-03 17:25:07 +00:00
/** Attempts to update the database schema. If it is already up to date, false is returned */
2017-07-18 20:38:23 +00:00
public function driverSchemaUpdate () : bool {
2017-08-29 14:50:31 +00:00
if ( $this -> db -> schemaVersion () < self :: SCHEMA_VERSION ) {
2017-07-21 02:40:09 +00:00
return $this -> db -> schemaUpdate ( self :: SCHEMA_VERSION );
}
2017-02-16 20:29:42 +00:00
return false ;
}
2016-10-18 15:42:21 +00:00
2019-02-03 17:25:07 +00:00
/** Returns whether the database's character set is Unicode */
2017-11-29 23:14:59 +00:00
public function driverCharsetAcceptable () : bool {
return $this -> db -> charsetAcceptable ();
}
2019-07-26 13:37:51 +00:00
/** Performs maintenance on the database to ensure good performance */
public function driverMaintenance () : bool {
return $this -> db -> maintenance ();
}
2019-02-03 17:25:07 +00:00
/** Computes the column and value text of an SQL " SET " clause , validating arbitrary input against a whitelist
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* Returns an indexed array containing the clause text , an array of types , and another array of values
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param array $props An associative array containing untrusted data ; keys are column names
* @ param array $valid An associative array containing a whitelist : keys are column names , and values are strings representing data types
*/
2017-04-21 01:59:12 +00:00
protected function generateSet ( array $props , array $valid ) : array {
$out = [
[], // query clause
[], // binding types
[], // binding values
];
2017-08-29 14:50:31 +00:00
foreach ( $valid as $prop => $type ) {
if ( ! array_key_exists ( $prop , $props )) {
2017-07-21 02:40:09 +00:00
continue ;
}
2018-12-21 02:23:06 +00:00
$out [ 0 ][] = " \" $prop\ " = ? " ;
2017-04-21 01:59:12 +00:00
$out [ 1 ][] = $type ;
$out [ 2 ][] = $props [ $prop ];
}
$out [ 0 ] = implode ( " , " , $out [ 0 ]);
return $out ;
}
2019-03-02 03:36:25 +00:00
/** Computes the contents of an SQL " IN() " clause , for each input value either embedding the value or producing a parameter placeholder
2019-05-02 02:52:20 +00:00
*
2019-03-02 03:36:25 +00:00
* Returns an indexed array containing the clause text , an array of types , and an array of values . Note that the array of output values may not match the array of input values
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param array $values Arbitrary values
* @ param string $type A single data type applied to each value
*/
2018-11-02 15:52:55 +00:00
protected function generateIn ( array $values , string $type ) : array {
2019-03-02 03:36:25 +00:00
if ( ! sizeof ( $values )) {
2018-12-21 02:23:06 +00:00
// if the set is empty, some databases require an explicit null
2019-03-02 03:36:25 +00:00
return [ " null " , [], []];
}
$t = ( Statement :: TYPES [ $type ] ? ? 0 ) % Statement :: T_NOT_NULL ;
if ( sizeof ( $values ) > self :: LIMIT_SET_SIZE && ( $t == Statement :: T_INTEGER || $t == Statement :: T_STRING )) {
$clause = [];
$params = [];
$count = 0 ;
$convType = Db\AbstractStatement :: TYPE_NORM_MAP [ Statement :: TYPES [ $type ]];
2019-05-02 02:52:20 +00:00
foreach ( $values as $v ) {
2019-03-02 03:36:25 +00:00
$v = ValueInfo :: normalize ( $v , $convType , null , " sql " );
if ( is_null ( $v )) {
// nulls are pointless to have
continue ;
} elseif ( is_string ( $v )) {
if ( strlen ( $v ) > self :: LIMIT_SET_STRING_LENGTH ) {
$clause [] = " ? " ;
$params [] = $v ;
} else {
$clause [] = $this -> db -> literalString ( $v );
}
} else {
$clause [] = ValueInfo :: normalize ( $v , ValueInfo :: T_STRING , null , " sql " );
}
$count ++ ;
}
if ( ! $count ) {
// the set is actually empty
return [ " null " , [], []];
} else {
return [ implode ( " , " , $clause ), array_fill ( 0 , sizeof ( $params ), $type ), $params ];
}
} else {
return [ implode ( " , " , array_fill ( 0 , sizeof ( $values ), " ? " )), array_fill ( 0 , sizeof ( $values ), $type ), $values ];
2018-11-28 22:18:33 +00:00
}
2017-04-21 01:59:12 +00:00
}
2019-02-22 23:50:39 +00:00
/** Computes basic LIKE - based text search constraints for use in a WHERE clause
2019-05-02 02:52:20 +00:00
*
2019-02-22 23:50:39 +00:00
* Returns an indexed array containing the clause text , an array of types , and another array of values
2019-05-02 02:52:20 +00:00
*
2019-02-22 23:50:39 +00:00
* The clause is structured such that all terms must be present across any of the columns
2019-05-02 02:52:20 +00:00
*
2019-02-22 23:50:39 +00:00
* @ param string [] $terms The terms to search for
* @ param string [] $cols The columns to match against ; these are - not - sanitized , so much - not - come directly from user input
2019-02-26 16:39:19 +00:00
* @ param boolean $matchAny Whether the search is successful when it matches any ( true ) or all ( false ) terms
2019-02-22 23:50:39 +00:00
*/
2019-02-26 16:39:19 +00:00
protected function generateSearch ( array $terms , array $cols , bool $matchAny = false ) : array {
2019-03-02 18:53:43 +00:00
if ( ! $cols ) {
throw new Exception ( " arrayEmpty " , " cols " ); // @codeCoverageIgnore
}
2019-02-22 23:50:39 +00:00
$clause = [];
$types = [];
$values = [];
$like = $this -> db -> sqlToken ( " like " );
2019-03-02 18:53:43 +00:00
$embedSet = sizeof ( $terms ) > (( int ) ( self :: LIMIT_SET_SIZE / sizeof ( $cols )));
2019-05-02 02:52:20 +00:00
foreach ( $terms as $term ) {
2019-03-02 18:53:43 +00:00
$embedTerm = ( $embedSet && strlen ( $term ) <= self :: LIMIT_SET_STRING_LENGTH );
2019-02-22 23:50:39 +00:00
$term = str_replace ([ " % " , " _ " , " ^ " ], [ " ^% " , " ^_ " , " ^^ " ], $term );
$term = " % $term % " ;
2019-03-02 18:53:43 +00:00
$term = $embedTerm ? $this -> db -> literalString ( $term ) : $term ;
2019-02-22 23:50:39 +00:00
$spec = [];
foreach ( $cols as $col ) {
2019-03-02 18:53:43 +00:00
if ( $embedTerm ) {
$spec [] = " $col $like $term escape '^' " ;
} else {
$spec [] = " $col $like ? escape '^' " ;
$types [] = " str " ;
$values [] = $term ;
}
2019-02-22 23:50:39 +00:00
}
$clause [] = " ( " . implode ( " or " , $spec ) . " ) " ;
}
2019-02-26 16:39:19 +00:00
$glue = $matchAny ? " or " : " and " ;
2019-03-02 18:53:43 +00:00
$clause = $clause ? " ( " . implode ( " $glue " , $clause ) . " ) " : " " ;
2019-02-22 23:50:39 +00:00
return [ $clause , $types , $values ];
}
2019-02-03 17:25:07 +00:00
/** Returns a Transaction object, which is rolled back unless explicitly committed */
2017-05-19 03:03:33 +00:00
public function begin () : Db\Transaction {
return $this -> db -> begin ();
}
2017-07-05 14:59:13 +00:00
2019-02-03 17:25:07 +00:00
/** Retrieve a value from the metadata table. If the key is not set null is returned */
2017-07-16 18:55:37 +00:00
public function metaGet ( string $key ) {
2018-12-21 02:23:06 +00:00
return $this -> db -> prepare ( " SELECT value from arsse_meta where \" key \" = ? " , " str " ) -> run ( $key ) -> getValue ();
2017-07-05 14:59:13 +00:00
}
2018-10-26 18:58:04 +00:00
2019-02-03 17:25:07 +00:00
/** Sets the given key in the metadata table to the given value. If the key already exists it is silently overwritten */
2017-07-18 20:38:23 +00:00
public function metaSet ( string $key , $value , string $type = " str " ) : bool {
2018-12-21 02:23:06 +00:00
$out = $this -> db -> prepare ( " UPDATE arsse_meta set value = ? where \" key \" = ? " , $type , " str " ) -> run ( $value , $key ) -> changes ();
2017-08-29 14:50:31 +00:00
if ( ! $out ) {
2018-12-21 02:23:06 +00:00
$out = $this -> db -> prepare ( " INSERT INTO arsse_meta( \" key \" ,value) values(?,?) " , " str " , $type ) -> run ( $key , $value ) -> changes ();
2017-02-16 20:29:42 +00:00
}
2017-06-01 20:24:11 +00:00
return ( bool ) $out ;
2017-02-16 20:29:42 +00:00
}
2016-10-17 20:49:39 +00:00
2019-02-03 17:25:07 +00:00
/** Unsets the given key in the metadata table. Returns false if the key does not exist */
2017-07-16 18:55:37 +00:00
public function metaRemove ( string $key ) : bool {
2018-12-21 02:23:06 +00:00
return ( bool ) $this -> db -> prepare ( " DELETE from arsse_meta where \" key \" = ? " , " str " ) -> run ( $key ) -> changes ();
2017-02-16 20:29:42 +00:00
}
2016-10-17 20:49:39 +00:00
2019-02-03 17:25:07 +00:00
/** Returns whether the specified user exists in the database */
2017-02-16 20:29:42 +00:00
public function userExists ( string $user ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-12-07 03:26:06 +00:00
return ( bool ) $this -> db -> prepare ( " SELECT count(*) from arsse_users where id = ? " , " str " ) -> run ( $user ) -> getValue ();
2017-02-16 20:29:42 +00:00
}
2016-10-18 15:42:21 +00:00
2019-02-03 17:25:07 +00:00
/** Adds a user to the database
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user to add
* @ param string $passwordThe user ' s password in cleartext . It will be stored hashed
*/
2018-11-02 15:52:55 +00:00
public function userAdd ( string $user , string $password ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
2017-08-29 14:50:31 +00:00
} elseif ( $this -> userExists ( $user )) {
2017-07-21 02:40:09 +00:00
throw new User\Exception ( " alreadyExists " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2018-11-02 15:52:55 +00:00
$hash = ( strlen ( $password ) > 0 ) ? password_hash ( $password , \PASSWORD_DEFAULT ) : " " ;
2017-03-28 04:12:12 +00:00
$this -> db -> prepare ( " INSERT INTO arsse_users(id,password) values(?,?) " , " str " , " str " ) -> runArray ([ $user , $hash ]);
2018-11-02 15:52:55 +00:00
return true ;
2017-02-16 20:29:42 +00:00
}
2016-10-28 12:27:35 +00:00
2019-02-03 17:25:07 +00:00
/** Removes a user from the database */
2017-02-16 20:29:42 +00:00
public function userRemove ( string $user ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-12-07 03:26:06 +00:00
if ( $this -> db -> prepare ( " DELETE from arsse_users where id = ? " , " str " ) -> run ( $user ) -> changes () < 1 ) {
2017-07-21 02:40:09 +00:00
throw new User\Exception ( " doesNotExist " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-02-16 20:29:42 +00:00
return true ;
}
2016-10-28 12:27:35 +00:00
2019-02-03 17:25:07 +00:00
/** Returns a flat, indexed array of all users in the database */
2018-10-28 14:59:17 +00:00
public function userList () : array {
2017-03-30 03:41:05 +00:00
$out = [];
2018-10-28 14:59:17 +00:00
if ( ! Arsse :: $user -> authorize ( " " , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => " " ]);
}
foreach ( $this -> db -> query ( " SELECT id from arsse_users " ) as $user ) {
$out [] = $user [ 'id' ];
2017-02-16 20:29:42 +00:00
}
2017-03-30 03:41:05 +00:00
return $out ;
2017-02-16 20:29:42 +00:00
}
2017-02-19 22:02:03 +00:00
2019-02-03 17:25:07 +00:00
/** Retrieves the hashed password of a user */
2019-03-24 18:42:23 +00:00
public function userPasswordGet ( string $user ) {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
2017-08-29 14:50:31 +00:00
} elseif ( ! $this -> userExists ( $user )) {
2017-07-21 02:40:09 +00:00
throw new User\Exception ( " doesNotExist " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2019-03-24 18:42:23 +00:00
return $this -> db -> prepare ( " SELECT password from arsse_users where id = ? " , " str " ) -> run ( $user ) -> getValue ();
2017-02-16 20:29:42 +00:00
}
2017-02-19 22:02:03 +00:00
2019-02-03 17:25:07 +00:00
/** Sets the password of an existing user
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user for whom to set the password
2019-03-24 18:42:23 +00:00
* @ param string $password The new password , in cleartext . The password will be stored hashed . If null is passed , the password is unset and authentication not possible
2019-02-03 17:25:07 +00:00
*/
2019-03-24 18:42:23 +00:00
public function userPasswordSet ( string $user , string $password = null ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
2017-08-29 14:50:31 +00:00
} elseif ( ! $this -> userExists ( $user )) {
2017-07-21 02:40:09 +00:00
throw new User\Exception ( " doesNotExist " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2019-03-24 18:42:23 +00:00
$hash = ( strlen ( $password ? ? " " ) > 0 ) ? password_hash ( $password , \PASSWORD_DEFAULT ) : $password ;
2017-12-07 03:26:06 +00:00
$this -> db -> prepare ( " UPDATE arsse_users set password = ? where id = ? " , " str " , " str " ) -> run ( $hash , $user );
2018-11-02 15:52:55 +00:00
return true ;
2017-02-16 20:29:42 +00:00
}
2016-10-28 12:27:35 +00:00
2019-02-03 17:25:07 +00:00
/** Creates a new session for the given user and returns the session identifier */
2017-09-16 23:57:33 +00:00
public function sessionCreate ( string $user ) : string {
// If the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// generate a new session ID and expiry date
$id = UUID :: mint () -> hex ;
$expires = Date :: add ( Arsse :: $conf -> userSessionTimeout );
// save the session to the database
2018-11-28 19:21:36 +00:00
$this -> db -> prepare ( " INSERT INTO arsse_sessions(id,expires, \" user \" ) values(?,?,?) " , " str " , " datetime " , " str " ) -> run ( $id , $expires , $user );
2017-09-16 23:57:33 +00:00
// return the ID
return $id ;
}
2019-02-03 17:25:07 +00:00
/** Explicitly removes a session from the database
2019-05-02 02:52:20 +00:00
*
* Sessions may also be invalidated as they expire , and then be automatically pruned .
2019-02-03 17:25:07 +00:00
* This function can be used to explicitly invalidate a session after a user logs out
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user who owns the session to be destroyed
2019-07-26 02:34:58 +00:00
* @ param string | null $id The identifier of the session to destroy
2019-02-03 17:25:07 +00:00
*/
2019-07-26 02:34:58 +00:00
public function sessionDestroy ( string $user , string $id = null ) : bool {
2017-09-16 23:57:33 +00:00
// If the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2019-07-26 02:34:58 +00:00
if ( is_null ( $id )) {
// delete all sessions and report success unconditionally if no identifier was specified
$this -> db -> prepare ( " DELETE FROM arsse_sessions where \" user \" = ? " , " str " ) -> run ( $user );
return true ;
} else {
// otherwise delete only the specified session and report success.
return ( bool ) $this -> db -> prepare ( " DELETE FROM arsse_sessions where id = ? and \" user \" = ? " , " str " , " str " ) -> run ( $id , $user ) -> changes ();
}
2017-09-16 23:57:33 +00:00
}
2019-02-03 17:25:07 +00:00
/** Resumes a session , returning available session data
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* This also has the side effect of refreshing the session if it is near its timeout
*/
2017-09-16 23:57:33 +00:00
public function sessionResume ( string $id ) : array {
2017-09-24 14:09:36 +00:00
$maxAge = Date :: sub ( Arsse :: $conf -> userSessionLifetime );
2018-11-28 19:21:36 +00:00
$out = $this -> db -> prepare ( " SELECT id,created,expires, \" user \" from arsse_sessions where id = ? and expires > CURRENT_TIMESTAMP and created > ? " , " str " , " datetime " ) -> run ( $id , $maxAge ) -> getRow ();
2017-09-16 23:57:33 +00:00
// if the session does not exist or is expired, throw an exception
if ( ! $out ) {
throw new User\ExceptionSession ( " invalid " , $id );
}
// if we're more than half-way from the session expiring, renew it
if ( $this -> sessionExpiringSoon ( Date :: normalize ( $out [ 'expires' ], " sql " ))) {
$expires = Date :: add ( Arsse :: $conf -> userSessionTimeout );
2017-12-07 03:26:06 +00:00
$this -> db -> prepare ( " UPDATE arsse_sessions set expires = ? where id = ? " , " datetime " , " str " ) -> run ( $expires , $id );
2017-09-16 23:57:33 +00:00
}
return $out ;
}
2019-02-03 17:25:07 +00:00
/** Deletes expires sessions from the database, returning the number of deleted sessions */
2017-09-16 23:57:33 +00:00
public function sessionCleanup () : int {
2017-09-24 14:09:36 +00:00
$maxAge = Date :: sub ( Arsse :: $conf -> userSessionLifetime );
return $this -> db -> prepare ( " DELETE FROM arsse_sessions where expires < CURRENT_TIMESTAMP or created < ? " , " datetime " ) -> run ( $maxAge ) -> changes ();
2017-09-16 23:57:33 +00:00
}
2019-02-03 17:25:07 +00:00
/** Checks if a given future timeout is less than half the session timeout interval */
2017-09-24 16:45:07 +00:00
protected function sessionExpiringSoon ( \DateTimeInterface $expiry ) : bool {
2017-09-16 23:57:33 +00:00
// calculate half the session timeout as a number of seconds
$now = time ();
$max = Date :: add ( Arsse :: $conf -> userSessionTimeout , $now ) -> getTimestamp ();
$diff = intdiv ( $max - $now , 2 );
// determine if the expiry time is less than half the session timeout into the future
return (( $now + $diff ) >= $expiry -> getTimestamp ());
}
2019-05-02 02:52:20 +00:00
/** Creates a new token for the given user in the given class
*
2019-03-09 21:23:56 +00:00
* @ param string $user The user for whom to create the token
* @ param string $class The class of the token e . g . the protocol name
* @ param string | null $id The value of the token ; if none is provided a UUID will be generated
* @ param \DateTimeInterface | null $expires An optional expiry date and time for the token
2019-09-13 01:19:26 +00:00
* @ param string $data Application - specific data associated with a token
2019-03-09 21:23:56 +00:00
*/
2019-09-13 01:19:26 +00:00
public function tokenCreate ( string $user , string $class , string $id = null , \DateTimeInterface $expires = null , string $data = null ) : string {
2019-03-09 21:23:56 +00:00
// If the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
2019-03-10 19:54:43 +00:00
} elseif ( ! $this -> userExists ( $user )) {
throw new User\Exception ( " doesNotExist " , [ " action " => __FUNCTION__ , " user " => $user ]);
2019-03-09 21:23:56 +00:00
}
// generate a token if it's not provided
$id = $id ? ? UUID :: mint () -> hex ;
// save the token to the database
2019-09-13 01:19:26 +00:00
$this -> db -> prepare ( " INSERT INTO arsse_tokens(id,class, \" user \" ,expires,data) values(?,?,?,?,?) " , " str " , " str " , " str " , " datetime " , " str " ) -> run ( $id , $class , $user , $expires , $data );
2019-03-09 21:23:56 +00:00
// return the ID
return $id ;
}
/** Revokes one or all tokens for a user in a class
2019-05-02 02:52:20 +00:00
*
2019-03-09 21:23:56 +00:00
* @ param string $user The user who owns the token to be revoked
* @ param string $class The class of the token e . g . the protocol name
* @ param string | null $id The ID of a specific token , or null for all tokens in the class
*/
public function tokenRevoke ( string $user , string $class , string $id = null ) : bool {
// If the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
if ( is_null ( $id )) {
$out = $this -> db -> prepare ( " DELETE FROM arsse_tokens where \" user \" = ? and class = ? " , " str " , " str " ) -> run ( $user , $class ) -> changes ();
} else {
$out = $this -> db -> prepare ( " DELETE FROM arsse_tokens where \" user \" = ? and class = ? and id = ? " , " str " , " str " , " str " ) -> run ( $user , $class , $id ) -> changes ();
}
return ( bool ) $out ;
}
/** Look up data associated with a token */
public function tokenLookup ( string $class , string $id ) : array {
2019-09-13 01:19:26 +00:00
$out = $this -> db -> prepare ( " SELECT id,class, \" user \" ,created,expires,data from arsse_tokens where class = ? and id = ? and (expires is null or expires > CURRENT_TIMESTAMP) " , " str " , " str " ) -> run ( $class , $id ) -> getRow ();
2019-03-09 21:23:56 +00:00
if ( ! $out ) {
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " token " , 'id' => $id ]);
}
return $out ;
}
/** Deletes expires tokens from the database, returning the number of deleted tokens */
public function tokenCleanup () : int {
return $this -> db -> query ( " DELETE FROM arsse_tokens where expires < CURRENT_TIMESTAMP " ) -> changes ();
}
2019-02-03 17:25:07 +00:00
/** Adds a folder for containing newsfeed subscriptions , returning an integer identifying the created folder
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* The $data array may contain the following keys :
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* - " name " : A folder name , which must be a non - empty string not composed solely of whitespace ; this key is required
* - " parent " : An integer ( or null ) identifying a parent folder ; this key is optional
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* If a folder with the same name and parent already exists , this is an error
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user who will own the folder
* @ param array $data An associative array defining the folder
*/
2017-03-07 23:01:13 +00:00
public function folderAdd ( string $user , array $data ) : int {
// If the user isn't authorized to perform this action then throw an exception.
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-03-07 23:01:13 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// normalize folder's parent, if there is one
2017-09-26 20:45:41 +00:00
$parent = array_key_exists ( " parent " , $data ) ? $this -> folderValidateId ( $user , $data [ 'parent' ])[ 'id' ] : null ;
// validate the folder name and parent (if specified); this also checks for duplicates
2017-09-28 14:16:24 +00:00
$name = array_key_exists ( " name " , $data ) ? $data [ 'name' ] : " " ;
2017-09-26 20:45:41 +00:00
$this -> folderValidateName ( $name , true , $parent );
// actually perform the insert
return $this -> db -> prepare ( " INSERT INTO arsse_folders(owner,parent,name) values(?,?,?) " , " str " , " int " , " str " ) -> run ( $user , $parent , $name ) -> lastId ();
2017-03-07 23:01:13 +00:00
}
2017-03-25 02:39:18 +00:00
2019-02-03 17:25:07 +00:00
/** Returns a result set listing a user ' s folders
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* Each record in the result set contains :
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* - " id " : The folder identifier , an integer
* - " name " : The folder ' s name , a string
* - " parent " : The integer identifier of the folder ' s parent , or null
* - " children " : The number of child folders contained in the given folder
2019-05-02 02:52:20 +00:00
* - " feeds " : The number of newsfeed subscriptions contained in the given folder , not including subscriptions in descendent folders
*
2019-02-03 17:25:07 +00:00
* @ param string $uer The user whose folders are to be listed
* @ param integer | null $parent Restricts the list to the descendents of the specified folder identifier
2019-02-26 16:39:19 +00:00
* @ param boolean $recursive Whether to list all descendents ( true ) or only direct children ( false )
2019-02-03 17:25:07 +00:00
*/
2017-09-28 14:16:24 +00:00
public function folderList ( string $user , $parent = null , bool $recursive = true ) : Db\Result {
2017-03-25 02:39:18 +00:00
// if the user isn't authorized to perform this action then throw an exception.
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-03-25 02:39:18 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-03-31 21:42:28 +00:00
// check to make sure the parent exists, if one is specified
2017-10-20 23:02:42 +00:00
$parent = $this -> folderValidateId ( $user , $parent )[ 'id' ];
2017-10-07 00:26:22 +00:00
$q = new Query (
" SELECT
2019-03-06 00:22:01 +00:00
id ,
name ,
arsse_folders . parent as parent ,
coalesce ( children , 0 ) as children ,
coalesce ( feeds , 0 ) as feeds
FROM arsse_folders
left join ( SELECT parent , count ( id ) as children from arsse_folders group by parent ) as child_stats on child_stats . parent = arsse_folders . id
left join ( SELECT folder , count ( id ) as feeds from arsse_subscriptions group by folder ) as sub_stats on sub_stats . folder = arsse_folders . id "
2017-10-07 00:26:22 +00:00
);
2017-08-29 14:50:31 +00:00
if ( ! $recursive ) {
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " owner = ? " , " str " , $user );
2019-03-06 00:22:01 +00:00
$q -> setWhere ( " coalesce(arsse_folders.parent,0) = ? " , " strict int " , $parent );
2017-03-25 02:39:18 +00:00
} else {
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " folders " , " SELECT id from arsse_folders where owner = ? and coalesce(parent,0) = ? union select arsse_folders.id from arsse_folders join folders on arsse_folders.parent=folders.id " , [ " str " , " strict int " ], [ $user , $parent ]);
2017-10-07 00:26:22 +00:00
$q -> setWhere ( " id in (SELECT id from folders) " );
2017-03-25 02:39:18 +00:00
}
2017-10-07 00:26:22 +00:00
$q -> setOrder ( " name " );
return $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ());
2017-03-25 02:39:18 +00:00
}
2017-03-26 20:16:15 +00:00
2019-02-03 17:25:07 +00:00
/** Deletes a folder from the database
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* Any descendent folders are also deleted , as are all newsfeed subscriptions contained in the deleted folder tree
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user to whom the folder to be deleted belongs
* @ param integer $id The identifier of the folder to delete
*/
2017-09-28 14:16:24 +00:00
public function folderRemove ( string $user , $id ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-09-28 14:16:24 +00:00
if ( ! ValueInfo :: id ( $id )) {
2017-10-05 21:42:12 +00:00
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => __FUNCTION__ , " field " => " folder " , 'type' => " int > 0 " ]);
2017-09-28 14:16:24 +00:00
}
2018-12-21 22:02:34 +00:00
$changes = $this -> db -> prepare ( " WITH RECURSIVE folders(folder) as (SELECT ? union select id from arsse_folders join folders on parent = folder) DELETE FROM arsse_folders where owner = ? and id in (select folder from folders) " , " int " , " str " ) -> run ( $id , $user ) -> changes ();
2017-08-29 14:50:31 +00:00
if ( ! $changes ) {
2017-07-21 02:40:09 +00:00
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " folder " , 'id' => $id ]);
}
2017-04-01 14:27:26 +00:00
return true ;
}
2019-02-03 17:25:07 +00:00
/** Returns the identifier, name, and parent of the given folder as an associative array */
2017-09-28 14:16:24 +00:00
public function folderPropertiesGet ( string $user , $id ) : array {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-09-28 14:16:24 +00:00
if ( ! ValueInfo :: id ( $id )) {
2017-10-05 21:42:12 +00:00
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => __FUNCTION__ , " field " => " folder " , 'type' => " int > 0 " ]);
2017-09-28 14:16:24 +00:00
}
2017-12-07 03:26:06 +00:00
$props = $this -> db -> prepare ( " SELECT id,name,parent from arsse_folders where owner = ? and id = ? " , " str " , " int " ) -> run ( $user , $id ) -> getRow ();
2017-08-29 14:50:31 +00:00
if ( ! $props ) {
2017-07-21 02:40:09 +00:00
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " folder " , 'id' => $id ]);
}
2017-04-01 14:27:26 +00:00
return $props ;
}
2019-02-03 17:25:07 +00:00
/** Modifies the properties of a folder
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* The $data array must contain one or more of the following keys :
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* - " name " : A new folder name , which must be a non - empty string not composed solely of whitespace
* - " parent " : An integer ( or null ) identifying a parent folder
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* If a folder with the new name and parent combination already exists , this is an error ; it is also an error to move a folder to itself or one of its descendents
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user who owns the folder to be modified
* @ param integer $id The identifier of the folder to be modified
* @ param array $data An associative array of properties to modify . Anything not specified will remain unchanged
*/
2017-09-28 14:16:24 +00:00
public function folderPropertiesSet ( string $user , $id , array $data ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-09-26 20:45:41 +00:00
// verify the folder belongs to the user
$in = $this -> folderValidateId ( $user , $id , true );
$name = array_key_exists ( " name " , $data );
$parent = array_key_exists ( " parent " , $data );
if ( $name && $parent ) {
// if a new name and parent are specified, validate both together
2017-05-18 17:21:17 +00:00
$this -> folderValidateName ( $data [ 'name' ]);
2017-09-26 20:45:41 +00:00
$in [ 'name' ] = $data [ 'name' ];
2017-09-28 14:16:24 +00:00
$in [ 'parent' ] = $this -> folderValidateMove ( $user , ( int ) $id , $data [ 'parent' ], $data [ 'name' ]);
2017-09-26 20:45:41 +00:00
} elseif ( $name ) {
2017-09-28 14:16:24 +00:00
// if we're trying to rename the root folder, this simply fails
if ( ! $id ) {
return false ;
}
2017-09-26 20:45:41 +00:00
// if a new name is specified, validate it
$this -> folderValidateName ( $data [ 'name' ], true , $in [ 'parent' ]);
$in [ 'name' ] = $data [ 'name' ];
} elseif ( $parent ) {
// if a new parent is specified, validate it
2017-09-28 14:16:24 +00:00
$in [ 'parent' ] = $this -> folderValidateMove ( $user , ( int ) $id , $data [ 'parent' ]);
2017-09-26 20:45:41 +00:00
} else {
2017-10-05 21:42:12 +00:00
// if no changes would actually be applied, just return
2017-09-26 20:45:41 +00:00
return false ;
2017-04-01 14:27:26 +00:00
}
$valid = [
'name' => " str " ,
'parent' => " int " ,
];
2017-09-26 20:45:41 +00:00
list ( $setClause , $setTypes , $setValues ) = $this -> generateSet ( $in , $valid );
2017-12-07 03:26:06 +00:00
return ( bool ) $this -> db -> prepare ( " UPDATE arsse_folders set $setClause , modified = CURRENT_TIMESTAMP where owner = ? and id = ? " , $setTypes , " str " , " int " ) -> run ( $setValues , $user , $id ) -> changes ();
2017-03-31 22:48:24 +00:00
}
2019-02-03 17:25:07 +00:00
/** Ensures the specified folder exists and raises an exception otherwise
2019-05-02 02:52:20 +00:00
*
* Returns an associative array containing the id , name , and parent of the folder if it exists
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user who owns the folder to be validated
* @ param integer | null $id The identifier of the folder to validate ; null or zero represent the implied root folder
2019-02-26 16:39:19 +00:00
* @ param boolean $subject Whether the folder is the subject ( true ) rather than the object ( false ) of the operation being performed ; this only affects the semantics of the error message if validation fails
2019-02-03 17:25:07 +00:00
*/
2017-09-26 20:45:41 +00:00
protected function folderValidateId ( string $user , $id = null , bool $subject = false ) : array {
2017-09-28 14:16:24 +00:00
// if the specified ID is not a non-negative integer (or null), this will always fail
if ( ! ValueInfo :: id ( $id , true )) {
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " folder " , 'type' => " int >= 0 " ]);
2017-09-26 20:45:41 +00:00
}
2017-09-28 14:16:24 +00:00
// if a null or zero ID is specified this is a no-op
if ( ! $id ) {
return [ 'id' => null , 'name' => null , 'parent' => null ];
2017-05-18 17:21:17 +00:00
}
// check whether the folder exists and is owned by the user
2017-12-07 03:26:06 +00:00
$f = $this -> db -> prepare ( " SELECT id,name,parent from arsse_folders where owner = ? and id = ? " , " str " , " int " ) -> run ( $user , $id ) -> getRow ();
2017-08-29 14:50:31 +00:00
if ( ! $f ) {
2017-09-26 20:45:41 +00:00
throw new Db\ExceptionInput ( $subject ? " subjectMissing " : " idMissing " , [ " action " => $this -> caller (), " field " => " folder " , 'id' => $id ]);
2017-05-18 17:21:17 +00:00
}
return $f ;
}
2019-02-03 17:25:07 +00:00
/** Ensures an operation to rename and/or move a folder does not result in a conflict or circular dependence, and raises an exception otherwise */
2017-12-20 00:08:08 +00:00
protected function folderValidateMove ( string $user , $id = null , $parent = null , string $name = null ) {
2017-09-26 20:45:41 +00:00
$errData = [ " action " => $this -> caller (), " field " => " parent " , 'id' => $parent ];
if ( ! $id ) {
// the root cannot be moved
throw new Db\ExceptionInput ( " circularDependence " , $errData );
}
$info = ValueInfo :: int ( $parent );
// the root is always a valid parent
if ( $info & ( ValueInfo :: NULL | ValueInfo :: ZERO )) {
$parent = null ;
} else {
// if a negative integer or non-integer is specified this will always fail
if ( ! ( $info & ValueInfo :: VALID ) || (( $info & ValueInfo :: NEG ))) {
throw new Db\ExceptionInput ( " idMissing " , $errData );
}
$parent = ( int ) $parent ;
}
// if the target parent is the folder itself, this is a circular dependence
2019-01-11 15:38:06 +00:00
if ( $id == $parent ) {
2017-09-26 20:45:41 +00:00
throw new Db\ExceptionInput ( " circularDependence " , $errData );
}
2017-09-28 14:16:24 +00:00
// make sure both that the prospective parent exists, and that the it is not one of its children (a circular dependence);
// also make sure that a folder with the same prospective name and parent does not already exist: if the parent is null,
// SQL will happily accept duplicates (null is not unique), so we must do this check ourselves
2017-09-26 20:45:41 +00:00
$p = $this -> db -> prepare (
" WITH RECURSIVE
2018-12-21 02:23:06 +00:00
target as ( select ? as userid , ? as source , ? as dest , ? as new_name ),
2018-11-28 19:21:36 +00:00
folders as ( SELECT id from arsse_folders join target on owner = userid and coalesce ( parent , 0 ) = source union select arsse_folders . id as id from arsse_folders join folders on arsse_folders . parent = folders . id )
2017-09-26 20:45:41 +00:00
" .
2018-10-26 18:58:04 +00:00
" SELECT
2018-12-14 00:47:51 +00:00
case when (( select dest from target ) is null or exists ( select id from arsse_folders join target on owner = userid and coalesce ( id , 0 ) = coalesce ( dest , 0 ))) then 1 else 0 end as extant ,
case when not exists ( select id from folders where id = coalesce (( select dest from target ), 0 )) then 1 else 0 end as valid ,
2018-12-21 02:23:06 +00:00
case when not exists ( select id from arsse_folders join target on coalesce ( parent , 0 ) = coalesce ( dest , 0 ) and name = coalesce (( select new_name from target ),( select name from arsse_folders join target on id = source ))) then 1 else 0 end as available
2017-12-07 20:18:25 +00:00
" ,
" str " ,
" strict int " ,
" int " ,
" str "
2017-09-26 20:45:41 +00:00
) -> run ( $user , $id , $parent , $name ) -> getRow ();
if ( ! $p [ 'extant' ]) {
// if the parent doesn't exist or doesn't below to the user, throw an exception
throw new Db\ExceptionInput ( " idMissing " , $errData );
} elseif ( ! $p [ 'valid' ]) {
// if using the desired parent would create a circular dependence, throw a different exception
throw new Db\ExceptionInput ( " circularDependence " , $errData );
} elseif ( ! $p [ 'available' ]) {
2017-09-28 14:16:24 +00:00
// if a folder with the same parent and name already exists, throw another different exception
2017-09-26 20:45:41 +00:00
throw new Db\ExceptionInput ( " constraintViolation " , [ " action " => $this -> caller (), " field " => ( is_null ( $name ) ? " parent " : " name " )]);
}
return $parent ;
}
2019-02-03 17:25:07 +00:00
/** Ensures a prospective folder name is valid , and optionally ensure it is not a duplicate if renamed
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $name The name to check
* @ param boolean $checkDuplicates Whether to also check if the new name would cause a collision
* @ param integer | null $parent The parent folder context in which to check for duplication
*/
2017-12-20 00:08:08 +00:00
protected function folderValidateName ( $name , bool $checkDuplicates = false , $parent = null ) : bool {
2017-09-26 20:45:41 +00:00
$info = ValueInfo :: str ( $name );
if ( $info & ( ValueInfo :: NULL | ValueInfo :: EMPTY )) {
2017-05-18 17:21:17 +00:00
throw new Db\ExceptionInput ( " missing " , [ " action " => $this -> caller (), " field " => " name " ]);
2017-09-26 20:45:41 +00:00
} elseif ( $info & ValueInfo :: WHITE ) {
2017-05-18 17:21:17 +00:00
throw new Db\ExceptionInput ( " whitespace " , [ " action " => $this -> caller (), " field " => " name " ]);
2017-09-26 20:45:41 +00:00
} elseif ( ! ( $info & ValueInfo :: VALID )) {
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " name " , 'type' => " string " ]);
2017-09-28 14:16:24 +00:00
} elseif ( $checkDuplicates ) {
// make sure that a folder with the same prospective name and parent does not already exist: if the parent is null,
// SQL will happily accept duplicates (null is not unique), so we must do this check ourselves
$parent = $parent ? $parent : null ;
2018-12-14 00:47:51 +00:00
if ( $this -> db -> prepare ( " SELECT count(*) from arsse_folders where coalesce(parent,0) = ? and name = ? " , " strict int " , " str " ) -> run ( $parent , $name ) -> getValue ()) {
2017-09-26 20:45:41 +00:00
throw new Db\ExceptionInput ( " constraintViolation " , [ " action " => $this -> caller (), " field " => " name " ]);
}
return true ;
2017-05-18 17:21:17 +00:00
} else {
return true ;
}
}
2019-02-03 17:25:07 +00:00
/** Adds a subscription to a newsfeed , and returns the numeric identifier of the added subscription
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user which will own the subscription
* @ param string $url The URL of the newsfeed or discovery source
* @ param string $fetchUser The user name required to access the newsfeed , if applicable
* @ param string $fetchPassword The password required to fetch the newsfeed , if applicable ; this will be stored in cleartext
2019-04-21 17:10:47 +00:00
* @ param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document
2019-02-03 17:25:07 +00:00
*/
2017-09-30 16:52:05 +00:00
public function subscriptionAdd ( string $user , string $url , string $fetchUser = " " , string $fetchPassword = " " , bool $discover = true ) : int {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2019-04-21 18:07:36 +00:00
// get the ID of the underlying feed, or add it if it's not yet in the database
$feedID = $this -> feedAdd ( $url , $fetchUser , $fetchPassword , $discover );
2017-05-04 15:13:24 +00:00
// Add the feed to the user's subscriptions and return the new subscription's ID.
2017-04-14 02:17:53 +00:00
return $this -> db -> prepare ( 'INSERT INTO arsse_subscriptions(owner,feed) values(?,?)' , 'str' , 'int' ) -> run ( $user , $feedID ) -> lastId ();
}
2019-02-03 17:25:07 +00:00
/** Lists a user ' s subscriptions , returning various data
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user whose subscriptions are to be listed
* @ param integer | null $folder The identifier of the folder under which to list subscriptions ; by default the root folder is used
* @ param boolean $recursive Whether to list subscriptions of descendent folders as well as the selected folder
* @ param integer | null $id The numeric identifier of a particular subscription ; used internally by subscriptionPropertiesGet
*/
2017-10-31 22:09:16 +00:00
public function subscriptionList ( string $user , $folder = null , bool $recursive = true , int $id = null ) : Db\Result {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-09-28 14:16:24 +00:00
// validate inputs
$folder = $this -> folderValidateId ( $user , $folder )[ 'id' ];
2017-06-04 22:00:18 +00:00
// create a complex query
$q = new Query (
2018-10-26 18:58:04 +00:00
" SELECT
2017-10-03 14:43:09 +00:00
arsse_subscriptions . id as id ,
2019-03-26 12:53:26 +00:00
arsse_subscriptions . feed as feed ,
2019-03-06 00:22:01 +00:00
url , favicon , source , folder , pinned , err_count , err_msg , order_type , added ,
2017-10-11 16:55:50 +00:00
arsse_feeds . updated as updated ,
2019-03-26 20:51:44 +00:00
arsse_feeds . modified as edited ,
arsse_subscriptions . modified as modified ,
2017-06-01 22:12:08 +00:00
topmost . top as top_folder ,
2017-06-10 17:29:46 +00:00
coalesce ( arsse_subscriptions . title , arsse_feeds . title ) as title ,
2019-03-06 00:22:01 +00:00
( articles - marked ) as unread
FROM arsse_subscriptions
left join topmost on topmost . f_id = arsse_subscriptions . folder
join arsse_feeds on arsse_feeds . id = arsse_subscriptions . feed
left join ( select feed , count ( * ) as articles from arsse_articles group by feed ) as article_stats on article_stats . feed = arsse_subscriptions . feed
left join ( select subscription , sum ( \ " read \" ) as marked from arsse_marks group by subscription) as mark_stats on mark_stats.subscription = arsse_subscriptions.id "
2017-06-04 22:00:18 +00:00
);
2019-03-06 00:22:01 +00:00
$q -> setWhere ( " arsse_subscriptions.owner = ? " , [ " str " ], [ $user ]);
2018-12-14 14:18:56 +00:00
$nocase = $this -> db -> sqlToken ( " nocase " );
$q -> setOrder ( " pinned desc, coalesce(arsse_subscriptions.title, arsse_feeds.title) collate $nocase " );
2017-06-04 22:00:18 +00:00
// topmost folders belonging to the user
2019-03-06 00:22:01 +00:00
$q -> setCTE ( " topmost(f_id,top) " , " SELECT id,id from arsse_folders where owner = ? and parent is null union select id,top from arsse_folders join topmost on parent=f_id " , [ " str " ], [ $user ]);
2017-09-28 14:16:24 +00:00
if ( $id ) {
2017-06-04 22:00:18 +00:00
// this condition facilitates the implementation of subscriptionPropertiesGet, which would otherwise have to duplicate the complex query; it takes precedence over a specified folder
// if an ID is specified, add a suitable WHERE condition and bindings
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " arsse_subscriptions.id = ? " , " int " , $id );
2017-10-31 22:09:16 +00:00
} elseif ( $folder && $recursive ) {
// if a folder is specified and we're listing recursively, add a common table expression to list it and its children so that we select from the entire subtree
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " folders(folder) " , " SELECT ? union select id from arsse_folders join folders on parent = folder " , " int " , $folder );
2017-06-04 22:00:18 +00:00
// add a suitable WHERE condition
$q -> setWhere ( " folder in (select folder from folders) " );
2017-10-31 22:09:16 +00:00
} elseif ( ! $recursive ) {
// if we're not listing recursively, match against only the specified folder (even if it is null)
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " coalesce(folder,0) = ? " , " strict int " , $folder );
2017-06-04 22:00:18 +00:00
}
2017-07-07 15:49:54 +00:00
return $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ());
2017-05-04 23:12:33 +00:00
}
2019-02-03 17:25:07 +00:00
/** Returns the number of subscriptions in a folder, counting recursively */
2017-10-03 16:43:46 +00:00
public function subscriptionCount ( string $user , $folder = null ) : int {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// validate inputs
$folder = $this -> folderValidateId ( $user , $folder )[ 'id' ];
// create a complex query
$q = new Query ( " SELECT count(*) from arsse_subscriptions " );
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " owner = ? " , " str " , $user );
2017-10-03 16:43:46 +00:00
if ( $folder ) {
2017-12-07 03:26:06 +00:00
// if the specified folder exists, add a common table expression to list it and its children so that we select from the entire subtree
$q -> setCTE ( " folders(folder) " , " SELECT ? union select id from arsse_folders join folders on parent = folder " , " int " , $folder );
2017-10-03 16:43:46 +00:00
// add a suitable WHERE condition
$q -> setWhere ( " folder in (select folder from folders) " );
}
2017-12-20 00:08:08 +00:00
return ( int ) $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> getValue ();
2017-10-03 16:43:46 +00:00
}
2019-02-03 17:25:07 +00:00
/** Deletes a subscription from the database
2019-05-02 02:52:20 +00:00
*
* This has the side effect of deleting all marks the user has set on articles
2019-02-03 17:25:07 +00:00
* belonging to the newsfeed , but may not delete the articles themselves , as
* other users may also be subscribed to the same newsfeed . There is also a
* configurable retention period for newsfeeds
*/
2017-09-28 14:16:24 +00:00
public function subscriptionRemove ( string $user , $id ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-09-28 14:16:24 +00:00
if ( ! ValueInfo :: id ( $id )) {
2017-10-05 21:42:12 +00:00
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => __FUNCTION__ , " field " => " feed " , 'type' => " int > 0 " ]);
2017-09-28 14:16:24 +00:00
}
2017-12-07 03:26:06 +00:00
$changes = $this -> db -> prepare ( " DELETE from arsse_subscriptions where owner = ? and id = ? " , " str " , " int " ) -> run ( $user , $id ) -> changes ();
2017-08-29 14:50:31 +00:00
if ( ! $changes ) {
2017-09-28 14:16:24 +00:00
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " feed " , 'id' => $id ]);
2017-07-21 02:40:09 +00:00
}
2017-05-11 22:00:35 +00:00
return true ;
2017-05-04 23:12:33 +00:00
}
2019-02-03 17:25:07 +00:00
/** Retrieves data about a particular subscription , as an associative array with the following keys :
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* - " id " : The numeric identifier of the subscription
* - " feed " : The numeric identifier of the underlying newsfeed
* - " url " : The URL of the newsfeed , after discovery and HTTP redirects
* - " title " : The title of the newsfeed
* - " favicon " : The URL of an icon representing the newsfeed or its source
* - " source " : The URL of the source of the newsfeed i . e . its parent Web site
* - " folder " : The numeric identifier ( or null ) of the subscription ' s folder
* - " top_folder " : The numeric identifier ( or null ) of the top - level folder for the subscription
* - " pinned " : Whether the subscription is pinned
* - " err_count " : The count of times attempting to refresh the newsfeed has resulted in an error since the last successful retrieval
* - " err_msg " : The error message of the last unsuccessful retrieval
* - " order_type " : Whether articles should be sorted in reverse cronological order ( 2 ), chronological order ( 1 ), or the default ( 0 )
* - " added " : The date and time at which the subscription was added
* - " updated " : The date and time at which the newsfeed was last updated ( not when it was last refreshed )
* - " unread " : The number of unread articles associated with the subscription
*/
2017-09-28 14:16:24 +00:00
public function subscriptionPropertiesGet ( string $user , $id ) : array {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-09-28 14:16:24 +00:00
if ( ! ValueInfo :: id ( $id )) {
2017-10-05 21:42:12 +00:00
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => __FUNCTION__ , " field " => " feed " , 'type' => " int > 0 " ]);
2017-09-28 14:16:24 +00:00
}
2017-10-31 22:09:16 +00:00
$sub = $this -> subscriptionList ( $user , null , true , ( int ) $id ) -> getRow ();
2017-08-29 14:50:31 +00:00
if ( ! $sub ) {
2017-07-21 02:40:09 +00:00
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " feed " , 'id' => $id ]);
}
2017-05-04 23:12:33 +00:00
return $sub ;
}
2019-02-03 17:25:07 +00:00
/** Modifies the properties of a subscription
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* The $data array must contain one or more of the following keys :
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* - " title " : The title of the newsfeed
* - " folder " : The numeric identifier ( or null ) of the subscription ' s folder
* - " pinned " : Whether the subscription is pinned
* - " order_type " : Whether articles should be sorted in reverse cronological order ( 2 ), chronological order ( 1 ), or the default ( 0 )
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user whose subscription is to be modified
2019-03-06 00:22:01 +00:00
* @ param integer $id the numeric identifier of the subscription to modfify
2019-02-03 17:25:07 +00:00
* @ param array $data An associative array of properties to modify ; any keys not specified will be left unchanged
*/
2017-09-28 14:16:24 +00:00
public function subscriptionPropertiesSet ( string $user , $id , array $data ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-05-06 16:02:27 +00:00
$tr = $this -> db -> begin ();
2017-09-28 14:16:24 +00:00
// validate the ID
$id = $this -> subscriptionValidateId ( $user , $id , true )[ 'id' ];
2017-08-29 14:50:31 +00:00
if ( array_key_exists ( " folder " , $data )) {
2017-05-18 17:21:17 +00:00
// ensure the target folder exists and belong to the user
2017-09-26 20:45:41 +00:00
$data [ 'folder' ] = $this -> folderValidateId ( $user , $data [ 'folder' ])[ 'id' ];
2017-05-18 17:21:17 +00:00
}
2017-08-29 14:50:31 +00:00
if ( array_key_exists ( " title " , $data )) {
2017-05-21 14:10:36 +00:00
// if the title is null, this signals intended use of the default title; otherwise make sure it's not effectively an empty string
2017-08-29 14:50:31 +00:00
if ( ! is_null ( $data [ 'title' ])) {
2017-09-26 20:45:41 +00:00
$info = ValueInfo :: str ( $data [ 'title' ]);
if ( $info & ValueInfo :: EMPTY ) {
2017-07-21 02:40:09 +00:00
throw new Db\ExceptionInput ( " missing " , [ " action " => __FUNCTION__ , " field " => " title " ]);
2017-09-26 20:45:41 +00:00
} elseif ( $info & ValueInfo :: WHITE ) {
2017-07-21 02:40:09 +00:00
throw new Db\ExceptionInput ( " whitespace " , [ " action " => __FUNCTION__ , " field " => " title " ]);
2017-09-26 20:45:41 +00:00
} elseif ( ! ( $info & ValueInfo :: VALID )) {
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => __FUNCTION__ , " field " => " title " , 'type' => " string " ]);
2017-07-21 02:40:09 +00:00
}
2017-05-21 14:10:36 +00:00
}
2017-05-18 17:21:17 +00:00
}
2017-05-04 23:12:33 +00:00
$valid = [
'title' => " str " ,
'folder' => " int " ,
'order_type' => " strict int " ,
'pinned' => " strict bool " ,
];
list ( $setClause , $setTypes , $setValues ) = $this -> generateSet ( $data , $valid );
2017-10-05 21:42:12 +00:00
if ( ! $setClause ) {
// if no changes would actually be applied, just return
return false ;
}
2017-12-07 03:26:06 +00:00
$out = ( bool ) $this -> db -> prepare ( " UPDATE arsse_subscriptions set $setClause , modified = CURRENT_TIMESTAMP where owner = ? and id = ? " , $setTypes , " str " , " int " ) -> run ( $setValues , $user , $id ) -> changes ();
2017-05-06 16:02:27 +00:00
$tr -> commit ();
return $out ;
2017-05-04 18:42:40 +00:00
}
2019-03-07 03:15:41 +00:00
/** Returns an indexed array listing the tags assigned to a subscription
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The user whose tags are to be listed
* @ param integer $id The numeric identifier of the subscription whose tags are to be listed
* @ param boolean $byName Whether to return the tag names ( true ) instead of the numeric tag identifiers ( false )
*/
public function subscriptionTagsGet ( string $user , $id , bool $byName = false ) : array {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$this -> subscriptionValidateId ( $user , $id , true );
$field = ! $byName ? " id " : " name " ;
$out = $this -> db -> prepare ( " SELECT $field from arsse_tags where id in (select tag from arsse_tag_members where subscription = ? and assigned = 1) order by $field " , " int " ) -> run ( $id ) -> getAll ();
return $out ? array_column ( $out , $field ) : [];
}
2019-02-03 17:25:07 +00:00
/** Retrieves the URL of the icon for a subscription .
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* Note that while the $user parameter is optional , it
2019-05-02 02:52:20 +00:00
* is NOT recommended to omit it , as this can lead to
* leaks of private information . The parameter is only
2019-02-03 17:25:07 +00:00
* optional because this is required for Tiny Tiny RSS ,
* the original implementation of which leaks private
* information due to a design flaw .
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param integer $id The numeric identifier of the subscription
* @ param string | null $user The user who owns the subscription being queried
*/
2018-10-26 18:40:20 +00:00
public function subscriptionFavicon ( int $id , string $user = null ) : string {
$q = new Query ( " SELECT favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id " );
$q -> setWhere ( " arsse_subscriptions.id = ? " , " int " , $id );
if ( isset ( $user )) {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$q -> setWhere ( " arsse_subscriptions.owner = ? " , " str " , $user );
}
return ( string ) $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> getValue ();
2017-11-10 17:02:59 +00:00
}
2019-03-26 20:51:44 +00:00
/** Returns the time at which any of a user's subscriptions (or a specific subscription) was last refreshed, as a DateTimeImmutable object */
public function subscriptionRefreshed ( string $user , int $id = null ) {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$q = new Query ( " SELECT max(arsse_feeds.updated) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id " );
$q -> setWhere ( " arsse_subscriptions.owner = ? " , " str " , $user );
if ( $id ) {
$q -> setWhere ( " arsse_subscriptions.id = ? " , " int " , $id );
}
$out = $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> getValue ();
if ( ! $out && $id ) {
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " feed " , 'id' => $id ]);
}
return ValueInfo :: normalize ( $out , ValueInfo :: T_DATE | ValueInfo :: M_NULL , " sql " );
}
2019-02-03 17:25:07 +00:00
/** Ensures the specified subscription exists and raises an exception otherwise
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* Returns an associative array containing the id of the subscription and the id of the underlying newsfeed
2019-05-02 02:52:20 +00:00
*
2019-02-03 17:25:07 +00:00
* @ param string $user The user who owns the subscription to be validated
2019-03-06 00:22:01 +00:00
* @ param integer $id The identifier of the subscription to validate
2019-02-26 16:39:19 +00:00
* @ param boolean $subject Whether the subscription is the subject ( true ) rather than the object ( false ) of the operation being performed ; this only affects the semantics of the error message if validation fails
2019-02-03 17:25:07 +00:00
*/
2017-09-28 14:16:24 +00:00
protected function subscriptionValidateId ( string $user , $id , bool $subject = false ) : array {
if ( ! ValueInfo :: id ( $id )) {
2017-10-05 21:42:12 +00:00
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " feed " , 'type' => " int > 0 " ]);
2017-09-28 14:16:24 +00:00
}
2017-12-07 03:26:06 +00:00
$out = $this -> db -> prepare ( " SELECT id,feed from arsse_subscriptions where id = ? and owner = ? " , " int " , " str " ) -> run ( $id , $user ) -> getRow ();
2017-08-29 14:50:31 +00:00
if ( ! $out ) {
2017-09-28 14:16:24 +00:00
throw new Db\ExceptionInput ( $subject ? " subjectMissing " : " idMissing " , [ " action " => $this -> caller (), " field " => " subscription " , 'id' => $id ]);
2017-07-21 02:40:09 +00:00
}
2017-05-19 03:03:33 +00:00
return $out ;
}
2019-04-21 18:07:36 +00:00
/** Adds a newsfeed to the database without adding any subscriptions , and returns the numeric identifier of the added feed
2019-05-02 02:52:20 +00:00
*
2019-04-21 18:07:36 +00:00
* If the feed already exists in the database , the existing ID is returned
2019-05-02 02:52:20 +00:00
*
2019-04-21 18:07:36 +00:00
* @ param string $url The URL of the newsfeed or discovery source
* @ param string $fetchUser The user name required to access the newsfeed , if applicable
* @ param string $fetchPassword The password required to fetch the newsfeed , if applicable ; this will be stored in cleartext
* @ param boolean $discover Whether to perform newsfeed discovery if $url points to an HTML document
*/
public function feedAdd ( string $url , string $fetchUser = " " , string $fetchPassword = " " , bool $discover = true ) : int {
2019-09-03 23:04:23 +00:00
// normalize the input URL
$url = URL :: normalize ( $url );
2019-04-21 18:07:36 +00:00
// check to see if the feed already exists
$check = $this -> db -> prepare ( " SELECT id from arsse_feeds where url = ? and username = ? and password = ? " , " str " , " str " , " str " );
$feedID = $check -> run ( $url , $fetchUser , $fetchPassword ) -> getValue ();
if ( $discover && is_null ( $feedID )) {
// if the feed doesn't exist, first perform discovery if requested and check for the existence of that URL
$url = Feed :: discover ( $url , $fetchUser , $fetchPassword );
$feedID = $check -> run ( $url , $fetchUser , $fetchPassword ) -> getValue ();
}
if ( is_null ( $feedID )) {
// if the feed still doesn't exist in the database, add it to the database; we do this unconditionally so as to lock SQLite databases for as little time as possible
$feedID = $this -> db -> prepare ( 'INSERT INTO arsse_feeds(url,username,password) values(?,?,?)' , 'str' , 'str' , 'str' ) -> run ( $url , $fetchUser , $fetchPassword ) -> lastId ();
try {
// perform an initial update on the newly added feed
$this -> feedUpdate ( $feedID , true );
} catch ( \Throwable $e ) {
// if the update fails, delete the feed we just added
$this -> db -> prepare ( 'DELETE from arsse_feeds where id = ?' , 'int' ) -> run ( $feedID );
throw $e ;
}
}
return ( int ) $feedID ;
}
2019-02-04 18:05:48 +00:00
/** Returns an indexed array of numeric identifiers for newsfeeds which should be refreshed */
2017-05-19 03:03:33 +00:00
public function feedListStale () : array {
2017-08-02 22:27:04 +00:00
$feeds = $this -> db -> query ( " SELECT id from arsse_feeds where next_fetch <= CURRENT_TIMESTAMP " ) -> getAll ();
2017-08-29 14:50:31 +00:00
return array_column ( $feeds , 'id' );
2017-05-19 03:03:33 +00:00
}
2018-10-26 18:58:04 +00:00
2019-02-04 18:05:48 +00:00
/** Attempts to refresh a newsfeed , returning an indication of success
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param integer $feedID The numerical identifier of the newsfeed to refresh
* @ param boolean $throwError Whether to throw an exception on failure in addition to storing error information in the database
*/
2017-10-02 19:42:15 +00:00
public function feedUpdate ( $feedID , bool $throwError = false ) : bool {
2017-05-06 16:02:27 +00:00
// check to make sure the feed exists
2017-09-28 14:16:24 +00:00
if ( ! ValueInfo :: id ( $feedID )) {
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => __FUNCTION__ , " field " => " feed " , 'id' => $feedID , 'type' => " int > 0 " ]);
}
2017-12-07 03:26:06 +00:00
$f = $this -> db -> prepare ( " SELECT url, username, password, modified, etag, err_count, scrape FROM arsse_feeds where id = ? " , " int " ) -> run ( $feedID ) -> getRow ();
2017-08-29 14:50:31 +00:00
if ( ! $f ) {
2017-07-21 02:40:09 +00:00
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " feed " , 'id' => $feedID ]);
}
2017-07-17 18:56:50 +00:00
// determine whether the feed's items should be scraped for full content from the source Web site
$scrape = ( Arsse :: $conf -> fetchEnableScraping && $f [ 'scrape' ]);
2017-05-06 16:02:27 +00:00
// the Feed object throws an exception when there are problems, but that isn't ideal
// here. When an exception is thrown it should update the database with the
// error instead of failing; if other exceptions are thrown, we should simply roll back
2017-04-09 22:15:00 +00:00
try {
2017-10-02 19:42:15 +00:00
$feed = new Feed (( int ) $feedID , $f [ 'url' ], ( string ) Date :: transform ( $f [ 'modified' ], " http " , " sql " ), $f [ 'etag' ], $f [ 'username' ], $f [ 'password' ], $scrape );
2017-08-29 14:50:31 +00:00
if ( ! $feed -> modified ) {
2017-05-06 16:02:27 +00:00
// if the feed hasn't changed, just compute the next fetch time and record it
2017-12-07 03:26:06 +00:00
$this -> db -> prepare ( " UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ? WHERE id = ? " , 'datetime' , 'int' ) -> run ( $feed -> nextFetch , $feedID );
2017-04-16 02:07:22 +00:00
return false ;
2017-04-21 01:59:12 +00:00
}
2017-05-06 16:02:27 +00:00
} catch ( Feed\Exception $e ) {
// update the database with the resultant error and the next fetch time, incrementing the error count
2017-04-30 21:54:29 +00:00
$this -> db -> prepare (
2017-12-07 03:26:06 +00:00
" UPDATE arsse_feeds SET updated = CURRENT_TIMESTAMP, next_fetch = ?, err_count = err_count + 1, err_msg = ? WHERE id = ? " ,
2017-12-07 20:18:25 +00:00
'datetime' ,
'str' ,
'int'
2017-08-29 14:50:31 +00:00
) -> run ( Feed :: nextFetchOnError ( $f [ 'err_count' ]), $e -> getMessage (), $feedID );
if ( $throwError ) {
2017-07-21 02:40:09 +00:00
throw $e ;
}
2017-05-06 16:02:27 +00:00
return false ;
}
//prepare the necessary statements to perform the update
2017-08-29 14:50:31 +00:00
if ( sizeof ( $feed -> newItems ) || sizeof ( $feed -> changedItems )) {
2017-06-03 18:08:33 +00:00
$qInsertEnclosure = $this -> db -> prepare ( " INSERT INTO arsse_enclosures(article,url,type) values(?,?,?) " , 'int' , 'str' , 'str' );
$qInsertCategory = $this -> db -> prepare ( " INSERT INTO arsse_categories(article,name) values(?,?) " , 'int' , 'str' );
$qInsertEdition = $this -> db -> prepare ( " INSERT INTO arsse_editions(article) values(?) " , 'int' );
2017-05-06 16:02:27 +00:00
}
2017-08-29 14:50:31 +00:00
if ( sizeof ( $feed -> newItems )) {
2017-05-06 16:02:27 +00:00
$qInsertArticle = $this -> db -> prepare (
2017-06-03 21:34:37 +00:00
" INSERT INTO arsse_articles(url,title,author,published,edited,guid,content,url_title_hash,url_content_hash,title_content_hash,feed) values(?,?,?,?,?,?,?,?,?,?,?) " ,
2017-12-07 20:18:25 +00:00
'str' ,
'str' ,
'str' ,
'datetime' ,
'datetime' ,
'str' ,
'str' ,
'str' ,
'str' ,
'str' ,
'int'
2017-05-06 16:02:27 +00:00
);
}
2017-08-29 14:50:31 +00:00
if ( sizeof ( $feed -> changedItems )) {
2017-12-07 03:26:06 +00:00
$qDeleteEnclosures = $this -> db -> prepare ( " DELETE FROM arsse_enclosures WHERE article = ? " , 'int' );
$qDeleteCategories = $this -> db -> prepare ( " DELETE FROM arsse_categories WHERE article = ? " , 'int' );
2018-12-21 02:23:06 +00:00
$qClearReadMarks = $this -> db -> prepare ( " UPDATE arsse_marks SET \" read \" = 0, modified = CURRENT_TIMESTAMP WHERE article = ? and \" read \" = 1 " , 'int' );
2017-05-06 16:02:27 +00:00
$qUpdateArticle = $this -> db -> prepare (
2017-12-07 03:26:06 +00:00
" UPDATE arsse_articles SET url = ?, title = ?, author = ?, published = ?, edited = ?, modified = CURRENT_TIMESTAMP, guid = ?, content = ?, url_title_hash = ?, url_content_hash = ?, title_content_hash = ? WHERE id = ? " ,
2017-12-07 20:18:25 +00:00
'str' ,
'str' ,
'str' ,
'datetime' ,
'datetime' ,
'str' ,
'str' ,
'str' ,
'str' ,
'str' ,
'int'
2017-05-06 16:02:27 +00:00
);
}
// actually perform updates
2017-12-01 21:37:58 +00:00
$tr = $this -> db -> begin ();
2017-08-29 14:50:31 +00:00
foreach ( $feed -> newItems as $article ) {
2017-05-06 16:02:27 +00:00
$articleID = $qInsertArticle -> run (
$article -> url ,
$article -> title ,
$article -> author ,
$article -> publishedDate ,
$article -> updatedDate ,
$article -> id ,
$article -> content ,
$article -> urlTitleHash ,
$article -> urlContentHash ,
$article -> titleContentHash ,
2017-04-14 02:17:53 +00:00
$feedID
2017-05-06 16:02:27 +00:00
) -> lastId ();
2017-08-29 14:50:31 +00:00
if ( $article -> enclosureUrl ) {
$qInsertEnclosure -> run ( $articleID , $article -> enclosureUrl , $article -> enclosureType );
2017-06-03 18:08:33 +00:00
}
2017-08-29 14:50:31 +00:00
foreach ( $article -> categories as $c ) {
2017-05-06 16:02:27 +00:00
$qInsertCategory -> run ( $articleID , $c );
}
$qInsertEdition -> run ( $articleID );
}
2017-08-29 14:50:31 +00:00
foreach ( $feed -> changedItems as $articleID => $article ) {
2017-05-06 16:02:27 +00:00
$qUpdateArticle -> run (
$article -> url ,
$article -> title ,
$article -> author ,
$article -> publishedDate ,
$article -> updatedDate ,
$article -> id ,
$article -> content ,
$article -> urlTitleHash ,
$article -> urlContentHash ,
$article -> titleContentHash ,
$articleID
2017-04-14 02:17:53 +00:00
);
2017-06-03 18:08:33 +00:00
$qDeleteEnclosures -> run ( $articleID );
2017-05-06 16:02:27 +00:00
$qDeleteCategories -> run ( $articleID );
2017-08-29 14:50:31 +00:00
if ( $article -> enclosureUrl ) {
$qInsertEnclosure -> run ( $articleID , $article -> enclosureUrl , $article -> enclosureType );
2017-06-03 18:08:33 +00:00
}
2017-08-29 14:50:31 +00:00
foreach ( $article -> categories as $c ) {
2017-05-06 16:02:27 +00:00
$qInsertCategory -> run ( $articleID , $c );
}
$qInsertEdition -> run ( $articleID );
$qClearReadMarks -> run ( $articleID );
2017-03-31 22:48:24 +00:00
}
2017-05-06 16:02:27 +00:00
// lastly update the feed database itself with updated information.
$this -> db -> prepare (
2019-08-06 13:58:30 +00:00
" UPDATE arsse_feeds SET title = ?, favicon = ?, source = ?, updated = CURRENT_TIMESTAMP, modified = ?, etag = ?, err_count = 0, err_msg = '', next_fetch = ?, size = ? WHERE id = ? " ,
2017-12-07 20:18:25 +00:00
'str' ,
'str' ,
'str' ,
'datetime' ,
'str' ,
'datetime' ,
'int' ,
'int'
2017-05-06 16:02:27 +00:00
) -> run (
$feed -> data -> title ,
$feed -> favicon ,
$feed -> data -> siteUrl ,
$feed -> lastModified ,
$feed -> resource -> getEtag (),
$feed -> nextFetch ,
2017-08-15 00:07:31 +00:00
sizeof ( $feed -> data -> items ),
2017-05-06 16:02:27 +00:00
$feedID
);
$tr -> commit ();
2017-04-14 02:17:53 +00:00
return true ;
2017-03-31 22:48:24 +00:00
}
2017-04-16 02:07:22 +00:00
2019-02-04 18:05:48 +00:00
/** Deletes orphaned newsfeeds from the database
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* Newsfeeds are orphaned if no users are subscribed to them . Deleting a newsfeed also deletes its articles
*/
2017-08-02 22:27:04 +00:00
public function feedCleanup () : bool {
$tr = $this -> begin ();
// first unmark any feeds which are no longer orphaned
2019-03-06 00:22:01 +00:00
$this -> db -> query ( " WITH active_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is not null and count is not null) UPDATE arsse_feeds set orphaned = null where id in (select id from active_feeds) " );
2017-08-02 22:27:04 +00:00
// next mark any newly orphaned feeds with the current date and time
2019-03-06 00:22:01 +00:00
$this -> db -> query ( " WITH orphaned_feeds as (select id from arsse_feeds left join (select feed, count(id) as count from arsse_subscriptions group by feed) as sub_stats on sub_stats.feed = arsse_feeds.id where orphaned is null and count is null) UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where id in (select id from orphaned_feeds) " );
2019-01-21 03:40:49 +00:00
// finally delete feeds that have been orphaned longer than the retention period, if a a purge threshold has been specified
2017-08-29 14:50:31 +00:00
if ( Arsse :: $conf -> purgeFeeds ) {
2019-01-21 03:40:49 +00:00
$limit = Date :: sub ( Arsse :: $conf -> purgeFeeds );
$out = ( bool ) $this -> db -> prepare ( " DELETE from arsse_feeds where orphaned <= ? " , " datetime " ) -> run ( $limit );
} else {
$out = false ;
2017-08-02 22:27:04 +00:00
}
$tr -> commit ();
return $out ;
}
2019-02-04 18:05:48 +00:00
/** Retrieves various identifiers for the latest $count articles in the given newsfeed . The identifiers are :
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* - " id " : The database record key for the article
* - " guid " : The ( theoretically ) unique identifier for the article
* - " edited " : The time at which the article was last edited , per the newsfeed
* - " url_title_hash " : A cryptographic hash of the article URL and its title
* - " url_content_hash " : A cryptographic hash of the article URL and its content
* - " title_content_hash " : A cryptographic hash of the article title and its content
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param integer $feedID The numeric identifier of the feed
* @ param integer $count The number of records to return
*/
2017-05-31 00:18:04 +00:00
public function feedMatchLatest ( int $feedID , int $count ) : Db\Result {
2017-04-23 03:40:57 +00:00
return $this -> db -> prepare (
2017-12-07 03:26:06 +00:00
" SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? ORDER BY modified desc, id desc limit ? " ,
2017-12-07 20:18:25 +00:00
'int' ,
'int'
2017-04-23 03:40:57 +00:00
) -> run ( $feedID , $count );
}
2019-02-04 18:05:48 +00:00
/** Retrieves various identifiers for articles in the given newsfeed which match the input identifiers . The output identifiers are :
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* - " id " : The database record key for the article
* - " guid " : The ( theoretically ) unique identifier for the article
* - " edited " : The time at which the article was last edited , per the newsfeed
* - " url_title_hash " : A cryptographic hash of the article URL and its title
* - " url_content_hash " : A cryptographic hash of the article URL and its content
* - " title_content_hash " : A cryptographic hash of the article title and its content
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param integer $feedID The numeric identifier of the feed
* @ param array $ids An array of GUIDs of articles
* @ param array $hashesUT An array of hashes of articles ' URL and title
* @ param array $hashesUC An array of hashes of articles ' URL and content
* @ param array $hashesTC An array of hashes of articles ' title and content
*/
2017-05-31 00:18:04 +00:00
public function feedMatchIds ( int $feedID , array $ids = [], array $hashesUT = [], array $hashesUC = [], array $hashesTC = []) : Db\Result {
2017-04-23 03:40:57 +00:00
// compile SQL IN() clauses and necessary type bindings for the four identifier lists
2019-03-02 03:36:25 +00:00
list ( $cId , $tId , $vId ) = $this -> generateIn ( $ids , " str " );
list ( $cHashUT , $tHashUT , $vHashUT ) = $this -> generateIn ( $hashesUT , " str " );
list ( $cHashUC , $tHashUC , $vHashUC ) = $this -> generateIn ( $hashesUC , " str " );
list ( $cHashTC , $tHashTC , $vHashTC ) = $this -> generateIn ( $hashesTC , " str " );
2017-04-23 03:40:57 +00:00
// perform the query
return $articles = $this -> db -> prepare (
2017-12-07 03:26:06 +00:00
" SELECT id, edited, guid, url_title_hash, url_content_hash, title_content_hash FROM arsse_articles WHERE feed = ? and (guid in( $cId ) or url_title_hash in( $cHashUT ) or url_content_hash in( $cHashUC ) or title_content_hash in( $cHashTC )) " ,
2017-12-07 20:18:25 +00:00
'int' ,
$tId ,
$tHashUT ,
$tHashUC ,
$tHashTC
2019-03-02 03:36:25 +00:00
) -> run ( $feedID , $vId , $vHashUT , $vHashUC , $vHashTC );
2017-04-23 03:40:57 +00:00
}
2017-06-04 12:15:10 +00:00
2019-04-04 15:22:50 +00:00
/** Returns an associative array of result column names and their SQL computations for article queries
2019-04-05 15:03:15 +00:00
*
2019-04-04 15:22:50 +00:00
* This is used for whitelisting and defining both output column and order - by columns , as well as for resolution of some context options
*/
protected function articleColumns () : array {
$greatest = $this -> db -> sqlToken ( " greatest " );
return [
'id' => " arsse_articles.id " ,
'edition' => " latest_editions.edition " ,
'url' => " arsse_articles.url " ,
'title' => " arsse_articles.title " ,
'author' => " arsse_articles.author " ,
'content' => " arsse_articles.content " ,
'guid' => " arsse_articles.guid " ,
'fingerprint' => " arsse_articles.url_title_hash || ':' || arsse_articles.url_content_hash || ':' || arsse_articles.title_content_hash " ,
'folder' => " coalesce(arsse_subscriptions.folder,0) " ,
'subscription' => " arsse_subscriptions.id " ,
'feed' => " arsse_subscriptions.feed " ,
'starred' => " coalesce(arsse_marks.starred,0) " ,
'unread' => " abs(coalesce(arsse_marks.read,0) - 1) " ,
'note' => " coalesce(arsse_marks.note,'') " ,
'published_date' => " arsse_articles.published " ,
'edited_date' => " arsse_articles.edited " ,
'modified_date' => " arsse_articles.modified " ,
'marked_date' => " $greatest (arsse_articles.modified, coalesce(arsse_marks.modified, '0001-01-01 00:00:00'), coalesce(label_stats.modified, '0001-01-01 00:00:00')) " ,
'subscription_title' => " coalesce(arsse_subscriptions.title, arsse_feeds.title) " ,
'media_url' => " arsse_enclosures.url " ,
'media_type' => " arsse_enclosures.type " ,
];
}
2019-02-04 18:05:48 +00:00
/** Computes an SQL query to find and retrieve data about articles in the database
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* If an empty column list is supplied , a count of articles matching the context is queried instead
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user whose articles are to be queried
* @ param Context $context The search context
* @ param array $cols The columns to request in the result set
*/
2018-12-05 01:41:21 +00:00
protected function articleQuery ( string $user , Context $context , array $cols = [ " id " ]) : Query {
2019-02-25 21:26:38 +00:00
// validate input
if ( $context -> subscription ()) {
$this -> subscriptionValidateId ( $user , $context -> subscription );
}
if ( $context -> folder ()) {
$this -> folderValidateId ( $user , $context -> folder );
2019-05-02 02:52:20 +00:00
}
2019-02-25 21:26:38 +00:00
if ( $context -> folderShallow ()) {
$this -> folderValidateId ( $user , $context -> folderShallow );
}
if ( $context -> edition ()) {
$this -> articleValidateEdition ( $user , $context -> edition );
2019-05-02 02:52:20 +00:00
}
2019-02-25 21:26:38 +00:00
if ( $context -> article ()) {
$this -> articleValidateId ( $user , $context -> article );
}
if ( $context -> label ()) {
$this -> labelValidateId ( $user , $context -> label , false );
}
if ( $context -> labelName ()) {
2019-02-27 15:48:11 +00:00
$this -> labelValidateId ( $user , $context -> labelName , true );
2019-02-25 21:26:38 +00:00
}
// prepare the output column list; the column definitions are also used later
2019-04-04 15:22:50 +00:00
$colDefs = $this -> articleColumns ();
2018-12-05 01:41:21 +00:00
if ( ! $cols ) {
2019-04-03 19:02:59 +00:00
// if no columns are specified return a count; don't borther with sorting
$outColumns = " count(distinct arsse_articles.id) as count " ;
2018-12-05 01:41:21 +00:00
} else {
2019-04-03 19:02:59 +00:00
// normalize requested output and sorting columns
$norm = function ( $v ) {
return trim ( strtolower ( ValueInfo :: normalize ( $v , ValueInfo :: T_STRING )));
};
$cols = array_map ( $norm , $cols );
// make an output column list
$outColumns = [];
2018-12-05 01:41:21 +00:00
foreach ( $cols as $col ) {
if ( ! isset ( $colDefs [ $col ])) {
continue ;
}
2019-04-03 19:02:59 +00:00
$outColumns [] = $colDefs [ $col ] . " as " . $col ;
2018-12-05 01:41:21 +00:00
}
2019-04-03 19:02:59 +00:00
$outColumns = implode ( " , " , $outColumns );
2017-07-21 02:40:09 +00:00
}
2018-12-05 01:41:21 +00:00
// define the basic query, to which we add lots of stuff where necessary
2017-06-18 14:23:37 +00:00
$q = new Query (
2018-12-05 01:41:21 +00:00
" SELECT
2019-04-03 19:02:59 +00:00
$outColumns
2018-12-05 01:41:21 +00:00
from arsse_articles
join arsse_subscriptions on arsse_subscriptions . feed = arsse_articles . feed and arsse_subscriptions . owner = ?
join arsse_feeds on arsse_subscriptions . feed = arsse_feeds . id
left join arsse_marks on arsse_marks . subscription = arsse_subscriptions . id and arsse_marks . article = arsse_articles . id
left join arsse_enclosures on arsse_enclosures . article = arsse_articles . id
2019-02-27 15:48:11 +00:00
join (
SELECT article , max ( id ) as edition from arsse_editions group by article
) as latest_editions on arsse_articles . id = latest_editions . article
left join (
SELECT arsse_label_members . article , max ( arsse_label_members . modified ) as modified , sum ( arsse_label_members . assigned ) as assigned from arsse_label_members join arsse_labels on arsse_labels . id = arsse_label_members . label where arsse_labels . owner = ? group by arsse_label_members . article
) as label_stats on label_stats . article = arsse_articles . id " ,
[ " str " , " str " ],
[ $user , $user ]
2017-06-18 14:23:37 +00:00
);
2019-02-25 21:26:38 +00:00
$q -> setLimit ( $context -> limit , $context -> offset );
// handle the simple context options
2019-02-26 03:41:12 +00:00
$options = [
2019-03-02 03:36:25 +00:00
// each context array consists of a column identifier (see $colDefs above), a comparison operator, a data type, and an option to pair with for BETWEEN evaluation
" edition " => [ " edition " , " = " , " int " , " " ],
" editions " => [ " edition " , " in " , " int " , " " ],
" article " => [ " id " , " = " , " int " , " " ],
" articles " => [ " id " , " in " , " int " , " " ],
" oldestArticle " => [ " id " , " >= " , " int " , " latestArticle " ],
" latestArticle " => [ " id " , " <= " , " int " , " oldestArticle " ],
" oldestEdition " => [ " edition " , " >= " , " int " , " latestEdition " ],
" latestEdition " => [ " edition " , " <= " , " int " , " oldestEdition " ],
" modifiedSince " => [ " modified_date " , " >= " , " datetime " , " notModifiedSince " ],
" notModifiedSince " => [ " modified_date " , " <= " , " datetime " , " modifiedSince " ],
" markedSince " => [ " marked_date " , " >= " , " datetime " , " notMarkedSince " ],
" notMarkedSince " => [ " marked_date " , " <= " , " datetime " , " markedSince " ],
" folderShallow " => [ " folder " , " = " , " int " , " " ],
2019-04-02 22:24:20 +00:00
" foldersShallow " => [ " folder " , " in " , " int " , " " ],
2019-03-02 03:36:25 +00:00
" subscription " => [ " subscription " , " = " , " int " , " " ],
2019-04-02 22:24:20 +00:00
" subscriptions " => [ " subscription " , " in " , " int " , " " ],
2019-03-02 03:36:25 +00:00
" unread " => [ " unread " , " = " , " bool " , " " ],
" starred " => [ " starred " , " = " , " bool " , " " ],
2019-02-26 03:41:12 +00:00
];
2019-03-02 03:36:25 +00:00
foreach ( $options as $m => list ( $col , $op , $type , $pair )) {
2019-02-25 21:26:38 +00:00
if ( ! $context -> $m ()) {
// context is not being used
continue ;
} elseif ( is_array ( $context -> $m )) {
2019-02-26 16:11:42 +00:00
// context option is an array of values
2019-02-25 21:26:38 +00:00
if ( ! $context -> $m ) {
throw new Db\ExceptionInput ( " tooShort " , [ 'field' => $m , 'action' => $this -> caller (), 'min' => 1 ]); // must have at least one array element
}
list ( $clause , $types , $values ) = $this -> generateIn ( $context -> $m , $type );
$q -> setWhere ( " { $colDefs [ $col ] } $op ( $clause ) " , $types , $values );
2019-02-26 16:11:42 +00:00
} elseif ( $pair && $context -> $pair ()) {
// option is paired with another which is also being used
if ( $op === " >= " ) {
2019-05-02 02:52:20 +00:00
$q -> setWhere ( " { $colDefs [ $col ] } BETWEEN ? AND ? " , [ $type , $type ], [ $context -> $m , $context -> $pair ]);
2019-02-26 16:11:42 +00:00
} else {
// option has already been paired
continue ;
}
2019-02-25 21:26:38 +00:00
} else {
$q -> setWhere ( " { $colDefs [ $col ] } $op ? " , $type , $context -> $m );
2017-10-07 00:26:22 +00:00
}
2017-06-18 14:23:37 +00:00
}
2019-02-26 16:11:42 +00:00
// further handle exclusionary options if specified
2019-03-02 03:36:25 +00:00
foreach ( $options as $m => list ( $col , $op , $type , $pair )) {
2019-02-26 16:11:42 +00:00
if ( ! method_exists ( $context -> not , $m ) || ! $context -> not -> $m ()) {
// context option is not being used
continue ;
} elseif ( is_array ( $context -> not -> $m )) {
if ( ! $context -> not -> $m ) {
// for exclusions we don't care if the array is empty
2019-02-26 03:41:12 +00:00
continue ;
2019-02-26 16:11:42 +00:00
}
list ( $clause , $types , $values ) = $this -> generateIn ( $context -> not -> $m , $type );
$q -> setWhereNot ( " { $colDefs [ $col ] } $op ( $clause ) " , $types , $values );
} elseif ( $pair && $context -> not -> $pair ()) {
// option is paired with another which is also being used
if ( $op === " >= " ) {
2019-05-02 02:52:20 +00:00
$q -> setWhereNot ( " { $colDefs [ $col ] } BETWEEN ? AND ? " , [ $type , $type ], [ $context -> not -> $m , $context -> not -> $pair ]);
2019-02-26 03:41:12 +00:00
} else {
2019-02-26 16:11:42 +00:00
// option has already been paired
continue ;
2019-02-26 03:41:12 +00:00
}
2019-02-26 16:11:42 +00:00
} else {
$q -> setWhereNot ( " { $colDefs [ $col ] } $op ? " , $type , $context -> not -> $m );
2019-02-26 03:41:12 +00:00
}
}
2019-04-02 22:24:20 +00:00
// handle labels and tags
$options = [
'label' => [
'match_col' => " arsse_articles.id " ,
'cte_name' => " labelled " ,
'cte_cols' => [ " article " , " label_id " , " label_name " ],
'cte_body' => " SELECT m.article, l.id, l.name from arsse_label_members as m join arsse_labels as l on l.id = m.label where l.owner = ? and m.assigned = 1 " ,
'cte_types' => [ " str " ],
'cte_values' => [ $user ],
'options' => [
'label' => [ 'use_name' => false , 'multi' => false ],
'labels' => [ 'use_name' => false , 'multi' => true ],
'labelName' => [ 'use_name' => true , 'multi' => false ],
'labelNames' => [ 'use_name' => true , 'multi' => true ],
],
],
'tag' => [
'match_col' => " arsse_subscriptions.id " ,
'cte_name' => " tagged " ,
'cte_cols' => [ " subscription " , " tag_id " , " tag_name " ],
'cte_body' => " SELECT m.subscription, t.id, t.name from arsse_tag_members as m join arsse_tags as t on t.id = m.tag where t.owner = ? and m.assigned = 1 " ,
'cte_types' => [ " str " ],
'cte_values' => [ $user ],
'options' => [
'tag' => [ 'use_name' => false , 'multi' => false ],
'tags' => [ 'use_name' => false , 'multi' => true ],
'tagName' => [ 'use_name' => true , 'multi' => false ],
'tagNames' => [ 'use_name' => true , 'multi' => true ],
],
],
];
foreach ( $options as $opt ) {
$seen = false ;
$match = $opt [ 'match_col' ];
$table = $opt [ 'cte_name' ];
foreach ( $opt [ 'options' ] as $m => $props ) {
$named = $props [ 'use_name' ];
$multi = $props [ 'multi' ];
$selection = $opt [ 'cte_cols' ][ 0 ];
$col = $opt [ 'cte_cols' ][ $named ? 2 : 1 ];
if ( $context -> $m ()) {
$seen = true ;
2019-04-02 22:37:46 +00:00
if ( ! $context -> $m ) {
throw new Db\ExceptionInput ( " tooShort " , [ 'field' => $m , 'action' => $this -> caller (), 'min' => 1 ]); // must have at least one array element
}
2019-04-02 22:24:20 +00:00
if ( $multi ) {
list ( $test , $types , $values ) = $this -> generateIn ( $context -> $m , $named ? " str " : " int " );
$test = " in ( $test ) " ;
} else {
$test = " = ? " ;
$types = $named ? " str " : " int " ;
$values = $context -> $m ;
}
$q -> setWhere ( " $match in (select $selection from $table where $col $test ) " , $types , $values );
}
if ( $context -> not -> $m ()) {
$seen = true ;
if ( $multi ) {
list ( $test , $types , $values ) = $this -> generateIn ( $context -> not -> $m , $named ? " str " : " int " );
$test = " in ( $test ) " ;
} else {
$test = " = ? " ;
$types = $named ? " str " : " int " ;
$values = $context -> not -> $m ;
}
$q -> setWhereNot ( " $match in (select $selection from $table where $col $test ) " , $types , $values );
}
}
if ( $seen ) {
2019-04-05 15:03:15 +00:00
$spec = $opt [ 'cte_name' ] . " ( " . implode ( " , " , $opt [ 'cte_cols' ]) . " ) " ;
2019-04-02 22:24:20 +00:00
$q -> setCTE ( $spec , $opt [ 'cte_body' ], $opt [ 'cte_types' ], $opt [ 'cte_values' ]);
}
}
2019-02-25 21:26:38 +00:00
// handle complex context options
2017-11-18 00:08:35 +00:00
if ( $context -> annotated ()) {
2018-12-05 01:41:21 +00:00
$comp = ( $context -> annotated ) ? " <> " : " = " ;
$q -> setWhere ( " coalesce(arsse_marks.note,'') $comp '' " );
2017-11-18 00:08:35 +00:00
}
2019-02-27 15:48:11 +00:00
if ( $context -> labelled ()) {
// any label (true) or no label (false)
$op = $context -> labelled ? " > " : " = " ;
$q -> setWhere ( " coalesce(label_stats.assigned,0) $op 0 " );
}
2019-02-25 21:26:38 +00:00
if ( $context -> folder ()) {
// add a common table expression to list the folder and its children so that we select from the entire subtree
2019-04-02 23:58:35 +00:00
$q -> setCTE ( " folders(folder) " , " SELECT ? union select id from arsse_folders join folders on coalesce(parent,0) = folder " , " int " , $context -> folder );
2019-02-25 21:26:38 +00:00
// limit subscriptions to the listed folders
2019-02-27 15:48:11 +00:00
$q -> setWhere ( " coalesce(arsse_subscriptions.folder,0) in (select folder from folders) " );
}
2019-04-03 02:44:09 +00:00
if ( $context -> folders ()) {
list ( $inClause , $inTypes , $inValues ) = $this -> generateIn ( $context -> folders , " int " );
// add a common table expression to list the folders and their children so that we select from the entire subtree
$q -> setCTE ( " folders_multi(folder) " , " SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ( $inClause )) as folders_multi union select id from arsse_folders join folders_multi on coalesce(parent,0) = folder " , [ " str " , $inTypes ], [ $user , $inValues ]);
// limit subscriptions to the listed folders
$q -> setWhere ( " coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi) " );
}
2019-02-27 15:48:11 +00:00
if ( $context -> not -> folder ()) {
// add a common table expression to list the folder and its children so that we exclude from the entire subtree
2019-04-02 23:58:35 +00:00
$q -> setCTE ( " folders_excluded(folder) " , " SELECT ? union select id from arsse_folders join folders_excluded on coalesce(parent,0) = folder " , " int " , $context -> not -> folder );
2019-02-27 15:48:11 +00:00
// excluded any subscriptions in the listed folders
$q -> setWhereNot ( " coalesce(arsse_subscriptions.folder,0) in (select folder from folders_excluded) " );
2019-02-25 21:26:38 +00:00
}
2019-04-03 02:44:09 +00:00
if ( $context -> not -> folders ()) {
list ( $inClause , $inTypes , $inValues ) = $this -> generateIn ( $context -> not -> folders , " int " );
// add a common table expression to list the folders and their children so that we select from the entire subtree
$q -> setCTE ( " folders_multi_excluded(folder) " , " SELECT id as folder from (select id from (select 0 as id union select id from arsse_folders where owner = ?) as f where id in ( $inClause )) as folders_multi_excluded union select id from arsse_folders join folders_multi_excluded on coalesce(parent,0) = folder " , [ " str " , $inTypes ], [ $user , $inValues ]);
// limit subscriptions to the listed folders
$q -> setWhereNot ( " coalesce(arsse_subscriptions.folder,0) in (select folder from folders_multi_excluded) " );
}
2019-02-25 21:26:38 +00:00
// handle text-matching context options
2019-02-26 16:39:19 +00:00
$options = [
2019-03-02 18:53:43 +00:00
" titleTerms " => [ " arsse_articles.title " ],
" searchTerms " => [ " arsse_articles.title " , " arsse_articles.content " ],
" authorTerms " => [ " arsse_articles.author " ],
" annotationTerms " => [ " arsse_marks.note " ],
2019-02-26 16:39:19 +00:00
];
2019-04-03 19:02:59 +00:00
foreach ( $options as $m => $columns ) {
2019-02-25 21:26:38 +00:00
if ( ! $context -> $m ()) {
continue ;
} elseif ( ! $context -> $m ) {
throw new Db\ExceptionInput ( " tooShort " , [ 'field' => $m , 'action' => $this -> caller (), 'min' => 1 ]); // must have at least one array element
2019-02-24 01:14:52 +00:00
}
2019-04-03 19:02:59 +00:00
$q -> setWhere ( ... $this -> generateSearch ( $context -> $m , $columns ));
2019-02-24 01:14:52 +00:00
}
2019-02-26 16:39:19 +00:00
// further handle exclusionary text-matching context options
2019-04-03 19:02:59 +00:00
foreach ( $options as $m => $columns ) {
2019-03-02 18:53:43 +00:00
if ( ! $context -> not -> $m () || ! $context -> not -> $m ) {
2019-02-26 16:39:19 +00:00
continue ;
}
2019-04-03 19:02:59 +00:00
$q -> setWhereNot ( ... $this -> generateSearch ( $context -> not -> $m , $columns , true ));
2019-02-26 16:39:19 +00:00
}
2017-10-07 00:26:22 +00:00
// return the query
return $q ;
}
2019-02-04 18:05:48 +00:00
/** Lists articles in the database which match a given query context
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* If an empty column list is supplied , a count of articles is returned instead
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user whose articles are to be listed
* @ param Context $context The search context
2019-04-04 15:22:50 +00:00
* @ param array $fieldss The columns to return in the result set , any of : id , edition , url , title , author , content , guid , fingerprint , folder , subscription , feed , starred , unread , note , published_date , edited_date , modified_date , marked_date , subscription_title , media_url , media_type
* @ param array $sort The columns to sort the result by eg . " edition desc " in decreasing order of importance
2019-02-04 18:05:48 +00:00
*/
2019-04-04 15:22:50 +00:00
public function articleList ( string $user , Context $context = null , array $fields = [ " id " ], array $sort = []) : Db\Result {
2017-10-07 00:26:22 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2019-04-04 15:22:50 +00:00
// make a base query based on context and output columns
2017-10-07 00:26:22 +00:00
$context = $context ? ? new Context ;
2019-03-02 03:36:25 +00:00
$q = $this -> articleQuery ( $user , $context , $fields );
2019-04-04 15:22:50 +00:00
// make an ORDER BY column list
$colDefs = $this -> articleColumns ();
// normalize requested output and sorting columns
$norm = function ( $v ) {
return trim ( strtolower (( string ) $v ));
};
$fields = array_map ( $norm , $fields );
$sort = array_map ( $norm , $sort );
foreach ( $sort as $spec ) {
2019-04-04 21:21:23 +00:00
$col = explode ( " " , $spec , 2 );
2019-04-04 15:22:50 +00:00
$order = $col [ 1 ] ? ? " " ;
$col = $col [ 0 ];
if ( $order === " desc " ) {
$order = " desc " ;
} elseif ( $order === " asc " || $order === " " ) {
$order = " " ;
} else {
// column direction spec is bogus
continue ;
}
if ( ! isset ( $colDefs [ $col ])) {
// column name spec is bogus
continue ;
} elseif ( in_array ( $col , $fields )) {
// if the sort column is also an output column, use it as-is
$q -> setOrder ( $col . $order );
} else {
// otherwise if the column name is valid, use its expression
$q -> setOrder ( $colDefs [ $col ] . $order );
}
}
2019-03-02 03:36:25 +00:00
// perform the query and return results
return $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ());
2017-06-18 14:23:37 +00:00
}
2019-02-04 18:05:48 +00:00
/** Returns a count of articles which match the given query context
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user whose articles are to be counted
* @ param Context $context The search context
*/
2017-10-13 21:05:06 +00:00
public function articleCount ( string $user , Context $context = null ) : int {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$context = $context ? ? new Context ;
2019-03-02 03:36:25 +00:00
$q = $this -> articleQuery ( $user , $context , []);
return ( int ) $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> getValue ();
2017-10-13 21:05:06 +00:00
}
2019-02-04 18:05:48 +00:00
/** Applies one or multiple modifications to all articles matching the given query context
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* The $data array enumerates the modifications to perform and must contain one or more of the following keys :
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* - " read " : Whether the article should be marked as read ( true ) or unread ( false )
* - " starred " : Whether the article should ( true ) or should not ( false ) be marked as starred / favourite
* - " note " : A string containing a freeform plain - text note for the article
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user who owns the articles to be modified
* @ param array $data An associative array of properties to modify . Anything not specified will remain unchanged
* @ param Context $context The query context to match articles against
*/
2017-10-20 22:17:47 +00:00
public function articleMark ( string $user , array $data , Context $context = null ) : int {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2018-12-05 01:41:21 +00:00
$data = [
'read' => $data [ 'read' ] ? ? null ,
'starred' => $data [ 'starred' ] ? ? null ,
'note' => $data [ 'note' ] ? ? null ,
];
if ( ! isset ( $data [ 'read' ]) && ! isset ( $data [ 'starred' ]) && ! isset ( $data [ 'note' ])) {
return 0 ;
}
2017-10-13 04:04:26 +00:00
$context = $context ? ? new Context ;
2019-03-02 03:36:25 +00:00
$tr = $this -> begin ();
$out = 0 ;
if ( $data [ 'read' ] || $data [ 'starred' ] || strlen ( $data [ 'note' ] ? ? " " )) {
// first prepare a query to insert any missing marks rows for the articles we want to mark
// but only insert new mark records if we're setting at least one "positive" mark
$q = $this -> articleQuery ( $user , $context , [ " id " , " subscription " , " note " ]);
$q -> setWhere ( " arsse_marks.starred is null " ); // null means there is no marks row for the article
$this -> db -> prepare ( " INSERT INTO arsse_marks(article,subscription,note) " . $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ());
}
if ( isset ( $data [ 'read' ]) && ( isset ( $data [ 'starred' ]) || isset ( $data [ 'note' ])) && ( $context -> edition () || $context -> editions ())) {
// if marking by edition both read and something else, do separate marks for starred and note than for read
// marking as read is ignored if the edition is not the latest, but the same is not true of the other two marks
$this -> db -> query ( " UPDATE arsse_marks set touched = 0 where touched <> 0 " );
// set read marks
$q = $this -> articleQuery ( $user , $context , [ " id " , " subscription " ]);
$q -> setWhere ( " arsse_marks.read <> coalesce(?,arsse_marks.read) " , " bool " , $data [ 'read' ]);
$q -> pushCTE ( " target_articles(article,subscription) " );
$q -> setBody ( " UPDATE arsse_marks set \" read \" = ?, touched = 1 where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles) " , " bool " , $data [ 'read' ]);
$this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ());
// get the articles associated with the requested editions
if ( $context -> edition ()) {
$context -> article ( $this -> articleValidateEdition ( $user , $context -> edition )[ 'article' ]) -> edition ( null );
} else {
$context -> articles ( $this -> editionArticle ( ... $context -> editions )) -> editions ( null );
2017-11-07 04:32:29 +00:00
}
2019-03-02 03:36:25 +00:00
// set starred and/or note marks (unless all requested editions actually do not exist)
if ( $context -> article || $context -> articles ) {
2018-12-05 01:41:21 +00:00
$q = $this -> articleQuery ( $user , $context , [ " id " , " subscription " ]);
2019-03-02 03:36:25 +00:00
$q -> setWhere ( " (arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred)) " , [ " str " , " bool " ], [ $data [ 'note' ], $data [ 'starred' ]]);
2018-12-05 01:41:21 +00:00
$q -> pushCTE ( " target_articles(article,subscription) " );
2019-03-02 03:36:25 +00:00
$data = array_filter ( $data , function ( $v ) {
return isset ( $v );
});
list ( $set , $setTypes , $setValues ) = $this -> generateSet ( $data , [ 'starred' => " bool " , 'note' => " str " ]);
$q -> setBody ( " UPDATE arsse_marks set touched = 1, $set where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles) " , $setTypes , $setValues );
2018-12-05 01:41:21 +00:00
$this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ());
2019-03-02 03:36:25 +00:00
}
// finally set the modification date for all touched marks and return the number of affected marks
$out = $this -> db -> query ( " UPDATE arsse_marks set modified = CURRENT_TIMESTAMP, touched = 0 where touched = 1 " ) -> changes ();
} else {
if ( ! isset ( $data [ 'read' ]) && ( $context -> edition () || $context -> editions ())) {
2018-12-05 01:41:21 +00:00
// get the articles associated with the requested editions
if ( $context -> edition ()) {
$context -> article ( $this -> articleValidateEdition ( $user , $context -> edition )[ 'article' ]) -> edition ( null );
} else {
$context -> articles ( $this -> editionArticle ( ... $context -> editions )) -> editions ( null );
}
2019-03-02 03:36:25 +00:00
if ( ! $context -> article && ! $context -> articles ) {
return 0 ;
2018-12-05 01:41:21 +00:00
}
2017-11-07 04:32:29 +00:00
}
2019-03-02 03:36:25 +00:00
$q = $this -> articleQuery ( $user , $context , [ " id " , " subscription " ]);
$q -> setWhere ( " (arsse_marks.note <> coalesce(?,arsse_marks.note) or arsse_marks.starred <> coalesce(?,arsse_marks.starred) or arsse_marks.read <> coalesce(?,arsse_marks.read)) " , [ " str " , " bool " , " bool " ], [ $data [ 'note' ], $data [ 'starred' ], $data [ 'read' ]]);
$q -> pushCTE ( " target_articles(article,subscription) " );
$data = array_filter ( $data , function ( $v ) {
return isset ( $v );
});
list ( $set , $setTypes , $setValues ) = $this -> generateSet ( $data , [ 'read' => " bool " , 'starred' => " bool " , 'note' => " str " ]);
$q -> setBody ( " UPDATE arsse_marks set $set , modified = CURRENT_TIMESTAMP where article in(select article from target_articles) and subscription in(select distinct subscription from target_articles) " , $setTypes , $setValues );
$out = $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> changes ();
2017-11-07 04:32:29 +00:00
}
2019-03-02 03:36:25 +00:00
$tr -> commit ();
return $out ;
2017-06-04 22:00:18 +00:00
}
2017-06-30 17:53:19 +00:00
2019-02-04 18:05:48 +00:00
/** Returns statistics about the articles starred by the given user
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* The associative array returned has the following keys :
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* - " total " : The count of all starred articles
* - " unread " : The count of starred articles which are unread
* - " read " : The count of starred articles which are read
*/
2017-10-11 16:55:50 +00:00
public function articleStarred ( string $user ) : array {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
return $this -> db -> prepare (
" SELECT
count ( * ) as total ,
2018-12-21 02:23:06 +00:00
coalesce ( sum ( abs ( \ " read \" - 1)),0) as unread,
coalesce ( sum ( \ " read \" ),0) as \" read \"
2017-10-11 16:55:50 +00:00
FROM (
2018-12-21 02:23:06 +00:00
select \ " read \" from arsse_marks where starred = 1 and subscription in (select id from arsse_subscriptions where owner = ?)
2018-12-05 01:41:21 +00:00
) as starred_data " ,
2017-12-07 20:18:25 +00:00
" str "
2017-10-11 16:55:50 +00:00
) -> run ( $user ) -> getRow ();
}
2019-02-04 18:05:48 +00:00
/** Returns an indexed array listing the labels assigned to an article
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user whose labels are to be listed
* @ param integer $id The numeric identifier of the article whose labels are to be listed
2019-02-26 16:39:19 +00:00
* @ param boolean $byName Whether to return the label names ( true ) instead of the numeric label identifiers ( false )
2019-02-04 18:05:48 +00:00
*/
2017-10-13 21:05:06 +00:00
public function articleLabelsGet ( string $user , $id , bool $byName = false ) : array {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$id = $this -> articleValidateId ( $user , $id )[ 'article' ];
2019-03-07 03:15:41 +00:00
$field = ! $byName ? " id " : " name " ;
$out = $this -> db -> prepare ( " SELECT $field from arsse_labels join arsse_label_members on arsse_label_members.label = arsse_labels.id where owner = ? and article = ? and assigned = 1 order by $field " , " str " , " int " ) -> run ( $user , $id ) -> getAll ();
return $out ? array_column ( $out , $field ) : [];
2017-10-13 21:05:06 +00:00
}
2019-02-04 18:05:48 +00:00
/** Returns the author-supplied categories associated with an article */
2017-11-21 14:22:58 +00:00
public function articleCategoriesGet ( string $user , $id ) : array {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$id = $this -> articleValidateId ( $user , $id )[ 'article' ];
2017-12-07 03:26:06 +00:00
$out = $this -> db -> prepare ( " SELECT name from arsse_categories where article = ? order by name " , " int " ) -> run ( $id ) -> getAll ();
2017-11-21 14:22:58 +00:00
if ( ! $out ) {
return $out ;
} else {
// flatten the result
return array_column ( $out , " name " );
}
}
2019-02-04 18:05:48 +00:00
/** Deletes from the database articles which are beyond the configured clean-up threshold */
2017-08-18 02:36:15 +00:00
public function articleCleanup () : bool {
$query = $this -> db -> prepare (
2019-03-06 00:22:01 +00:00
" WITH RECURSIVE
exempt_articles as ( SELECT id from arsse_articles join ( SELECT article , max ( id ) as edition from arsse_editions group by article ) as latest_editions on arsse_articles . id = latest_editions . article where feed = ? order by edition desc limit ? ),
target_articles as (
select id from arsse_articles
left join ( select article , sum ( starred ) as starred , sum ( \ " read \" ) as \" read \" , max(arsse_marks.modified) as marked_date from arsse_marks join arsse_subscriptions on arsse_subscriptions.id = arsse_marks.subscription group by article) as mark_stats on mark_stats.article = arsse_articles.id
left join ( select feed , count ( * ) as subs from arsse_subscriptions group by feed ) as feed_stats on feed_stats . feed = arsse_articles . feed
where arsse_articles . feed = ? and coalesce ( starred , 0 ) = 0 and ( coalesce ( marked_date , modified ) <= ? or ( coalesce ( \ " read \" ,0) = coalesce(subs,0) and coalesce(marked_date,modified) <= ?))
2017-08-18 02:36:15 +00:00
)
2019-03-06 00:22:01 +00:00
DELETE FROM arsse_articles WHERE id not in ( select id from exempt_articles ) and id in ( select id from target_articles ) " ,
" int " ,
2017-12-07 20:18:25 +00:00
" int " ,
" int " ,
" datetime " ,
" datetime "
2017-08-18 02:36:15 +00:00
);
$limitRead = null ;
$limitUnread = null ;
2017-08-29 14:50:31 +00:00
if ( Arsse :: $conf -> purgeArticlesRead ) {
2017-08-20 19:46:35 +00:00
$limitRead = Date :: sub ( Arsse :: $conf -> purgeArticlesRead );
2017-08-18 02:36:15 +00:00
}
2017-08-29 14:50:31 +00:00
if ( Arsse :: $conf -> purgeArticlesUnread ) {
2017-08-20 19:46:35 +00:00
$limitUnread = Date :: sub ( Arsse :: $conf -> purgeArticlesUnread );
2017-08-18 02:36:15 +00:00
}
$feeds = $this -> db -> query ( " SELECT id, size from arsse_feeds " ) -> getAll ();
2019-07-26 13:37:51 +00:00
$deleted = 0 ;
2017-08-29 14:50:31 +00:00
foreach ( $feeds as $feed ) {
2019-07-26 13:37:51 +00:00
$deleted += $query -> run ( $feed [ 'id' ], $feed [ 'size' ], $feed [ 'id' ], $limitUnread , $limitRead ) -> changes ();
2017-08-18 02:36:15 +00:00
}
2019-07-26 13:37:51 +00:00
return ( bool ) $deleted ;
2017-08-18 02:36:15 +00:00
}
2019-02-04 18:05:48 +00:00
/** Ensures the specified article exists and raises an exception otherwise
2019-05-02 02:52:20 +00:00
*
* Returns an associative array containing the id and latest edition of the article if it exists
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user who owns the article to be validated
2019-03-06 00:22:01 +00:00
* @ param integer $id The identifier of the article to validate
2019-02-04 18:05:48 +00:00
*/
2017-09-28 14:16:24 +00:00
protected function articleValidateId ( string $user , $id ) : array {
if ( ! ValueInfo :: id ( $id )) {
2017-10-05 21:42:12 +00:00
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " article " , 'type' => " int > 0 " ]); // @codeCoverageIgnore
2017-09-28 14:16:24 +00:00
}
2017-06-30 17:53:19 +00:00
$out = $this -> db -> prepare (
2019-03-06 00:22:01 +00:00
" SELECT articles.article as article, max(arsse_editions.id) as edition from (
select arsse_articles . id as article
FROM arsse_articles
join arsse_subscriptions on arsse_subscriptions . feed = arsse_articles . feed
WHERE arsse_articles . id = ? and arsse_subscriptions . owner = ?
) as articles join arsse_editions on arsse_editions . article = articles . article group by articles . article " ,
2017-12-07 20:18:25 +00:00
" int " ,
" str "
2017-06-30 17:53:19 +00:00
) -> run ( $id , $user ) -> getRow ();
2017-08-29 14:50:31 +00:00
if ( ! $out ) {
2017-07-21 02:40:09 +00:00
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => $this -> caller (), " field " => " article " , 'id' => $id ]);
}
2017-06-30 17:53:19 +00:00
return $out ;
}
2019-02-04 18:05:48 +00:00
/** Ensures the specified article edition exists and raises an exception otherwise
2019-05-02 02:52:20 +00:00
*
* Returns an associative array containing the edition id , article id , and latest edition of the edition if it exists
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user who owns the edition to be validated
2019-03-06 00:22:01 +00:00
* @ param integer $id The identifier of the edition to validate
2019-02-04 18:05:48 +00:00
*/
2017-07-05 13:09:38 +00:00
protected function articleValidateEdition ( string $user , int $id ) : array {
2017-09-28 14:16:24 +00:00
if ( ! ValueInfo :: id ( $id )) {
2017-10-05 21:42:12 +00:00
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " edition " , 'type' => " int > 0 " ]); // @codeCoverageIgnore
2017-09-28 14:16:24 +00:00
}
2017-06-30 17:53:19 +00:00
$out = $this -> db -> prepare (
2018-10-26 18:58:04 +00:00
" SELECT
2019-03-06 00:22:01 +00:00
arsse_editions . id , arsse_editions . article , edition_stats . edition as current
from arsse_editions
join arsse_articles on arsse_articles . id = arsse_editions . article
join arsse_subscriptions on arsse_subscriptions . feed = arsse_articles . feed
join ( select article , max ( id ) as edition from arsse_editions group by article ) as edition_stats on edition_stats . article = arsse_editions . article
where arsse_editions . id = ? and arsse_subscriptions . owner = ? " ,
2017-12-07 20:18:25 +00:00
" int " ,
" str "
2017-06-30 17:53:19 +00:00
) -> run ( $id , $user ) -> getRow ();
2017-08-29 14:50:31 +00:00
if ( ! $out ) {
2017-07-21 02:40:09 +00:00
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => $this -> caller (), " field " => " edition " , 'id' => $id ]);
}
2018-12-05 01:41:21 +00:00
return array_map ( " intval " , $out );
2017-06-30 17:53:19 +00:00
}
2017-08-02 22:27:04 +00:00
2019-02-04 18:05:48 +00:00
/** Returns the numeric identifier of the most recent edition of an article matching the given context */
2017-08-02 22:27:04 +00:00
public function editionLatest ( string $user , Context $context = null ) : int {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
2017-08-02 22:27:04 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-10-07 00:26:22 +00:00
$context = $context ? ? new Context ;
2018-12-05 01:41:21 +00:00
$q = new Query ( " SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id join arsse_subscriptions on arsse_articles.feed = arsse_subscriptions.feed and arsse_subscriptions.owner = ? " , " str " , $user );
2017-08-29 14:50:31 +00:00
if ( $context -> subscription ()) {
2017-08-02 22:27:04 +00:00
// if a subscription is specified, make sure it exists
2018-12-05 01:41:21 +00:00
$this -> subscriptionValidateId ( $user , $context -> subscription );
2017-08-02 22:27:04 +00:00
// a simple WHERE clause is required here
2018-12-05 01:41:21 +00:00
$q -> setWhere ( " arsse_subscriptions.id = ? " , " int " , $context -> subscription );
2017-08-02 22:27:04 +00:00
}
return ( int ) $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> getValue ();
}
2017-10-05 21:42:12 +00:00
2019-02-04 18:05:48 +00:00
/** Returns a map between all the given edition identifiers and their associated article identifiers */
2018-12-05 01:41:21 +00:00
public function editionArticle ( int ... $edition ) : array {
$out = [];
$context = ( new Context ) -> editions ( $edition );
2019-03-02 03:36:25 +00:00
list ( $in , $inTypes , $inValues ) = $this -> generateIn ( $context -> editions , " int " );
$out = $this -> db -> prepare ( " SELECT id as edition, article from arsse_editions where id in( $in ) " , $inTypes ) -> run ( $inValues ) -> getAll ();
return $out ? array_combine ( array_column ( $out , " edition " ), array_column ( $out , " article " )) : [];
2018-12-05 01:41:21 +00:00
}
2019-02-04 18:05:48 +00:00
/** Creates a label , and returns its numeric identifier
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* Labels are discrete objects in the database and can be associated with multiple articles ; an article may in turn be associated with multiple labels
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user who will own the created label
* @ param array $data An associative array defining the label ' s properties ; currently only " name " is understood
*/
2017-10-05 21:42:12 +00:00
public function labelAdd ( string $user , array $data ) : int {
// if the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// validate the label name
$name = array_key_exists ( " name " , $data ) ? $data [ 'name' ] : " " ;
$this -> labelValidateName ( $name , true );
// perform the insert
return $this -> db -> prepare ( " INSERT INTO arsse_labels(owner,name) values(?,?) " , " str " , " str " ) -> run ( $user , $name ) -> lastId ();
}
2019-02-04 18:05:48 +00:00
/** Lists a user ' s article labels
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* The following keys are included in each record :
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* - " id " : The label ' s numeric identifier
* - " name " The label ' s textual name
* - " articles " : The count of articles which have the label assigned to them
* - " read " : How many of the total articles assigned to the label are read
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user whose labels are to be listed
* @ param boolean $includeEmpty Whether to include ( true ) or supress ( false ) labels which have no articles assigned to them
*/
2017-10-05 21:42:12 +00:00
public function labelList ( string $user , bool $includeEmpty = true ) : Db\Result {
// if the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
return $this -> db -> prepare (
2018-12-05 01:41:21 +00:00
" SELECT * FROM (
SELECT
2019-03-06 00:22:01 +00:00
id , name , coalesce ( articles , 0 ) as articles , coalesce ( marked , 0 ) as \ " read \"
from arsse_labels
left join (
SELECT label , sum ( assigned ) as articles from arsse_label_members group by label
) as label_stats on label_stats . label = arsse_labels . id
left join (
SELECT
label , sum ( \ " read \" ) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions . id = arsse_marks . subscription
join arsse_label_members on arsse_label_members . article = arsse_marks . article
where arsse_subscriptions . owner = ?
group by label
) as mark_stats on mark_stats . label = arsse_labels . id
WHERE owner = ?
) as label_data
2018-12-05 01:41:21 +00:00
where articles >= ? order by name
2017-12-07 20:18:25 +00:00
" ,
" str " ,
2019-03-06 00:22:01 +00:00
" str " ,
2017-12-07 20:18:25 +00:00
" int "
2019-03-06 00:22:01 +00:00
) -> run ( $user , $user , ! $includeEmpty );
2017-10-05 21:42:12 +00:00
}
2019-02-04 18:05:48 +00:00
/** Deletes a label from the database
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* Any articles associated with the label remains untouched
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The owner of the label to remove
* @ param integer | string $id The numeric identifier or name of the label
* @ param boolean $byName Whether to interpret the $id parameter as the label ' s name ( true ) or identifier ( false )
*/
2017-10-05 21:42:12 +00:00
public function labelRemove ( string $user , $id , bool $byName = false ) : bool {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-10-13 04:04:26 +00:00
$this -> labelValidateId ( $user , $id , $byName , false );
2017-10-05 21:42:12 +00:00
$field = $byName ? " name " : " id " ;
$type = $byName ? " str " : " int " ;
2017-12-07 03:26:06 +00:00
$changes = $this -> db -> prepare ( " DELETE FROM arsse_labels where owner = ? and $field = ? " , " str " , $type ) -> run ( $user , $id ) -> changes ();
2017-10-05 21:42:12 +00:00
if ( ! $changes ) {
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " label " , 'id' => $id ]);
}
return true ;
}
2019-02-04 18:05:48 +00:00
/** Retrieves the properties of a label
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* The following keys are included in the output array :
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* - " id " : The label ' s numeric identifier
* - " name " The label ' s textual name
* - " articles " : The count of articles which have the label assigned to them
* - " read " : How many of the total articles assigned to the label are read
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The owner of the label to remove
* @ param integer | string $id The numeric identifier or name of the label
* @ param boolean $byName Whether to interpret the $id parameter as the label ' s name ( true ) or identifier ( false )
*/
2017-10-05 21:42:12 +00:00
public function labelPropertiesGet ( string $user , $id , bool $byName = false ) : array {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-10-13 04:04:26 +00:00
$this -> labelValidateId ( $user , $id , $byName , false );
2017-10-05 21:42:12 +00:00
$field = $byName ? " name " : " id " ;
$type = $byName ? " str " : " int " ;
$out = $this -> db -> prepare (
2018-10-26 18:58:04 +00:00
" SELECT
2019-03-06 00:22:01 +00:00
id , name , coalesce ( articles , 0 ) as articles , coalesce ( marked , 0 ) as \ " read \"
FROM arsse_labels
left join (
SELECT label , sum ( assigned ) as articles from arsse_label_members group by label
) as label_stats on label_stats . label = arsse_labels . id
left join (
SELECT
label , sum ( \ " read \" ) as marked
from arsse_marks
join arsse_subscriptions on arsse_subscriptions . id = arsse_marks . subscription
join arsse_label_members on arsse_label_members . article = arsse_marks . article
where arsse_subscriptions . owner = ?
group by label
) as mark_stats on mark_stats . label = arsse_labels . id
WHERE $field = ? and owner = ?
2017-12-07 20:18:25 +00:00
" ,
2019-03-06 00:22:01 +00:00
" str " ,
2017-12-07 20:18:25 +00:00
$type ,
" str "
2019-03-06 00:22:01 +00:00
) -> run ( $user , $id , $user ) -> getRow ();
2017-10-05 21:42:12 +00:00
if ( ! $out ) {
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " label " , 'id' => $id ]);
}
return $out ;
}
2019-02-04 18:05:48 +00:00
/** Sets the properties of a label
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The owner of the label to query
* @ param integer | string $id The numeric identifier or name of the label
* @ param array $data An associative array defining the label ' s properties ; currently only " name " is understood
* @ param boolean $byName Whether to interpret the $id parameter as the label ' s name ( true ) or identifier ( false )
*/
2017-10-05 21:42:12 +00:00
public function labelPropertiesSet ( string $user , $id , array $data , bool $byName = false ) : bool {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2017-10-13 04:04:26 +00:00
$this -> labelValidateId ( $user , $id , $byName , false );
2017-10-05 21:42:12 +00:00
if ( isset ( $data [ 'name' ])) {
$this -> labelValidateName ( $data [ 'name' ]);
}
$field = $byName ? " name " : " id " ;
$type = $byName ? " str " : " int " ;
$valid = [
'name' => " str " ,
];
list ( $setClause , $setTypes , $setValues ) = $this -> generateSet ( $data , $valid );
if ( ! $setClause ) {
// if no changes would actually be applied, just return
return false ;
}
2017-12-07 03:26:06 +00:00
$out = ( bool ) $this -> db -> prepare ( " UPDATE arsse_labels set $setClause , modified = CURRENT_TIMESTAMP where owner = ? and $field = ? " , $setTypes , " str " , $type ) -> run ( $setValues , $user , $id ) -> changes ();
2017-10-05 21:42:12 +00:00
if ( ! $out ) {
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " label " , 'id' => $id ]);
}
return $out ;
}
2019-02-04 18:05:48 +00:00
/** Returns an indexed array of article identifiers assigned to a label
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The owner of the label to query
* @ param integer | string $id The numeric identifier or name of the label
* @ param boolean $byName Whether to interpret the $id parameter as the label ' s name ( true ) or identifier ( false )
*/
2017-10-13 04:04:26 +00:00
public function labelArticlesGet ( string $user , $id , bool $byName = false ) : array {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// just do a syntactic check on the label ID
$this -> labelValidateId ( $user , $id , $byName , false );
$field = ! $byName ? " id " : " name " ;
$type = ! $byName ? " int " : " str " ;
2018-12-05 14:05:43 +00:00
$out = $this -> db -> prepare ( " SELECT article from arsse_label_members join arsse_labels on label = id where assigned = 1 and $field = ? and owner = ? order by article " , $type , " str " ) -> run ( $id , $user ) -> getAll ();
2017-10-13 04:04:26 +00:00
if ( ! $out ) {
// if no results were returned, do a full validation on the label ID
$this -> labelValidateId ( $user , $id , $byName , true , true );
// if the validation passes, return the empty result
return $out ;
} else {
// flatten the result to return just the article IDs in a simple array
return array_column ( $out , " article " );
}
}
2019-02-04 18:05:48 +00:00
/** Makes or breaks associations between a given label and articles matching the given query context
2019-05-02 02:52:20 +00:00
*
2019-02-04 18:05:48 +00:00
* @ param string $user The owner of the label
* @ param integer | string $id The numeric identifier or name of the label
* @ param Context $context The query context matching the desired articles
2019-04-27 22:32:15 +00:00
* @ param int $mode Whether to add ( ASSOC_ADD ), remove ( ASSOC_REMOVE ), or replace with ( ASSOC_REPLACE ) the matching associations
2019-02-04 18:05:48 +00:00
* @ param boolean $byName Whether to interpret the $id parameter as the label ' s name ( true ) or identifier ( false )
*/
2019-04-27 22:32:15 +00:00
public function labelArticlesSet ( string $user , $id , Context $context , int $mode = self :: ASSOC_ADD , bool $byName = false ) : int {
if ( ! in_array ( $mode , [ self :: ASSOC_ADD , self :: ASSOC_REMOVE , self :: ASSOC_REPLACE ])) {
throw new Exception ( " constantUnknown " , $mode ); // @codeCoverageIgnore
}
2017-10-13 04:04:26 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
2019-04-27 22:32:15 +00:00
// validate the tag ID, and get the numeric ID if matching by name
2017-10-13 04:04:26 +00:00
$id = $this -> labelValidateId ( $user , $id , $byName , true )[ 'id' ];
2019-04-27 22:32:15 +00:00
// get the list of articles matching the context
$articles = iterator_to_array ( $this -> articleList ( $user , $context ? ? new Context ));
// an empty article list is a special case
if ( ! sizeof ( $articles )) {
if ( $mode == self :: ASSOC_REPLACE ) {
// replacing with an empty set means setting everything to zero
return $this -> db -> prepare ( " UPDATE arsse_label_members set assigned = 0, modified = CURRENT_TIMESTAMP where label = ? and assigned = 1 " , " int " ) -> run ( $id ) -> changes ();
} else {
// adding or removing is a no-op
return 0 ;
}
} else {
$articles = array_column ( $articles , " id " );
}
// prepare up to three queries: removing requires one, adding two, and replacing three
list ( $inClause , $inTypes , $inValues ) = $this -> generateIn ( $articles , " int " );
$updateQ = " UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned <> ? and article %in% ( $inClause ) " ;
$updateT = [ " bool " , " int " , " bool " , $inTypes ];
$insertQ = " INSERT INTO arsse_label_members(label,article,subscription) SELECT ?,a.id,s.id from arsse_articles as a join arsse_subscriptions as s on a.feed = s.feed where s.owner = ? and a.id not in (select article from arsse_label_members where label = ?) and a.id in ( $inClause ) " ;
$insertT = [ " int " , " str " , " int " , $inTypes ];
$clearQ = str_replace ( " %in% " , " not in " , $updateQ );
$clearT = $updateT ;
$updateQ = str_replace ( " %in% " , " in " , $updateQ );
$qList = [];
switch ( $mode ) {
case self :: ASSOC_REMOVE :
$qList [] = [ $updateQ , $updateT , [ false , $id , false , $inValues ]]; // soft-delete any existing associations
break ;
case self :: ASSOC_ADD :
$qList [] = [ $updateQ , $updateT , [ true , $id , true , $inValues ]]; // re-enable any previously soft-deleted association
$qList [] = [ $insertQ , $insertT , [ $id , $user , $id , $inValues ]]; // insert any newly-required associations
break ;
case self :: ASSOC_REPLACE :
$qList [] = [ $clearQ , $clearT , [ false , $id , false , $inValues ]]; // soft-delete any existing associations for articles not in the list
$qList [] = [ $updateQ , $updateT , [ true , $id , true , $inValues ]]; // re-enable any previously soft-deleted association
$qList [] = [ $insertQ , $insertT , [ $id , $user , $id , $inValues ]]; // insert any newly-required associations
break ;
2019-03-07 03:15:41 +00:00
}
// execute them in a transaction
$out = 0 ;
$tr = $this -> begin ();
2019-04-27 22:32:15 +00:00
foreach ( $qList as list ( $q , $t , $v )) {
$out += $this -> db -> prepare ( $q , ... $t ) -> run ( ... $v ) -> changes ();
2017-10-13 04:04:26 +00:00
}
$tr -> commit ();
2017-10-20 22:17:47 +00:00
return $out ;
2017-10-13 04:04:26 +00:00
}
2019-02-04 18:05:48 +00:00
/** Ensures the specified label identifier or name is valid ( and optionally whether it exists ) and raises an exception otherwise
2019-05-02 02:52:20 +00:00
*
* Returns an associative array containing the id , name of the label if it exists
*
2019-02-04 18:05:48 +00:00
* @ param string $user The user who owns the label to be validated
* @ param integer | string $id The numeric identifier or name of the label to validate
* @ param boolean $byName Whether to interpret the $id parameter as the label ' s name ( true ) or identifier ( false )
* @ param boolean $checkDb Whether to check whether the label exists ( true ) or only if the identifier or name is syntactically valid ( false )
2019-02-26 16:39:19 +00:00
* @ param boolean $subject Whether the label is the subject ( true ) rather than the object ( false ) of the operation being performed ; this only affects the semantics of the error message if validation fails
2019-02-04 18:05:48 +00:00
*/
2017-10-13 04:04:26 +00:00
protected function labelValidateId ( string $user , $id , bool $byName , bool $checkDb = true , bool $subject = false ) : array {
if ( ! $byName && ! ValueInfo :: id ( $id )) {
// if we're not referring to a label by name and the ID is invalid, throw an exception
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " label " , 'type' => " int > 0 " ]);
} elseif ( $byName && ! ( ValueInfo :: str ( $id ) & ValueInfo :: VALID )) {
// otherwise if we are referring to a label by name but the ID is not a string, also throw an exception
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " label " , 'type' => " string " ]);
} elseif ( $checkDb ) {
$field = ! $byName ? " id " : " name " ;
$type = ! $byName ? " int " : " str " ;
2017-12-07 03:26:06 +00:00
$l = $this -> db -> prepare ( " SELECT id,name from arsse_labels where $field = ? and owner = ? " , $type , " str " ) -> run ( $id , $user ) -> getRow ();
2017-10-13 04:04:26 +00:00
if ( ! $l ) {
throw new Db\ExceptionInput ( $subject ? " subjectMissing " : " idMissing " , [ " action " => $this -> caller (), " field " => " label " , 'id' => $id ]);
} else {
return $l ;
}
}
return [
'id' => ! $byName ? $id : null ,
'name' => $byName ? $id : null ,
];
}
2019-02-04 18:05:48 +00:00
/** Ensures a prospective label name is syntactically valid and raises an exception otherwise */
2017-10-05 21:42:12 +00:00
protected function labelValidateName ( $name ) : bool {
$info = ValueInfo :: str ( $name );
if ( $info & ( ValueInfo :: NULL | ValueInfo :: EMPTY )) {
throw new Db\ExceptionInput ( " missing " , [ " action " => $this -> caller (), " field " => " name " ]);
} elseif ( $info & ValueInfo :: WHITE ) {
throw new Db\ExceptionInput ( " whitespace " , [ " action " => $this -> caller (), " field " => " name " ]);
} elseif ( ! ( $info & ValueInfo :: VALID )) {
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " name " , 'type' => " string " ]);
} else {
return true ;
}
}
2019-03-07 03:15:41 +00:00
/** Creates a tag , and returns its numeric identifier
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* Tags are discrete objects in the database and can be associated with multiple subscriptions ; a subscription may in turn be associated with multiple tags
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The user who will own the created tag
* @ param array $data An associative array defining the tag ' s properties ; currently only " name " is understood
*/
public function tagAdd ( string $user , array $data ) : int {
// if the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// validate the tag name
$name = array_key_exists ( " name " , $data ) ? $data [ 'name' ] : " " ;
$this -> tagValidateName ( $name , true );
// perform the insert
return $this -> db -> prepare ( " INSERT INTO arsse_tags(owner,name) values(?,?) " , " str " , " str " ) -> run ( $user , $name ) -> lastId ();
}
/** Lists a user ' s subscription tags
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* The following keys are included in each record :
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* - " id " : The tag ' s numeric identifier
* - " name " The tag ' s textual name
* - " subscriptions " : The count of subscriptions which have the tag assigned to them
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The user whose tags are to be listed
* @ param boolean $includeEmpty Whether to include ( true ) or supress ( false ) tags which have no subscriptions assigned to them
*/
public function tagList ( string $user , bool $includeEmpty = true ) : Db\Result {
// if the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
return $this -> db -> prepare (
" SELECT * FROM (
SELECT
id , name , coalesce ( subscriptions , 0 ) as subscriptions
from arsse_tags
left join ( SELECT tag , sum ( assigned ) as subscriptions from arsse_tag_members group by tag ) as tag_stats on tag_stats . tag = arsse_tags . id
WHERE owner = ?
) as tag_data
where subscriptions >= ? order by name
" ,
" str " ,
" int "
) -> run ( $user , ! $includeEmpty );
}
/** Lists the associations between all tags and subscription
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* The following keys are included in each record :
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* - " tag_id " : The tag ' s numeric identifier
* - " tag_name " The tag ' s textual name
* - " subscription_id " : The numeric identifier of the associated subscription
* - " subscription_name " The subscription ' s textual name
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The user whose tags are to be listed
*/
public function tagSummarize ( string $user ) : Db\Result {
// if the user isn't authorized to perform this action then throw an exception.
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
return $this -> db -> prepare (
" SELECT
2019-03-07 13:20:09 +00:00
arsse_tags . id as id ,
arsse_tags . name as name ,
arsse_tag_members . subscription as subscription
2019-03-07 03:15:41 +00:00
FROM arsse_tag_members
join arsse_tags on arsse_tags . id = arsse_tag_members . tag
WHERE arsse_tags . owner = ? and assigned = 1 " ,
" str "
) -> run ( $user );
}
/** Deletes a tag from the database
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* Any subscriptions associated with the tag remains untouched
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The owner of the tag to remove
* @ param integer | string $id The numeric identifier or name of the tag
* @ param boolean $byName Whether to interpret the $id parameter as the tag ' s name ( true ) or identifier ( false )
*/
public function tagRemove ( string $user , $id , bool $byName = false ) : bool {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$this -> tagValidateId ( $user , $id , $byName , false );
$field = $byName ? " name " : " id " ;
$type = $byName ? " str " : " int " ;
$changes = $this -> db -> prepare ( " DELETE FROM arsse_tags where owner = ? and $field = ? " , " str " , $type ) -> run ( $user , $id ) -> changes ();
if ( ! $changes ) {
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " tag " , 'id' => $id ]);
}
return true ;
}
/** Retrieves the properties of a tag
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* The following keys are included in the output array :
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* - " id " : The tag ' s numeric identifier
* - " name " The tag ' s textual name
* - " subscriptions " : The count of subscriptions which have the tag assigned to them
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The owner of the tag to remove
* @ param integer | string $id The numeric identifier or name of the tag
* @ param boolean $byName Whether to interpret the $id parameter as the tag ' s name ( true ) or identifier ( false )
*/
public function tagPropertiesGet ( string $user , $id , bool $byName = false ) : array {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$this -> tagValidateId ( $user , $id , $byName , false );
$field = $byName ? " name " : " id " ;
$type = $byName ? " str " : " int " ;
$out = $this -> db -> prepare (
" SELECT
id , name , coalesce ( subscriptions , 0 ) as subscriptions
FROM arsse_tags
left join ( SELECT tag , sum ( assigned ) as subscriptions from arsse_tag_members group by tag ) as tag_stats on tag_stats . tag = arsse_tags . id
WHERE $field = ? and owner = ?
" ,
$type ,
" str "
) -> run ( $id , $user ) -> getRow ();
if ( ! $out ) {
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " tag " , 'id' => $id ]);
}
return $out ;
}
/** Sets the properties of a tag
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The owner of the tag to query
* @ param integer | string $id The numeric identifier or name of the tag
* @ param array $data An associative array defining the tag ' s properties ; currently only " name " is understood
* @ param boolean $byName Whether to interpret the $id parameter as the tag ' s name ( true ) or identifier ( false )
*/
public function tagPropertiesSet ( string $user , $id , array $data , bool $byName = false ) : bool {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
$this -> tagValidateId ( $user , $id , $byName , false );
if ( isset ( $data [ 'name' ])) {
$this -> tagValidateName ( $data [ 'name' ]);
}
$field = $byName ? " name " : " id " ;
$type = $byName ? " str " : " int " ;
$valid = [
'name' => " str " ,
];
list ( $setClause , $setTypes , $setValues ) = $this -> generateSet ( $data , $valid );
if ( ! $setClause ) {
// if no changes would actually be applied, just return
return false ;
}
$out = ( bool ) $this -> db -> prepare ( " UPDATE arsse_tags set $setClause , modified = CURRENT_TIMESTAMP where owner = ? and $field = ? " , $setTypes , " str " , $type ) -> run ( $setValues , $user , $id ) -> changes ();
if ( ! $out ) {
throw new Db\ExceptionInput ( " subjectMissing " , [ " action " => __FUNCTION__ , " field " => " tag " , 'id' => $id ]);
}
return $out ;
}
/** Returns an indexed array of subscription identifiers assigned to a tag
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The owner of the tag to query
* @ param integer | string $id The numeric identifier or name of the tag
* @ param boolean $byName Whether to interpret the $id parameter as the tag ' s name ( true ) or identifier ( false )
*/
public function tagSubscriptionsGet ( string $user , $id , bool $byName = false ) : array {
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// just do a syntactic check on the tag ID
$this -> tagValidateId ( $user , $id , $byName , false );
$field = ! $byName ? " id " : " name " ;
$type = ! $byName ? " int " : " str " ;
$out = $this -> db -> prepare ( " SELECT subscription from arsse_tag_members join arsse_tags on tag = id where assigned = 1 and $field = ? and owner = ? order by subscription " , $type , " str " ) -> run ( $id , $user ) -> getAll ();
if ( ! $out ) {
// if no results were returned, do a full validation on the tag ID
$this -> tagValidateId ( $user , $id , $byName , true , true );
// if the validation passes, return the empty result
return $out ;
} else {
// flatten the result to return just the subscription IDs in a simple array
return array_column ( $out , " subscription " );
}
}
/** Makes or breaks associations between a given tag and specified subscriptions
2019-05-02 02:52:20 +00:00
*
2019-03-07 03:15:41 +00:00
* @ param string $user The owner of the tag
* @ param integer | string $id The numeric identifier or name of the tag
2019-04-27 22:32:15 +00:00
* @ param integer [] $subscriptions An array listing the desired subscriptions
* @ param int $mode Whether to add ( ASSOC_ADD ), remove ( ASSOC_REMOVE ), or replace with ( ASSOC_REPLACE ) the listed associations
2019-03-07 03:15:41 +00:00
* @ param boolean $byName Whether to interpret the $id parameter as the tag ' s name ( true ) or identifier ( false )
*/
2019-04-27 22:32:15 +00:00
public function tagSubscriptionsSet ( string $user , $id , array $subscriptions , int $mode = self :: ASSOC_ADD , bool $byName = false ) : int {
if ( ! in_array ( $mode , [ self :: ASSOC_ADD , self :: ASSOC_REMOVE , self :: ASSOC_REPLACE ])) {
throw new Exception ( " constantUnknown " , $mode ); // @codeCoverageIgnore
}
2019-03-07 03:15:41 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// validate the tag ID, and get the numeric ID if matching by name
$id = $this -> tagValidateId ( $user , $id , $byName , true )[ 'id' ];
2019-04-27 22:32:15 +00:00
// an empty subscription list is a special case
if ( ! sizeof ( $subscriptions )) {
if ( $mode == self :: ASSOC_REPLACE ) {
// replacing with an empty set means setting everything to zero
return $this -> db -> prepare ( " UPDATE arsse_tag_members set assigned = 0, modified = CURRENT_TIMESTAMP where tag = ? and assigned = 1 " , " int " ) -> run ( $id ) -> changes ();
} else {
// adding or removing is a no-op
return 0 ;
}
}
// prepare up to three queries: removing requires one, adding two, and replacing three
2019-03-07 03:15:41 +00:00
list ( $inClause , $inTypes , $inValues ) = $this -> generateIn ( $subscriptions , " int " );
2019-04-27 22:32:15 +00:00
$updateQ = " UPDATE arsse_tag_members set assigned = ?, modified = CURRENT_TIMESTAMP where tag = ? and assigned <> ? and subscription in (select id from arsse_subscriptions where owner = ? and id %in% ( $inClause )) " ;
$updateT = [ " bool " , " int " , " bool " , " str " , $inTypes ];
$insertQ = " INSERT INTO arsse_tag_members(tag,subscription) SELECT ?,id from arsse_subscriptions where id not in (select subscription from arsse_tag_members where tag = ?) and owner = ? and id in ( $inClause ) " ;
$insertT = [ " int " , " int " , " str " , $inTypes ];
$clearQ = str_replace ( " %in% " , " not in " , $updateQ );
$clearT = $updateT ;
$updateQ = str_replace ( " %in% " , " in " , $updateQ );
$qList = [];
switch ( $mode ) {
case self :: ASSOC_REMOVE :
$qList [] = [ $updateQ , $updateT , [ 0 , $id , 0 , $user , $inValues ]]; // soft-delete any existing associations
break ;
case self :: ASSOC_ADD :
$qList [] = [ $updateQ , $updateT , [ 1 , $id , 1 , $user , $inValues ]]; // re-enable any previously soft-deleted association
$qList [] = [ $insertQ , $insertT , [ $id , $id , $user , $inValues ]]; // insert any newly-required associations
break ;
case self :: ASSOC_REPLACE :
$qList [] = [ $clearQ , $clearT , [ 0 , $id , 0 , $user , $inValues ]]; // soft-delete any existing associations for subscriptions not in the list
$qList [] = [ $updateQ , $updateT , [ 1 , $id , 1 , $user , $inValues ]]; // re-enable any previously soft-deleted association
$qList [] = [ $insertQ , $insertT , [ $id , $id , $user , $inValues ]]; // insert any newly-required associations
break ;
2019-03-07 03:15:41 +00:00
}
// execute them in a transaction
$out = 0 ;
$tr = $this -> begin ();
2019-04-27 22:32:15 +00:00
foreach ( $qList as list ( $q , $t , $v )) {
$out += $this -> db -> prepare ( $q , ... $t ) -> run ( ... $v ) -> changes ();
2019-03-07 03:15:41 +00:00
}
$tr -> commit ();
return $out ;
}
/** Ensures the specified tag identifier or name is valid ( and optionally whether it exists ) and raises an exception otherwise
2019-05-02 02:52:20 +00:00
*
* Returns an associative array containing the id , name of the tag if it exists
*
2019-03-07 03:15:41 +00:00
* @ param string $user The user who owns the tag to be validated
* @ param integer | string $id The numeric identifier or name of the tag to validate
* @ param boolean $byName Whether to interpret the $id parameter as the tag ' s name ( true ) or identifier ( false )
* @ param boolean $checkDb Whether to check whether the tag exists ( true ) or only if the identifier or name is syntactically valid ( false )
* @ param boolean $subject Whether the tag is the subject ( true ) rather than the object ( false ) of the operation being performed ; this only affects the semantics of the error message if validation fails
*/
protected function tagValidateId ( string $user , $id , bool $byName , bool $checkDb = true , bool $subject = false ) : array {
if ( ! $byName && ! ValueInfo :: id ( $id )) {
// if we're not referring to a tag by name and the ID is invalid, throw an exception
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " tag " , 'type' => " int > 0 " ]);
} elseif ( $byName && ! ( ValueInfo :: str ( $id ) & ValueInfo :: VALID )) {
// otherwise if we are referring to a tag by name but the ID is not a string, also throw an exception
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " tag " , 'type' => " string " ]);
} elseif ( $checkDb ) {
$field = ! $byName ? " id " : " name " ;
$type = ! $byName ? " int " : " str " ;
$l = $this -> db -> prepare ( " SELECT id,name from arsse_tags where $field = ? and owner = ? " , $type , " str " ) -> run ( $id , $user ) -> getRow ();
if ( ! $l ) {
throw new Db\ExceptionInput ( $subject ? " subjectMissing " : " idMissing " , [ " action " => $this -> caller (), " field " => " tag " , 'id' => $id ]);
} else {
return $l ;
}
}
return [
'id' => ! $byName ? $id : null ,
'name' => $byName ? $id : null ,
];
}
/** Ensures a prospective tag name is syntactically valid and raises an exception otherwise */
protected function tagValidateName ( $name ) : bool {
$info = ValueInfo :: str ( $name );
if ( $info & ( ValueInfo :: NULL | ValueInfo :: EMPTY )) {
throw new Db\ExceptionInput ( " missing " , [ " action " => $this -> caller (), " field " => " name " ]);
} elseif ( $info & ValueInfo :: WHITE ) {
throw new Db\ExceptionInput ( " whitespace " , [ " action " => $this -> caller (), " field " => " name " ]);
} elseif ( ! ( $info & ValueInfo :: VALID )) {
throw new Db\ExceptionInput ( " typeViolation " , [ " action " => $this -> caller (), " field " => " name " , 'type' => " string " ]);
} else {
return true ;
}
}
2017-08-29 14:50:31 +00:00
}