Backend is done. Here's the contents of my config.php
:
<?php
### Files Gallery share link extension
# put this file in _files/config/config.php and make it unwritable.
# user files are served from this dir
$user_root = '/userfiles';
# logged-in users get these permissions by default
# NOTE: put system permissions in _filesconfig.php, next to index.php and not
# here! if 'assets' or 'storage_path' are changed, they MUST go there!
$user_defaults = [
'allow_upload' => true,
'allow_delete' => true,
'allow_rename' => true,
'allow_new_folder' => true,
'allow_new_file' => true,
'allow_duplicate' => true,
'allow_text_edit' => true,
'allow_zip' => true,
'allow_unzip' => true,
'allow_move' => true,
'allow_copy' => true,
'allow_download' => true,
'allow_mass_download' => true,
'allow_mass_copy_links' => true
];
$support_email = 'support@example.com'; # for share auth page failures
$db_uri = 'sqlite:' . __DIR__ . '/db.sqlite'; # uses PDO, can be any DB URI
# start separate share session to main session
function session_start_share($share) {
if (session_status() !== PHP_SESSION_NONE) {
# shouldn't happen but just in case
session_write_close();
}
session_set_cookie_params(60 * 60, "/s/$share/", secure: true);
session_start();
}
# this password is needed for internal logins to generate the login hash
$bypass_auth_password = 'some_random_salt';
function login($user, $password) {
# HACK: satisfy the desires of the files gallery login gods by mimicking how
# it sets up logins and injecting our own values
if (session_status() === PHP_SESSION_NONE) { session_start(); }
# NOTE: supportive functions copied from index.php cuz they're private
function ip() {
foreach(['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED',
'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR'] as $key) {
$ip = explode(',', server($key))[0];
if($ip && filter_var($ip, FILTER_VALIDATE_IP)) return $ip;
}
return ''; # return empty string if nothing found
}
function server($str) {
return isset($_SERVER[$str]) ? $_SERVER[$str] : '';
}
$_SESSION['login'] = md5(
$user
. $password
. md5(
ip()
. server('HTTP_USER_AGENT')
. dirname(__DIR__, 2) . '/index.php'
. server('HTTP_HOST')
)
);
}
function opendb($db_uri) {
return new PDO($db_uri, options: [
PDO::ATTR_TIMEOUT => 1,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);
}
### shares
$share = @$_SERVER['SHARE_URI'];
if (!empty($share)) {
session_start_share($share);
if ($_SERVER['REQUEST_METHOD'] == 'GET' && @$_GET['logout'] == 1) {
session_unset();
session_destroy();
session_write_close();
header("Location: /", true, 302);
exit();
}
$row = null;
$dbcache = "share:$share";
if (array_key_exists($dbcache, $_SESSION)) {
$row = &$_SESSION[$dbcache];
} else {
$db = opendb($db_uri);
$col = null;
$stmt = $db ->prepare("SELECT
rowid, path, realpath, owner,
expires, controls, config
FROM share WHERE uri=?");
if (!$stmt) {
http_response_code(500);
echo 'statement fail';
exit;
}
if (!$stmt->execute([$share])) {
http_response_code(500);
echo 'query fail';
exit;
}
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
# share error
http_response_code(404);
echo 'not found';
exit;
}
$_SESSION[$dbcache] = $row;
$db = null;
}
if (time() > $row['expires']) {
# share is expired
http_response_code(403);
$cfail = 'expired';
goto cfail_msg;
}
if (!file_exists($row['realpath'])) {
http_response_code(404);
echo 'file that share points to is not found';
exit;
}
$controls = $row['controls'];
if ($controls) {
$controls = json_decode($controls, true, 3, JSON_THROW_ON_ERROR);
}
if (!$controls) {
$controls = [];
}
$cnum = 0;
$cfail = null;
$user = null;
foreach ($controls as &$control) {
$ctype = $control['type'];
$cval = @$control['value'];
$statekey = "share:$share:$ctype:" .$cnum++;
if (!array_key_exists($statekey, $_SESSION)) {
$_SESSION[$statekey] = [];
}
$state = &$_SESSION[$statekey];
$my_post = $_SERVER['REQUEST_METHOD'] == 'POST'
&& @$_POST['state'] == $statekey;
# XXX: attempt throttles should be locked to IP not session. a new session
# can be created and earlier controls passed so the stopped one can be
# retried repeatedly. could also be used for email spamming.
if (!empty($state['next_attempt'])
&& time() <= $state['next_attempt']) {
$cfail = 'throttle';
} elseif (!array_key_exists('pass', $state)) {
if ($ctype == 'login') {
$ex = null;
$auth = '';
if (array_key_exists('X_AUTHENTIK_USERNAME', $_SERVER)) {
# NOTE: auth is not enabled on share uris except /auth, so it's safe
# to assume we are trying to auth
# unconditionally save username and return to main page
$state['user'] = $_SERVER['X_AUTHENTIK_USERNAME'];
$ex = '@(/s/[^/]+/)auth(\?.*)?$@';
} elseif (!array_key_exists('user', $state)) {
# no user so redirect to /auth
$ex = '@(/s/[^/]+/)(?:index.php)?(\?.*)?$@';
$auth = 'auth';
}
if ($ex) {
if (preg_match($ex, $_SERVER['REQUEST_URI'], $m)) {
if (!empty($m[2])) {
$loc = $m[1].$auth.$m[2];
} else {
$loc = $m[1].$auth;
}
session_write_close();
header('Location: ' . $loc, true, 302);
exit;
}
if ($auth) {
# couldn't redirect to auth page
$cfail = 'error';
}
# it's fine to continue if we can't redirect back from auth page
}
if (array_key_exists('user', $state)) {
# we either authed and returned, or authed and stayed on page
if ($cval && !in_array($state['user'], array_map('trim', explode(',', $cval)))) {
$cfail = 'no_access';
} else {
$session['pass'] = true;
}
}
}
elseif ($ctype == 'email') {
$code = null;
if ($my_post && !empty($_POST['value']) && $email = trim($_POST['value'])) {
if ($cval && !in_array($email, array_map('trim', explode(',', $cval)))) {
$cfail = 'no_access';
} elseif (!empty($state['next_attempt_email'])
&& time() <= $state['next_attempt_email']) {
$cfail = 'email_throttle';
} else {
$state['user'] = $email;
$src = 'abcdefghijkmopqstuvwxyzABCDEFGHJKLNOPQRSTUVWXYZ';
$len = strlen($src) - 1;
$tmp = [];
for ($i = 0; $i < 16; $i++) {
array_push($tmp, $src[random_int(0, $len)]);
}
$state['code'] = $tmp = implode('', $tmp);
$qs_pre = $_SERVER['QUERY_STRING'] ? '?' : '';
$qs_post = $_SERVER['QUERY_STRING'] ? '&' : '?';
$url = "https://$_SERVER[SERVER_NAME]/s/$share/$qs_pre$_SERVER[QUERY_STRING]{$qs_post}code=$tmp";
if (mail($email, 'Your Authentication Code',
"Your verification code is: $tmp\nDo not share this code "
. "with anyone.\n\nYou may enter this code on the page you "
. "requested it, or click this link to verify:\n$url"
. "\n\nYour code will expire in five minutes.\n\n\nIf you "
. "didn't request this email, you may safely ignore it.",
['From' => "noreply@$_SERVER[SERVER_NAME]"])) {
$state['code_expires'] = time() + 60 * 5;
$state['next_attempt_email'] = time() + 30;
$cfail = 'email_submitted';
} else {
$cfail = 'email_failed';
}
}
} elseif (($my_post && !empty($_POST['code']) && $code = trim($_POST['code']))
|| (!$my_post && !empty($_GET['code']) && $code = trim($_GET['code']))) {
if (empty($state['user']) || empty($state['code'])) {
# must use link in same browser session, offer to resend or for
# them to enter it
$cfail = 'email_code_unset';
} elseif (time() > $state['code_expires']) {
$cfail = 'email_code_expired';
} elseif ($code == $state['code']) {
# code is correct, allow in
$state['pass'] = true;
if (preg_match("/[?&]code=$code\$/", $_SERVER['REQUEST_URI'])) {
session_write_close();
header('Location: ' . substr($_SERVER['REQUEST_URI'], 0, -6 - strlen($code)), true, 302);
exit;
# continue if the code was in a different spot or was POSTed
}
} else {
# email code is wrong, offer to try again
$cfail = 'email_code_wrong';
$state['next_attempt'] = time() + 5;
}
} elseif ($my_post) {
# dingus didnt enter an email, offer them to enter it again';
$cfail = 'blank';
} else {
# enter email to get a code
$cfail = 'form';
}
}
elseif ($ctype == 'password') {
if ($my_post) {
if (!empty($_POST['value'])) {
if ($_POST['value'] != $cval) {
$state['next_attempt'] = time() + 5;
$cfail = 'password_wrong';
} else {
$state['pass'] = true;
}
} else {
# dingus didnt enter a password, offer them to enter it again
$cfail = 'blank';
}
} else {
$cfail = 'form';
}
}
elseif ($ctype == 'counter') {
if (empty($cval) || $cval <= 0) {
$cfail = 'expired';
} else {
# update ref which should update $controls array
$control['value']--;
# save changes to db cache (session)
$row['controls'] = json_encode($controls);
# write to db
$db = opendb($db_uri);
# update the db too
$db->prepare('UPDATE share SET controls = :controls WHERE rowid = :rowid')
->execute([':rowid'=>$row['rowid'],
':controls'=>$row['controls']]);
$db = null;
$state['pass'] = true;
}
}
elseif ($ctype == 'terms') {
if ($my_post) {
if (isset($_POST['agree'])) {
$state['pass'] = true;
} else {
$cfail = 'terms_not_agreed';
}
} else {
$cfail = 'form';
}
}
else {
http_response_code(500);
exit;
}
}
if (in_array($ctype, ['login', 'email']) || !$user) {
$user = $state['user'] ?? 'Guest';
}
if ($cfail) {
break;
}
}
cfail_msg:
if ($cfail) {
U::html_header("Share Link: $share", "page-login");
?>
<body class="page-login-body">
<article class="login-container share-auth-container">
<h1>Share Link Authentication</h1>
<?php if ($cfail != 'form'):
$alert_class = 'danger';
switch ($cfail):
case 'email_throttle':
$alert = 'You must wait ' . ($state['next_attempt_email'] - time()) . ' seconds before sending another email.';
break;
case 'email_submitted':
$alert_class = 'warning';
$alert = 'We emailed you a verification code and link.';
break;
case 'email_code_unset':
$alert = 'We could not find your verification code. Either your session expired, or you are using the code in a different browser. The verification code and link will only work in the same browser window you requested it in.';
break;
case 'email_code_expired':
$alert = 'Code expired. Please enter your email again to request a new code.';
break;
case 'email_code_wrong':
$alert = 'Code does not match. Please try again.';
break;
case 'password_wrong':
$alert = 'Password incorrect. Please try again.';
break;
case 'terms_not_agreed':
$alert = 'You must agree to the terms to continue. If you do not agree, you may not access the page.';
break;
case 'throttle':
$alert = 'You have been submitting requests too quickly. Please wait ' . ($state['next_attempt'] - time()) . ' seconds.';
break;
case 'blank':
$alert = 'Input must not be blank. Please fill the form and resubmit.';
break;
case 'no_access':
$alert = 'You do not have access to this share.';
break;
case 'expired':
$alert = 'This share link has expired.';
break;
default:
$alert = "There was a problem processing the share link. Please close your browser and try opening the share link again. If this problem persists, please contact the person that gave you the link, or email us at <a href=mailto:$support_email>$support_email</a>";
endswitch; ?>
<div class="alert alert-<?php echo $alert_class; ?>" role=alert>
<?php echo $alert; ?>
</div>
<?php endif;
if (in_array($ctype, ['email', 'password', 'terms'])): ?>
<form method=post class=login-form>
<input type=hidden name=state value=<?php echo $statekey; ?>>
<?php if ($ctype == 'terms'): ?>
<p>You must agree to the following terms before accessing this share link:</p>
<div class=terms><p>
<?php echo str_replace("\n", '<br>', str_replace("\n\n", '</p><p>', htmlspecialchars($cval))); ?>
</p></div>
<label><input type=checkbox name=agree> I agree</input></label><br>
<?php else: ?>
<p><?php if ($ctype == 'email'):
?>This share link is protected with email verification. Enter your email address, and we will send you a verification code and link. To continue, type or copy the code into this page, or click the link.
<?php elseif ($ctype == 'password'):
?>This share link is password-protected. Please enter the password to continue.
<?php endif; ?></p>
<label><?php echo $ctype == 'email' ? 'Email address' : 'Password'; ?>:
<input <?php echo $ctype == 'password' ? 'type=password' : ''; ?> class=input name=value spellcheck=false autocorrect=off autocapitalize=off autocomplete=off>
</label>
<?php if ($ctype == 'email' && array_key_exists('code', $state)): ?>
<br><p>If you have received the code via email, you may enter it below.</p>
<label>Code: <input class=input name=code spellcheck=false autocorrect=off autocapitalize=off autocomplete=off></label>
<?php endif;
endif; ?>
<button class="button">Submit</button>
</form>
<?php else: ?>
<a class="button" href="javascript:window.close();">Close</a>
<?php endif; ?>
</article>
</body>
<script>
document.querySelector('.login-form').addEventListener('submit', (e) => {
document.body.classList.add('form-loading');
}, false);
</script>
</html>
<?php exit;
}
# log user in for this session if a username was provided
# TODO: invalidate php session when logging out of shared session instead of
# logging out of authentik, and redirect to home
if ($user) {
login($user, $bypass_auth_password);
$config_login = ['username' => $user, 'password' => $bypass_auth_password];
} else {
$config_login = [];
}
# load the share config
$config = $row['config'];
if ($config) {
$config = json_decode($config, true, 3, JSON_THROW_ON_ERROR);
}
if (!$config) {
$config = [];
}
# NOTE: this assumes files gallery defaults, if you changed any system-wide
# you'll need to reset them for a sane share perm baseline here
return array_merge(['allow_download' => false], $config,
$config_login, ['root' => $row['realpath']]);
}
### handle user files for default actions
$ext_actions = ['initdb', 'share.create', 'share.get'];
if (empty($action = @$_GET['action']) || !in_array($action, $ext_actions)) {
# no action or files gallery action, so auth user and return user config
$user = $_SERVER['X_AUTHENTIK_USERNAME'];
login($user, $bypass_auth_password);
# TODO: get user settings from db, continue if none found
return array_merge($user_defaults, [
'root' => "$user_root/$user",
'username' => $user,
'password' => $bypass_auth_password,
]);
}
### ext actions (API)
$success = false;
$ret = 'unknown error';
try {
# db maintenance
if ($action == 'initdb') {
$db = opendb($db_uri);
$db->exec('
CREATE TABLE share (
uri TEXT UNIQUE,
path TEXT,
realpath TEXT,
owner TEXT,
created INTEGER,
expires INTEGER,
controls TEXT,
config TEXT
);
CREATE TABLE usersettings (
user TEXT UNIQUE,
config TEXT
);');
$ret = 'created tables';
$success = true;
}
# user settings
# TODO
# share link
elseif ($action == 'share.create') {
if (empty($_GET['path'])) {
throw new InvalidArgumentException('Must specify "path"');
} elseif (preg_match('/(?:^|\/)..(?:$|\/)/', $_GET['path'])) {
throw new InvalidArgumentException('"path" must not contain relative directory segments');
}
$uri = trim($_GET['uri'] ?? '');
if (empty($uri)) {
$src = "zM6cVZ7B4nQWa1FDHIPtgKYd3wl9ExOq28CrUAhuRLipovsm5ef0bjyJNTXkGS";
$len = strlen($src);
$x = explode(' ', microtime());
$x = intval((intval($x[1]) - 1731638000) . substr($x[0], 2, -2)) / 10;
$tmp = [];
while ($x >= $len) {
$i = intval($x) % $len;
array_push($tmp, $src[$i]);
$x /= $len;
}
array_push($tmp, $src[intval($x)]);
$len = count($tmp);
$uri = [];
for ($i = 0; $i < $len; $i++) {
if ($i % 2 == $i < 4 ? 1 : 0) {
array_push($uri, $tmp[intval($i / 2)]);
} else {
array_push($uri, $tmp[$len - intval($i / 2) - 1]);
}
}
$uri = implode('', $uri);
}
# sanitize: collapse segments and make relative to root
$path = preg_replace('_//+_', preg_replace('_^/+_', $path, ''), '/');
# XXX: hardcoded root
# NOTE: we should have authentik user...
$path = "/$_SERVER[X_AUTHENTIK_USERNAME]/$_GET[path]";
$realpath = "$user_root$path";
if (!file_exists($realpath)) {
throw new InvalidArgumentException('Error validating "path": File not found');
}
$owner = $_SERVER['X_AUTHENTIK_USERNAME'];
$expires = (int)@$_GET['expires'];
if ($expires <= 0) {
$expires = time() + 60 * 60 * 24 * 7;
}
require_once __DIR__ . '/lib/vendor/autoload.php';
if (empty($_GET['controls']) || in_array(trim($_GET['controls']), ['', '[]'])) {
$controls = null;
} else {
$controls = json_decode($_GET['controls'], false, 3, JSON_THROW_ON_ERROR);
}
$result = (new Opis\JsonSchema\Validator())
->validate($controls,
file_get_contents(dirname(__DIR__) . '/_files/share.controls.schema.json'));
if (!$result->isValid()) {
throw new InvalidArgumentException('Error validating "controls": ' .
json_encode((new Opis\JsonSchema\Errors\ErrorFormatter)
->format($result->error())));
}
if (empty($_GET['config']) || in_array(trim($_GET['config']), ['', '[]'])) {
$config = null;
} else {
$config = json_decode($_GET['config'], false, 3, JSON_THROW_ON_ERROR);
}
$result = (new Opis\JsonSchema\Validator())
->validate($config,
file_get_contents(dirname(__DIR__) . '/_files/share.config.schema.json'));
if (!$result->isValid()) {
throw new InvalidArgumentException('Error validating "config": ' .
json_encode((new Opis\JsonSchema\Errors\ErrorFormatter)
->format($result->error())));
}
$created = time();
$db = opendb($db_uri);
$db
->prepare('INSERT INTO share
VALUES (:uri, :path, :realpath, :owner,
:created, :expires, :controls, :config)')
->execute([':uri'=>$uri, ':path'=>$path, ':realpath'=>$realpath,
':owner'=>$owner, ':created'=>$created, ':expires'=>$expires,
':controls'=>$controls ? json_encode($controls): null,
':config'=>$config ? json_encode($config) : null]);
$ret = [
'id' => $db->lastInsertId(),
'uri' => $uri,
'path' => $path,
'owner' => $owner,
'created' => $created,
'expires' => $expires,
'controls' => $controls,
'config' => $config
];
$success = true;
}
elseif (action == 'share.get') {
if (empty($_GET['uri'])) {
throw new InvalidArgumentException('Must specify "uri"');
}
$db = opendb($db_uri);
$row = $db ->query("SELECT
uri, path, owner, created, expires, controls, config
FROM share WHERE uri=?")
->fetch([$share]);
if (!$row) {
$ret = 'not found';
} else {
$ret = $row;
$success = true;
}
}
else {
$ret = 'Not implemented';
}
} catch (Throwable $e) {
$ret = $e->__toString();
while($e = $e->getPrevious()) {
$ret .= "\nPrevious exception: " . $e->__toString();
}
$ret = str_replace(dirname(__DIR__, 2), '', $ret);
}
# close db if it was opened
$db = null;
header('Content-Type: application/json');
if (is_array($ret)) {
echo json_encode(array_merge(['success' => $success], $ret),
JSON_THROW_ON_ERROR);
} else {
echo json_encode(['success'=>$success, 'message'=>$ret]);
}
exit;
# vim: ft=php sts=2 sw=2 et
Currently reliance on authentik is hardcoded, but it would be trivial to change the header. I might make it more admin-friendly later.
Custom CSS for the share link page and UI:
/*** share client UI ***/
.modal-popup-share {
/* custom top border color to fit files gallery customizations */
--swal-border-top: var(--color-info-light);
}
.modal-popup-share .swal2-title {
/* override files gallery for properly cased filename in title */
text-transform: inherit;
}
/* show basic perms in side-by-side columns */
.modal-popup-share .share-basic-perms {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.modal-popup-share .share-basic-perms label:has(input[type="checkbox"]) {
width: 50%;
padding: 0 0 4px 0;
}
/* hide advanced config in an accordion */
.modal-popup-share .share-adv-container {
display: grid;
grid-template-rows: min-content 0fr;
transition: grid-template-rows 0.3s ease;
background-color: var(--surface3);
padding: 1.5rem 1.5rem 1.25rem;
margin: 0 -1.5rem;
border-top: 1px solid var(--surface2);
border-bottom: 1px solid var(--surface2);
}
.modal-popup-share .share-adv-container .svg-chevron_down {
height: 100%;
transform: rotateX(0);
transition: transform 0.3s ease;
}
.modal-popup-share .share-adv-container .share-adv {
overflow: hidden;
}
.modal-popup-share .share-adv-container.share-adv-show {
grid-template-rows: min-content 1fr;
}
.modal-popup-share .share-adv-container.share-adv-show .svg-chevron_down {
transform: rotateX(180deg);
}
/*** share link auth page ***/
/* give p's some space */
.login-container p {
margin-bottom: 1rem;
}
.share-auth-container {
max-width: 680px;
}
/* vim: sw=4 sts=4 et
*/
Accompanying nginx config required for proper routing:
upstream files_gallery {
server unix:/my/php-fpm/files_gallery.sock;
}
# add ?download to non-proxied file downloads if action=download, so they get
# attachment Content-Disposition otherwise use inline
map $arg_action#$arg_file $files_gallery_download {
~*download#(?:.*%2F)?(.*) ?download&name=$1;
default '';
}
map $uri#$arg_name $files_gallery_download_attachment {
~*[^#]*#(?:.*%2F)?(.+) "; filename=$1";
~(?:[^#/]*/)*([^#]+)#$ "; filename=$1";
default '';
}
server {
server_name files.example.com;
access_log /var/log/nginx/files_gallery.access.log;
error_log /var/log/nginx/files_gallery.error.log;
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# prefer server ciphers is set in nginx.conf
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_ecdh_curve secp521r1:secp384r1:prime256v1;
ssl_dhparam /my/dh.pem;
ssl_certificate /my/fullchain.pem;
ssl_certificate_key /my/privkey.pem;
ssl_stapling on;
ssl_stapling_verify on;
# use short-lived IDs instead of tickets for best tradeoff between server/client perf and security
# shared session cache is set in nginx.conf
ssl_session_tickets off;
ssl_session_timeout 2m;
# Special location for when the /auth endpoint returns a 401,
# redirect to the /start URL which initiates SSO
location @goauthentik_proxy_signin {
internal;
return 302 /outpost.goauthentik.io/start?rd=$request_uri;
}
# https://goauthentik.io/docs/providers/proxy/forward_auth
# all requests to /outpost.goauthentik.io must be accessible without authentication
location /outpost.goauthentik.io {
proxy_pass http://authentik/outpost.goauthentik.io;
# more space for request headers
proxy_buffer_size 16k;
proxy_busy_buffers_size 24k; # 16k + 8k
# tell authentik the site to authenticate
proxy_set_header Host $host;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
# outpost doesn't need any body content, might've gotten it from a form
proxy_pass_request_body off;
proxy_set_header Content-Length "";
}
root /your/files/gallery/path;
# timeout for choked downloads
send_timeout 10m;
# NOTE: files gallery has internal links pointing here
location = /index.php {
# if user has files with internal names in their dir that they're
# trying to open, proxy them through the script instead of serving the
# internal file with nginx
set $x $arg_action#$arg_resize#$arg_file;
if ($x ~ ^(?:file|download)##(?!/*index\.php$|(?:s|_files)/)) {
# use rewrite for uri normalization
rewrite ^ /$arg_file$files_gallery_download? redirect;
}
# intercept logout button
# NOTE: removing the cookie is pointless as it's set in every session,
# so no need to actually visit the logout page
if ($arg_logout) {
return 301 https://auth.example.com/application/o/files_gallery/end-session/;
}
fastcgi_pass files_gallery;
include fastcgi.conf;
# tuning upload buffers for http2
# https://blog.cloudflare.com/delivering-http-2-upload-speed-improvements/
client_body_buffer_size 512k;
# set max upload size and increase upload timeout
client_max_body_size 5G;
client_body_timeout 2h;
# more response buffers for better large file perf
fastcgi_buffers 64 8k;
# timeout for choked downloads
fastcgi_read_timeout 10m;
# https://goauthentik.io/docs/providers/proxy/forward_auth#single-application
# authentik-specific config
auth_request /outpost.goauthentik.io/auth/nginx;
# send cookies from successful outpost auth_request back to the client
auth_request_set $auth_cookie $upstream_http_set_cookie;
more_set_headers -a "Set-Cookie: $auth_cookie";
# translate headers from the outpost back to the actual upstream
auth_request_set $authentik_username $upstream_http_x_authentik_username;
fastcgi_param X_AUTHENTIK_USERNAME $authentik_username;
error_page 401 = @goauthentik_proxy_signin;
}
# open files gallery at root dir
location = / {
rewrite / /index.php last;
}
# share links
# add slash if it's missing so any relative files are inside of the dir and
# catch our rewrite
rewrite ^(/s/[^/]+)$ $1/ redirect;
# redirect files dir cuz it might be used
rewrite ^/s/[^/]+(/_files(?:/.*)?$) $1 last;
# handle the actual share and send it to files gallery if no need to auth
location ~ ^/s/([^/]+)/auth$ {
fastcgi_pass files_gallery;
include fastcgi.conf;
# tuning upload buffers for http2
# https://blog.cloudflare.com/delivering-http-2-upload-speed-improvements/
client_body_buffer_size 512k;
# set max upload size and increase upload timeout
client_max_body_size 5G;
client_body_timeout 2h;
# more response buffers for better large file perf
fastcgi_buffers 64 8k;
# timeout for choked downloads
fastcgi_read_timeout 10m;
# share-specific
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SHARE_URI $1;
# https://goauthentik.io/docs/providers/proxy/forward_auth#single-application
# authentik-specific config
auth_request /outpost.goauthentik.io/auth/nginx;
# send cookies from successful outpost auth_request back to the client
auth_request_set $auth_cookie $upstream_http_set_cookie;
more_set_headers -a "Set-Cookie: $auth_cookie";
# translate headers from the outpost back to the actual upstream
auth_request_set $authentik_username $upstream_http_x_authentik_username;
fastcgi_param X_AUTHENTIK_USERNAME $authentik_username;
error_page 401 = @goauthentik_proxy_signin;
}
location ~ ^/s/([^/]+)/(?:index\.php)?$ {
fastcgi_pass files_gallery;
include fastcgi.conf;
# tuning upload buffers for http2
# https://blog.cloudflare.com/delivering-http-2-upload-speed-improvements/
client_body_buffer_size 512k;
# set max upload size and increase upload timeout
client_max_body_size 5G;
client_body_timeout 2h;
# more response buffers for better large file perf
fastcgi_buffers 64 8k;
# timeout for choked downloads
fastcgi_read_timeout 10m;
# share-specific
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param SHARE_URI $1;
}
# serve assets
location /_files/ {
# block config
location /_files/config {
return 404;
}
}
# serve user files
location / {
# explore directories in the ui
# NOTE: nginx should auto-redirect to dir/ if it exists at
# $document_root and there is no slash
if ($uri ~ ^/(.+)/$) {
return 303 /?$1;
}
root /myfiles/$authentik_username;
error_page 404 @show_file_in_ui;
if ($args ~ ^download) {
more_set_headers "Content-Disposition: attachment$files_gallery_download_attachment";
}
if ($args !~ ^download) {
more_set_headers "Content-Disposition: inline";
}
include includes/authentik/location.conf;
}
# make 404's go back to ui, looking for the specific file
location @show_file_in_ui {
rewrite ^/(.*)/(.*) /?$1#$2 redirect;
rewrite ^/(.*) /?#$1 redirect;
}
}
# vim:ft=nginx:sts=4:sw=4:et
I expanded all my include
s for the nginx config, so that's why it looks insane.
Pretty happy with how this turned out. I also managed to fix all the issues I had and no longer need any changes to the Files Gallery index.php.
edit: forgot the schemas.
share.config.schema.json
:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Permissions & configurations",
"type": ["object", "null"],
"additionalProperties": false,
"properties": {
"load_images": {
"title": "Show image previews",
"type": "boolean",
"default": true
},
"video_thumbs": {
"title": "Show video thumbnails",
"type": "boolean",
"default": true
},
"folder_preview_image": {
"title": "Show previews of folder contents",
"type": "boolean",
"default": true
},
"menu_enabled": {
"title": "Enable the navigation sidebar",
"type": "boolean",
"default": true
},
"menu_max_depth": {
"title": "Sidebar folder depth",
"description": "How many folders deep should the menu sidebar go? The higher the number, the more subfolders the user can navigate into before no more are shown.\nTurning this too high may make the page load slower. Zero means no limit.",
"type": "integer",
"minimum": 0,
"default": 5
},
"menu_sort": {
"title": "Sidebar sort order",
"type": "string",
"enum": ["name_asc", "name_desc", "date_asc", "date_desc"],
"default": "name_asc"
},
"layout": {
"title": "File list layout",
"description": "How to present the file list in the user interface.\nList: One file per row, with file attribute columns.\nImagelist: Like List, but with large image previews and digital camera EXIF metadata.\nBlocks: Like Grid, but with file attributes on the side.\nGrid: Files are tiled into squares.\nRows: Files dynamically change size based on content, aligned into rows.\nColumns: Like Rows, but files are aligned into columns.",
"type": "string",
"enum": ["list", "imagelist", "blocks", "grid", "rows", "columns"],
"default": "rows"
},
"sort": {
"title": "Sort order",
"type": "string",
"enum": ["name_asc", "name_desc", "date_asc", "date_desc",
"filesize_asc", "filesize_desc", "kind_asc", "kind_desc",
"shuffle"],
"default": "name_asc"
},
"sort_dirs_first": {
"title": "Sort directories first",
"type": "boolean",
"default": true
},
"files_exclude": {
"title": "Exclude files/folders matching pattern",
"description": "Any files or folders whose names match this pattern are excluded from the file list. This can be used to only show specific files to users when there are many in a folder, or create an upload-only folder by hiding all files.\nThis value is a Regular Expression (regex).\n\nLearn how to write regex: https://www.regular-expressions.info\nTest your own: https://phpliveregex.com",
"type": "string",
"default": "",
"examples": [
"Hide all files/folders that start with <code>@</code> or <code>.</code>:\n<code>/^[@.]/</code>",
"Only show files/folders that start with <code>public-</code>:\n<code>/^(?!public-)/</code>",
"Hide all files/folders that end with <code>.pdf</code>, <code>.jpeg</code> and <code>.jpg</code>, ignoring case:\n<code>/\\.(pdf|jpe?g)$/i</code>"
]
},
"dirs_exclude": {
"title": "Exclude paths matching pattern",
"description": "Any files or folders whose paths match this pattern are excluded from the file list. This can be used to allow users to navigate through only the folders you want to show them.\n\nThis value is a Regular Expression (regex).\nLearn how to write regex: https://www.regular-expressions.info\nTest your own: https://phpliveregex.com",
"type": "string",
"default": "",
"examples": [
"Hide all files/folders that are in a folder that starts with <code>@</code> or <code>.</code>:\n<code>/(\\/|^)[@.]/</code>",
"Only show files/folders that are in a folder that starts with <code>public-</code>:\n<code>/(\\/|^)(?!public-)/</code>",
"Hide all files/folders that are in a folder named <code>private</code> or <code>secret</code>, ignoring case:\n<code>/(\\/|^)(private|secret)(\\/|$)/i</code>"
]
},
"click": {
"title": "Mouse click action",
"description": "What action to perform when clicking a file in the file list.\nPopup: Show the file in a small popup window.\nModal: Show the file full-screen.\nDownload: Don't show the file and download it immediately. Note that this will not work if you have not also given permission to download the file.\nWindow: Open the file in a new window or tab.\nMenu: Show the context menu.",
"type": "string",
"enum": ["popup", "modal", "download", "window", "menu"],
"default": "popup"
},
"click_window": {
"title": "File types to open in new window",
"description": "Comma-separated list of file types to open in a new window when clicked.",
"type": "string",
"default": "",
"examples": ["pdf, docx"]
},
"context_menu": {
"title": "Enable right-click context menu",
"type": "boolean",
"default": true
},
"prevent_right_click": {
"title": "Block native right-click context menu",
"description": "This prevents the native browser right-click context menu from opening. This can help prevent the disallowed download of images or videos, or unauthorized copying of text. However, enabling this option cannot fully prevent such actions for determined users, and should not be relied on as a security or privacy measure.",
"type": "boolean",
"default": false
},
"allow_upload": { "type": "boolean", "default": false },
"allow_delete": { "type": "boolean", "default": false },
"allow_rename": { "type": "boolean", "default": false },
"allow_new_folder": { "type": "boolean", "default": false },
"allow_new_file": { "type": "boolean", "default": false },
"allow_duplicate": { "type": "boolean", "default": false },
"allow_text_edit": { "type": "boolean", "default": false },
"allow_zip": { "type": "boolean", "default": false },
"allow_unzip": { "type": "boolean", "default": false },
"allow_move": { "type": "boolean", "default": false },
"allow_copy": { "type": "boolean", "default": false },
"allow_download": { "type": "boolean", "default": false },
"allow_mass_download": { "type": "boolean", "default": false },
"allow_mass_copy_links": { "type": "boolean", "default": false },
"upload_allowed_file_types": {
"title": "Allowed file types for upload",
"description": "Comma-separated list of file types to open in a new window when clicked.",
"type": "string",
"default": "",
"examples": ["pdf, docx"]
},
"upload_max_filesize": {
"title": "Max size of uploaded files",
"description": "Maximum size in bytes of uploaded files. 0 means the maximum amount allowed by the server. Setting this value higher than the maxium server file size will not allow users to upload files larger than that limit.",
"type": "integer",
"minimum": 0,
"default": 0
}
}
}
share.control.schema.json
:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Access controls",
"description": "These access controls can be used to authenticate users that attempt to visit share links. If the user is unable to pass any of the assigned controls, they will not have access to the link.",
"type": ["array", "null"],
"additionalItems": false, "items": {
"type": "object", "additionalProperties": false,
"properties": {
"type": {
"title": "Access control requirement", "description": "Login: The user must be logged in to their account in order to access the shared link. They will be taken to the login page if they are not already logged in. A comma-separated list of internal users that are allowed to access the shared link may be provided. Leaving the list empty will allow any authenticated user access.\nEmail: Users will be prompted to enter their email address when accessing the link. They will be sent an access link to their email. A comma-separated list of email addresses that are allowed to access the shared link may be provided. Leaving the list empty will allow any email address to be sent an access link.\nPassword: The provided password must be entered in order to access the shared link.\nCounter: The shared link can be accessed only this many times before it no longer works. Every time a user visits the shared link, they are given access for that session on that device only. Visiting the shared link again on a new device, in a new browser, or after closing the session will count as another access.\nTerms: The user must agree to the presented terms before they gain access to the shared link. They will have to agree every time they access it.",
"enum": ["login", "email", "password", "counter", "terms"]
},
"value": {},
"deny_message": {
"title": "Access denied message",
"description": "If a user is denied access to a shared link, they will be shown general information about their denial, as well as this message.",
"type": "string"
}
},
"required": ["type"],
"anyOf": [{
"if": {
"anyOf": [{
"properties": {"type": {"const": "password"}},
"properties": {"type": {"const": "counter"}},
"properties": {"type": {"const": "terms"}}
}]
},
"then": {"required": ["value"], "value": {"minLength": "1"}}
}, {
"if": {"properties": {"type": {"const": "counter"}}},
"then": {"properties": {"value": {"type": "integer", "minimum": 1}}},
"else": {"properties": {"value": {"type": "string"}}}
}]
}
}
edit2: frontend working
https://i.imgur.com/dJJfj7B.mp4