1
0
forked from Public/pics

10 Commits

Author SHA1 Message Date
555a3dbb95 Match OIDC users by sub claim, auto-enroll, sync admin from groups
Switch from email-based OIDC matching to the stable `sub` claim.
Existing users are migrated by email on first login, new users are
auto-enrolled from OIDC claims, and admin status is synced from the
IdP's groups claim. Also expose oidc_sub on the admin edit-user page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 19:58:58 +01:00
a361df2668 Add OIDC login support for external identity providers
Adds "Login with <provider>" as an alternative login method using the
jumbojett/openid-connect-php library. OIDC users must already exist in
the database (matched by email). Configurable via OIDC_PROVIDER_URL,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_PROVIDER_NAME constants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 16:08:45 +01:00
c862d14e45 Fix pinch-to-zoom being misinterpreted as swipe navigation
Multi-finger gestures like pinch-to-zoom were triggering photo
navigation because the touch handler didn't check touch count.
Move swipe detection to touchend, reject multi-touch gestures,
and require a minimum 40px horizontal distance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:48:22 +01:00
d788391f4a Refactor fragile SQLite regex rewrites into driver-aware query methods
Move the ON DUPLICATE KEY UPDATE and UPDATE...AS alias SQL rewrites out
of Database::rewriteForSQLite() and into Tag::createNew() and
Tag::recount() as driver-aware branches via a new Database::getDriver()
method. This keeps dialect-specific SQL explicit at the call site rather
than buried in fragile regex transforms.

Also fix Tag::getAlbums() and Tag::getPeople() failing on SQLite when
id_parent is NULL by adding an IS NULL fallback for root-level queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:40:46 +01:00
a4d453792d Add Nix flake for dev environment
Provides PHP with imagick, pdo_mysql, pdo_sqlite extensions, composer,
and sqlite CLI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:23:45 +01:00
5832ce6228 Dispatcher: fix trigger404 using nonexistent ViewErrorPage class
Use the existing errorPage() helper, consistent with trigger400 and
trigger403.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:23:44 +01:00
a71b8c9717 Add SQLite support as alternative database backend
Support config-driven choice between MySQL and SQLite via DB_DRIVER
constant, defaulting to MySQL for backward compatibility. All SQL
adaptation lives in Database.php (UDFs + query rewriting), so model
files need no changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

SQLite: remove FK constraints, revert 0→null sentinel changes

The SQLite schema had FOREIGN KEY constraints that don't exist in the
MySQL schema. These forced a cascade of 0→null changes to satisfy FK
enforcement. Removing them keeps the two backends behaviorally consistent
and minimises the diff. Real SQLite compat fixes (UDFs, query rewriting,
rowCount→count, Router fixes, EditAlbum guard) are preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 15:23:41 +01:00
b0ee3081a6 Tag: invert behaviour of getCount and getOffset methods 2026-02-14 12:56:50 +01:00
2cd2f472d0 Merge pull request 'ViewPeople: fix incorrect pagination count' (#55) from yorick/pics:fix/viewpeople-pagination-count into master
Reviewed-on: Public/pics#55
2026-02-14 12:43:56 +01:00
7f7067852a ViewPeople: fix incorrect pagination count
Tag::getCount was called without the third argument, causing it to
count tags where kind \!= 'Person' instead of kind = 'Person'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 11:19:04 +01:00
23 changed files with 641 additions and 94 deletions

View File

@@ -14,7 +14,13 @@ require_once 'vendor/autoload.php';
// Initialise the database. // Initialise the database.
Registry::set('start', microtime(true)); Registry::set('start', microtime(true));
Registry::set('db', new Database(DB_SERVER, DB_USER, DB_PASS, DB_NAME)); if (defined('DB_DRIVER') && DB_DRIVER === 'sqlite')
Registry::set('db', new Database('sqlite', ['file' => DB_FILE]));
else
Registry::set('db', new Database('mysql', [
'host' => DB_SERVER, 'user' => DB_USER,
'password' => DB_PASS, 'name' => DB_NAME,
]));
// Handle errors our own way. // Handle errors our own way.
ErrorHandler::enable(); ErrorHandler::enable();

View File

@@ -22,6 +22,7 @@
"ext-imagick": "*", "ext-imagick": "*",
"ext-mysqli": "*", "ext-mysqli": "*",
"twbs/bootstrap": "^5.3", "twbs/bootstrap": "^5.3",
"twbs/bootstrap-icons": "^1.10" "twbs/bootstrap-icons": "^1.10",
"jumbojett/openid-connect-php": "^1.0"
} }
} }

View File

@@ -34,3 +34,10 @@ const DB_LOG_QUERIES = false;
const SITE_TITLE = 'HashRU Pics'; const SITE_TITLE = 'HashRU Pics';
const SITE_SLOGAN = 'Nijmeegs Nerdclubje'; const SITE_SLOGAN = 'Nijmeegs Nerdclubje';
// OIDC authentication (e.g. Kanidm). OIDC is enabled when OIDC_PROVIDER_URL is non-empty.
const OIDC_PROVIDER_URL = ''; // e.g. 'https://kanidm.example.com/oauth2/openid/pics'
const OIDC_CLIENT_ID = '';
const OIDC_CLIENT_SECRET = '';
const OIDC_PROVIDER_NAME = ''; // e.g. 'Kanidm' — used as button label
const OIDC_ADMIN_GROUP = ''; // OIDC group claim value that grants admin, e.g. 'pics_admins'

