library: login for everything - public catalogue off, read-only guest for Find a book
- the public catalogue is bound to the machine itself by default (OPAC_BIND) and, if ever exposed, requires a library-card login (OpacPublic=0) - a read-only guest login (VIEWER_USER/VIEWER_PASSWORD) in Quick Add: it may load the page, read state and search; every other endpoint is admin-only (403); the page shows only Find a book - the guest deliberately has NO Koha account: Koha's 'catalogue' permission, the only one that allows searching, also allows creating/editing label layouts and templates (tested: a guest created a layout), so it cannot be read-only - koha/ensure.pl (every start): staff-home 'Library tools' block, and removal of a guest account an earlier version created - kiosk add-on shows Find a book signed in as the guest; the admin login never goes into the kiosk config (tested, incl. a mutation check); old public-catalogue tabs are cleaned up - existing installs keep their current public-catalogue behaviour on update Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf
This commit is contained in:
@@ -44,7 +44,8 @@ services:
|
|||||||
KOHA_SHELVES: ${SHELVES:-}
|
KOHA_SHELVES: ${SHELVES:-}
|
||||||
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
|
KOHA_ITEM_TYPES: ${ITEM_TYPES:-}
|
||||||
ports:
|
ports:
|
||||||
- "${BIND_ADDR:-0.0.0.0}:${OPAC_PORT:-8080}:80"
|
# The public catalogue is off by default (loopback only); set OPAC_BIND=0.0.0.0 in .env to expose it.
|
||||||
|
- "${OPAC_BIND:-127.0.0.1}:${OPAC_PORT:-8080}:80"
|
||||||
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
|
- "${BIND_ADDR:-0.0.0.0}:${STAFF_PORT:-8081}:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- koha-data:/var/lib/koha
|
- koha-data:/var/lib/koha
|
||||||
@@ -61,6 +62,8 @@ services:
|
|||||||
KOHA_API: http://koha:8081/api/v1
|
KOHA_API: http://koha:8081/api/v1
|
||||||
ADMIN_USER: ${ADMIN_USER:-admin}
|
ADMIN_USER: ${ADMIN_USER:-admin}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
VIEWER_USER: ${VIEWER_USER:-guest}
|
||||||
|
VIEWER_PASSWORD: ${VIEWER_PASSWORD:-}
|
||||||
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
|
KOHA_LIBRARY: ${LIBRARY_CODE:-HOME}
|
||||||
OPAC_PORT: ${OPAC_PORT:-8080}
|
OPAC_PORT: ${OPAC_PORT:-8080}
|
||||||
STAFF_PORT: ${STAFF_PORT:-8081}
|
STAFF_PORT: ${STAFF_PORT:-8081}
|
||||||
|
|||||||
+16
-3
@@ -80,9 +80,13 @@ show_status() {
|
|||||||
dc ps
|
dc ps
|
||||||
echo
|
echo
|
||||||
echo " Your library: http://$ip:$STAFF_PORT"
|
echo " Your library: http://$ip:$STAFF_PORT"
|
||||||
echo " sign in here: add books at /add/ (or the 'Add books' button), find a book, lending, labels, people"
|
echo " sign in here: find a book, add books (scan an ISBN or use the phone camera), lending, labels, people"
|
||||||
echo " Public catalogue: http://$ip:$OPAC_PORT (search only - for a kiosk or visitors)"
|
echo " Admin login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env)"
|
||||||
echo " Login: $ADMIN_USER / $ADMIN_PASSWORD (also stored in $HERE/.env; Add books asks once more with the same login)"
|
echo " Read-only login: ${VIEWER_USER:-guest} / ${VIEWER_PASSWORD:-?} (to hand out, with this address: http://$ip:$STAFF_PORT/add/)"
|
||||||
|
echo " It opens the Find a book page: search only, it cannot add or change anything."
|
||||||
|
if [ "${OPAC_BIND:-127.0.0.1}" != "127.0.0.1" ]; then
|
||||||
|
echo " Public catalogue: http://$ip:$OPAC_PORT (search only, asks for a library card login)"
|
||||||
|
fi
|
||||||
echo
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +121,12 @@ OPAC_PORT=$(free_port "${OPAC_PORT:-8080}")
|
|||||||
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
|
STAFF_PORT=$(free_port "${STAFF_PORT:-8081}")
|
||||||
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
|
QUICKADD_PORT=$(free_port "${QUICKADD_PORT:-8082}")
|
||||||
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||||
|
# The public catalogue is off by default: 127.0.0.1 = not reachable from the network.
|
||||||
|
OPAC_BIND=${OPAC_BIND:-127.0.0.1}
|
||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=$(rand 16)
|
ADMIN_PASSWORD=$(rand 16)
|
||||||
|
VIEWER_USER=guest
|
||||||
|
VIEWER_PASSWORD=$(rand 14)
|
||||||
DB_ROOT_PASSWORD=$(rand 32)
|
DB_ROOT_PASSWORD=$(rand 32)
|
||||||
DB_PASSWORD=$(rand 32)
|
DB_PASSWORD=$(rand 32)
|
||||||
KOHA_SUITE=stable
|
KOHA_SUITE=stable
|
||||||
@@ -131,6 +139,11 @@ else
|
|||||||
if ! grep -q '^QUICKADD_PORT=' .env; then
|
if ! grep -q '^QUICKADD_PORT=' .env; then
|
||||||
echo "QUICKADD_PORT=$(free_port 8082)" >> .env
|
echo "QUICKADD_PORT=$(free_port 8082)" >> .env
|
||||||
fi
|
fi
|
||||||
|
if ! grep -q '^VIEWER_PASSWORD=' .env; then
|
||||||
|
{ echo "VIEWER_USER=guest"; echo "VIEWER_PASSWORD=$(rand 14)"; } >> .env
|
||||||
|
fi
|
||||||
|
# Existing installs keep their current public-catalogue behaviour.
|
||||||
|
grep -q '^OPAC_BIND=' .env || echo "OPAC_BIND=0.0.0.0" >> .env
|
||||||
if ! grep -q '^BIND_ADDR=' .env; then
|
if ! grep -q '^BIND_ADDR=' .env; then
|
||||||
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
|
echo "BIND_ADDR=${BIND_ADDR:-0.0.0.0}" >> .env
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s
|
|||||||
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
command -v "$c" >/dev/null || { echo "BUILD ERROR: $c is missing from the image" >&2; exit 1; }; \
|
||||||
done
|
done
|
||||||
|
|
||||||
COPY bootstrap.pl entrypoint.sh /opt/koha-init/
|
COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/
|
||||||
COPY seed/ /opt/koha-init/seed/
|
COPY seed/ /opt/koha-init/seed/
|
||||||
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
RUN chmod 0755 /opt/koha-init/entrypoint.sh && chmod -R a+rX /opt/koha-init
|
||||||
|
|
||||||
|
|||||||
@@ -153,19 +153,6 @@ Koha::CirculationRules->set_rules({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
# ---- Staff home page: a "Library tools" block with a link to the Add books page ----
|
|
||||||
# (Koha 26.05 stores these in the HTML customizations tool, not in a preference.)
|
|
||||||
{
|
|
||||||
my $html = '<div class="main-heading"><h3>Library tools</h3></div><ul class="list-unstyled">'
|
|
||||||
. '<li><a class="btn btn-primary" href="/add/">Add books (scan an ISBN)</a></li>'
|
|
||||||
. '<li><a href="/add/#find">Find a book</a></li></ul>';
|
|
||||||
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
|
|
||||||
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
|
|
||||||
my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
|
|
||||||
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
|
|
||||||
VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html );
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---- Preferences ----------------------------------------------------------
|
# ---- Preferences ----------------------------------------------------------
|
||||||
my %prefs = (
|
my %prefs = (
|
||||||
autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request)
|
autoBarcode => 'OFF', # barcodes are assigned by Quick Add (zero-padded, or none/scanned on request)
|
||||||
@@ -173,11 +160,11 @@ my %prefs = (
|
|||||||
finesMode => 'off', # a home library does not charge overdue fines
|
finesMode => 'off', # a home library does not charge overdue fines
|
||||||
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
|
itemcallnumber => '082ab', # Dewey call number copied from the MARC record
|
||||||
RESTBasicAuth => 1, # lets the quick-add page use the REST API
|
RESTBasicAuth => 1, # lets the quick-add page use the REST API
|
||||||
|
OpacPublic => 0, # the public catalogue, if ever exposed, asks for a login
|
||||||
OpenLibraryCovers => 1, # cover images in the catalogue
|
OpenLibraryCovers => 1, # cover images in the catalogue
|
||||||
OPACSearchForTitleIn => '',
|
OPACSearchForTitleIn => '',
|
||||||
BiblioDefaultView => 'normal',
|
BiblioDefaultView => 'normal',
|
||||||
OpacPublic => 1,
|
opacuserlogin => 1,
|
||||||
opacuserlogin => 0, # catalogue is browse-only; no patron logins needed
|
|
||||||
OpacMaintenance => 0,
|
OpacMaintenance => 0,
|
||||||
ExpireReservesMaxPickUpDelay => 0,
|
ExpireReservesMaxPickUpDelay => 0,
|
||||||
AllowOfflineCirculation => 0,
|
AllowOfflineCirculation => 0,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/perl
|
||||||
|
# Idempotent housekeeping, run on EVERY container start (after the first-time install or
|
||||||
|
# the schema upgrade), so existing installs pick these up too:
|
||||||
|
# * removal of the Koha guest account an earlier version created (the guest login is Quick Add only)
|
||||||
|
# * the "Library tools" block on the staff home page
|
||||||
|
use Modern::Perl;
|
||||||
|
use C4::Context;
|
||||||
|
use Koha::Patrons;
|
||||||
|
use Koha::Patron::Categories;
|
||||||
|
|
||||||
|
my $dbh = C4::Context->dbh;
|
||||||
|
# The read-only "guest" login lives in Quick Add only (the Find a book page), NOT in Koha:
|
||||||
|
# Koha's own "catalogue" permission also allows editing label layouts and templates, so a
|
||||||
|
# Koha account cannot be made truly read-only. Remove any guest account an earlier version created.
|
||||||
|
for my $p ( Koha::Patrons->search( { categorycode => 'VIEWER' } )->as_list ) {
|
||||||
|
say "ensure: removing the old Koha guest account '" . $p->userid . "'";
|
||||||
|
$p->delete;
|
||||||
|
}
|
||||||
|
if ( my $cat = Koha::Patron::Categories->find('VIEWER') ) {
|
||||||
|
$cat->delete unless Koha::Patrons->search( { categorycode => 'VIEWER' } )->count;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Staff home page block (Koha 26.05 keeps these in the HTML customizations tool).
|
||||||
|
my ($have) = $dbh->selectrow_array(q{SELECT COUNT(*) FROM additional_contents WHERE code = 'library_tools'});
|
||||||
|
unless ($have) {
|
||||||
|
my $html = '<div class="main-heading"><h3>Library tools</h3></div><ul class="list-unstyled">'
|
||||||
|
. '<li><a class="btn btn-primary" href="/add/#find">Find a book</a></li>'
|
||||||
|
. '<li><a href="/add/">Add books (scan an ISBN)</a></li></ul>';
|
||||||
|
$dbh->do(q{INSERT INTO additional_contents (category, code, location, branchcode, published_on, number)
|
||||||
|
VALUES ('html_customizations', 'library_tools', 'IntranetmainUserblock', NULL, CURDATE(), 1)});
|
||||||
|
my $id = $dbh->last_insert_id( undef, undef, 'additional_contents', 'id' );
|
||||||
|
$dbh->do( q{INSERT INTO additional_contents_localizations (additional_content_id, title, content, lang)
|
||||||
|
VALUES (?, 'Library tools', ?, 'default')}, undef, $id, $html );
|
||||||
|
say "ensure: added the staff home 'Library tools' block";
|
||||||
|
}
|
||||||
@@ -108,6 +108,11 @@ else
|
|||||||
FIRST_RUN=0
|
FIRST_RUN=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- 4b. housekeeping that must hold on every start (staff home block, old guest account cleanup)
|
||||||
|
runuser -u "$INST-koha" -- env KOHA_CONF="/etc/koha/sites/$INST/koha-conf.xml" PERL5LIB=/usr/share/koha/lib \
|
||||||
|
perl /opt/koha-init/ensure.pl \
|
||||||
|
|| log "WARNING: ensure.pl reported a problem (see above)"
|
||||||
|
|
||||||
# --- 5. background services (their `daemon` processes die with the container,
|
# --- 5. background services (their `daemon` processes die with the container,
|
||||||
# so they are started explicitly on every start) ------------------------
|
# so they are started explicitly on every start) ------------------------
|
||||||
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
|
if [ "$FIRST_RUN" = 1 ] || [ ! -e "/var/lib/koha/$INST/biblios/register" ]; then
|
||||||
|
|||||||
+30
-14
@@ -27,6 +27,9 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/")
|
KOHA_API = os.environ.get("KOHA_API", "http://koha:8081/api/v1").rstrip("/")
|
||||||
USER = os.environ.get("ADMIN_USER", "admin")
|
USER = os.environ.get("ADMIN_USER", "admin")
|
||||||
PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
PASSWORD = os.environ.get("ADMIN_PASSWORD", "")
|
||||||
|
# Optional read-only login to hand out: may open the page and search, nothing else.
|
||||||
|
VIEWER_USER = os.environ.get("VIEWER_USER", "")
|
||||||
|
VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "")
|
||||||
LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME")
|
LIBRARY = os.environ.get("KOHA_LIBRARY", "HOME")
|
||||||
STAFF_PORT = os.environ.get("STAFF_PORT", "8081")
|
STAFF_PORT = os.environ.get("STAFF_PORT", "8081")
|
||||||
OPAC_PORT = os.environ.get("OPAC_PORT", "8080")
|
OPAC_PORT = os.environ.get("OPAC_PORT", "8080")
|
||||||
@@ -802,37 +805,50 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _authed(self):
|
def _role(self):
|
||||||
|
"""'admin', 'viewer' (read-only guest) or None."""
|
||||||
if not PASSWORD:
|
if not PASSWORD:
|
||||||
return False
|
return None
|
||||||
header = self.headers.get("Authorization", "")
|
header = self.headers.get("Authorization", "")
|
||||||
if header.startswith("Basic "):
|
if not header.startswith("Basic "):
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
|
u, _, p = base64.b64decode(header[6:]).decode().partition(":")
|
||||||
ok_u = hmac.compare_digest(u.encode(), USER.encode())
|
|
||||||
ok_p = hmac.compare_digest(p.encode(), PASSWORD.encode())
|
|
||||||
return ok_u and ok_p
|
|
||||||
except (ValueError, UnicodeDecodeError):
|
except (ValueError, UnicodeDecodeError):
|
||||||
return False
|
return None
|
||||||
return False
|
ub, pb = u.encode(), p.encode()
|
||||||
|
# Both comparisons always run, so timing does not reveal which account exists.
|
||||||
|
is_admin = hmac.compare_digest(ub, USER.encode()) & hmac.compare_digest(pb, PASSWORD.encode())
|
||||||
|
is_viewer = bool(VIEWER_USER and VIEWER_PASSWORD) & hmac.compare_digest(ub, VIEWER_USER.encode()) \
|
||||||
|
& hmac.compare_digest(pb, VIEWER_PASSWORD.encode())
|
||||||
|
return "admin" if is_admin else "viewer" if is_viewer else None
|
||||||
|
|
||||||
def _guard(self):
|
def _guard(self, need_admin=True):
|
||||||
if not self._authed():
|
role = self._role()
|
||||||
|
if role is None:
|
||||||
self._send(401, b"Login required", "text/plain",
|
self._send(401, b"Login required", "text/plain",
|
||||||
{"WWW-Authenticate": 'Basic realm="Library quick add"'})
|
{"WWW-Authenticate": 'Basic realm="Library"'})
|
||||||
return False
|
return False
|
||||||
|
if need_admin and role != "admin":
|
||||||
|
self._send(403, {"status": "error", "message": "This login can search but not change anything."})
|
||||||
|
return False
|
||||||
|
self.role = role
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path == "/healthz":
|
if self.path == "/healthz":
|
||||||
return self._send(200, b"ok", "text/plain")
|
return self._send(200, b"ok", "text/plain")
|
||||||
if not self._guard():
|
|
||||||
return
|
|
||||||
path = self.path.split("?")[0]
|
path = self.path.split("?")[0]
|
||||||
|
if not self._guard(need_admin=path not in ("/", "/api/state", "/api/search")):
|
||||||
|
return
|
||||||
if path == "/":
|
if path == "/":
|
||||||
return self._send(200, INDEX, "text/html; charset=utf-8")
|
return self._send(200, INDEX, "text/html; charset=utf-8")
|
||||||
|
if path == "/api/state" and self.role == "viewer":
|
||||||
|
return self._send(200, {"role": "viewer", "library": LIBRARY, "recent": [], "types": {}, "locations": [],
|
||||||
|
"settings": {}, "staff_port": STAFF_PORT, "staff_url": STAFF_URL,
|
||||||
|
"opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": []})
|
||||||
if path == "/api/state":
|
if path == "/api/state":
|
||||||
return self._send(200, {"recent": load_recent()[:100], "types": get_item_types(),
|
return self._send(200, {"role": "admin", "recent": load_recent()[:100], "types": get_item_types(),
|
||||||
"library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT,
|
"library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT,
|
||||||
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
|
"sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]})
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
|
|||||||
@@ -455,10 +455,12 @@
|
|||||||
|
|
||||||
// ---- under the staff site (/add/) everything is same-origin
|
// ---- under the staff site (/add/) everything is same-origin
|
||||||
const embedded = location.pathname.startsWith('/add/');
|
const embedded = location.pathname.startsWith('/add/');
|
||||||
|
let viewer = false; // the read-only guest login: search only
|
||||||
|
|
||||||
// ---- views: Add books / Find a book
|
// ---- views: Add books / Find a book
|
||||||
function showView() {
|
function showView() {
|
||||||
const find = location.hash === '#find';
|
const find = viewer || location.hash === '#find';
|
||||||
|
$('tab-add').hidden = viewer;
|
||||||
$('view-add').hidden = find; $('view-find').hidden = !find;
|
$('view-add').hidden = find; $('view-find').hidden = !find;
|
||||||
$('tab-add').classList.toggle('on', !find); $('tab-find').classList.toggle('on', find);
|
$('tab-add').classList.toggle('on', !find); $('tab-find').classList.toggle('on', find);
|
||||||
$('title').textContent = find ? 'Find a book' : 'Add to library';
|
$('title').textContent = find ? 'Find a book' : 'Add to library';
|
||||||
@@ -482,18 +484,22 @@
|
|||||||
el('span', {}, c.location || 'no shelf set'), c.callnumber ? el('span', { className: 'meta' }, c.callnumber) : '',
|
el('span', {}, c.location || 'no shelf set'), c.callnumber ? el('span', { className: 'meta' }, c.callnumber) : '',
|
||||||
el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status)))
|
el('span', { className: 'chip ' + (c.status === 'On shelf' ? 'in' : 'out') }, c.status)))
|
||||||
: [el('div', { className: 'small' }, 'No copies recorded.')]),
|
: [el('div', { className: 'small' }, 'No copies recorded.')]),
|
||||||
el('div', { className: 'small', style: 'margin-top:6px' },
|
viewer ? '' : el('div', { className: 'small', style: 'margin-top:6px' },
|
||||||
el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.biblio_id, target: '_blank', rel: 'noopener' }, 'Open full record')))));
|
el('a', { href: staffRoot() + '/cgi-bin/koha/catalogue/detail.pl?biblionumber=' + b.biblio_id, target: '_blank', rel: 'noopener' }, 'Open full record')))));
|
||||||
}
|
}
|
||||||
$('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); };
|
$('findform').onsubmit = ev => { ev.preventDefault(); find($('q').value); };
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const s = await api('/api/state');
|
const s = await api('/api/state');
|
||||||
types = s.types; if (!types[type]) type = 'BOOK'; typeButtons();
|
viewer = s.role === 'viewer';
|
||||||
|
types = s.types || {}; if (!types[type]) type = 'BOOK'; if (!viewer) typeButtons();
|
||||||
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
|
staffPort = s.staff_port; settings = s.settings || {}; shelf = settings.location || '';
|
||||||
staffBase = s.staff_url || '';
|
staffBase = s.staff_url || '';
|
||||||
$('lnk-opac').href = s.opac_url ? s.opac_url + '/' : 'http://' + location.hostname + ':' + s.opac_port + '/';
|
// The public catalogue is off by default: only link to it when a public address was configured.
|
||||||
|
$('lnk-opac').hidden = !s.opac_url;
|
||||||
|
if (s.opac_url) $('lnk-opac').href = s.opac_url + '/';
|
||||||
$('lnk-staff').href = staffRoot() + '/';
|
$('lnk-staff').href = staffRoot() + '/';
|
||||||
|
$('lnk-staff').hidden = viewer;
|
||||||
$('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl';
|
$('lnk-labels').href = staffRoot() + '/cgi-bin/koha/labels/label-edit-range.pl';
|
||||||
if (s.locations && s.locations.length) {
|
if (s.locations && s.locations.length) {
|
||||||
const sel = $('shelf');
|
const sel = $('shelf');
|
||||||
@@ -504,7 +510,7 @@
|
|||||||
sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); };
|
sel.onchange = () => { shelf = sel.value; saveSettings({ location: shelf }); $('code').focus(); };
|
||||||
$('shelfbox').hidden = false;
|
$('shelfbox').hidden = false;
|
||||||
}
|
}
|
||||||
api('/api/settings').then(applySettings);
|
if (!viewer) api('/api/settings').then(applySettings);
|
||||||
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
$('earlier').replaceChildren(...s.recent.slice(0, 25).map(e => rowFor(e, false)));
|
||||||
showView();
|
showView();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+36
-26
@@ -1,29 +1,30 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
################################################################################
|
################################################################################
|
||||||
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
|
# menus/addon_library.sh - "Library Catalog" addon: a self-hosted library
|
||||||
# catalogue (Koha) running in Docker, shown on the kiosk as a public
|
# catalogue (Koha) running in Docker, with a "Find a book" page on the kiosk.
|
||||||
# search terminal.
|
|
||||||
#
|
#
|
||||||
# What gets installed (all from this checkout's library/ directory, deployed to
|
# What gets installed (all from this checkout's library/ directory, deployed to
|
||||||
# $LIBRARY_DIR - see library/README.md for the stack itself):
|
# $LIBRARY_DIR - see library/README.md for the stack itself):
|
||||||
# - Koha: the catalogue ("OPAC", public search, no login) and the staff
|
# - Koha: cataloguing, Z39.50 lookup, label printing, people and lending.
|
||||||
# interface (cataloguing, Z39.50 lookup, label printing).
|
# - Quick Add: the "scan an ISBN, the book is added" page, with a Find a book tab.
|
||||||
# - Quick Add: a one-box "scan an ISBN, the book is added" page.
|
|
||||||
# - MariaDB.
|
# - MariaDB.
|
||||||
# Docker itself is installed on demand (lib/docker.sh).
|
# Docker itself is installed on demand (lib/docker.sh).
|
||||||
#
|
#
|
||||||
# On the kiosk the catalogue is added as one of the Sites (tabs) with
|
# Everything needs a login. The kiosk shows the "Find a book" page signed in as a
|
||||||
# duration 0 (stay put - it's a search terminal, not a rotating display).
|
# READ-ONLY guest login (Find a book is search only; the guest has no Koha account at
|
||||||
# Staff and Quick Add are meant for the librarian's own device; whether they
|
# all, because Koha's own search permission also allows editing label layouts); the page
|
||||||
# are reachable from the LAN or only from this kiosk is the "Network access"
|
# list stores that guest login like any other Basic Auth site. The admin login never
|
||||||
# choice below. NOTE: Docker publishes ports straight through iptables and
|
# goes into the kiosk's config. The public catalogue (OPAC) is off by default.
|
||||||
# bypasses ufw, so "this kiosk only" is enforced by binding the published
|
#
|
||||||
# ports to 127.0.0.1 (BIND_ADDR in library/.env), not by firewall rules.
|
# Whether other devices on the LAN can reach the library site is the "Network access"
|
||||||
|
# choice below. NOTE: Docker publishes ports straight through iptables and bypasses
|
||||||
|
# ufw, so "this kiosk only" is enforced by binding the published ports to 127.0.0.1
|
||||||
|
# (BIND_ADDR in library/.env), not by firewall rules.
|
||||||
#
|
#
|
||||||
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
|
# Depends on: lib/menu.sh, lib/config.sh, lib/docker.sh being sourced first.
|
||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
LIBRARY_CATALOGUE_TAB_NAME="Library catalogue"
|
LIBRARY_CATALOGUE_TAB_NAME="Find a book"
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Detection / state
|
# Detection / state
|
||||||
@@ -42,7 +43,13 @@ library_is_running() {
|
|||||||
[[ "$(docker_container_state library-koha)" == "running" ]]
|
[[ "$(docker_container_state library-koha)" == "running" ]]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The page the kiosk shows: Quick Add's Find a book view on the library site.
|
||||||
library_catalogue_url() {
|
library_catalogue_url() {
|
||||||
|
echo "http://localhost:$(library_env_get STAFF_PORT)/add/#find"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pages from earlier versions of this add-on pointed at the public catalogue.
|
||||||
|
library_old_catalogue_url() {
|
||||||
echo "http://localhost:$(library_env_get OPAC_PORT)/"
|
echo "http://localhost:$(library_env_get OPAC_PORT)/"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,19 +73,19 @@ addon_library_status() {
|
|||||||
health="$(docker_container_health library-koha)"
|
health="$(docker_container_health library-koha)"
|
||||||
ip="$(get_ip_address)"
|
ip="$(get_ip_address)"
|
||||||
echo "Library Catalog: ${state}${health:+ ($health)}"
|
echo "Library Catalog: ${state}${health:+ ($health)}"
|
||||||
echo " Catalogue: http://localhost:$(library_env_get OPAC_PORT)/ (shown on the kiosk)"
|
echo " Find a book: $(library_catalogue_url) (shown on the kiosk, as the read-only guest)"
|
||||||
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
if [[ "$(library_env_get BIND_ADDR)" == "127.0.0.1" ]]; then
|
||||||
echo " Network: this kiosk only"
|
echo " Network: this kiosk only"
|
||||||
else
|
else
|
||||||
echo " Network: other devices on the LAN can reach it at http://${ip}:$(library_env_get OPAC_PORT)/"
|
echo " Network: other devices on the LAN can reach the library site at http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
addon_library_menu_builder() {
|
addon_library_menu_builder() {
|
||||||
if library_is_installed; then
|
if library_is_installed; then
|
||||||
load_existing_config
|
load_existing_config
|
||||||
local tab_label="Show the catalogue on the kiosk"
|
local tab_label="Show Find a book on the kiosk"
|
||||||
library_tab_exists && tab_label="Stop showing the catalogue on the kiosk"
|
library_tab_exists && tab_label="Stop showing Find a book on the kiosk"
|
||||||
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
|
MENU_LABELS=("Addresses and staff login" "$tab_label" "Network access (this kiosk only / LAN)" "Start" "Stop" "Update" "Uninstall")
|
||||||
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
|
MENU_HANDLERS=(action_library_show_access action_library_toggle_tab action_library_network action_library_start action_library_stop action_library_update action_library_uninstall)
|
||||||
else
|
else
|
||||||
@@ -111,13 +118,13 @@ library_sync_files() {
|
|||||||
library_add_tab() {
|
library_add_tab() {
|
||||||
load_existing_config
|
load_existing_config
|
||||||
if library_tab_exists; then
|
if library_tab_exists; then
|
||||||
log_info "The catalogue is already one of the kiosk's pages."
|
log_info "Find a book is already one of the kiosk's pages."
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
URLS+=("$(library_catalogue_url)")
|
URLS+=("$(library_catalogue_url)")
|
||||||
DURS+=("0")
|
DURS+=("0")
|
||||||
USERS+=("")
|
USERS+=("$(library_env_get VIEWER_USER)") # read-only guest, never the admin login
|
||||||
PASSES+=("")
|
PASSES+=("$(library_env_get VIEWER_PASSWORD)")
|
||||||
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
|
NAMES+=("$LIBRARY_CATALOGUE_TAB_NAME")
|
||||||
save_config
|
save_config
|
||||||
}
|
}
|
||||||
@@ -125,11 +132,12 @@ library_add_tab() {
|
|||||||
# Drop every page that points at the catalogue's local address.
|
# Drop every page that points at the catalogue's local address.
|
||||||
library_remove_tab() {
|
library_remove_tab() {
|
||||||
load_existing_config
|
load_existing_config
|
||||||
local url idx
|
local url old idx
|
||||||
url="$(library_catalogue_url)"
|
url="$(library_catalogue_url)"
|
||||||
|
old="$(library_old_catalogue_url)"
|
||||||
local -a nu=() nd=() nus=() np=() nn=()
|
local -a nu=() nd=() nus=() np=() nn=()
|
||||||
for idx in "${!URLS[@]}"; do
|
for idx in "${!URLS[@]}"; do
|
||||||
[[ "${URLS[$idx]}" == "$url" ]] && continue
|
[[ "${URLS[$idx]}" == "$url" || "${URLS[$idx]}" == "$old" ]] && continue
|
||||||
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
|
nu+=("${URLS[$idx]}"); nd+=("${DURS[$idx]:-0}"); nus+=("${USERS[$idx]:-}")
|
||||||
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
|
np+=("${PASSES[$idx]:-}"); nn+=("${NAMES[$idx]:-}")
|
||||||
done
|
done
|
||||||
@@ -199,7 +207,7 @@ action_library_install() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ask_yes_no "Show the catalogue on the kiosk screen?" "y"; then
|
if ask_yes_no "Show the Find a book page on the kiosk screen (signed in as the read-only guest)?" "y"; then
|
||||||
library_add_tab
|
library_add_tab
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
@@ -218,9 +226,11 @@ action_library_show_access() {
|
|||||||
echo "(Network access is limited to this kiosk.)"
|
echo "(Network access is limited to this kiosk.)"
|
||||||
fi
|
fi
|
||||||
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
|
echo " Your library (sign in here): http://${ip}:$(library_env_get STAFF_PORT)/"
|
||||||
echo " Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
|
echo " Find a book / Add books: http://${ip}:$(library_env_get STAFF_PORT)/add/"
|
||||||
echo " Public catalogue (kiosk): http://${ip}:$(library_env_get OPAC_PORT)/"
|
echo " Admin login: ${user} / ${pass}"
|
||||||
echo " Login: ${user} / ${pass} (Add books asks once more with the same login)"
|
echo " Read-only login to share: $(library_env_get VIEWER_USER) / $(library_env_get VIEWER_PASSWORD)"
|
||||||
|
echo " Give it out with http://${ip}:$(library_env_get STAFF_PORT)/add/ - it opens Find a book: search only,"
|
||||||
|
echo " it cannot add or change anything (and it has no login on the staff site itself)."
|
||||||
echo
|
echo
|
||||||
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
|
echo " Passwords live in $LIBRARY_DIR/.env (root only)."
|
||||||
pause
|
pause
|
||||||
|
|||||||
+26
-13
@@ -74,6 +74,8 @@ STAFF_PORT=8081
|
|||||||
QUICKADD_PORT=8082
|
QUICKADD_PORT=8082
|
||||||
ADMIN_USER=admin
|
ADMIN_USER=admin
|
||||||
ADMIN_PASSWORD=s3cretPASS
|
ADMIN_PASSWORD=s3cretPASS
|
||||||
|
VIEWER_USER=guest
|
||||||
|
VIEWER_PASSWORD=guestPASS
|
||||||
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
BIND_ADDR=${BIND_ADDR:-0.0.0.0}
|
||||||
ENV
|
ENV
|
||||||
echo running > "$STUB_STATE"
|
echo running > "$STUB_STATE"
|
||||||
@@ -98,7 +100,7 @@ source "$REPO/menus/addon_library.sh"
|
|||||||
export KIOSK_UNATTENDED=0
|
export KIOSK_UNATTENDED=0
|
||||||
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
|
docker_ensure_installed() { return 0; } # the stub stands in for a working Docker
|
||||||
|
|
||||||
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)"' "$CONFIG_PATH"; }
|
tabs() { jq -r '.tabs[] | "\(.url)|\(.duration)|\(.name)|\(.username)"' "$CONFIG_PATH"; }
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo "== lib/docker.sh"
|
echo "== lib/docker.sh"
|
||||||
@@ -133,8 +135,10 @@ printf 'Smith Family Library\nn\ny\n\n' | action_library_install >"$T/install.ou
|
|||||||
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
|
check "install.sh got the library name" grep -q 'LIBRARY_NAME=Smith Family Library' "$STUB_LOG"
|
||||||
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
|
check "install.sh got BIND_ADDR=127.0.0.1" grep -q 'BIND_ADDR=127.0.0.1' "$STUB_LOG"
|
||||||
check "is_installed is now true" library_is_installed
|
check "is_installed is now true" library_is_installed
|
||||||
check "catalogue tab added, duration 0" grep -qx 'http://localhost:8080/|0|Library catalogue' <(tabs)
|
check "Find a book tab added, duration 0" grep -qx 'http://localhost:8081/add/#find|0|Find a book|guest' <(tabs)
|
||||||
check "existing tab preserved" grep -qx 'https://example.com|60|Example' <(tabs)
|
check "tab carries the read-only guest password" test "$(jq -r '.tabs[] | select(.name=="Find a book") | .password' "$CONFIG_PATH")" = guestPASS
|
||||||
|
check "admin password never goes in kiosk config" bash -c "! grep -q s3cretPASS '$CONFIG_PATH'"
|
||||||
|
check "existing tab preserved" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
|
check "untracked config keys preserved" test "$(jq -r .somethingUntracked "$CONFIG_PATH")" = keep-me
|
||||||
addon_library_status > "$T/status.out"
|
addon_library_status > "$T/status.out"
|
||||||
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
|
check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/status.out"
|
||||||
@@ -142,21 +146,29 @@ check "status shows 'this kiosk only'" grep -q 'this kiosk only' "$T/sta
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo "== menu while installed"
|
echo "== menu while installed"
|
||||||
addon_library_menu_builder
|
addon_library_menu_builder
|
||||||
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing the catalogue' <<< "${MENU_LABELS[*]}"
|
check "offers 'stop showing' (tab exists)" grep -q 'Stop showing Find a book' <<< "${MENU_LABELS[*]}"
|
||||||
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
|
check "has Update and Uninstall" grep -q 'Update.*Uninstall' <<< "${MENU_LABELS[*]}"
|
||||||
|
|
||||||
echo "== tab toggling is idempotent"
|
echo "== tab toggling is idempotent"
|
||||||
library_add_tab >/dev/null 2>&1
|
library_add_tab >/dev/null 2>&1
|
||||||
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8080')" = 1
|
check "adding again does not duplicate" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||||
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
|
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # now removes it
|
||||||
check "toggle removes the catalogue tab" test "$(tabs | grep -c 'localhost:8080')" = 0
|
check "toggle removes the Find a book tab" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example' <(tabs)
|
check "toggle keeps the other tab" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
addon_library_menu_builder
|
addon_library_menu_builder
|
||||||
check "menu now offers 'Show'" grep -q 'Show the catalogue on the kiosk' <<< "${MENU_LABELS[*]}"
|
check "menu now offers 'Show'" grep -q 'Show Find a book on the kiosk' <<< "${MENU_LABELS[*]}"
|
||||||
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
|
printf '\n' | action_library_toggle_tab >/dev/null 2>&1 # and adds it back
|
||||||
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8080')" = 1
|
check "toggle adds it back" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 1
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
echo "== an earlier version's tab (public catalogue address) is cleaned up"
|
||||||
|
jq '.tabs += [{"url":"http://localhost:8080/","duration":0,"username":"","password":"","name":"Library catalogue"}]' "$CONFIG_PATH" > "$T/cfg.tmp" && cp "$T/cfg.tmp" "$CONFIG_PATH"
|
||||||
|
check "old catalogue tab present before" test "$(tabs | grep -c '^http://localhost:8080/|')" = 1
|
||||||
|
library_remove_tab >/dev/null 2>&1
|
||||||
|
check "old catalogue tab removed" test "$(tabs | grep -c '^http://localhost:8080/|')" = 0
|
||||||
|
check "other tabs untouched" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
|
library_add_tab >/dev/null 2>&1
|
||||||
|
|
||||||
echo "== network access"
|
echo "== network access"
|
||||||
: > "$STUB_LOG"
|
: > "$STUB_LOG"
|
||||||
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
|
printf 'y\n\n' | action_library_network >/dev/null 2>&1 # allow network
|
||||||
@@ -168,7 +180,8 @@ check ".env BIND_ADDR back to 127.0.0.1" test "$(library_env_get BIND_ADDR
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
echo "== access screen"
|
echo "== access screen"
|
||||||
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
|
printf '\n' | action_library_show_access > "$T/access.out" 2>&1
|
||||||
check "shows the login" grep -q 'admin / s3cretPASS' "$T/access.out"
|
check "shows the admin login" grep -q 'admin / s3cretPASS' "$T/access.out"
|
||||||
|
check "shows the read-only login to share" grep -q 'guest / guestPASS' "$T/access.out"
|
||||||
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
|
check "shows the one library address" grep -q 'sign in here.*:8081/' "$T/access.out"
|
||||||
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
|
check "shows the add-books path on that address" grep -q ':8081/add/' "$T/access.out"
|
||||||
|
|
||||||
@@ -186,7 +199,7 @@ printf 'n\n\n' | action_library_uninstall >/dev/null 2>&1
|
|||||||
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
|
check "compose down without -v" grep -qE 'compose .* down$' "$STUB_LOG"
|
||||||
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
|
check "no volume deletion" bash -c "! grep -q -- ' -v' '$STUB_LOG'"
|
||||||
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
|
check "install dir and .env kept" test -f "$LIBRARY_DIR/.env"
|
||||||
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8080')" = 0
|
check "catalogue tab removed" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
|
|
||||||
echo "== purge needs the typed confirmation"
|
echo "== purge needs the typed confirmation"
|
||||||
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
|
printf 'y\nnope\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||||
@@ -196,8 +209,8 @@ library_add_tab >/dev/null 2>&1
|
|||||||
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
|
printf 'y\nDELETE\n\n' | action_library_uninstall >/dev/null 2>&1
|
||||||
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
|
check "compose down -v --rmi local" grep -q 'down -v --rmi local' "$STUB_LOG"
|
||||||
check "install dir removed" test ! -e "$LIBRARY_DIR"
|
check "install dir removed" test ! -e "$LIBRARY_DIR"
|
||||||
check "tab removed again" test "$(tabs | grep -c 'localhost:8080')" = 0
|
check "tab removed again" test "$(tabs | grep -c 'localhost:8081/add/#find')" = 0
|
||||||
check "other tab still there" grep -qx 'https://example.com|60|Example' <(tabs)
|
check "other tab still there" grep -qx 'https://example.com|60|Example|' <(tabs)
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "passed: $PASS failed: $FAIL"
|
echo "passed: $PASS failed: $FAIL"
|
||||||
|
|||||||
Reference in New Issue
Block a user