📄 markdown_hosting.cpython-312.pyc
/home/palash/git/local-ai/__pycache__/markdown_hosting.cpython-312.pyc
Language: pyc • Lines: 829
�

>v�j\y�	�x�ddlZddlZddlZddlZddlZddlmZmZmZm	Z	ddl
mZddlm
Z
mZddlmZddlmZe�e�ZdZej*j-ej.dej*j-d	���Zej*j-ej.d
d��Zej*j-ej.dd��Zd
efdeffD]\ZZer�	ed
e�d���dddddd�Zedd�edd�edd�d�Zdede dzfd�Z!dede fd�Z"d8de dzde dzde#fd�Z$de de fd�Z%de de&fd�Z'd9de dedzde dzfd�Z(de#d e de fd!�Z)ejUe�ded"efd#��Z+d$�Z,d%�Z-d&�Z.d'e fd(�Z/ejad)e
�*�defd+��Z1ejad,�d-e d.e d/e defd0��Z2d-e d.e d1e de fd2�Z3ejad3�d-e d.e defd4��Z4ejkd5�d-e d.e defd6��Z6ejad5e
�*�d-e d.e defd7��Z7y):�N)�FastAPI�
HTTPException�Request�status)�http_exception_handler)�HTMLResponse�FileResponse��quote)�load_dotenvz	./stories�STORIES_FREE_DIRz~/local-ai-files/stories�STORIES_PREMIUM_DIR��STORIES_ADMIN_DIRz@markdown_hosting.py cannot start: required environment variable z is not set.��)�guest�free�user�premium�admin)�path�	min_level)�free_stories�premium_stories�
admin_stories�request�returnc�2�ddlm}||j�S)aUsername authenticated through nginx's auth_request (X-Authentik-*).

    Direct localhost callers may present ``Authorization: Bearer <jwt>``; that
    is verified against Authentik's JWKS (see server/auth.py). Returns None
    when no identity is present.
    r)�get_current_user)�server.authr �headers)r�_get_current_users  �-/home/palash/git/local-ai/markdown_hosting.pyr r 3s��B��W�_�_�-�-�c�D�ddlm}||j�}|r|dSdS)zBRole (free/premium/admin) derived from Authentik group membership.r)�get_identity�roler)r!r'r")r�
_get_identity�identitys   r$�get_current_roler+>s%��9��W�_�_�-�H�'�8�F��3�V�3r%�usernamer(c��|�1|s	tdStjt|�td�Stj|td�S)zOMap a username (or resolved role) to its hierarchy level (0=guest ... 2=admin).rr)�
ROLE_LEVEL�get�
get_user_role)r,r(s  r$�user_role_levelr1EsF���|���g�&�&��~�~�m�H�5�z�&�7I�J�J��>�>�$�
�6� 2�3�3r%c�0�ddlm}|t|��S)z�Resolve role from the shared identity provider's group membership.

    Kept for backward compatibility; the chat server derives role from
    X-Authentik-Groups via server/auth.py.
    r)�role_from_groups)r!r3�_stories_role_groups_for)r,r3s  r$r0r0Ns��-��4�X�>�?�?r%c�$�|dvrdgS|dvrdgSdgS)z�Best-effort group mapping for a username seen without claim headers.

    Legacy fallback so direct localhost requests (no nginx auth_request) still
    get a sensible role. Once nginx fronts the service this path is never hit.
    >�palashr>�totanrr�)r,s r$r4r4Xs+���:���y���9���{���8�Or%�collection_folderc�^�tj|�}|s