View File

@@ -206,7 +206,7 @@ class EditAlbum extends HTMLController
$data = $this->form->getData(); $data = $this->form->getData();
// Sanity check: don't let an album be its own parent // Sanity check: don't let an album be its own parent
if ($data['id_parent'] == $id_tag) if ($id_tag && $data['id_parent'] == $id_tag)
{ {
return $this->formview->adopt(new Alert('Invalid parent', 'An album cannot be its own parent.', 'danger')); return $this->formview->adopt(new Alert('Invalid parent', 'An album cannot be its own parent.', 'danger'));
} }

View File

@@ -109,6 +109,14 @@ class EditUser extends HTMLController
'maxlength' => 255, 'maxlength' => 255,
'is_optional' => true, 'is_optional' => true,
], ],
'oidc_sub' => [
'header' => 'OIDC',
'type' => 'text',
'label' => 'OIDC subject identifier',
'size' => 50,
'maxlength' => 255,
'is_optional' => true,
],
'is_admin' => [ 'is_admin' => [
'header' => 'Privileges', 'header' => 'Privileges',
'type' => 'checkbox', 'type' => 'checkbox',
@@ -145,6 +153,10 @@ class EditUser extends HTMLController
// Quick stripping. // Quick stripping.
$data['slug'] = strtr(strtolower($data['slug']), [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']); $data['slug'] = strtr(strtolower($data['slug']), [' ' => '-', '--' => '-', '&' => 'and', '=>' => '', "'" => "", ":"=> "", '/' => '-', '\\' => '-']);
// Normalise empty OIDC sub to null (unique constraint).
if (empty($data['oidc_sub']))
$data['oidc_sub'] = null;
// Checkboxes, fun! // Checkboxes, fun!
$data['is_admin'] = empty($data['is_admin']) ? 0 : 1; $data['is_admin'] = empty($data['is_admin']) ? 0 : 1;

View File

@@ -59,10 +59,10 @@ class ManageAlbums extends HTMLController
'items_per_page' => 9999, 'items_per_page' => 9999,
'base_url' => BASEURL . '/managealbums/', 'base_url' => BASEURL . '/managealbums/',
'get_data' => function($offset, $limit, $order, $direction) { 'get_data' => function($offset, $limit, $order, $direction) {
return Tag::getOffset($offset, $limit, $order, $direction, true); return Tag::getOffset($offset, $limit, $order, $direction);
}, },
'get_count' => function() { 'get_count' => function() {
return Tag::getCount(false, 'Album', true); return Tag::getCount(false, 'Album');
} }
]; ];

View File

@@ -72,10 +72,10 @@ class ManageTags extends HTMLController
'items_per_page' => 9999, 'items_per_page' => 9999,
'base_url' => BASEURL . '/managetags/', 'base_url' => BASEURL . '/managetags/',
'get_data' => function($offset, $limit, $order, $direction) { 'get_data' => function($offset, $limit, $order, $direction) {
return Tag::getOffset($offset, $limit, $order, $direction, false); return Tag::getOffset($offset, $limit, $order, $direction, true);
}, },
'get_count' => function() { 'get_count' => function() {
return Tag::getCount(false, null, false); return Tag::getCount(false, null, true);
} }
]; ];

