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-02-20 22:04:13 +00:00
use PasswordGenerator\Generator as PassGen ;
2017-09-16 23:57:33 +00:00
use JKingWeb\DrUUID\UUID ;
2017-06-18 14:23:37 +00:00
use JKingWeb\Arsse\Misc\Query ;
use JKingWeb\Arsse\Misc\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 ;
2016-10-02 21:07:17 +00:00
class Database {
2017-12-07 23:05:34 +00:00
const SCHEMA_VERSION = 3 ;
2017-11-07 04:32:29 +00:00
const LIMIT_ARTICLES = 50 ;
2017-11-17 22:52:00 +00:00
// articleList verbosity levels
2017-11-17 23:12:00 +00:00
const LIST_MINIMAL = 0 ; // only that metadata which is required for context matching
const LIST_CONSERVATIVE = 1 ; // base metadata plus anything that is not potentially large text
const LIST_TYPICAL = 2 ; // conservative, with the addition of content
const LIST_FULL = 3 ; // all possible fields
2017-07-14 14:16:16 +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
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-07-22 19:29:12 +00:00
$this -> db = new $driver ();
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
2017-05-18 17:21:17 +00:00
protected function caller () : string {
return debug_backtrace ( DEBUG_BACKTRACE_IGNORE_ARGS , 3 )[ 2 ][ 'function' ];
}
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
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
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
2017-11-29 23:14:59 +00:00
public function driverCharsetAcceptable () : bool {
return $this -> db -> charsetAcceptable ();
}
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 ;
}
2017-04-21 01:59:12 +00:00
$out [ 0 ][] = " $prop = ? " ;
$out [ 1 ][] = $type ;
$out [ 2 ][] = $props [ $prop ];
}
$out [ 0 ] = implode ( " , " , $out [ 0 ]);
return $out ;
}
protected function generateIn ( array $values , string $type ) {
$out = [
[], // query clause
[], // binding types
];
// the query clause is just a series of question marks separated by commas
2017-08-29 14:50:31 +00:00
$out [ 0 ] = implode ( " , " , array_fill ( 0 , sizeof ( $values ), " ? " ));
2017-04-21 01:59:12 +00:00
// the binding types are just a repetition of the supplied type
2017-08-29 14:50:31 +00:00
$out [ 1 ] = array_fill ( 0 , sizeof ( $values ), $type );
2017-04-21 01:59:12 +00:00
return $out ;
}
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
2017-07-16 18:55:37 +00:00
public function metaGet ( string $key ) {
2017-12-07 03:26: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
}
2017-05-19 03:03:33 +00:00
2017-07-18 20:38:23 +00:00
public function metaSet ( string $key , $value , string $type = " str " ) : bool {
2017-12-07 03:26: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 ) {
2017-07-18 20:38:23 +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
2017-07-16 18:55:37 +00:00
public function metaRemove ( string $key ) : bool {
2017-12-07 03:26: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
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
2017-02-20 22:04:13 +00:00
public function userAdd ( string $user , string $password = null ) : string {
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 ]);
}
2017-08-29 14:50:31 +00:00
if ( $password === null ) {
2017-07-21 02:40:09 +00:00
$password = ( new PassGen ) -> length ( Arsse :: $conf -> userTempPasswordLength ) -> get ();
}
2017-02-20 22:04:13 +00:00
$hash = " " ;
2017-08-29 14:50:31 +00:00
if ( strlen ( $password ) > 0 ) {
2017-07-21 02:40:09 +00:00
$hash = 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 ]);
2017-02-20 22:04:13 +00:00
return $password ;
2017-02-16 20:29:42 +00:00
}
2016-10-28 12:27:35 +00:00
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
2017-02-16 20:29:42 +00:00
public function userList ( string $domain = null ) : array {
2017-03-30 03:41:05 +00:00
$out = [];
2017-08-29 14:50:31 +00:00
if ( $domain !== null ) {
if ( ! Arsse :: $user -> authorize ( " @ " . $domain , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $domain ]);
}
2017-08-29 14:50:31 +00:00
$domain = str_replace ([ " \\ " , " % " , " _ " ], [ " \\ \\ " , " \\ % " , " \\ _ " ], $domain );
2017-02-16 20:29:42 +00:00
$domain = " %@ " . $domain ;
2017-08-29 14:50:31 +00:00
foreach ( $this -> db -> prepare ( " SELECT id from arsse_users where id like ? " , " str " ) -> run ( $domain ) as $user ) {
2017-03-30 03:41:05 +00:00
$out [] = $user [ 'id' ];
}
2017-02-16 20:29:42 +00:00
} else {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( " " , __FUNCTION__ )) {
2017-07-21 02:40:09 +00:00
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => " global " ]);
}
2017-08-29 14:50:31 +00:00
foreach ( $this -> db -> query ( " SELECT id from arsse_users " ) as $user ) {
2017-03-30 03:41:05 +00:00
$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
2017-02-16 20:29:42 +00:00
public function userPasswordGet ( string $user ) : string {
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 ]);
}
2017-12-07 03:26:06 +00:00
return ( string ) $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
2017-02-20 22:04:13 +00:00
public function userPasswordSet ( string $user , string $password = null ) : string {
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 ]);
}
2017-08-29 14:50:31 +00:00
if ( $password === null ) {
2017-07-21 02:40:09 +00:00
$password = ( new PassGen ) -> length ( Arsse :: $conf -> userTempPasswordLength ) -> get ();
}
2017-02-20 22:04:13 +00:00
$hash = " " ;
2017-08-29 14:50:31 +00:00
if ( strlen ( $password ) > 0 ) {
2017-07-21 02:40:09 +00:00
$hash = password_hash ( $password , \PASSWORD_DEFAULT );
}
2017-12-07 03:26:06 +00:00
$this -> db -> prepare ( " UPDATE arsse_users set password = ? where id = ? " , " str " , " str " ) -> run ( $hash , $user );
2017-02-20 22:04:13 +00:00
return $password ;
2017-02-16 20:29:42 +00:00
}
2016-10-28 12:27:35 +00:00
2017-02-16 20:29:42 +00:00
public function userPropertiesGet ( string $user ) : 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-12-07 03:26:06 +00:00
$prop = $this -> db -> prepare ( " SELECT name,rights from arsse_users where id = ? " , " str " ) -> run ( $user ) -> getRow ();
2017-08-29 14:50:31 +00:00
if ( ! $prop ) {
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 $prop ;
}
2016-10-28 12:27:35 +00:00
2017-03-30 03:41:05 +00:00
public function userPropertiesSet ( string $user , array $properties ) : 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-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 ]);
}
2017-02-16 20:29:42 +00:00
$valid = [ // FIXME: add future properties
2017-02-19 22:02:03 +00:00
" name " => " str " ,
2017-02-16 20:29:42 +00:00
];
2017-04-06 17:29:39 +00:00
list ( $setClause , $setTypes , $setValues ) = $this -> generateSet ( $properties , $valid );
2017-10-05 21:42:12 +00:00
if ( ! $setClause ) {
// if no changes would actually be applied, just return
return $this -> userPropertiesGet ( $user );
}
2017-12-07 03:26:06 +00:00
$this -> db -> prepare ( " UPDATE arsse_users set $setClause where id = ? " , $setTypes , " str " ) -> run ( $setValues , $user );
2017-02-16 20:29:42 +00:00
return $this -> userPropertiesGet ( $user );
}
2016-11-04 02:54:27 +00:00
2017-02-16 20:29:42 +00:00
public function userRightsGet ( string $user ) : 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 ]);
}
2017-12-07 03:26:06 +00:00
return ( int ) $this -> db -> prepare ( " SELECT rights from arsse_users where id = ? " , " str " ) -> run ( $user ) -> getValue ();
2017-02-16 20:29:42 +00:00
}
2016-11-04 02:54:27 +00:00
2017-02-16 20:29:42 +00:00
public function userRightsSet ( string $user , int $rights ) : bool {
2017-08-29 14:50:31 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ , $rights )) {
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 ]);
}
2017-12-07 03:26:06 +00:00
$this -> db -> prepare ( " UPDATE arsse_users set rights = ? where id = ? " , " int " , " str " ) -> run ( $rights , $user );
2017-02-16 20:29:42 +00:00
return true ;
}
2016-10-28 12:27:35 +00:00
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
$this -> db -> prepare ( " INSERT INTO arsse_sessions(id,expires,user) values(?,?,?) " , " str " , " datetime " , " str " ) -> run ( $id , $expires , $user );
// return the ID
return $id ;
}
public function sessionDestroy ( string $user , string $id ) : 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 ]);
}
// delete the session and report success.
2017-12-07 03:26:06 +00:00
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
}
public function sessionResume ( string $id ) : array {
2017-09-24 14:09:36 +00:00
$maxAge = Date :: sub ( Arsse :: $conf -> userSessionLifetime );
2017-12-07 03:26:06 +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 ;
}
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
}
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 ());
}
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
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
id , name , parent ,
2017-12-07 03:26:06 +00:00
( select count ( * ) from arsse_folders as parents where coalesce ( parents . parent , 0 ) = coalesce ( arsse_folders . id , 0 )) as children ,
( select count ( * ) from arsse_subscriptions where coalesce ( folder , 0 ) = coalesce ( arsse_folders . id , 0 )) as feeds
2017-10-07 00:26:22 +00:00
FROM arsse_folders "
);
2017-08-29 14:50:31 +00:00
if ( ! $recursive ) {
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " owner = ? " , " str " , $user );
$q -> setWhere ( " coalesce(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
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
}
2017-12-07 03:26:06 +00:00
$changes = $this -> db -> prepare ( " DELETE FROM arsse_folders where owner = ? and id = ? " , " str " , " int " ) -> run ( $user , $id ) -> 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 ;
}
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 ;
}
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
}
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 ;
}
2017-09-26 20:45:41 +00:00
protected function folderValidateMove ( string $user , int $id = null , $parent = null , string $name = null ) {
$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
if ( $id == $parent ) {
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
target as ( select ? as user , ? as source , ? as dest , ? as rename ),
2017-12-07 03:26:06 +00:00
folders as ( SELECT id from arsse_folders join target on owner = user 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
" .
" SELECT
2017-12-07 03:26:06 +00:00
(( select dest from target ) is null or exists ( select id from arsse_folders join target on owner = user and coalesce ( id , 0 ) = coalesce ( dest , 0 ))) as extant ,
not exists ( select id from folders where id = coalesce (( select dest from target ), 0 )) as valid ,
not exists ( select id from arsse_folders join target on coalesce ( parent , 0 ) = coalesce ( dest , 0 ) and name = coalesce (( select rename from target ),( select name from arsse_folders join target on id = source ))) 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 ;
}
protected function folderValidateName ( $name , bool $checkDuplicates = false , int $parent = null ) : bool {
$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 ;
2017-12-07 03:26:06 +00:00
if ( $this -> db -> prepare ( " SELECT exists(select id 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 ;
}
}
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 ]);
}
2017-05-04 15:13:24 +00:00
// check to see if the feed exists
2017-12-07 03:26:06 +00:00
$check = $this -> db -> prepare ( " SELECT id from arsse_feeds where url = ? and username = ? and password = ? " , " str " , " str " , " str " );
2017-10-02 19:42:15 +00:00
$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 ();
}
2017-08-29 14:50:31 +00:00
if ( is_null ( $feedID )) {
2017-10-02 19:42:15 +00:00
// 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
2017-05-04 15:13:24 +00:00
$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
2017-10-02 19:42:15 +00:00
$this -> feedUpdate ( $feedID , true );
2017-08-29 14:50:31 +00:00
} catch ( \Throwable $e ) {
2017-05-04 15:13:24 +00:00
// if the update fails, delete the feed we just added
2017-12-07 03:26:06 +00:00
$this -> db -> prepare ( 'DELETE from arsse_feeds where id = ?' , 'int' ) -> run ( $feedID );
2017-05-04 15:13:24 +00:00
throw $e ;
}
2017-03-31 22:48:24 +00:00
}
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 ();
}
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 (
" SELECT
2017-10-03 14:43:09 +00:00
arsse_subscriptions . id as id ,
2017-10-03 20:14:37 +00:00
feed , 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 ,
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 ,
2017-12-07 03:26:06 +00:00
( SELECT count ( * ) from arsse_articles where feed = arsse_subscriptions . feed ) - ( SELECT count ( * ) from arsse_marks where subscription = arsse_subscriptions . id and read = 1 ) as unread
2017-06-10 17:29:46 +00:00
from arsse_subscriptions
2017-12-07 03:26:06 +00:00
join user on user = owner
2017-06-10 17:29:46 +00:00
join arsse_feeds on feed = arsse_feeds . id
2017-07-07 19:25:47 +00:00
left join topmost on folder = f_id "
2017-06-04 22:00:18 +00:00
);
2017-12-07 23:17:16 +00:00
$q -> setOrder ( " pinned desc, title collate nocase " );
2017-06-04 22:00:18 +00:00
// define common table expressions
2017-07-07 15:49:54 +00:00
$q -> setCTE ( " user(user) " , " SELECT ? " , " str " , $user ); // the subject user; this way we only have to pass it to prepare() once
2017-06-04 22:00:18 +00:00
// topmost folders belonging to the user
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " topmost(f_id,top) " , " SELECT id,id from arsse_folders join user on owner = user where parent is null union select id,top from arsse_folders join topmost on parent=f_id " );
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
}
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) " );
}
return $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> getValue ();
}
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
}
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-05-04 23:12:33 +00:00
// disable authorization checks for the list call
2017-07-17 11:47:57 +00:00
Arsse :: $user -> authorizationEnabled ( false );
2017-10-31 22:09:16 +00:00
$sub = $this -> subscriptionList ( $user , null , true , ( int ) $id ) -> getRow ();
2017-07-17 11:47:57 +00:00
Arsse :: $user -> authorizationEnabled ( true );
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 ;
}
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
}
2017-11-10 17:02:59 +00:00
public function subscriptionFavicon ( int $id ) : string {
2017-12-07 03:26:06 +00:00
return ( string ) $this -> db -> prepare ( " SELECT favicon from arsse_feeds join arsse_subscriptions on feed = arsse_feeds.id where arsse_subscriptions.id = ? " , " int " ) -> run ( $id ) -> getValue ();
2017-11-10 17:02:59 +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 ;
}
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
}
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' );
$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 (
2017-12-07 03:26:06 +00:00
" UPDATE arsse_feeds SET url = ?, 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' ,
'str' ,
'datetime' ,
'str' ,
'datetime' ,
'int' ,
'int'
2017-05-06 16:02:27 +00:00
) -> run (
$feed -> data -> feedUrl ,
$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
2017-08-02 22:27:04 +00:00
public function feedCleanup () : bool {
$tr = $this -> begin ();
// first unmark any feeds which are no longer orphaned
2017-12-07 03:26:06 +00:00
$this -> db -> query ( " UPDATE arsse_feeds set orphaned = null where exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id) " );
2017-08-02 22:27:04 +00:00
// next mark any newly orphaned feeds with the current date and time
2017-12-07 03:26:06 +00:00
$this -> db -> query ( " UPDATE arsse_feeds set orphaned = CURRENT_TIMESTAMP where orphaned is null and not exists(SELECT id from arsse_subscriptions where feed = arsse_feeds.id) " );
2017-08-02 22:27:04 +00:00
// finally delete feeds that have been orphaned longer than the retention period
$limit = Date :: normalize ( " now " );
2017-08-29 14:50:31 +00:00
if ( Arsse :: $conf -> purgeFeeds ) {
2017-08-02 22:27:04 +00:00
// if there is a retention period specified, compute it; otherwise feed are deleted immediatelty
2018-01-02 21:27:58 +00:00
$limit = Date :: sub ( Arsse :: $conf -> purgeFeeds , $limit );
2017-08-02 22:27:04 +00:00
}
$out = ( bool ) $this -> db -> prepare ( " DELETE from arsse_feeds where orphaned <= ? " , " datetime " ) -> run ( $limit );
// commit changes and return
$tr -> commit ();
return $out ;
}
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 );
}
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
2017-08-29 14:50:31 +00:00
list ( $cId , $tId ) = $this -> generateIn ( $ids , " str " );
2017-04-23 03:40:57 +00:00
list ( $cHashUT , $tHashUT ) = $this -> generateIn ( $hashesUT , " str " );
list ( $cHashUC , $tHashUC ) = $this -> generateIn ( $hashesUC , " str " );
list ( $cHashTC , $tHashTC ) = $this -> generateIn ( $hashesTC , " str " );
// 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
2017-04-23 03:40:57 +00:00
) -> run ( $feedID , $ids , $hashesUT , $hashesUC , $hashesTC );
}
2017-06-04 12:15:10 +00:00
2017-10-07 00:26:22 +00:00
protected function articleQuery ( string $user , Context $context , array $extraColumns = []) : Query {
$extraColumns = implode ( " , " , $extraColumns );
if ( strlen ( $extraColumns )) {
$extraColumns .= " , " ;
2017-07-21 02:40:09 +00:00
}
2017-06-18 14:23:37 +00:00
$q = new Query (
2017-06-04 22:00:18 +00:00
" SELECT
2017-10-07 00:26:22 +00:00
$extraColumns
2017-06-22 17:07:56 +00:00
arsse_articles . id as id ,
2017-10-07 00:26:22 +00:00
arsse_articles . feed as feed ,
2017-11-17 22:52:00 +00:00
arsse_articles . modified as modified_date ,
2017-07-07 19:25:47 +00:00
max (
2017-10-07 00:26:22 +00:00
arsse_articles . modified ,
2017-12-07 03:26:06 +00:00
coalesce (( select modified from arsse_marks where article = arsse_articles . id and subscription in ( select sub from subscribed_feeds )), '' ),
coalesce (( select modified from arsse_label_members where article = arsse_articles . id and subscription in ( select sub from subscribed_feeds )), '' )
2017-11-17 22:52:00 +00:00
) as marked_date ,
2017-12-07 03:26:06 +00:00
NOT ( select count ( * ) from arsse_marks where article = arsse_articles . id and read = 1 and subscription in ( select sub from subscribed_feeds )) as unread ,
( select count ( * ) from arsse_marks where article = arsse_articles . id and starred = 1 and subscription in ( select sub from subscribed_feeds )) as starred ,
( select max ( id ) from arsse_editions where article = arsse_articles . id ) as edition ,
2017-10-07 00:26:22 +00:00
subscribed_feeds . sub as subscription
FROM arsse_articles "
2017-06-18 14:23:37 +00:00
);
2017-07-07 15:49:54 +00:00
$q -> setLimit ( $context -> limit , $context -> offset );
$q -> setCTE ( " user(user) " , " SELECT ? " , " str " , $user );
2017-08-29 14:50:31 +00:00
if ( $context -> subscription ()) {
2017-06-18 14:23:37 +00:00
// if a subscription is specified, make sure it exists
$id = $this -> subscriptionValidateId ( $user , $context -> subscription )[ 'feed' ];
// add a basic CTE that will join in only the requested subscription
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " subscribed_feeds(id,sub) " , " SELECT ?,? " , [ " int " , " int " ], [ $id , $context -> subscription ], " join subscribed_feeds on feed = subscribed_feeds.id " );
2017-08-29 14:50:31 +00:00
} elseif ( $context -> folder ()) {
2017-06-18 14:23:37 +00:00
// if a folder is specified, make sure it exists
$this -> folderValidateId ( $user , $context -> folder );
// if it does exist, 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 " , $context -> folder );
2017-06-18 14:23:37 +00:00
// add another CTE for the subscriptions within the folder
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " subscribed_feeds(id,sub) " , " SELECT feed,id from arsse_subscriptions join user on user = owner join folders on arsse_subscriptions.folder = folders.folder " , [], [], " join subscribed_feeds on feed = subscribed_feeds.id " );
2017-11-16 20:56:14 +00:00
} elseif ( $context -> folderShallow ()) {
// if a shallow folder is specified, make sure it exists
$this -> folderValidateId ( $user , $context -> folderShallow );
// if it does exist, add a CTE with only its subscriptions (and not those of its descendents)
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " subscribed_feeds(id,sub) " , " SELECT feed,id from arsse_subscriptions join user on user = owner and coalesce(folder,0) = ? " , " strict int " , $context -> folderShallow , " join subscribed_feeds on feed = subscribed_feeds.id " );
2017-06-18 14:23:37 +00:00
} else {
// otherwise add a CTE for all the user's subscriptions
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " subscribed_feeds(id,sub) " , " SELECT feed,id from arsse_subscriptions join user on user = owner " , [], [], " join subscribed_feeds on feed = subscribed_feeds.id " );
2017-10-07 00:26:22 +00:00
}
if ( $context -> edition ()) {
// if an edition is specified, filter for its previously identified article
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " arsse_articles.id = (select article from arsse_editions where id = ?) " , " int " , $context -> edition );
2017-10-07 00:26:22 +00:00
} elseif ( $context -> article ()) {
// if an article is specified, filter for it (it has already been validated above)
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " arsse_articles.id = ? " , " int " , $context -> article );
2017-10-07 00:26:22 +00:00
}
if ( $context -> editions ()) {
// if multiple specific editions have been requested, prepare a CTE to list them and their articles
if ( ! $context -> editions ) {
throw new Db\ExceptionInput ( " tooShort " , [ 'field' => " editions " , 'action' => __FUNCTION__ , 'min' => 1 ]); // must have at least one array element
2017-11-07 04:32:29 +00:00
} elseif ( sizeof ( $context -> editions ) > self :: LIMIT_ARTICLES ) {
throw new Db\ExceptionInput ( " tooLong " , [ 'field' => " editions " , 'action' => __FUNCTION__ , 'max' => self :: LIMIT_ARTICLES ]); // @codeCoverageIgnore
2017-10-07 00:26:22 +00:00
}
list ( $inParams , $inTypes ) = $this -> generateIn ( $context -> editions , " int " );
2017-12-07 20:18:25 +00:00
$q -> setCTE (
" requested_articles(id,edition) " ,
2017-10-07 00:26:22 +00:00
" SELECT article,id as edition from arsse_editions where edition in ( $inParams ) " ,
$inTypes ,
$context -> editions
);
$q -> setWhere ( " arsse_articles.id in (select id from requested_articles) " );
} elseif ( $context -> articles ()) {
// if multiple specific articles have been requested, prepare a CTE to list them and their articles
if ( ! $context -> articles ) {
throw new Db\ExceptionInput ( " tooShort " , [ 'field' => " articles " , 'action' => __FUNCTION__ , 'min' => 1 ]); // must have at least one array element
2017-11-07 04:32:29 +00:00
} elseif ( sizeof ( $context -> articles ) > self :: LIMIT_ARTICLES ) {
throw new Db\ExceptionInput ( " tooLong " , [ 'field' => " articles " , 'action' => __FUNCTION__ , 'max' => self :: LIMIT_ARTICLES ]); // @codeCoverageIgnore
2017-10-07 00:26:22 +00:00
}
list ( $inParams , $inTypes ) = $this -> generateIn ( $context -> articles , " int " );
2017-12-07 20:18:25 +00:00
$q -> setCTE (
" requested_articles(id,edition) " ,
2017-12-07 03:26:06 +00:00
" SELECT id,(select max(id) from arsse_editions where article = arsse_articles.id) as edition from arsse_articles where arsse_articles.id in ( $inParams ) " ,
2017-10-07 00:26:22 +00:00
$inTypes ,
$context -> articles
);
$q -> setWhere ( " arsse_articles.id in (select id from requested_articles) " );
} else {
// if neither list is specified, mock an empty table
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " requested_articles(id,edition) " , " SELECT 'empty','table' where 1 = 0 " );
2017-06-18 14:23:37 +00:00
}
2017-10-13 04:04:26 +00:00
// filter based on label by ID or name
2017-11-16 20:56:14 +00:00
if ( $context -> labelled ()) {
// any label (true) or no label (false)
2017-12-07 03:26:06 +00:00
$q -> setWhere (( ! $context -> labelled ? " not " : " " ) . " exists(select article from arsse_label_members where assigned = 1 and article = arsse_articles.id and subscription in (select sub from subscribed_feeds)) " );
2017-11-16 20:56:14 +00:00
} elseif ( $context -> label () || $context -> labelName ()) {
// specific label ID or name
2017-10-13 04:04:26 +00:00
if ( $context -> label ()) {
$id = $this -> labelValidateId ( $user , $context -> label , false )[ 'id' ];
} else {
$id = $this -> labelValidateId ( $user , $context -> labelName , true )[ 'id' ];
}
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " exists(select article from arsse_label_members where assigned = 1 and article = arsse_articles.id and label = ?) " , " int " , $id );
2017-10-13 04:04:26 +00:00
}
2017-11-18 21:06:49 +00:00
// filter based on article or edition offset
if ( $context -> oldestArticle ()) {
$q -> setWhere ( " arsse_articles.id >= ? " , " int " , $context -> oldestArticle );
}
if ( $context -> latestArticle ()) {
$q -> setWhere ( " arsse_articles.id <= ? " , " int " , $context -> latestArticle );
}
2017-08-29 14:50:31 +00:00
if ( $context -> oldestEdition ()) {
2017-07-21 02:40:09 +00:00
$q -> setWhere ( " edition >= ? " , " int " , $context -> oldestEdition );
}
2017-08-29 14:50:31 +00:00
if ( $context -> latestEdition ()) {
2017-07-21 02:40:09 +00:00
$q -> setWhere ( " edition <= ? " , " int " , $context -> latestEdition );
}
2017-11-17 22:52:00 +00:00
// filter based on time at which an article was changed by feed updates (modified), or by user action (marked)
2017-08-29 14:50:31 +00:00
if ( $context -> modifiedSince ()) {
2017-07-21 02:40:09 +00:00
$q -> setWhere ( " modified_date >= ? " , " datetime " , $context -> modifiedSince );
}
2017-08-29 14:50:31 +00:00
if ( $context -> notModifiedSince ()) {
2017-07-21 02:40:09 +00:00
$q -> setWhere ( " modified_date <= ? " , " datetime " , $context -> notModifiedSince );
}
2017-11-17 22:52:00 +00:00
if ( $context -> markedSince ()) {
$q -> setWhere ( " marked_date >= ? " , " datetime " , $context -> markedSince );
}
if ( $context -> notMarkedSince ()) {
$q -> setWhere ( " marked_date <= ? " , " datetime " , $context -> notMarkedSince );
}
2017-06-18 14:23:37 +00:00
// filter for un/read and un/starred status if specified
2017-08-29 14:50:31 +00:00
if ( $context -> unread ()) {
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " unread = ? " , " bool " , $context -> unread );
2017-07-21 02:40:09 +00:00
}
2017-08-29 14:50:31 +00:00
if ( $context -> starred ()) {
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " starred = ? " , " bool " , $context -> starred );
2017-07-21 02:40:09 +00:00
}
2017-11-18 00:08:35 +00:00
// filter based on whether the article has a note
if ( $context -> annotated ()) {
2017-12-07 03:26:06 +00:00
$q -> setWhere (( ! $context -> annotated ? " not " : " " ) . " exists(select modified from arsse_marks where article = arsse_articles.id and note <> '' and subscription in (select sub from subscribed_feeds)) " );
2017-11-18 00:08:35 +00:00
}
2017-10-07 00:26:22 +00:00
// return the query
return $q ;
}
2017-11-07 04:32:29 +00:00
protected function articleChunk ( Context $context ) : array {
$exception = " " ;
if ( $context -> editions ()) {
// editions take precedence over articles
if ( sizeof ( $context -> editions ) > self :: LIMIT_ARTICLES ) {
$exception = " editions " ;
}
} elseif ( $context -> articles ()) {
if ( sizeof ( $context -> articles ) > self :: LIMIT_ARTICLES ) {
$exception = " articles " ;
}
}
if ( $exception ) {
$out = [];
$list = array_chunk ( $context -> $exception , self :: LIMIT_ARTICLES );
foreach ( $list as $chunk ) {
$out [] = ( clone $context ) -> $exception ( $chunk );
}
return $out ;
} else {
return [];
}
}
2017-11-17 23:12:00 +00:00
public function articleList ( string $user , Context $context = null , int $fields = self :: LIST_FULL ) : 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 ]);
}
$context = $context ? ? new Context ;
2017-11-07 04:32:29 +00:00
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ( $contexts = $this -> articleChunk ( $context )) {
$out = [];
$tr = $this -> begin ();
foreach ( $contexts as $context ) {
2017-11-17 22:52:00 +00:00
$out [] = $this -> articleList ( $user , $context , $fields );
2017-11-07 04:32:29 +00:00
}
$tr -> commit ();
return new Db\ResultAggregate ( ... $out );
} else {
2017-11-17 22:52:00 +00:00
$columns = [];
switch ( $fields ) {
// NOTE: the cases all cascade into each other: a given verbosity level is always a superset of the previous one
2017-11-17 23:12:00 +00:00
case self :: LIST_FULL : // everything
2017-11-30 03:42:50 +00:00
$columns = array_merge ( $columns , [
2017-12-07 03:26:06 +00:00
" (select note from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)) as note " ,
2017-11-17 22:52:00 +00:00
]);
2017-12-07 20:18:25 +00:00
// no break
2017-11-17 23:12:00 +00:00
case self :: LIST_TYPICAL : // conservative, plus content
2017-11-30 03:42:50 +00:00
$columns = array_merge ( $columns , [
2017-11-17 22:52:00 +00:00
" content " ,
" arsse_enclosures.url as media_url " , // enclosures are potentially large due to data: URLs
" arsse_enclosures.type as media_type " , // FIXME: enclosures should eventually have their own fetch method
]);
2017-12-07 20:18:25 +00:00
// no break
2017-11-17 23:12:00 +00:00
case self :: LIST_CONSERVATIVE : // base metadata, plus anything that is not likely to be large text
2017-11-30 03:42:50 +00:00
$columns = array_merge ( $columns , [
2017-11-17 22:52:00 +00:00
" arsse_articles.url as url " ,
" arsse_articles.title as title " ,
2017-12-07 03:26:06 +00:00
" (select coalesce(arsse_subscriptions.title,arsse_feeds.title) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed = arsse_feeds.id where arsse_feeds.id = arsse_articles.feed) as subscription_title " ,
2017-11-17 22:52:00 +00:00
" author " ,
" guid " ,
" published as published_date " ,
" edited as edited_date " ,
" url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint " ,
]);
2017-12-07 20:18:25 +00:00
// no break
2017-11-17 23:12:00 +00:00
case self :: LIST_MINIMAL : // base metadata (always included: required for context matching)
2017-11-30 03:42:50 +00:00
$columns = array_merge ( $columns , [
2017-11-27 20:05:50 +00:00
// id, subscription, feed, modified_date, marked_date, unread, starred, edition
" edited as edited_date " ,
]);
2017-11-17 22:52:00 +00:00
break ;
default :
2017-11-18 03:53:54 +00:00
throw new Exception ( " constantUnknown " , $fields );
2017-11-17 22:52:00 +00:00
}
2017-11-07 04:32:29 +00:00
$q = $this -> articleQuery ( $user , $context , $columns );
2017-11-27 19:11:35 +00:00
$q -> setOrder ( " edited_date " . ( $context -> reverse ? " desc " : " " ));
2017-11-27 20:05:50 +00:00
$q -> setOrder ( " edition " . ( $context -> reverse ? " desc " : " " ));
2017-12-07 03:26:06 +00:00
$q -> setJoin ( " left join arsse_enclosures on arsse_enclosures.article = arsse_articles.id " );
2017-11-07 04:32:29 +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
}
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 ;
2017-11-07 04:32:29 +00:00
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ( $contexts = $this -> articleChunk ( $context )) {
$out = 0 ;
$tr = $this -> begin ();
foreach ( $contexts as $context ) {
$out += $this -> articleCount ( $user , $context );
}
$tr -> commit ();
return $out ;
} else {
$q = $this -> articleQuery ( $user , $context );
$q -> pushCTE ( " selected_articles " );
$q -> setBody ( " SELECT count(*) from selected_articles " );
return $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> getValue ();
}
2017-10-13 21:05:06 +00:00
}
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 ]);
}
2017-10-13 04:04:26 +00:00
$context = $context ? ? new Context ;
2017-11-07 04:32:29 +00:00
// if the context has more articles or editions than we can process in one query, perform a series of queries and return an aggregate result
if ( $contexts = $this -> articleChunk ( $context )) {
$out = 0 ;
$tr = $this -> begin ();
foreach ( $contexts as $context ) {
$out += $this -> articleMark ( $user , $data , $context );
2017-07-21 02:40:09 +00:00
}
2017-11-07 04:32:29 +00:00
$tr -> commit ();
return $out ;
} else {
// sanitize input
$values = [
isset ( $data [ 'read' ]) ? $data [ 'read' ] : null ,
isset ( $data [ 'starred' ]) ? $data [ 'starred' ] : null ,
2017-11-09 19:21:12 +00:00
isset ( $data [ 'note' ]) ? $data [ 'note' ] : null ,
2017-11-07 04:32:29 +00:00
];
// the two queries we want to execute to make the requested changes
$queries = [
" UPDATE arsse_marks
set
2017-12-07 03:26:06 +00:00
read = case when ( select honour_read from target_articles where target_articles . id = article ) = 1 then ( select read from target_values ) else read end ,
2017-11-07 04:32:29 +00:00
starred = coalesce (( select starred from target_values ), starred ),
2017-11-09 19:21:12 +00:00
note = coalesce (( select note from target_values ), note ),
2017-11-07 04:32:29 +00:00
modified = CURRENT_TIMESTAMP
WHERE
subscription in ( select sub from subscribed_feeds )
2017-12-07 03:26:06 +00:00
and article in ( select id from target_articles where to_insert = 0 and ( honour_read = 1 or honour_star = 1 or ( select note from target_values ) is not null )) " ,
2017-11-09 19:21:12 +00:00
" INSERT INTO arsse_marks(subscription,article,read,starred,note)
2017-11-07 04:32:29 +00:00
select
2017-12-07 03:26:06 +00:00
( select id from arsse_subscriptions join user on user = owner where arsse_subscriptions . feed = target_articles . feed ),
2017-11-07 04:32:29 +00:00
id ,
coalesce (( select read from target_values ) * honour_read , 0 ),
2017-11-09 19:21:12 +00:00
coalesce (( select starred from target_values ), 0 ),
coalesce (( select note from target_values ), '' )
2017-12-07 03:26:06 +00:00
from target_articles where to_insert = 1 and ( honour_read = 1 or honour_star = 1 or coalesce (( select note from target_values ), '' ) <> '' ) "
2017-11-07 04:32:29 +00:00
];
$out = 0 ;
// wrap this UPDATE and INSERT together into a transaction
$tr = $this -> begin ();
// if an edition context is specified, make sure it's valid
if ( $context -> edition ()) {
// make sure the edition exists
$edition = $this -> articleValidateEdition ( $user , $context -> edition );
// if the edition is not the latest, do not mark the read flag
if ( ! $edition [ 'current' ]) {
$values [ 0 ] = null ;
}
} elseif ( $context -> article ()) {
// otherwise if an article context is specified, make sure it's valid
$this -> articleValidateId ( $user , $context -> article );
}
// execute each query in sequence
foreach ( $queries as $query ) {
// first build the query which will select the target articles; we will later turn this into a CTE for the actual query that manipulates the articles
$q = $this -> articleQuery ( $user , $context , [
2017-12-07 03:26:06 +00:00
" (not exists(select article from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds))) as to_insert " ,
" ((select read from target_values) is not null and (select read from target_values) <> (coalesce((select read from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),0)) and (not exists(select * from requested_articles) or (select max(id) from arsse_editions where article = arsse_articles.id) in (select edition from requested_articles))) as honour_read " ,
" ((select starred from target_values) is not null and (select starred from target_values) <> (coalesce((select starred from arsse_marks where article = arsse_articles.id and subscription in (select sub from subscribed_feeds)),0))) as honour_star " ,
2017-11-07 04:32:29 +00:00
]);
// common table expression with the values to set
2017-11-09 19:21:12 +00:00
$q -> setCTE ( " target_values(read,starred,note) " , " SELECT ?,?,? " , [ " bool " , " bool " , " str " ], $values );
2017-11-07 04:32:29 +00:00
// push the current query onto the CTE stack and execute the query we're actually interested in
$q -> pushCTE ( " target_articles " );
$q -> setBody ( $query );
$out += $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> changes ();
}
// commit the transaction
$tr -> commit ();
return $out ;
}
2017-06-04 22:00:18 +00:00
}
2017-06-30 17:53:19 +00:00
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 ,
coalesce ( sum ( not read ), 0 ) as unread ,
coalesce ( sum ( read ), 0 ) as read
FROM (
2017-12-07 03:26:06 +00:00
select read from arsse_marks where starred = 1 and subscription in ( select id from arsse_subscriptions where owner = ? )
2017-12-07 20:18:25 +00:00
) " ,
" str "
2017-10-11 16:55:50 +00:00
) -> run ( $user ) -> getRow ();
}
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' ];
2017-12-07 03:26:06 +00:00
$out = $this -> db -> prepare ( " SELECT id,name from arsse_labels where owner = ? and exists(select id from arsse_label_members where article = ? and label = arsse_labels.id and assigned = 1) " , " str " , " int " ) -> run ( $user , $id ) -> getAll ();
2017-10-13 21:05:06 +00:00
if ( ! $out ) {
return $out ;
} else {
// flatten the result to return just the label ID or name
return array_column ( $out , ! $byName ? " id " : " name " );
}
}
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 " );
}
}
2017-08-18 02:36:15 +00:00
public function articleCleanup () : bool {
$query = $this -> db -> prepare (
" WITH target_feed(id,subs) as ( " .
" SELECT
2017-12-07 03:26:06 +00:00
id , ( select count ( * ) from arsse_subscriptions where feed = arsse_feeds . id ) as subs
from arsse_feeds where id = ? " .
2017-08-18 02:36:15 +00:00
" ), excepted_articles(id,edition) as ( " .
" SELECT
2017-12-07 03:26:06 +00:00
arsse_articles . id , ( select max ( id ) from arsse_editions where article = arsse_articles . id ) as edition
2017-08-18 02:36:15 +00:00
from arsse_articles
2017-12-07 03:26:06 +00:00
join target_feed on arsse_articles . feed = target_feed . id
2017-08-18 02:36:15 +00:00
order by edition desc limit ? " .
" ) " .
" DELETE from arsse_articles where
2017-12-07 03:26:06 +00:00
feed = ( select max ( id ) from target_feed )
2017-08-18 02:36:15 +00:00
and id not in ( select id from excepted_articles )
2017-12-07 03:26:06 +00:00
and ( select count ( * ) from arsse_marks where article = arsse_articles . id and starred = 1 ) = 0
2017-08-18 02:36:15 +00:00
and (
2017-12-07 03:26:06 +00:00
coalesce (( select max ( modified ) from arsse_marks where article = arsse_articles . id ), modified ) <= ?
or (( select max ( subs ) from target_feed ) = ( select count ( * ) from arsse_marks where article = arsse_articles . id and read = 1 ) and coalesce (( select max ( modified ) from arsse_marks where article = arsse_articles . id ), modified ) <= ? )
2017-08-18 02:36:15 +00:00
)
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 ();
2017-08-29 14:50:31 +00:00
foreach ( $feeds as $feed ) {
2017-08-18 02:36:15 +00:00
$query -> run ( $feed [ 'id' ], $feed [ 'size' ], $limitUnread , $limitRead );
}
return true ;
}
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 (
" SELECT
arsse_articles . id as article ,
2017-12-07 03:26:06 +00:00
( select max ( id ) from arsse_editions where article = arsse_articles . id ) as edition
2017-06-30 17:53:19 +00:00
FROM arsse_articles
2017-12-07 03:26:06 +00:00
join arsse_feeds on arsse_feeds . id = arsse_articles . feed
join arsse_subscriptions on arsse_subscriptions . feed = arsse_feeds . id
2017-06-30 17:53:19 +00:00
WHERE
2017-12-07 03:26:06 +00:00
arsse_articles . 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 " => " article " , 'id' => $id ]);
}
2017-06-30 17:53:19 +00:00
return $out ;
}
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 (
" SELECT
arsse_editions . id as edition ,
arsse_editions . article as article ,
2017-12-07 03:26:06 +00:00
( arsse_editions . id = ( select max ( id ) from arsse_editions where article = arsse_editions . article )) as current
2017-06-30 17:53:19 +00:00
FROM arsse_editions
2017-12-07 03:26:06 +00:00
join arsse_articles on arsse_editions . article = arsse_articles . id
join arsse_feeds on arsse_feeds . id = arsse_articles . feed
join arsse_subscriptions on arsse_subscriptions . feed = arsse_feeds . id
2017-06-30 17:53:19 +00:00
WHERE
2017-12-07 03:26:06 +00:00
edition = ? 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 ]);
}
2017-06-30 17:53:19 +00:00
return $out ;
}
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 ;
2017-12-07 03:26:06 +00:00
$q = new Query ( " SELECT max(arsse_editions.id) from arsse_editions left join arsse_articles on article = arsse_articles.id left join arsse_feeds on arsse_articles.feed = arsse_feeds.id " );
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
$id = $this -> subscriptionValidateId ( $user , $context -> subscription )[ 'feed' ];
// a simple WHERE clause is required here
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " arsse_feeds.id = ? " , " int " , $id );
2017-08-02 22:27:04 +00:00
} else {
$q -> setCTE ( " user(user) " , " SELECT ? " , " str " , $user );
2017-12-07 03:26:06 +00:00
$q -> setCTE ( " feeds(feed) " , " SELECT feed from arsse_subscriptions join user on user = owner " , [], [], " join feeds on arsse_articles.feed = feeds.feed " );
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
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 ();
}
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 (
" SELECT
id , name ,
2017-12-07 03:26:06 +00:00
( select count ( * ) from arsse_label_members where label = id and assigned = 1 ) as articles ,
2017-10-07 00:26:22 +00:00
( select count ( * ) from arsse_label_members
2017-12-07 03:26:06 +00:00
join arsse_marks on arsse_label_members . article = arsse_marks . article and arsse_label_members . subscription = arsse_marks . subscription
where label = id and assigned = 1 and read = 1
2017-10-07 00:26:22 +00:00
) as read
2017-12-07 03:26:06 +00:00
FROM arsse_labels where owner = ? and articles >= ? order by name
2017-12-07 20:18:25 +00:00
" ,
" str " ,
" int "
2017-10-07 00:26:22 +00:00
) -> run ( $user , ! $includeEmpty );
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 ;
}
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 (
" SELECT
id , name ,
2017-12-07 03:26:06 +00:00
( select count ( * ) from arsse_label_members where label = id and assigned = 1 ) as articles ,
2017-10-13 04:04:26 +00:00
( select count ( * ) from arsse_label_members
2017-12-07 03:26:06 +00:00
join arsse_marks on arsse_label_members . article = arsse_marks . article and arsse_label_members . subscription = arsse_marks . subscription
where label = id and assigned = 1 and read = 1
2017-10-13 04:04:26 +00:00
) as read
2017-12-07 03:26:06 +00:00
FROM arsse_labels where $field = ? and owner = ?
2017-12-07 20:18:25 +00:00
" ,
$type ,
" str "
2017-10-13 04:04:26 +00:00
) -> run ( $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 ;
}
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 ;
}
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 " ;
2017-12-07 03:26:06 +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 = ? " , $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 " );
}
}
2017-10-20 22:17:47 +00:00
public function labelArticlesSet ( string $user , $id , Context $context = null , bool $remove = false , bool $byName = false ) : int {
2017-10-13 04:04:26 +00:00
if ( ! Arsse :: $user -> authorize ( $user , __FUNCTION__ )) {
throw new User\ExceptionAuthz ( " notAuthorized " , [ " action " => __FUNCTION__ , " user " => $user ]);
}
// validate the label ID, and get the numeric ID if matching by name
$id = $this -> labelValidateId ( $user , $id , $byName , true )[ 'id' ];
$context = $context ? ? new Context ;
$out = 0 ;
// wrap this UPDATE and INSERT together into a transaction
$tr = $this -> begin ();
// first update any existing entries with the removal or re-addition of their association
$q = $this -> articleQuery ( $user , $context );
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " exists(select article from arsse_label_members where label = ? and article = arsse_articles.id) " , " int " , $id );
2017-10-13 04:04:26 +00:00
$q -> pushCTE ( " target_articles " );
$q -> setBody (
2017-12-07 03:26:06 +00:00
" UPDATE arsse_label_members set assigned = ?, modified = CURRENT_TIMESTAMP where label = ? and assigned = not ? and article in (select id from target_articles) " ,
2017-10-20 23:02:42 +00:00
[ " bool " , " int " , " bool " ],
2017-10-13 04:04:26 +00:00
[ ! $remove , $id , ! $remove ]
);
$out += $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> changes ();
// next, if we're not removing, add any new entries that need to be added
if ( ! $remove ) {
$q = $this -> articleQuery ( $user , $context );
2017-12-07 03:26:06 +00:00
$q -> setWhere ( " not exists(select article from arsse_label_members where label = ? and article = arsse_articles.id) " , " int " , $id );
2017-10-13 04:04:26 +00:00
$q -> pushCTE ( " target_articles " );
$q -> setBody (
" INSERT INTO
arsse_label_members ( label , article , subscription )
SELECT
? , id ,
2017-12-07 03:26:06 +00:00
( select id from arsse_subscriptions join user on user = owner where arsse_subscriptions . feed = target_articles . feed )
2017-10-13 04:04:26 +00:00
FROM target_articles " ,
2017-12-07 20:18:25 +00:00
" int " ,
$id
2017-10-13 04:04:26 +00:00
);
$out += $this -> db -> prepare ( $q -> getQuery (), $q -> getTypes ()) -> run ( $q -> getValues ()) -> changes ();
}
// commit the transaction
$tr -> commit ();
2017-10-20 22:17:47 +00:00
return $out ;
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 ,
];
}
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 ;
}
}
2017-08-29 14:50:31 +00:00
}