tdd���|�*tdt	|��}tt
|��}nt|�}t|�}|d}||kr=|s |dkDrttjd���ttjd���y)	zGChecks the user's role against the collection's minimum required level.�zCollection not found��status_code�detailNrrzAuthentication required.zKAccess Denied: higher subscription/role required for this story collection.)
�COLLECTION_RULESr/rr1r+�boolr r�HTTP_401_UNAUTHORIZED�HTTP_403_FORBIDDEN)r9rr,�rule�level�authedrs       r$�enforce_rbacrFes������ 1�2�D����4J�K�K�����&6�w�&?�@���&�w�/�0���h�����)���[�!�I��y���)�a�-��"�8�8�1��
���1�1�`�
�	
�
r%r=r>c	��dddd�}|j|d|���}|dk(rd}ntjt|��}d}d	|�d
|�d|�d|�d
�	S)u�Render a styled HTML error page for browser navigation.

    API and live-polling endpoints keep their JSON error responses (see
    ``_http_exception_html_handler``); anything a user would navigate to in the
    browser gets a proper page instead of raw JSON. Unauthenticated browser
    traffic never reaches here anonymously in production — nginx runs
    ``auth_request`` against the Authentik proxy outpost, so a 401 means the
    Authentik session simply needs to be established.
    zAuthentication Requiredz
Access Deniedz	Not Found)�i�r;zError rHz,Please log in to view this story collection.z}
    <span class="login-toggle"><a href="/sso/outpost.goauthentik.io/start?rd=%2Fstories%2F">Sign in with SSO</a></span>
    ��
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>u�
</title>
        <style>
            * { box-sizing: border-box; }
            html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
            body { font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }
            h1 { color: #c44; }
            a.back { color: #666; text-decoration: none; }
            .topbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-family: sans-serif; font-size: 14px; }
            .topbar .login { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
            .topbar .login.hidden { display: none; }
            .topbar .login-toggle a { color: #06c; font-weight: bold; }
            .topbar input { padding: 6px 8px; border: 1px solid #aaa; border-radius: 6px; font-size: 14px; background: #fff; color: #111; }
            .topbar button { background: none; border: 1px solid #888; color: #888; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-family: sans-serif; font-size: 13px; }
            .topbar button:hover { background: #eee; }
            .topbar button.primary { background: #06c; color: #fff; border-color: #06c; }
            .topbar button.primary:hover { background: #0577e6; }
            .topbar #login-msg { color: #c44; font-size: 12px; width: 100%; }
            @media (max-width: 600px) {
                body { padding: 12px; font-size: 19px; }
                .topbar { flex-direction: column; align-items: stretch; }
                .topbar .login { flex-direction: column; align-items: stretch; }
                .topbar input { width: 100%; }
            }
            @media (prefers-color-scheme: dark) {
                body { background: #16181d; color: #e6e6e6; }
                h1 { color: #ff8080; }
                a.back { color: #999; }
                .topbar .login-toggle a { color: #7ab8ff; }
                .topbar input { background: #1f232b; border-color: #3a3f4a; color: #e6e6e6; }
                .topbar input::placeholder { color: #888; }
                .topbar button { background: #1f232b; border-color: #555; color: #cfcfcf; }
                .topbar button:hover { background: #262b34; }
                .topbar button.primary { background: #3a7ee0; border-color: #3a7ee0; color: #fff; }
                .topbar button.primary:hover { background: #4a8cec; }
                .topbar #login-msg { color: #ff8080; }
            }
        </style>
    </head>
    <body>
        <nav class="topbar">
            <a href="/" class="back">← Back to Collections</a>
            z
        </nav>
        <h1>z</h1>
        <p>z!</p>
    </body>
    </html>
    )r/�html�escape�str)r=r>�titles�title�message�login_blocks      r$�
error_pagerQs���'�
�
��F�

�J�J�{�f�[�M�$:�;�E��c��@���+�+�c�&�k�*���K���w�)
�R�M�
�
�G���9��e5�5r%�excc��K�|jj}|jd�s|jd�rt	||��d{���St|jt|j|j���S7�9�w)zGJSON errors for API/live-poll endpoints, HTML pages for browser routes.z/api/z/contentN)r=�content)	�urlr�
startswith�endswithrrr=rQr>)rrRrs   r$�_http_exception_html_handlerrX�si�����;�;���D����w��4�=�=��#<�+�G�S�9�9�9���O�O��3�?�?�C�J�J�7���:�s�AB�
B�:Bc��tj|�D�cgc]}|jd�s�|��}}|sy|D�cgc]}|jd�s�|��}}tjj	||xs|d�Scc}wcc}w)zKPrefer the editor's revised file (story_rN_ts.edited.md) over the original.�.mdNz
.edited.mdr)�os�listdirrWr�join)�folder_path�f�mds�editeds    r$�
pick_story_mdrb�sv���j�j��-�
C�����E�1B�1�
C�C�
C����
9�A��
�
�<� 8�a�
9�F�
9�
�7�7�<�<��f�m��Q�%7�8�8��	D��:s�B�B�B�Bc�N�tj|�D]`}|jd�s�	ttjj||�d��5}t
j|�cddd�cSy#1swYnxYw�q#ttjf$rYywxYw)z7Return the moderator verdict dict for a story, or None.z.moderation.json�utf-8��encodingN)
r[r\rW�openrr]�json�load�OSError�JSONDecodeError)r^r_�fhs   r$�story_moderationrm�s���
�Z�Z��
$����:�:�(�)�
��"�'�'�,�,�{�A�6��I�)�R��9�9�R�=�)�)���	)�)��)���T�1�1�2�
��
�s)�+B�A:�,	B�:B	�?B�B$�#B$c	���|sy|jdd�}|dk(rdn|dk(rdnd}|dk(rJtj|jd�xs|jd	�xsd
�}d|�d|�d
|�d|�d�	Sd|�d|�d�S)z�HTML snippet showing the GREEN/RED verdict, or empty string.

    For RED verdicts, includes the moderator's reason as a tap/hover tooltip
    (see .mod-badge CSS/JS shared by both pages).
    r�verdict�GREEN�#2a7�RED�#c44�#888�reasons�reason�No reason provided.z3 <span class="mod-badge" tabindex="0" data-reason="z" style="color:zS; font-size:11px; font-family:sans-serif; cursor:pointer; border-bottom:1px dotted z;">(z)</span>z <span style="color:z,; font-size:11px; font-family:sans-serif;">()r/rJrK)�mod�v�colorrvs    r$�moderation_badger{�s��������	�2��A��7�l�F�1��:��6�E��E�z����S�W�W�Y�/�]�3�7�7�8�3D�]�H]�^��A�&��J�!�7�#8�8=�w�d�1�#�X�
O�	
��u�g�&�
�3�h�	�r%�rootc	����ttj���fd�d��}g}|D�]8}tjj	�|�}tjj|�s�Dt
d�tj|�D��r|jd|f��}ttj|�d��D]�}tjj	||�}tjj|�s�Ct
d�tj|�D��s�i|j|tjj	||�f�����;|S)z�Return [(genre_label | None, story_id)] for a collection root.

    Legacy flat story folders (md directly inside root) are reported with
    genre None; genre folders contain story subdirectories.
    c�~��tjjtjj�|��S�N)r[r�getmtimer])�er|s �r$�<lambda>z)list_collection_stories.<locals>.<lambda>s%���b�g�g�&�&�r�w�w�|�|�D�!�'<�=�r%T)�key�reversec3�>K�|]}|jd����y�w�rZN�rW��.0r_s  r$�	<genexpr>z*list_collection_stories.<locals>.<genexpr> s����;�Q�q�z�z�%� �;���N)r�c3�>K�|]}|jd����y�wr�r�r�s  r$r�z*list_collection_stories.<locals>.<genexpr>%s����.�&'��
�
�5�!�.�r�)�sortedr[r\rr]�isdir�any�append)r|�entries�items�entry�full�sub�subfulls`      r$�list_collection_storiesr�s����
�
�
�4��=���G�

�E��@���w�w�|�|�D�%�(���w�w�}�}�T�"���;�"�*�*�T�*:�;�;��L�L�$���'���"�*�*�T�*�D�9�	@�C��g�g�l�l�4��-�G��w�w�}�}�W�%�#�.�+-�:�:�g�+>�.�+����e�R�W�W�\�\�%��%=�>�?�	@�@��Lr%�/)�response_classc��fK�t|�}t|�}t||�}g}tj	�D�]�\}}|d|kDr�|d}t
jj|�s�5t|�}|s�Ci}	|D]&\}
}|	j|
g�j|��(g}|	j	�D]�\}
}
|
r$d|
jdd�j��d�nd}g}|
D]�}ttt
jj||���}djd	�|j!d�D��}t#j$|j!d�d
�}|jd|�d|�d|�d
|�d�	���|j|dzdj|�zdz���|jd|jdd�j��d�dj|�z����dj|�xsd}|r
d|�d|�d�}nd}d|�d|�d�S�w)zHCollections index listing available story collections and their stories.rrz<h3>�_� z</h3>rr�c3�2K�|]}t|����y�wrr
�r��parts  r$r�zindex.<locals>.<genexpr>Gs����&N�t�u�T�{�&N��������z<li><a href="/story/z">z</a>z</li>z<ul>z</ul>z<h2>z</h2>z&<p>No story collections found yet.</p>z3
        <span class="logged">Logged in as <strong>z</strong> (zB)</span>
        <button id="logout-btn">Log out</button>
        z�
        <span class="login-toggle"><a href="/sso/outpost.goauthentik.io/start?rd=%2Fstories%2F">Sign in with SSO</a></span>
        a�

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Story Collections</title>
        <style>
            * { box-sizing: border-box; }
            html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
            body { font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }
            h1, h2, h3 { color: #333; line-height: 1.3; }
            ul { margin: 0 0 1.2em; padding-left: 1.4em; }
            li { margin-bottom: 0.6em; }
            a { color: #06c; text-decoration: none; }
            .topbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 20px; font-family: sans-serif; font-size: 14px; }
            .topbar .logged { margin: 0; color: #555; }
            .topbar .login { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
            .topbar .login.hidden { display: none; }
            .topbar .login-toggle a { color: #06c; font-weight: bold; }
            .topbar input { padding: 6px 8px; border: 1px solid #aaa; border-radius: 6px; font-size: 14px; background: #fff; color: #111; }
            .topbar button { background: none; border: 1px solid #888; color: #888; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-family: sans-serif; font-size: 13px; }
            .topbar button:hover { background: #eee; }
            .topbar button.primary { background: #06c; color: #fff; border-color: #06c; }
            .topbar button.primary:hover { background: #0577e6; }
            .topbar #login-msg { color: #c44; font-size: 12px; width: 100%; }
            .mod-badge { position: relative; }
            .mod-badge:hover::after, .mod-badge.show-tip::after {
                content: attr(data-reason);
                position: absolute; left: 0; top: 100%; margin-top: 4px;
                background: #222; color: #fff; padding: 6px 10px; border-radius: 6px;
                font-size: 12px; line-height: 1.4; white-space: normal;
                width: max-content; max-width: 240px; z-index: 10;
            }
            @media (max-width: 600px) {
                body { padding: 12px; font-size: 19px; }
                .topbar { flex-direction: column; align-items: stretch; }
                .topbar .login { flex-direction: column; align-items: stretch; }
                .topbar input { width: 100%; }
            }
            @media (prefers-color-scheme: dark) {
                body { background: #16181d; color: #e6e6e6; }
                h1, h2, h3 { color: #f0f0f0; }
                a { color: #7ab8ff; }
                .topbar .logged { color: #aaa; }
                .topbar .login-toggle a { color: #7ab8ff; }
                .topbar input { background: #1f232b; border-color: #3a3f4a; color: #e6e6e6; }
                .topbar input::placeholder { color: #888; }
                .topbar button { background: #1f232b; border-color: #555; color: #cfcfcf; }
                .topbar button:hover { background: #262b34; }
                .topbar button.primary { background: #3a7ee0; border-color: #3a7ee0; color: #fff; }
                .topbar button.primary:hover { background: #4a8cec; }
                .topbar #login-msg { color: #ff8080; }
            }
        </style>
    </head>
    <body>
        <nav class="topbar">z2</nav>
        <h1>Story Collections</h1>
        a$
        <script>
            const logoutBtn = document.getElementById('logout-btn');
            if (logoutBtn) {
                logoutBtn.addEventListener('click', () => {
                    window.location.href = '/outpost.goauthentik.io/sign_out?rd=%2Fstories%2F';
                });
            }
            // Tap-to-toggle tooltip for moderation badges (title= doesn't work on mobile touch).
            document.querySelectorAll('.mod-badge').forEach(el => {
                el.addEventListener('click', e => {
                    e.stopPropagation();
                    document.querySelectorAll('.mod-badge.show-tip').forEach(o => {
                        if (o !== el) o.classList.remove('show-tip');
                    });
                    el.classList.toggle('show-tip');
                });
            });
            document.addEventListener('click', () => {
                document.querySelectorAll('.mod-badge.show-tip').forEach(o => o.classList.remove('show-tip'));
            });
        </script>
    </body>
    </html>
    )r r+r1r?r�r[rr�r��
setdefaultr��replacerNr{rmr]�splitrJrK)rr,r(�
user_level�cards�namerCr|r��grouped�genre�sid�sections�sids�heading�lis�badge�encoded_sid�display_name�body�	auth_htmls                     r$�indexr�,s`���� ��(�H��G�$�D� ��4�0�J��E�&�,�,�.�
�
��d����z�)���F�|���w�w�}�}�T�"��'��-�������	6�J�E�3����u�b�)�0�0��5�	6���"�=�=�?�
	G�K�E�4�GL��U�]�]�3��4�:�:�<�=�U�C�RT�G��C��	