152
controllers/OIDCLogin.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
/*****************************************************************************
* OIDCLogin.php
* Handles OIDC authentication via Kanidm (or other OIDC providers).
*****************************************************************************/
use Jumbojett\OpenIDConnectClient;
class OIDCLogin
{
public function __construct()
{
if (empty(OIDC_PROVIDER_URL))
{
header('Location: ' . BASEURL . '/login/');
exit;
}
// Already logged in? Redirect home.
if (Registry::get('user')->isLoggedIn())
{
header('Location: ' . BASEURL . '/');
exit;
}
// Store redirect URL in session before OIDC flow.
if (isset($_GET['redirect']))
$_SESSION['oidc_redirect_url'] = base64_decode($_GET['redirect']);
$oidc = new OpenIDConnectClient(OIDC_PROVIDER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET);
$oidc->setRedirectURL(BASEURL . '/oidclogin/');
$oidc->addScope(['openid', 'email', 'profile', 'groups']);
try
{
$oidc->authenticate();
}
catch (\Exception $e)
{
$_SESSION['login_msg'] = ['', 'OIDC authentication failed: ' . $e->getMessage(), 'danger'];
header('Location: ' . BASEURL . '/login/');
exit;
}
// Get the stable subject identifier from the ID token.
$sub = $oidc->getVerifiedClaims('sub');
if (empty($sub))
{
$_SESSION['login_msg'] = ['', 'No subject identifier received from OIDC provider.', 'danger'];
header('Location: ' . BASEURL . '/login/');
exit;
}
// Step 1: Look up user by oidc_sub.
$user = Member::fromOidcSub($sub);
// Step 2: One-time email migration — link existing account by email.
if ($user === null || $user === false)
{
$email = $oidc->requestUserInfo('email');
if (!empty($email))
{
$user = Member::fromEmailAddress($email);
if ($user !== null && $user !== false)
$user->update(['oidc_sub' => $sub]);
}
}
// Step 3: Auto-enroll — create a new user from OIDC claims.
if ($user === null || $user === false)
{
$first_name = $oidc->requestUserInfo('given_name') ?: '';
$last_name = $oidc->requestUserInfo('family_name') ?: '';
$email = $oidc->requestUserInfo('email') ?: $sub . '@oidc.placeholder';
$slug = $this->generateSlug($first_name, $last_name);
$user = Member::createNew([
'first_name' => $first_name ?: 'OIDC',
'surname' => $last_name ?: 'User',
'slug' => $slug,
'emailaddress' => $email,
'oidc_sub' => $sub,
]);
if ($user === false)
{
$_SESSION['login_msg'] = ['', 'Failed to create account. Please contact an administrator.', 'danger'];
header('Location: ' . BASEURL . '/login/');
exit;
}
}
// Step 4: Sync admin status from groups claim.
$groups = $oidc->requestUserInfo('groups');
$should_be_admin = is_array($groups) && in_array(OIDC_ADMIN_GROUP, $groups);
if ($should_be_admin !== $user->isAdmin())
$user->update(['is_admin' => $should_be_admin ? 1 : 0]);
// Set session and redirect.
$_SESSION['user_id'] = $user->getUserId();
if (!empty($_SESSION['oidc_redirect_url']))
{
$redirect = $_SESSION['oidc_redirect_url'];
unset($_SESSION['oidc_redirect_url']);
header('Location: ' . $redirect);
}
elseif (!empty($_SESSION['login_url']))
{
$redirect = $_SESSION['login_url'];
unset($_SESSION['login_url']);
header('Location: ' . $redirect);
}
else
header('Location: ' . BASEURL . '/');
exit;
}
private function generateSlug($first_name, $last_name)
{
$base = trim($first_name . ' ' . $last_name);
if (empty($base))
$base = 'user';
$slug = strtolower(preg_replace('/[^a-zA-Z0-9]+/', '-', $base));
$slug = trim($slug, '-');
if (empty($slug))
$slug = 'user';
// Check if slug is available.
$candidate = $slug;
for ($i = 0; $i < 10; $i++)
{
try
{
Member::fromSlug($candidate);
// Slug taken — append random suffix.
$candidate = $slug . '-' . bin2hex(random_bytes(3));
}
catch (NotFoundException $e)
{
// Slug is available.
return $candidate;
}
}
// Fallback: use sub-based slug.
return 'user-' . bin2hex(random_bytes(4));
}
}

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1770843696,
"narHash": "sha256-LovWTGDwXhkfCOmbgLVA10bvsi/P8eDDpRudgk68HA8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2343bbb58f99267223bc2aac4fc9ea301a155a16",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

47
flake.nix Normal file
View File

