"""Public browsing, creator tools and the compact administration portal.""" from __future__ import annotations from html import escape as h from urllib.parse import urlencode import io import os from PIL import Image, ImageOps from DB import Database from hatena import client_ip, request_args from server_config import load_config, save_config from web_ui import page_number, pagination def staff(account): return bool(account and account.get("is_admin")) def channel_options(selected=1, *, excluding=None): return ''.join(f'' for row in Database.list_channels() if row["id"] != excluding) class WebFeatures: def _error(self, request, message, status=400): request.setResponseCode(status) return self._page(request, "Could not save", '

Could not save

' + h(str(message)) + '

Home

') def _feature_get(self, request, path, account): args = request_args(request) value = lambda key, default="": (args.get(key) or [default])[0] include_hidden = staff(account) if path in {"/browse", "/search"}: query = value("q").strip()[:100] sort = value("sort", "new") sort = sort if sort in {"new", "popular", "top"} else "new" scope = "creators" if path == "/search" and value("type") == "creators" else "flipnotes" try: channel = int(value("channel", "0")) or None except ValueError: channel = None per_page = 24 total = Database.count_creators(query) if scope == "creators" else Database.count_flipnotes(q=query, channel_id=channel, include_hidden=include_hidden) page = page_number(value("page", "1"), total, per_page) if scope == "creators": rows = Database.search_creators(query, page=page, per_page=per_page) cards = '
' + ''.join(f'{h(row["display_name"])}Creator’s Room' for row in rows) + '
' else: rows = Database.list_flipnotes(q=query, sort=sort, channel_id=channel, page=page, per_page=per_page, include_hidden=include_hidden) cards = '
' + ''.join(self._flipnote_card(row) for row in rows) + '
' title = "Search" if path == "/search" else "Browse Flipnotes" scope_field = ('') if path == '/search' else '' form = f'''
{scope_field}
''' body = f'

{title}

{form}

{total} {scope}

' body += cards if rows else '

Nothing matched this search.

' body += pagination(page, total, per_page, path, q=query, sort=sort, channel=channel or 0, type=scope) return self._page(request, title, body, account=account, active="browse") if path == "/channels": cards = ''.join(f'{h(row["title"])}

{h(row["description"])}

{Database.count_flipnotes(channel_id=row["id"])} Flipnotes
' for row in Database.list_channels()) return self._page(request, "Channels", '

Channels

Browse Flipnotes by subject.

' + cards + '
', account=account) if path.startswith("/creator/"): room = Database.get_creator_room_by_public_id(path[len("/creator/"):].strip("/")) if not room: return self._error(request, "Creator not found.", 404) total = Database.count_flipnotes(room_id=room["id"], include_hidden=include_hidden) page = page_number(value("page", "1"), total, 24) rows = Database.list_flipnotes(room_id=room["id"], page=page, per_page=24, include_hidden=include_hidden) body = f'

{h(room["display_name"])}

{total} Flipnotes

' + ''.join(self._flipnote_card(row) for row in rows) + '
' body += pagination(page, total, 24, path) return self._page(request, room["display_name"], body, account=account) if path.startswith("/watch/") and path.endswith("/manage"): note_id = path[len("/watch/"):-len("/manage")].strip("/") note = Database.get_flipnote_by_public_id(note_id, include_hidden=include_hidden) if not note: return self._error(request, "Flipnote not found.", 404) if not staff(account) and not Database.owns_flipnote(account["id"] if account else None, client_ip(request), note): return self._error(request, "Only the creator can change this Flipnote.", 403) body = f'''

Manage Flipnote

Back to Flipnote
Delete this Flipnote

This removes the Flipnote from this server’s web and DSi listings.

''' return self._page(request, "Manage Flipnote", body, account=account) if path == "/admin": if not staff(account): return self._error(request, "Administrator access is required.", 403) return self._admin_page(request, account, args) return None def _admin_page(self, request, account, args): value = lambda key, default="": (args.get(key) or [default])[0] tabs = {"flipnotes": "Flipnotes", "comments": "Comments", "channels": "Channels", "server": "Server settings", "bans": "IP bans"} tab = value("tab", "flipnotes") tab = tab if tab in tabs else "flipnotes" body = '

Admin

Manage this server and its content.

' if tab in {"flipnotes", "comments"}: query = value("q").strip()[:100] total = Database.count_moderation_comments(query) if tab == "comments" else Database.count_flipnotes(q=query, include_hidden=True) page = page_number(value("page", "1"), total, 25) rows = Database.list_moderation_comments(query, page=page, per_page=25) if tab == "comments" else Database.list_flipnotes(q=query, page=page, per_page=25, include_hidden=True) body += f'
' for row in rows: hidden = bool(row["hidden"]) title = row.get("text_content") if tab == "comments" and row["type"] == "text" else "Mini Flipnote comment" if tab == "comments" else row["title"] public_id = row.get("flipnote_public_id") if tab == "comments" else row["public_id"] body += f'''''' body += pagination(page, total, 25, "/admin", tab=tab, q=query) if not rows: body += '

No matching content.

' elif tab == "channels": body += '
New channel
' for channel in Database.list_channels(): body += f'''
''' if channel["id"] != 1: body += f'
Remove channel
' body += '
' elif tab == "server": config = load_config() body += '
Server appearance
' for key,label,limit in (("site_name","Server name",60),("tagline","Tagline",100),("welcome_message","Welcome message",500)): body += f'' body += f'' for key,label in (("registration_enabled","Allow new accounts"),("guest_posting","Allow guest posts and comments")): body += f'' body += '
Server logo
Up to 2 MB. Converted to PNG and fitted within 1024 × 512 pixels.
' else: body += '
Block an IP
' for ban in Database.list_bans(): body += f'
{h(ban["ip"])}

{h(ban["reason"])}

' return self._page(request, "Admin", body, account=account, active="admin") def _feature_post(self, request, path, fields, account): def redirect(target): request.setResponseCode(303) request.setHeader(b"Location", target.encode()) return b"" try: if path.startswith('/watch/') and path.rsplit('/',1)[-1] in {'manage','delete'}: public_id, action = path[len('/watch/'):].rsplit('/',1) note = Database.get_flipnote_by_public_id(public_id, include_hidden=staff(account)) if not note: return self._error(request, "Flipnote not found.", 404) if not staff(account) and not Database.owns_flipnote(account['id'] if account else None, client_ip(request), note): return self._error(request, "Only the creator can change this Flipnote.", 403) if action == 'delete': Database.delete_flipnote(note['id']) return redirect('/creator/' + note['creator_public_id']) Database.update_flipnote(note['id'], fields.get('title',''), fields.get('description',''), int(fields.get('channel_id','1'))) return redirect('/watch/' + public_id) if path.startswith('/comments/') and path.endswith('/delete'): comment = Database.get_comment(int(path.split('/')[2]), include_hidden=staff(account)) if not comment: return self._error(request, "Comment not found.", 404) if not staff(account) and not Database.owns_comment(account['id'] if account else None, client_ip(request), comment): return self._error(request, "Only the commenter can remove this comment.", 403) Database.delete_comment(comment['id']) parent = Database.get_flipnote_by_id(comment['flipnote_id'], include_hidden=staff(account)) return redirect('/watch/' + parent['public_id'] if parent else '/') if not path.startswith('/admin/'): return None if not staff(account): return self._error(request, "Administrator access is required.", 403) if path in {'/admin/flipnote-visibility','/admin/comment-visibility'}: if fields.get('hidden') not in {'0','1'}: raise ValueError('Choose whether to hide or restore the content.') setter = Database.set_comment_hidden if path == '/admin/comment-visibility' else Database.set_flipnote_hidden setter(int(fields.get('id','0')), fields['hidden']=='1', fields.get('reason','')) return redirect('/admin?tab=' + ('comments' if path == '/admin/comment-visibility' else 'flipnotes')) if path == '/admin/channel-create': Database.create_channel(fields.get('title',''), fields.get('description','')) elif path == '/admin/channel-update': Database.update_channel(int(fields.get('id','0')), fields.get('title',''), fields.get('description','')) elif path == '/admin/channel-delete': Database.delete_channel(int(fields.get('id','0')), int(fields.get('target_channel_id','1'))) elif path == '/admin/server': changes = {key: fields.get(key,'') for key in ('site_name','tagline','welcome_message','accent_colour')} changes.update(registration_enabled=fields.get('registration_enabled')=='1', guest_posting=fields.get('guest_posting')=='1', max_upload_bytes=int(fields.get('max_upload_kib','2048'))*1024) save_config(changes) return redirect('/admin?tab=server') elif path == '/admin/logo': values = request.args.get(b'logo', []) raw = values[0] if values else b'' if not raw or len(raw)>2*1024*1024: raise ValueError('Choose an image up to 2 MB.') with Image.open(io.BytesIO(raw)) as original: if original.width*original.height>16_000_000: raise ValueError('This image is too large.') image = ImageOps.exif_transpose(original).convert('RGBA') image.thumbnail((1024,512), Image.Resampling.LANCZOS) target = Database.db_path.parent/'site-logo.png' temporary = target.with_suffix('.tmp') image.save(temporary, format='PNG') os.replace(temporary,target) save_config({'logo_url':'/branding/logo.png'}) return redirect('/admin?tab=server') elif path == '/admin/logo-reset': save_config({'logo_url':'/static/RexiMemo_OSE_Logo.png'}) return redirect('/admin?tab=server') else: return None return redirect('/admin?tab=channels') except (ValueError, TypeError, OSError, Image.DecompressionBombError) as exc: return self._error(request, str(exc))