��(�)9�"�'�'�,�,�t�S�:Q�)R�S��"�h�h�&N�s�y�y��~�&N�N��#�{�{�3�9�9�S�>�"�+=�>���
�
�*�4�&��+��b���d�SX�RY�Y^�_��	
�
�O�O�G�f�,�r�w�w�s�|�;�g�E�F�
	G�	����4�<�<��S�)�/�/�1�2�%�8�2�7�7�8�;L�L�	
�7
�<�7�7�5�>�E�E�D��3�3;�*�K��v�N	��	�
�	�9�r'�K�(	�	
���wS�S�s�H/H1z./media/{collection}/{story_id:path}/{filename}�
collection�story_id�filenamec���K�t||��t|d}tjj	|||�}tjj|�s
t
dd���t|�S�w)zBServes media dynamically while checking the authenticated session.�rrr;zImage not foundr<)rFr?r[rr]�existsrr	)r�r�r�rr|�	file_paths      r$�serve_story_imager��s`������W�-��J�'��/�D������T�8�X�6�I�
�7�7�>�>�)�$���4E�F�F��	�"�"�s�A2A4rTc��tj|gd�dddii��}|jdd�jdd	�}|jd
d|�d|�d��S)
aPRender story markdown and rewrite image srcs to the authenticated media route.

    pymdownx.arithmatex (generic mode) leaves $...$ / $$...$$ math untouched
    but wraps it in <span class="arithmatex"> / <div class="arithmatex">
    so the KaTeX auto-render script loaded on the story page can find and
    typeset it client-side.
    )�extra�tables�fenced_code�pymdownx.arithmatexr��genericT)�
extensions�extension_configsz<table>z<div class="table-wrap"><table>z</table>z</table></div>zsrc="zsrc="/media/r�)�markdownr�)r�r�rT�html_contents    r$�render_story_htmlr��ss���$�$��L�0�9�d�2C�D��L�
 �'�'��4��
�g�j�*�+������<�
�|�1�X�J�a�)P�Q�Qr%z+/story/{collection}/{story_id:path}/contentc��K�t||��tjjt|d|�}tjj|�s
t
dd���t|�}|s
t
dd���t|dd�	�5}|j�}d
d
d
�dt||�iS#1swY�xYw�w)z9Returns the current rendered story HTML for live polling.r�rr;�Story folder not foundr<�)No markdown file found in story directory�rrdreNrJ)rFr[rr]r?r�rrbrg�readr�)r�r�rr^�md_filer_rTs       r$�
story_contentr��s�������W�-��'�'�,�,�/�
�;�F�C�X�N�K�
�7�7�>�>�+�&���4L�M�M��K�(�G����4_�`�`�	
�g�s�W�	-����&�&�(���
�%�j�(�G�D�E�E���s�BC�B6�C�6B?�;Cz#/story/{collection}/{story_id:path}c��K�t||��t|�dk7rttjd���t
jjt|d|�}t
jj|�s
tdd���tj|d�	�t
jj|�r
td
d���d|d�S�w)
zBDeletes the story folder (markdown + images). Admin role required.r�rz&Admin role required to delete stories.r<rr;r�T)�
ignore_errorsi�zFailed to delete story folder)�ok�deleted)rFr+rrrBr[rr]r?r��shutil�rmtree)r�r�rr^s    r$�delete_storyr��s�������W�-��� �G�+���1�1�;�
�	
�
�'�'�,�,�/�
�;�F�C�X�N�K�
�7�7�>�>�+�&���4L�M�M�
�M�M�+�T�2�	�w�w�~�~�k�"���4S�T�T��8�,�,�s�CCc��K�t||��tjjt|d|�}tjj|�s
t
dd���t|�}|s
t
dd���t|dd�	�5}|j�}d
d
d
�t||�}t|�dk(}d}	t|�}
|
r{|
jd
d�}|dk(rdn|dk(rdnd}|dk(rKtj|
jd�xs|
jd�xsd�}
d|
�d|�d|�d|�d�	}	n	d|�d|�d�}	|rdnd}t!j"|�}t%|�}djd �|j'd�D��}d!tj|j'd�d"j)d#d$�j+���d%|�d&|	�d'|�d(|�d)|�d|�d*|�d|�d+�S#1swY��]xYw�w),z>Reads story Markdown dynamically and enforces access controls.r�rr;r�r<r�r�rdreNrrrorprqrrrsrtrurvrwz1<div class="mod-badge" tabindex="0" data-reason="z'" style="font-family:sans-serif; color:ze; font-size:13px; margin-bottom:12px; display:inline-block; cursor:pointer; border-bottom:1px dotted z;">Moderation: z (tap for reason)</div>z*<div style="font-family:sans-serif; color:z3; font-size:13px; margin-bottom:12px;">Moderation: z</div>z-<button id="delete-btn">Delete story</button>r�c3�2K�|]}t|����y�wrr
r�s  r$r�zread_story.<locals>.<genexpr>3s����!N�$�%��+�!N�r�rIr��-r�u
</title>
        <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
        <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
        <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
        <style>
            * { box-sizing: border-box; }
            html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
            body { font-family: Georgia, 'Times New Roman', serif; font-size: 18px; max-width: 40em; margin: 0 auto; padding: 16px; line-height: 1.7; background: #fafafa; color: #111; }
            article { overflow-wrap: break-word; }
            article p, article li { font-size: 1em; }
            img { max-width: 100%; height: auto; border-radius: 8px; margin: 20px 0; display: block; }
            .topbar { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; font-family: sans-serif; font-size: 14px; }
            a.back { color: #666; text-decoration: none; }
            .topbar button { background: none; border: 1px solid #c44; color: #c44; border-radius: 6px; padding: 4px 12px; cursor: pointer; font-family: sans-serif; font-size: 14px; }
            .topbar button:hover { background: #fceaea; }
            blockquote { border-left: 4px solid #ddd; margin: 0 0 1em; padding: 0 0 0 16px; color: #555; }
            code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; font-family: monospace; font-size: 0.85em; }
            pre { background: #f0f0f0; padding: 12px; border-radius: 6px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
            pre code { background: none; padding: 0; font-size: 0.85em; }
            .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 1em 0; }
            table { border-collapse: collapse; font-size: 0.9em; }
            th, td { border: 1px solid #ccc; padding: 6px 10px; }
            .mod-badge { position: relative; }
            .mod-badge:hover::after, .mod-badge.show-tip::after {
                content: attr(data-reason);
                position: absolute; left: 0; top: 100%; margin-top: 4px;
                background: #222; color: #fff; padding: 6px 10px; border-radius: 6px;
                font-size: 12px; line-height: 1.4; white-space: normal;
                width: max-content; max-width: 280px; z-index: 10;
            }
            @media (max-width: 600px) {
                body { padding: 12px; font-size: 19px; }
                .topbar { flex-direction: column; align-items: stretch; }
                .topbar button { width: 100%; }
            }
            @media (prefers-color-scheme: dark) {
                body { background: #16181d; color: #e6e6e6; }
                a.back { color: #999; }
                .topbar button { border-color: #e05a5a; color: #ff7a7a; }
                .topbar button:hover { background: #2a1c1c; }
                blockquote { border-left-color: #444; color: #aaa; }
                code { background: #2a2e37; }
                pre { background: #2a2e37; }
                th, td { border-color: #3a3f4a; }
            }
        </style>
    </head>
    <body>
        <nav class="topbar">
            <a href="/" class="back">← Back to Collections</a>
            z
        </nav>
        z%
        <article id="story-article">z8</article>
        <script>
            const storyId = a�;
            const article = document.getElementById('story-article');
            let lastHtml = article.innerHTML;

            function typesetMath() {
                if (typeof renderMathInElement !== 'function') {
                    setTimeout(typesetMath, 100);
                    return;
                }
                renderMathInElement(article, {
                    delimiters: [
                        {left: '\\[', right: '\\]', display: true},
                        {left: '\\(', right: '\\)', display: false},
                        {left: '$$', right: '$$', display: true},
                        {left: '$', right: '$', display: false}
                    ],
                    throwOnError: false
                });
            }
            typesetMath();

            async function poll() {
                try {
                    const r = await fetch('/story/a�/content');
                    if (!r.ok) return;
                    const data = await r.json();
                    const newHtml = data.html;
                    if (newHtml !== lastHtml) {
                        if (newHtml.startsWith(lastHtml)) {
                            const nearBottom = window.innerHeight + window.scrollY > document.body.scrollHeight - 150;
                            article.insertAdjacentHTML('beforeend', newHtml.slice(lastHtml.length));
                            if (nearBottom) window.scrollTo(0, document.body.scrollHeight);
                        } else {
                            article.innerHTML = newHtml;
                        }
                        lastHtml = newHtml;
                        typesetMath();
                    }
                } catch (e) {}
                setTimeout(poll, 3000);
            }
            poll();

            const deleteBtn = document.getElementById('delete-btn');
            if (deleteBtn) {
                deleteBtn.addEventListener('click', async () => {
                    if (!confirm(`Delete story "${storyId}" and all its images?`)) return;
                    try {
                        const r = await fetch('/story/a', { method: 'DELETE' });
                        if (r.ok) {
                            window.location.href = '/';
                        } else {
                            const d = await r.json();
                            alert('Delete failed: ' + (d.detail || r.status));
                        }
                    } catch (e) {
                        alert('Delete failed: ' + e.message);
                    }
                });
            }

            // Tap-to-toggle tooltip for the moderation reason (title= doesn't work on mobile touch).
            document.querySelectorAll('.mod-badge').forEach(el => {
                el.addEventListener('click', e => {
                    e.stopPropagation();
                    el.classList.toggle('show-tip');
                });
            });
            document.addEventListener('click', () => {
                document.querySelectorAll('.mod-badge.show-tip').forEach(o => o.classList.remove('show-tip'));
            });
        </script>
    </body>
    </html>
    )rFr[rr]r?r�rrbrgr�r�r+rmr/rJrKrh�dumpsrr�r�rN)r�r�rr^r�r_rTr��is_admin�verdict_htmlrxryrzrv�delete_button_html�story_id_js�encoded_collection�encoded_story_paths                  r$�
read_storyr�s`������W�-��'�'�,�,�/�
�;�F�C�X�N�K�
�7�7�>�>�+�&���4L�M�M��K�(�G����4_�`�`�	
�g�s�W�	-����&�&�(���%�Z��7�C�L���(�G�3�H��L�
�;�
'�C�
��G�G�I�r�"���w�,��q�E�z�V�v����:��[�[�����!3�!a�s�w�w�x�7H�!a�La�b�F�C�F�8�L8�8=�w�?,�,1�7�/�!��D[�]�
�=�U�G�D4�45�3�f�>�
�
MU�H�Z\���*�*�X�&�K��z�*�����!N�(�.�.��:M�!N�N������H�N�N�3�/��3�;�;�C��E�K�K�M�N�O�2P
�d � �!	�	��%�%1�N�3�(�M�*3�.4F�2F�a�HZ�G[�\7�28J�6J�!�L^�K_�`�[G�G�A��s�BH�G:�EH�:H�?Hr)NN)8r[rhrJr�r��fastapirrrr�fastapi.exception_handlersr�fastapi.responsesrr	�urllib.parser�
server.dotenvr�app�BASE_STORIES_DIRr�
expanduser�getenv�	_FREE_DIR�_PREMIUM_DIR�
_ADMIN_DIR�_var�_val�RuntimeErrorr.r?rLr r+�intr1r0�listr4rFrQ�exception_handlerrXrbrmr{r�r/r�r�r�r��deleter�r�r8r%r$�<module>r�sy��	���
��;�;�=�8��%��
�
�i����

�G�G���y�r�y�y�);�R�W�W�=O�=O�Pj�=k�l�m�	��w�w�!�!�)�"�)�)�,A�2�"F�G��
�W�W�
�
�	��	�	�*=�r� B�
C�
�)�<�8�;N�PZ�:[�\�
�J�D�$���N��f�L�
"�
�	
�
��
�
��
��
�'�Q�7� ,�1�=�(�q�9���.�g�.�#��*�.�4�g�4�#�4�4�c�D�j�4��d�
�4�c�4�@�C�@�C�@�
�s�
�t�
�
�C�
�'�D�.�
�SV�Y]�S]�
�4L�C�L��L��L�^���}�%����m��&��*9�	��.�#��8����\��*�A��A�+�A�D���	9�:�#��#��#��#��	#�;�#�"R�#�R��R�s�R�s�R�&���	6�7�F��F��F��F�8�F�,���1�2�-��-��-��-�3�-�0���	.�|��L�w��w��w��w�M�wr%