@@ -0,0 +1,47 @@
{
description = "HashRU Pics dev environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs = { self, nixpkgs }:
let
forAllSystems = f: nixpkgs.lib.genAttrs [
"x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin"
] (system: f nixpkgs.legacyPackages.${system});
php = pkgs: pkgs.php.buildEnv {
extensions = { enabled, all }: enabled ++ (with all; [
imagick
pdo_mysql
pdo_sqlite
]);
extraConfig = ''
memory_limit = 256M
upload_max_filesize = 50M
post_max_size = 50M
'';
};
in
{
devShells = forAllSystems (pkgs: {
default = pkgs.mkShell {
buildInputs = [
(php pkgs)
(php pkgs).packages.composer
pkgs.sqlite
];
shellHook = ''
export COMPOSER_HOME="$PWD/.composer"
if [ ! -d vendor ]; then
echo "Running composer install..."
composer install
fi
'';
};
});
};
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN oidc_sub TEXT;
CREATE UNIQUE INDEX idx_users_oidc_sub ON users (oidc_sub);

View File

@@ -10,7 +10,6 @@ class AssetIterator implements Iterator
{ {
private $direction; private $direction;
private $return_format; private $return_format;
private $rowCount;
private $assets_iterator; private $assets_iterator;
private $meta_iterator; private $meta_iterator;
@@ -21,7 +20,6 @@ class AssetIterator implements Iterator
{ {
$this->direction = $direction; $this->direction = $direction;
$this->return_format = $return_format; $this->return_format = $return_format;
$this->rowCount = $stmt_assets->rowCount();
$this->assets_iterator = new CachedPDOIterator($stmt_assets); $this->assets_iterator = new CachedPDOIterator($stmt_assets);
$this->assets_iterator->rewind(); $this->assets_iterator->rewind();
@@ -209,7 +207,7 @@ class AssetIterator implements Iterator
public function num(): int public function num(): int
{ {
return $this->rowCount; return count($this->assets_iterator);
} }
public function rewind(): void public function rewind(): void

View File

@@ -9,18 +9,35 @@
class Database class Database
{ {
private $connection; private $connection;
private $driver;
private $query_count = 0; private $query_count = 0;
private $logged_queries = []; private $logged_queries = [];
public function __construct($host, $user, $password, $name) public function __construct($driver, array $options)
{ {
$this->driver = $driver;
try try
{ {
$this->connection = new PDO("mysql:host=$host;dbname=$name;charset=utf8mb4", $user, $password, [ if ($driver === 'sqlite')
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, {
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, $this->connection = new PDO("sqlite:" . $options['file'], null, null, [
PDO::ATTR_EMULATE_PREPARES => false, PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]); PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$this->connection->exec('PRAGMA journal_mode=WAL');
$this->registerSQLiteFunctions();
}
else
{
$this->connection = new PDO(
"mysql:host={$options['host']};dbname={$options['name']};charset=utf8mb4",
$options['user'], $options['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
} }
// Give up if we have a connection error. // Give up if we have a connection error.
catch (PDOException $e) catch (PDOException $e)
@@ -31,6 +48,54 @@ class Database
} }
} }
private function registerSQLiteFunctions()
{
$pdo = $this->connection;
$pdo->sqliteCreateFunction('CONCAT', function () {
return implode('', func_get_args());
}, -1);
$pdo->sqliteCreateFunction('IF', function ($cond, $t, $f) {
return $cond ? $t : $f;
}, 3);
$pdo->sqliteCreateFunction('FROM_UNIXTIME', function ($ts) {
return date('Y-m-d H:i:s', $ts);
}, 1);
$pdo->sqliteCreateFunction('UNIX_TIMESTAMP', function () {
return time();
}, 0);
$pdo->sqliteCreateFunction('CURRENT_TIMESTAMP', function () {
return date('Y-m-d H:i:s');
}, 0);
}
public function getDriver()
{
return $this->driver;
}
private function rewriteForSQLite($sql)
{
// REPLACE INTO → INSERT OR REPLACE INTO
$sql = preg_replace('/\bREPLACE\s+INTO\b/i', 'INSERT OR REPLACE INTO', $sql);
// INSERT IGNORE INTO → INSERT OR IGNORE INTO
$sql = preg_replace('/\bINSERT\s+IGNORE\s+INTO\b/i', 'INSERT OR IGNORE INTO', $sql);
// LIMIT :offset, :limit → LIMIT :limit OFFSET :offset
$sql = preg_replace(
'/\bLIMIT\s+:offset\s*,\s*:limit\b/i',
'LIMIT :limit OFFSET :offset',
$sql
);
return $sql;
}
public function getQueryCount() public function getQueryCount()
{ {
return $this->query_count; return $this->query_count;
@@ -180,6 +245,10 @@ class Database
// Preprocessing/checks: prepare any arrays for binding // Preprocessing/checks: prepare any arrays for binding
$db_string = $this->expandPlaceholders($db_string, $db_values); $db_string = $this->expandPlaceholders($db_string, $db_values);
// SQLite query rewriting
if ($this->driver === 'sqlite')
$db_string = $this->rewriteForSQLite($db_string);
// Prepare query for execution // Prepare query for execution
$statement = $this->connection->prepare($db_string); $statement = $this->connection->prepare($db_string);
@@ -223,13 +292,10 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if (!$res || $this->rowCount($res) === 0)
return null;
$object = $this->fetchObject($res, $class); $object = $this->fetchObject($res, $class);
$this->free($res); $this->free($res);
return $object; return $object ?: null;
} }
/** /**
@@ -239,9 +305,6 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if (!$res || $this->rowCount($res) === 0)
return [];
$rows = []; $rows = [];
while ($object = $this->fetchObject($res, $class)) while ($object = $this->fetchObject($res, $class))
$rows[] = $object; $rows[] = $object;
@@ -258,13 +321,10 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$row = $this->fetchNum($res); $row = $this->fetchNum($res);
$this->free($res); $this->free($res);
return $row; return $row ?: [];
} }
/** /**
@@ -274,9 +334,6 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$rows = []; $rows = [];
while ($row = $this->fetchNum($res)) while ($row = $this->fetchNum($res))
$rows[] = $row; $rows[] = $row;
@@ -293,9 +350,6 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$rows = []; $rows = [];
while ($row = $this->fetchNum($res)) while ($row = $this->fetchNum($res))
$rows[$row[0]] = $row[1]; $rows[$row[0]] = $row[1];
@@ -312,9 +366,6 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if (!$res || $this->rowCount($res) === 0)
return [];
$rows = []; $rows = [];
while ($row = $this->fetchAssoc($res)) while ($row = $this->fetchAssoc($res))
{ {
@@ -334,13 +385,10 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$row = $this->fetchAssoc($res); $row = $this->fetchAssoc($res);
$this->free($res); $this->free($res);
return $row; return $row ?: [];
} }
/** /**
@@ -350,9 +398,6 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$rows = []; $rows = [];
while ($row = $this->fetchAssoc($res)) while ($row = $this->fetchAssoc($res))
$rows[] = $row; $rows[] = $row;
@@ -369,14 +414,13 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
// If this happens, you're doing it wrong. $row = $this->fetchNum($res);
if ($this->rowCount($res) === 0)
return null;
list($value) = $this->fetchNum($res);
$this->free($res); $this->free($res);
return $value; if (!$row)
return null;
return $row[0];
} }
/** /**
@@ -386,9 +430,6 @@ class Database
{ {
$res = $this->query($db_string, $db_values); $res = $this->query($db_string, $db_values);
if ($this->rowCount($res) === 0)
return [];
$rows = []; $rows = [];
while ($row = $this->fetchNum($res)) while ($row = $this->fetchNum($res))
$rows[] = $row[0]; $rows[] = $row[0];
@@ -412,7 +453,10 @@ class Database
$data = [$data]; $data = [$data];
// Determine the method of insertion. // Determine the method of insertion.
$method = $method == 'replace' ? 'REPLACE' : ($method == 'ignore' ? 'INSERT IGNORE' : 'INSERT'); if ($this->driver === 'sqlite')
$method = $method == 'replace' ? 'INSERT OR REPLACE' : ($method == 'ignore' ? 'INSERT OR IGNORE' : 'INSERT');
else
$method = $method == 'replace' ? 'REPLACE' : ($method == 'ignore' ? 'INSERT IGNORE' : 'INSERT');
// What columns are we inserting? // What columns are we inserting?
$columns = array_keys($data[0]); $columns = array_keys($data[0]);

View File

@@ -90,7 +90,7 @@ class Dispatcher
private static function trigger404() private static function trigger404()
{ {
http_response_code(404); http_response_code(404);
$page = new ViewErrorPage('Page not found!'); self::errorPage('Page not found!', 'The page you requested could not be found.');
$page->showContent(); exit;
} }
} }

View File

@@ -123,7 +123,7 @@ class ErrorHandler
'debug_info' => $debug_info, 'debug_info' => $debug_info,
'file' => str_replace(BASEDIR, '', $file), 'file' => str_replace(BASEDIR, '', $file),
'line' => $line, 'line' => $line,
'id_user' => Registry::has('user') ? Registry::get('user')->getUserId() : 0, 'id_user' => Registry::has('user') && Registry::get('user')->getUserId() ? Registry::get('user')->getUserId() : 0,
'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '',
'request_uri' => isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '', 'request_uri' => isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '',
])) ]))

View File

@@ -27,6 +27,15 @@ class Member extends User
['email_address' => $email_address]); ['email_address' => $email_address]);
} }
public static function fromOidcSub($sub)
{
return Registry::get('db')->queryObject(static::class, '
SELECT *
FROM users
WHERE oidc_sub = :oidc_sub',
['oidc_sub' => $sub]);
}
public static function fromId($id_user) public static function fromId($id_user)
{ {
$row = Registry::get('db')->queryAssoc(' $row = Registry::get('db')->queryAssoc('
@@ -73,18 +82,27 @@ class Member extends User
'surname' => !empty($data['surname']) ? $data['surname'] : $error |= true, 'surname' => !empty($data['surname']) ? $data['surname'] : $error |= true,
'slug' => !empty($data['slug']) ? $data['slug'] : $error |= true, 'slug' => !empty($data['slug']) ? $data['slug'] : $error |= true,
'emailaddress' => !empty($data['emailaddress']) ? $data['emailaddress'] : $error |= true, 'emailaddress' => !empty($data['emailaddress']) ? $data['emailaddress'] : $error |= true,
'password_hash' => !empty($data['password']) ? Authentication::computeHash($data['password']) : $error |= true,
'creation_time' => time(), 'creation_time' => time(),
'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', 'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '',
'is_admin' => empty($data['is_admin']) ? 0 : 1, 'is_admin' => empty($data['is_admin']) ? 0 : 1,
'reset_key' => '', 'reset_key' => '',
]; ];
// Password is required unless oidc_sub is provided.
if (!empty($data['password']))
$new_user['password_hash'] = Authentication::computeHash($data['password']);
elseif (!empty($data['oidc_sub']))
$new_user['password_hash'] = '';
else
$error |= true;
if (!empty($data['oidc_sub']))
$new_user['oidc_sub'] = $data['oidc_sub'];
if ($error) if ($error)
return false; return false;
$db = Registry::get('db'); $columns = [
$bool = $db->insert('insert', 'users', [
'first_name' => 'string-30', 'first_name' => 'string-30',
'surname' => 'string-60', 'surname' => 'string-60',
'slug' => 'string-90', 'slug' => 'string-90',
@@ -93,8 +111,14 @@ class Member extends User
'creation_time' => 'int', 'creation_time' => 'int',
'ip_address' => 'string-45', 'ip_address' => 'string-45',
'is_admin' => 'int', 'is_admin' => 'int',
'reset_key' => 'string-16' 'reset_key' => 'string-16',
], $new_user, ['id_user']); ];
if (isset($new_user['oidc_sub']))
$columns['oidc_sub'] = 'string-255';
$db = Registry::get('db');
$bool = $db->insert('insert', 'users', $columns, $new_user, ['id_user']);
if (!$bool) if (!$bool)
return false; return false;
@@ -113,7 +137,7 @@ class Member extends User
{ {
foreach ($new_data as $key => $value) foreach ($new_data as $key => $value)
{ {
if (in_array($key, ['first_name', 'surname', 'slug', 'emailaddress'])) if (in_array($key, ['first_name', 'surname', 'slug', 'emailaddress', 'oidc_sub']))
$this->$key = $value; $this->$key = $value;
elseif ($key === 'password') elseif ($key === 'password')
$this->password_hash = Authentication::computeHash($value); $this->password_hash = Authentication::computeHash($value);
@@ -132,7 +156,8 @@ class Member extends User
slug = :slug, slug = :slug,
emailaddress = :emailaddress, emailaddress = :emailaddress,
password_hash = :password_hash, password_hash = :password_hash,
is_admin = :is_admin is_admin = :is_admin,
oidc_sub = :oidc_sub
WHERE id_user = :id_user', WHERE id_user = :id_user',
get_object_vars($this)); get_object_vars($this));
} }

View File

@@ -13,13 +13,14 @@ class Router
$possibleActions = [ $possibleActions = [
'accountsettings' => 'AccountSettings', 'accountsettings' => 'AccountSettings',
'addalbum' => 'EditAlbum', 'addalbum' => 'EditAlbum',
'albums' => 'ViewPhotoAlbums', 'albums' => 'ViewPhotoAlbum',
'editalbum' => 'EditAlbum', 'editalbum' => 'EditAlbum',
'editasset' => 'EditAsset', 'editasset' => 'EditAsset',
'edittag' => 'EditTag', 'edittag' => 'EditTag',
'edituser' => 'EditUser', 'edituser' => 'EditUser',
'login' => 'Login', 'login' => 'Login',
'logout' => 'Logout', 'logout' => 'Logout',
'oidclogin' => 'OIDCLogin',
'managealbums' => 'ManageAlbums', 'managealbums' => 'ManageAlbums',
'manageassets' => 'ManageAssets', 'manageassets' => 'ManageAssets',
'manageerrors' => 'ManageErrors', 'manageerrors' => 'ManageErrors',
@@ -54,7 +55,7 @@ class Router
return new GenerateThumbnail(); return new GenerateThumbnail();
} }
// Look for particular actions... // Look for particular actions...
elseif (preg_match('~^/(?<action>[a-z]+)(?:/page/(?<page>\d+))?/?~', $_SERVER['PATH_INFO'], $path) && isset($possibleActions[$path['action']])) elseif (preg_match('~^/(?<action>[a-z]+)(?:/page/(?<page>\d+))?/?$~', $_SERVER['PATH_INFO'], $path) && isset($possibleActions[$path['action']]))
{ {
$_GET = array_merge($_GET, $path); $_GET = array_merge($_GET, $path);
return new $possibleActions[$path['action']](); return new $possibleActions[$path['action']]();

View File

@@ -122,10 +122,12 @@ class Tag
public static function getAlbums($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array') public static function getAlbums($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array')
{ {
$parent_clause = empty($id_parent) ? '(id_parent = :id_parent OR id_parent IS NULL)' : 'id_parent = :id_parent';
$rows = Registry::get('db')->queryAssocs(' $rows = Registry::get('db')->queryAssocs('
SELECT * SELECT *
FROM tags FROM tags
WHERE id_parent = :id_parent AND kind = :kind WHERE ' . $parent_clause . ' AND kind = :kind
ORDER BY tag ASC ORDER BY tag ASC
LIMIT :offset, :limit', LIMIT :offset, :limit',
[ [
@@ -163,10 +165,12 @@ class Tag
public static function getPeople($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array') public static function getPeople($id_parent = 0, $offset = 0, $limit = 24, $return_format = 'array')
{ {
$parent_clause = empty($id_parent) ? '(id_parent = :id_parent OR id_parent IS NULL)' : 'id_parent = :id_parent';
$rows = Registry::get('db')->queryAssocs(' $rows = Registry::get('db')->queryAssocs('
SELECT * SELECT *
FROM tags FROM tags
WHERE id_parent = :id_parent AND kind = :kind WHERE ' . $parent_clause . ' AND kind = :kind
ORDER BY tag ASC ORDER BY tag ASC
LIMIT :offset, :limit', LIMIT :offset, :limit',
[ [
@@ -249,7 +253,21 @@ class Tag
public static function recount(array $id_tags = []) public static function recount(array $id_tags = [])
{ {
return Registry::get('db')->query(' $db = Registry::get('db');
if ($db->getDriver() === 'sqlite')
{
return $db->query('
UPDATE tags SET count = (
SELECT COUNT(*)
FROM `assets_tags` AS at
WHERE at.id_tag = tags.id_tag
)' . (!empty($id_tags) ? '
WHERE tags.id_tag IN(@id_tags)' : ''),
['id_tags' => $id_tags]);
}
return $db->query('
UPDATE tags AS t SET count = ( UPDATE tags AS t SET count = (
SELECT COUNT(*) SELECT COUNT(*)
FROM `assets_tags` AS at FROM `assets_tags` AS at
@@ -272,13 +290,26 @@ class Tag
if (!isset($data['count'])) if (!isset($data['count']))
$data['count'] = 0; $data['count'] = 0;
$res = $db->query(' if ($db->getDriver() === 'sqlite')
INSERT IGNORE INTO tags {
(id_parent, tag, slug, kind, description, count) $res = $db->query('
VALUES INSERT INTO tags
(:id_parent, :tag, :slug, :kind, :description, :count) (id_parent, tag, slug, kind, description, count)
ON DUPLICATE KEY UPDATE count = count + 1', VALUES
$data); (:id_parent, :tag, :slug, :kind, :description, :count)
ON CONFLICT(slug) DO UPDATE SET count = count + 1',
$data);
}
else
{
$res = $db->query('
INSERT IGNORE INTO tags
(id_parent, tag, slug, kind, description, count)
VALUES
(:id_parent, :tag, :slug, :kind, :description, :count)
ON DUPLICATE KEY UPDATE count = count + 1',
$data);
}
if (!$res) if (!$res)
throw new Exception('Could not create the requested tag.'); throw new Exception('Could not create the requested tag.');
@@ -294,6 +325,8 @@ class Tag
public function save() public function save()
{ {
$vars = get_object_vars($this);
return Registry::get('db')->query(' return Registry::get('db')->query('
UPDATE tags UPDATE tags
SET SET
@@ -306,7 +339,7 @@ class Tag
description = :description, description = :description,
count = :count count = :count
WHERE id_tag = :id_tag', WHERE id_tag = :id_tag',
get_object_vars($this)); $vars);
} }
public function delete() public function delete()
@@ -417,16 +450,17 @@ class Tag
['tags' => $tags]); ['tags' => $tags]);
} }
public static function getCount($only_used = true, $kind = '', $isAlbum = false) public static function getCount($only_used = true, $kind = '', $invert = false)
{ {
$where = []; $where = [];
if ($only_used)
$where[] = 'count > 0'; $where[] = 'kind ' . ($invert ? '!=' : '=' ) . ' :kind';
if (empty($kind)) if (empty($kind))
$kind = 'Album'; $kind = 'Album';
$operator = $isAlbum ? '=' : '!='; if ($only_used)
$where[] = 'kind ' . $operator . ' :kind'; $where[] = 'count > 0';
$where = implode(' AND ', $where); $where = implode(' AND ', $where);
return Registry::get('db')->queryValue(' return Registry::get('db')->queryValue('
@@ -438,19 +472,17 @@ class Tag
]); ]);
} }
public static function getOffset($offset, $limit, $order, $direction, $isAlbum = false) public static function getOffset($offset, $limit, $order, $direction, $invert = false)
{ {
assert(in_array($order, ['id_tag', 'tag', 'slug', 'count'])); assert(in_array($order, ['id_tag', 'tag', 'slug', 'count']));
$order = $order . ($direction === 'up' ? ' ASC' : ' DESC'); $order = $order . ($direction === 'up' ? ' ASC' : ' DESC');
$operator = $isAlbum ? '=' : '!=';
$db = Registry::get('db'); $db = Registry::get('db');
$res = $db->query(' $res = $db->query('
SELECT t.*, u.id_user, u.first_name, u.surname SELECT t.*, u.id_user, u.first_name, u.surname
FROM tags AS t FROM tags AS t
LEFT JOIN users AS u ON t.id_user_owner = u.id_user LEFT JOIN users AS u ON t.id_user_owner = u.id_user
WHERE kind ' . $operator . ' :album WHERE kind ' . ($invert ? '!=' : '=') . ' :album
ORDER BY id_parent, ' . $order . ' ORDER BY id_parent, ' . $order . '
LIMIT :offset, :limit', LIMIT :offset, :limit',
[ [
@@ -462,10 +494,12 @@ class Tag
$albums_by_parent = []; $albums_by_parent = [];
while ($row = $db->fetchAssoc($res)) while ($row = $db->fetchAssoc($res))
{ {
if (!isset($albums_by_parent[$row['id_parent']])) $parent = $row['id_parent'];
$albums_by_parent[$row['id_parent']] = [];
$albums_by_parent[$row['id_parent']][] = $row + ['children' => []]; if (!isset($albums_by_parent[$parent]))
$albums_by_parent[$parent] = [];
$albums_by_parent[$parent][] = $row + ['children' => []];
} }
$albums = self::getChildrenRecursively(0, 0, $albums_by_parent); $albums = self::getChildrenRecursively(0, 0, $albums_by_parent);

View File

@@ -24,6 +24,7 @@ abstract class User
protected $is_admin; protected $is_admin;
protected $reset_key; protected $reset_key;
protected $reset_blocked_until; protected $reset_blocked_until;
protected $oidc_sub;
protected bool $is_logged; protected bool $is_logged;
protected bool $is_guest; protected bool $is_guest;

View File

@@ -30,21 +30,38 @@ function disableKeyDownPropagation(obj) {
function enableTouchNavigation() { function enableTouchNavigation() {
var x_down = null; var x_down = null;
var y_down = null; var y_down = null;
var cancelled = false;
document.addEventListener('touchstart', function(event) { document.addEventListener('touchstart', function(event) {
if (event.touches.length > 1) {
cancelled = true;
return;
}
x_down = event.touches[0].clientX; x_down = event.touches[0].clientX;
y_down = event.touches[0].clientY; y_down = event.touches[0].clientY;
cancelled = false;
}, false); }, false);
document.addEventListener('touchmove', function(event) { document.addEventListener('touchmove', function(event) {
if (!x_down || !y_down) { if (event.touches.length > 1) {
cancelled = true;
}
}, false);
document.addEventListener('touchend', function(event) {
if (cancelled || x_down === null || y_down === null) {
x_down = null;
y_down = null;
return; return;
} }
var x_diff = x_down - event.touches[0].clientX; var x_diff = x_down - event.changedTouches[0].clientX;
var y_diff = y_down - event.touches[0].clientY; var y_diff = y_down - event.changedTouches[0].clientY;
if (Math.abs(y_diff) > 50) { x_down = null;
y_down = null;
if (Math.abs(x_diff) < 40 || Math.abs(y_diff) > 50) {
return; return;
} }
@@ -52,13 +69,11 @@ function enableTouchNavigation() {
if (x_diff > 0) { if (x_diff > 0) {
var target = document.getElementById("previous_photo").href; var target = document.getElementById("previous_photo").href;
if (target) { if (target) {
event.preventDefault();
document.location.href = target + '#photo_frame'; document.location.href = target + '#photo_frame';
} }
} else { } else {
var target = document.getElementById("next_photo").href; var target = document.getElementById("next_photo").href;
if (target) { if (target) {
event.preventDefault();
document.location.href = target + '#photo_frame'; document.location.href = target + '#photo_frame';
} }
} }

104
schema.sqlite.sql Normal file
View File

@@ -0,0 +1,104 @@
-- SQLite schema for Kabuki CMS / pics
--
-- Usage:
-- sqlite3 data/pics.sqlite < schema.sqlite.sql
--
-- Config (add to config.php):
-- define('DB_DRIVER', 'sqlite');
-- define('DB_FILE', __DIR__ . '/data/pics.sqlite');
CREATE TABLE IF NOT EXISTS users (
id_user INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
surname TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
emailaddress TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
creation_time INTEGER NOT NULL,
last_action_time INTEGER,
ip_address TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
reset_key TEXT,
reset_blocked_until INTEGER,
oidc_sub TEXT UNIQUE
);
CREATE TABLE IF NOT EXISTS assets (
id_asset INTEGER PRIMARY KEY,
id_user_uploaded INTEGER NOT NULL,
subdir TEXT NOT NULL,
filename TEXT NOT NULL,
title TEXT,
slug TEXT UNIQUE,
mimetype TEXT,
image_width INTEGER,
image_height INTEGER,
date_captured TEXT,
priority INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS assets_meta (
id_asset INTEGER NOT NULL,
variable TEXT NOT NULL,
value TEXT,
PRIMARY KEY (id_asset, variable)
);
CREATE TABLE IF NOT EXISTS assets_thumbs (
id_asset INTEGER NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
mode TEXT,
filename TEXT,
PRIMARY KEY (id_asset, width, height, mode)
);
CREATE TABLE IF NOT EXISTS tags (
id_tag INTEGER PRIMARY KEY,
id_parent INTEGER,
id_asset_thumb INTEGER,
id_user_owner INTEGER,
tag TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
kind TEXT NOT NULL DEFAULT 'Tag',
count INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS assets_tags (
id_asset INTEGER NOT NULL,
id_tag INTEGER NOT NULL,
PRIMARY KEY (id_asset, id_tag)
);
CREATE TABLE IF NOT EXISTS posts_assets (
id_post INTEGER NOT NULL,
id_asset INTEGER NOT NULL,
PRIMARY KEY (id_post, id_asset)
);
CREATE TABLE IF NOT EXISTS posts_tags (
id_post INTEGER NOT NULL,
id_tag INTEGER NOT NULL,
PRIMARY KEY (id_post, id_tag)
);
CREATE TABLE IF NOT EXISTS settings (
id_user INTEGER NOT NULL,
variable TEXT NOT NULL,
value TEXT,
time_set TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id_user, variable)
);
CREATE TABLE IF NOT EXISTS log_errors (
id_entry INTEGER PRIMARY KEY,
id_user INTEGER,
message TEXT,
debug_info TEXT,
file TEXT,
line INTEGER,
request_uri TEXT,
time TEXT DEFAULT CURRENT_TIMESTAMP,
ip_address TEXT
);

58
seed.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
/*****************************************************************************
* seed.php
* Seeds a fresh database with an admin user and root album.
*
* Usage: php seed.php
*****************************************************************************/
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/vendor/autoload.php';
if (!defined('DB_DRIVER') || DB_DRIVER !== 'sqlite')
{
echo "Error: seed.php currently only supports SQLite.\n";
echo "Set DB_DRIVER to 'sqlite' in config.php.\n";
exit(1);
}
if (!file_exists(DB_FILE))
{
echo "Error: database file not found at " . DB_FILE . "\n";
echo "Create it first: sqlite3 " . DB_FILE . " < schema.sqlite.sql\n";
exit(1);
}
$db = new Database('sqlite', ['file' => DB_FILE]);
Registry::set('db', $db);
// Create admin user.
$password = 'admin';
$hash = password_hash($password, PASSWORD_DEFAULT);
$db->insert('insert', 'users', [], [
'first_name' => 'Admin',
'surname' => 'User',
'slug' => 'admin',
'emailaddress' => 'admin@localhost',
'password_hash' => $hash,
'creation_time' => time(),
'ip_address' => '',
'is_admin' => 1,
'reset_key' => '',
]);
echo "Created admin user (admin@localhost / admin)\n";
// Create root album (id_tag = 1).
$db->insert('insert', 'tags', [], [
'id_parent' => 0,
'tag' => 'Albums',
'slug' => 'albums',
'kind' => 'Album',
'description' => '',
'count' => 0,
]);
echo "Created root album (id_tag = 1)\n";
echo "\nDone. You can now log in at the web UI.\n";

View File

@@ -63,5 +63,18 @@ class LogInForm extends SubTemplate
</div> </div>
</div> </div>
</form>'; </form>';
if (!empty(OIDC_PROVIDER_URL))
{
$oidc_url = BASEURL . '/oidclogin/';
if (!empty($this->redirect_url))
$oidc_url .= '?redirect=' . base64_encode($this->redirect_url);
echo '
<div class="mt-4 text-center">
<hr>
<a class="btn btn-secondary" href="', htmlspecialchars($oidc_url), '">Login with ', htmlspecialchars(OIDC_PROVIDER_NAME), '</a>
</div>';
}
} }
} }