# Pwic.wiki server running on Python and SQLite
# Copyright (C) 2020-2025 Alexandre Bréard
#
#   https://pwic.wiki
#   https://github.com/gitbra/pwic
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

import argparse
import binascii
import gzip
import json
import os
import re
import sqlite3
import sys
from base64 import b64decode
from bisect import bisect_left, insort
from datetime import datetime
from difflib import HtmlDiff
from gettext import translation
from html import escape
from http.cookies import CookieError
from io import BytesIO
from ipaddress import ip_address, ip_network
from os import listdir, urandom
from os.path import getsize, isdir, isfile, join
from random import randint
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import parse_qs, quote
from zipfile import ZIP_DEFLATED, ZIP_STORED, BadZipFile, ZipFile

import imagesize
from aiohttp import ClientSession, MultipartReader, hdrs, web
from aiohttp_session import Session, get_session, new_session, setup
from aiohttp_session.cookie_storage import EncryptedCookieStorage
from jinja2 import Environment, FileSystemLoader
from multidict import MultiDict
from pyotp import TOTP

from .pwic_exporter import PwicExporter, PwicStylerHtml
from .pwic_extension import PwicExtension
from .pwic_importer import PwicImporter, PwicImporterHtml
from .pwic_lib import PwicConst, PwicLib

IPR_EQ, IPR_NET, IPR_REG = range(3)


# ==================
#  Pwic.wiki server
# ==================


class PwicServer:
    """Main server for Pwic.wiki"""

    def __init__(self, dbconn: sqlite3.Connection) -> None:
        """Constructor"""
        self.dbconn = dbconn

    def _lock(self, sql: Optional[sqlite3.Cursor]) -> bool:
        """Lock the current database"""
        if sql is None:
            return False
        try:
            sql.execute(""" BEGIN EXCLUSIVE TRANSACTION""")
            return True
        except sqlite3.OperationalError:
            sql.close()
            return False

    def _commit(self, sql: Optional[sqlite3.Cursor], save: bool) -> None:
        if save:
            self.dbconn.commit()
        else:
            self.dbconn.rollback()
        if sql is not None:
            sql.close()

    def _check_mime(self, obj: Dict[str, Any]) -> bool:
        """Check the consistency of the MIME with the file signature"""
        extension = PwicLib.file_ext(obj["filename"])
        for item in PwicConst.MIMES:
            if extension in item.exts:
                # Expected mime
                if obj["mime"] in ["", "application/octet-stream"]:
                    obj["mime"] = item.mimes[0]
                elif obj["mime"] not in item.mimes:
                    return False

                # Magic bytes
                if item.magic is not None:
                    for mb in item.magic:
                        if obj["content"][: len(mb)] == PwicLib.str2bytearray(mb):
                            return True
                    return False
                break
        return obj["mime"] != ""

    def _check_roles(
        self,
        sql: Optional[sqlite3.Cursor],
        project: Optional[str],
        user: str,
        admin: Optional[bool] = None,
        manager: Optional[bool] = None,
        editor: Optional[bool] = None,
        validator: Optional[bool] = None,
        reader: Optional[bool] = None,
    ) -> Optional[bool]:
        """Check the roles of the user for a given project or globally"""
        if sql is None:
            return None

        # Case without a project
        if project in [None, ""]:
            query = """ SELECT 1
                        FROM roles
                        WHERE user     = ?
                          AND %s       = ?
                          AND disabled = '' """
            for b, k in [
                (admin, "admin"),
                (manager, "manager"),
                (editor, "editor"),
                (validator, "validator"),
                (reader, "reader"),
            ]:
                if b is not None:
                    sql.execute(query % k, (user, PwicLib.x(b)))
                    if sql.fetchone() is None:
                        return False
            return True

        # Case with a project
        # ... read
        sql.execute(
            """ SELECT admin, manager, editor, validator, reader
                        FROM roles
                        WHERE project  = ?
                          AND user     = ?
                          AND disabled = '' """,
            (project, user),
        )
        row = sql.fetchone()
        if row is None:
            return None
        # ... analyze
        if (
            ((admin is not None) and (row["admin"] != admin))
            or ((manager is not None) and (row["manager"] != manager))
            or ((editor is not None) and (row["editor"] != editor))
            or ((validator is not None) and (row["validator"] != validator))
            or ((reader is not None) and (row["reader"] != reader))
        ):
            return False
        return True

    def _check_reader_only(self, sql: Optional[sqlite3.Cursor], project: str, user: str) -> Optional[bool]:
        """Check if the user is only a reader"""
        return self._check_roles(
            sql, project, user, admin=False, manager=False, editor=False, validator=False, reader=True
        )

    def _check_ip(self, ip: str) -> None:
        """Check if the IP address is authorized"""
        # Initialization
        okIncl = False
        hasIncl = False
        koExcl = False

        # Apply the rules
        try:
            ipobj = ip_address(ip)
        except ValueError as e:
            raise web.HTTPUnauthorized() from e
        for mask in app["options"]["ip_filter"]:
            if mask[0] == IPR_NET:
                condition = ipobj in mask[2]
            elif mask[0] == IPR_REG:
                condition = mask[2].match(ip) is not None
            else:
                condition = ip == mask[2]

            # Evaluate
            if mask[1]:  # Negated
                koExcl = koExcl or condition
                if koExcl:  # Boolean accelerator
                    break
            else:
                okIncl = okIncl or condition
                hasIncl = True

        # Validate the access
        unauth = koExcl or (hasIncl != okIncl)
        unauth = not PwicExtension.on_ip_check(ip, not unauth)
        if unauth:
            raise web.HTTPUnauthorized()

    def _redirect_revision(self, sql: sqlite3.Cursor, project: str, user: str, page: str, revision: int) -> int:
        # Check if the user is a pure reader
        pure_reader = self._check_reader_only(sql, project, user)
        if pure_reader is None:
            return 0

        # Route to the latest validated version
        if pure_reader:
            if PwicLib.option(sql, project, "no_history") is not None:
                revision = 0
            if (revision == 0) and (PwicLib.option(sql, project, "validated_only") is not None):
                sql.execute(
                    """ SELECT MAX(revision) AS revision
                                FROM pages
                                WHERE project  = ?
                                  AND page     = ?
                                  AND valuser <> '' """,
                    (project, page),
                )
                row = sql.fetchone()
                if row["revision"] is not None:
                    revision = row["revision"]

        # Check if the chosen revision exists
        if revision > 0:
            sql.execute(
                """ SELECT 1
                            FROM pages
                            WHERE project  = ?
                              AND page     = ?
                              and revision = ?""",
                (project, page, revision),
            )
            if sql.fetchone() is None:
                revision = 0

        # Find the default latest revision
        else:
            sql.execute(
                """ SELECT revision
                            FROM pages
                            WHERE project = ?
                              AND page    = ?
                              AND latest  = 'X' """,
                (project, page),
            )
            row = sql.fetchone()
            if row is not None:
                revision = row["revision"]
        return revision

    async def _get_session(self, request: web.Request) -> Session:
        """Get the current session safely"""
        try:
            session = await get_session(request)
        except CookieError as e:
            # session = await new_session(request)
            raise web.HTTPBadRequest() from e
        return session

    async def _suser(self, request: web.Request) -> str:
        """Retrieve the logged user after some technical checks"""
        # Bots by user agent that don't respect robots.txt
        ua = request.headers.get("User-Agent", "").lower()
        for bot in app["bots"]:
            if (ua == "") or (bot in ua):
                # raise web.HTTPServiceUnavailable()        # Hard
                return PwicConst.USERS["bot"]  # Soft

        # Check the IP address
        ip = PwicExtension.on_ip_header(request)
        self._check_ip(ip)
        session = await self._get_session(request)
        if ip != session.get("ip", ip):
            return ""

        # Check the expiration of the session
        expiry = app["options"]["session_expiry"]
        if expiry > 0:
            cur_time = PwicLib.timestamp()
            if PwicLib.intval(session.get("timestamp", cur_time)) < cur_time - expiry:
                session.invalidate()
                return ""
            session["timestamp"] = PwicLib.timestamp()

        # Check the HTTP referer in POST method and the user
        user = PwicLib.safe_user_name(session.get("user"))
        if (request.method == "POST") and app["options"]["http_referer"]:
            referer = request.headers.get("Referer", "")
            if referer[: len(app["options"]["base_url"])] != app["options"]["base_url"]:
                user = ""
        return PwicConst.USERS["anonymous"] if (user == "") and app["options"]["no_login"] else user

    async def _handle_post(self, request: web.Request) -> Dict[str, Any]:
        """Return the POST as a readable object.get()"""
        result: Dict[str, Any] = {}
        if request.body_exists:
            data = await request.text()
            result = parse_qs(data)
            for res in result:
                result[res] = result[res][0].replace("\r", "")
                if res not in ["markdown"]:
                    result[res] = result[res][: PwicLib.intval(PwicConst.DEFAULTS["limit_field"])]
        return result

    async def _handle_login(self, request: web.Request) -> web.Response:
        """Show the login page"""
        session = await new_session(request)
        session["user_secret"] = PwicLib.random_hash()
        return await self._handle_output(None, request, "login", {})

    async def _handle_logout(self, request: web.Request) -> web.Response:
        """Show the logout page"""
        # Logging the disconnection (not visible online) aims to not report a reader as inactive.
        # Knowing that the session is encrypted in the cookie, the event does NOT guarantee that
        # it is effectively destroyed by the user (his web browser generally does it). The session
        # is fully lost upon server restart if the option 'keep_sessions' is not used.
        user = await self._suser(request)
        if user not in ["", PwicConst.USERS["anonymous"]]:
            sql = self.dbconn.cursor()
            PwicLib.audit(sql, {"author": user, "event": "logout"}, request)
            self._commit(sql, True)

        # Destroy the session
        session = await self._get_session(request)
        session.invalidate()
        return await self._handle_output(None, request, "logout", {})

    async def _handle_output(
        self,
        sql: Optional[sqlite3.Cursor],
        request: web.Request,
        name: str,
        pwic: Dict[str, Any],
    ) -> web.Response:
        """Serve the right template, in the right language, with the right structure and additional data"""
        # Constants
        pwic["user"] = await self._suser(request)
        pwic["emojis"] = PwicConst.EMOJIS
        pwic["constants"] = {
            "anonymous_user": PwicConst.USERS["anonymous"],
            "default_home": PwicConst.DEFAULTS["page"],
            "languages": app["langs"],
            "not_project": PwicConst.NOT_PROJECT,
            "rtl": PwicConst.RTL,
            "unsafe_chars": PwicConst.CHARS_UNSAFE,
            "version": PwicConst.VERSION,
        }

        # The project-dependent variables have the priority
        project = pwic.get("project", "")
        if sql is None:
            sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT project, key, value
                        FROM env
                        WHERE ( project = ?
                             OR project = '' )
                          AND   value   <> ''
                        ORDER BY key     ASC,
                                 project DESC""",
            (project,),
        )
        pwic["env"] = {}
        for row in sql.fetchall():
            if row["key"] not in PwicConst.ENV:
                continue
            (global_, key, value) = (row["project"] == "", row["key"], row["value"])
            if PwicConst.ENV[key].private or (key in pwic["env"]):
                continue
            pwic["env"][key] = {"value": value, "global": global_}

        # Dynamic settings for the robots
        robots = PwicLib.str2robots(str(PwicLib.option(sql, project, "robots", "")))
        if PwicExtension.on_html_robots(sql, request, project, pwic["user"], name, pwic, robots):
            if "robots" not in pwic["env"]:
                pwic["env"]["robots"] = {"value": "", "global": True}
            pwic["env"]["robots"]["value"] = PwicLib.robots2str(robots)

        # Session
        session = await self._get_session(request)
        pwic["oauth_user_secret"] = session.get("user_secret", None)
        # ... language
        session_lang = session.get("language", "")
        new_lang = session_lang or PwicLib.detect_language(request, app["langs"])
        if new_lang not in app["langs"]:
            new_lang = PwicConst.DEFAULTS["language"]
        if new_lang != session_lang:
            session["language"] = new_lang
        pwic["language"] = new_lang

        # Render the template
        pwic["template"] = name
        pwic["args"] = request.rel_url.query
        PwicExtension.on_render_pre(app, sql, request, pwic)
        output = app["jinja"][pwic["language"]].get_template(f"html/{name}.html").render(pwic=pwic)
        output = PwicExtension.on_render_post(app, sql, request, pwic, output)
        headers: MultiDict = MultiDict({})
        PwicExtension.on_http_headers(sql, request, headers, project, name)
        sql.close()
        return web.Response(text=output, content_type=PwicLib.mime("html"), headers=headers)

    async def _handle_headers(self, request: web.Request, response: web.Response) -> None:
        response.headers["Server"] = f"Pwic.wiki v{PwicConst.VERSION}"

    async def static_robots(self, request: web.Request) -> web.Response:
        """Deliver the policy for the robots"""
        return web.HTTPPermanentRedirect("/static/robots.txt")

    async def project_searchlink(self, request: web.Request) -> web.Response:
        """Search link to be added to the browser"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the parameters
        project = PwicLib.safe_name(request.match_info.get("project"))

        # Verify that the user has access to the project
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user):
            sql.close()
            raise web.HTTPUnauthorized()
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        row = sql.fetchone()

        # Additional parameters
        if (app["options"]["base_url"] == "") or (PwicLib.option(sql, project, "no_search") is not None):
            sql.close()
            raise web.HTTPForbidden()

        # Result
        xml = """<?xml version="1.0" encoding="UTF-8"?>
                <OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
                    <Description>%s</Description>
                    <InputEncoding>UTF-8</InputEncoding>
                    <Language>%s</Language>
                    <ShortName>%s</ShortName>
                    <Url rel="results" type="text/html" method="get" template="%s/%s/special/search?q={searchTerms}"></Url>
                </OpenSearchDescription>""" % (
            escape(row["description"]),
            PwicLib.option(sql, project, "language", str(PwicConst.DEFAULTS["language"])),
            escape(project),
            escape(app["options"]["base_url"]),
            escape(project),
        )
        sql.close()
        return web.Response(
            text=PwicLib.recursive_replace(xml.strip(), " <", "<"),
            headers={"Cache-Control": "max-age=2592000"},  # Expires is then optional, 30 days
            content_type=PwicLib.mime("xml"),
        )

    async def project_sitemap(self, request: web.Request) -> web.Response:
        """Produce the site map of the project"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Fetch the parameters
        project = PwicLib.safe_name(request.match_info.get("project"))
        dt = PwicLib.dt()

        # Check the authorizations
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user):
            sql.close()
            raise web.HTTPUnauthorized()
        if PwicLib.option(sql, project, "no_sitemap") is not None:
            sql.close()
            raise web.HTTPForbidden()

        # Generate the site map
        buffer = (
            '<?xml version="1.0" encoding="UTF-8"?>' + '\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'
        )
        sql.execute(
            """ SELECT page, header, date
                        FROM pages
                        WHERE project = ?
                          AND latest  = 'X' """,
            (project,),
        )
        while True:
            row = sql.fetchone()
            if row is None:
                break

            # Mapping
            days = PwicLib.dt_diff(row["date"], dt["date"])
            if row["page"] == PwicConst.DEFAULTS["page"]:
                priority = 1.0
            elif row["header"]:
                priority = 0.7
            elif days <= 90:
                priority = 0.5
            else:
                priority = 0.3
            buffer += (
                "\n<url>"
                + ("<loc>%s/%s/%s</loc>" % (escape(app["options"]["base_url"]), quote(project), quote(row["page"])))
                + ("<changefreq>%s</changefreq>" % ("monthly" if days >= 35 else "weekly"))
                + ("<lastmod>%s</lastmod>" % escape(row["date"]))
                + ("<priority>%.1f</priority>" % priority)
                + "</url>"
            )
        buffer += "\n</urlset>"
        sql.close()
        return web.Response(text=buffer, content_type=PwicLib.mime("xml"))

    async def project_feed(self, request: web.Request) -> web.Response:
        """ATOM/RSS/JSON feeds for the project"""

        # Sub-features
        def _feed_atom(sql: sqlite3.Cursor, project: str, project_description: str, feed_size: int) -> web.Response:
            dt = PwicLib.dt()
            url = f'{app["options"]["base_url"]}/{project}/special/feed/atom'
            legnot = str(PwicLib.option(sql, project, "legal_notice", ""))
            atom = """<?xml version="1.0" encoding="utf-8"?>
                        <feed xmlns="http://www.w3.org/2005/Atom" xml:base="%s" xml:lang="%s">
                            <id>%s</id>
                            <title type="text">Project %s (ATOM)</title>
                            <subtitle type="text">%s</subtitle>
                            <link href="/%s/special/feed/atom" rel="self" type="%s" />
                            <updated>%sT%sZ</updated>%s
                            <generator uri="https://pwic.wiki" version="%s">Pwic.wiki v%s</generator>
                    """ % (
                escape(app["options"]["base_url"]),
                escape(str(PwicLib.option(sql, project, "language", str(PwicConst.DEFAULTS["language"])))),
                escape(url),
                escape(project),
                escape(project_description),
                escape(project),
                escape(str(PwicLib.mime("atom"))),
                escape(dt["date"]),
                escape(dt["time"]),
                "" if legnot == "" else ("\n<rights>%s</rights>" % escape(legnot)),
                escape(PwicConst.VERSION),
                escape(PwicConst.VERSION),
            )
            sql.execute(
                """ SELECT page, revision, author, date, time, title, tags, comment
                            FROM pages
                            WHERE project = ?
                              AND latest  = 'X'
                              AND date   >= ?
                            ORDER BY date DESC,
                                     time DESC
                            LIMIT ?""",
                (project, dt["date-90d"], feed_size),
            )
            for row in sql.fetchall():
                url = f'{app["options"]["base_url"]}/{project}/{row["page"]}/rev{row["revision"]}'
                atom += """<entry>
                             <title>[%s] %s</title>
                             <summary>%s</summary>
                             <updated>%sT%sZ</updated>
                             <link href="%s" />
                             <id>%s</id>
                            <author>
                                <name>%s</name>
                                <uri>%s/special/user/%s</uri>
                            </author>
                         </entry>""" % (
                    escape(row["page"]),
                    escape(row["title"]),
                    escape(row["comment"]),
                    escape(row["date"]),
                    escape(row["time"]),
                    escape(url),
                    escape(url),
                    escape(row["author"]),
                    escape(app["options"]["base_url"]),
                    escape(row["author"]),
                )
            atom += "</feed>"
            sql.close()
            return web.Response(
                text=PwicLib.recursive_replace(atom.strip(), " <", "<"), content_type=PwicLib.mime("atom")
            )

        def _feed_rss(sql: sqlite3.Cursor, project: str, project_description: str, feed_size: int) -> web.Response:
            def _author2rss(author: str) -> str:
                p = author.find("@")
                if p == -1:
                    return f"{author}@no.reply ({author})"
                return f"{author} ({author[:p]})"

            dt = PwicLib.dt()
            url = f'{app["options"]["base_url"]}/{project}/special/feed/rss'
            rss = """<?xml version="1.0" encoding="utf8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
                    <channel>
                        <title>Project %s (RSS)</title>
                        <description>%s</description>
                        <lastBuildDate>%s</lastBuildDate>
                        <link>%s</link>
                        <atom:link href="%s" rel="self" type="%s" />
                    """ % (
                escape(project),
                escape(project_description),
                escape(PwicLib.dt2rfc822(dt["date"], dt["time"])),
                escape(url),
                escape(url),
                escape(str(PwicLib.mime("rss"))),
            )
            sql.execute(
                """ SELECT page, revision, author, date, time, title, tags, comment
                            FROM pages
                            WHERE project = ?
                              AND latest  = 'X'
                              AND date   >= ?
                            ORDER BY date DESC,
                                     time DESC
                            LIMIT ?""",
                (project, dt["date-90d"], feed_size),
            )
            for row in sql.fetchall():
                url = f'{app["options"]["base_url"]}/{project}/{row["page"]}/rev{row["revision"]}'
                rss += """<item>
                            <title>[%s] %s</title>
                            <description>%s</description>
                            <pubDate>%s</pubDate>
                            <author>%s</author>%s
                            <link>%s</link>
                            <guid isPermaLink="false">%s</guid>
                        </item>""" % (
                    escape(row["page"]),
                    escape(row["title"]),
                    escape(row["comment"]),
                    escape(PwicLib.dt2rfc822(row["date"], row["time"])),
                    escape(_author2rss(row["author"])),
                    "" if row["tags"] == "" else ("\n<category>%s</category>" % escape(row["tags"])),
                    escape(url),
                    escape("%s-%s-%d" % (project, row["page"], row["revision"])),
                )
            rss += "</channel></rss>"
            sql.close()
            return web.Response(
                text=PwicLib.recursive_replace(rss.strip(), " <", "<"), content_type=PwicLib.mime("rss")
            )

        def _feed_json(sql: sqlite3.Cursor, project: str, days: int) -> web.Response:
            dt = PwicLib.dt(days=days)
            sql.execute(
                """ SELECT page, MAX(date, valdate) AS date
                            FROM pages
                            WHERE   project  = ?
                              AND   latest   = 'X'
                              AND ( date    >= ?
                                 OR valdate >= ? )
                            ORDER BY date DESC,
                                     page ASC""",
                (project, dt["date-nd"], dt["date-nd"]),
            )
            data = sql.fetchall()
            sql.close()
            return web.Response(text=json.dumps(data), content_type=PwicLib.mime("json"))

        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the parameters
        fmt = request.match_info.get("format", "atom")
        project = PwicLib.safe_name(request.match_info.get("project"))
        days = min(max(0, PwicLib.intval(request.rel_url.query.get("days", "7"))), 90)
        if fmt not in ["atom", "rss", "json"]:
            raise web.HTTPBadRequest()

        # Verify that the user has access to the feed
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user):
            sql.close()
            raise web.HTTPUnauthorized()
        if PwicLib.option(sql, project, "no_feed") is not None:
            sql.close()
            raise web.HTTPForbidden()
        if (PwicLib.option(sql, project, "no_history") is not None) and self._check_reader_only(sql, project, user):
            sql.close()
            raise web.HTTPForbidden()
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        row = sql.fetchone()

        # Result
        feed_size = max(1, PwicLib.intval(PwicLib.option(sql, project, "feed_size", "25")))
        if fmt == "atom":
            return _feed_atom(sql, project, row["description"], feed_size)
        if fmt == "rss":
            return _feed_rss(sql, project, row["description"], feed_size)
        return _feed_json(sql, project, days)

    async def project_manifest(self, request: web.Request) -> web.Response:
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Verify the authorization
        project = PwicLib.safe_name(request.match_info.get("project"))
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user):
            sql.close()
            raise web.HTTPUnauthorized()
        if PwicLib.option(sql, project, "manifest") is None:
            sql.close()
            raise web.HTTPServiceUnavailable()

        # Manifest
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        row = sql.fetchone()
        manifest = {
            "name": project,
            "short_name": project,
            "description": row["description"],
            "start_url": f"/{project}",
            "scope": f"/{project}",
            "display": "standalone",
            "orientation": "portrait",
            "lang": PwicLib.option(sql, project, "language", str(PwicConst.DEFAULTS["language"])),
            "icons": [{"src": "/static/icon.png", "sizes": "320x320", "type": "image/png"}],
            "screenshots": [
                {"src": "/static/icon.png", "type": "image/png", "sizes": "320x320", "form_factor": "narrow"},
                {"src": "/static/icon.png", "type": "image/jpg", "sizes": "320x320", "form_factor": "wide"},
            ],
        }
        sql.close()
        return web.Response(
            text=json.dumps(manifest),
            headers={"Cache-Control": "max-age=2592000"},  # Expires is then optional, 30 days
            content_type=PwicLib.mime("json"),
        )

    async def project_export(self, request: web.Request) -> web.Response:
        """Download the project as a ZIP file"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the parameters
        project = PwicLib.safe_name(request.match_info.get("project"))
        fmt = request.match_info.get("format")
        if fmt != "zip":
            raise web.HTTPUnsupportedMediaType()

        # Verify that the export is authorized
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user, admin=True):
            sql.close()
            raise web.HTTPUnauthorized()
        if PwicLib.option(sql, project, "no_export_project") is not None:
            sql.close()
            raise web.HTTPForbidden()
        with_revisions = PwicLib.option(sql, project, "export_project_revisions") is not None

        # Fetch the attached documents
        sql.execute(
            """ SELECT id, filename, SUBSTR(mime, 1, 6) == 'image/' AS image, exturl
                        FROM documents
                        WHERE project = ?""",
            (project,),
        )
        documents = sql.fetchall()
        for doc in documents:
            doc["image"] = doc["image"] == 1

        # Build the ZIP file
        folder_rev = "revisions/"
        converter = PwicExporter(app["markdown"], user)
        converter.set_option("relative_html", True)
        try:
            inmemory = BytesIO()
            with ZipFile(inmemory, mode="w", compression=ZIP_DEFLATED) as archive:
                # Fetch the relevant pages
                sql_sub = self.dbconn.cursor()
                sql.execute(
                    """ SELECT page, revision, latest, author, date, time, title, markdown
                                FROM pages
                                WHERE   project = ?
                                  AND ( latest  = 'X'
                                     OR draft   = '' )""",
                    (project,),
                )
                while True:
                    page = sql.fetchone()
                    if page is None:
                        break
                    if not with_revisions and not page["latest"]:
                        continue

                    # Raw markdown
                    if with_revisions:
                        archive.writestr(f'{folder_rev}{page["page"]}.rev{page["revision"]}.md', page["markdown"])
                    if page["latest"]:
                        archive.writestr(f'{page["page"]}.md', page["markdown"])

                    # Regenerate HTML
                    html = converter.convert(sql_sub, project, page["page"], page["revision"], "html")
                    if html is None:
                        continue
                    html = str(html)

                    # Fix the relative links
                    for doc in documents:
                        if doc["exturl"] == "":
                            if doc["image"]:
                                html = html.replace(
                                    f'<img src="/special/document/{doc["id"]}"',
                                    f'<img src="documents/{doc["filename"]}"',
                                )
                            html = html.replace(
                                f'<a href="/special/document/{doc["id"]}"', f'<a href="documents/{doc["filename"]}"'
                            )
                            html = html.replace(
                                f'<a href="/special/document/{doc["id"]}/', f'<a href="documents/{doc["filename"]}'
                            )
                        else:
                            if doc["image"]:
                                html = html.replace(
                                    f'<img src="/special/document/{doc["id"]}"', f'<img src="{doc["exturl"]}"'
                                )
                            html = html.replace(
                                f'<a href="/special/document/{doc["id"]}"', f'<a href="{doc["exturl"]}"'
                            )
                            html = html.replace(f'<a href="/special/document/{doc["id"]}/', f'<a href="{doc["exturl"]}')
                    if with_revisions:
                        archive.writestr(f'{folder_rev}{page["page"]}.rev{page["revision"]}.html', html)
                    if page["latest"]:
                        archive.writestr(f'{page["page"]}.html', html)

                # Dependent files for the pages
                cssfn = PwicStylerHtml().css
                content = b""
                with open(cssfn, "rb") as f:
                    content = f.read()
                archive.writestr(cssfn, content)
                if with_revisions:
                    archive.writestr(folder_rev + cssfn, content)
                del content

                # Attached documents
                PwicExtension.on_project_export_documents(sql, request, project, user, documents)
                for doc in documents:
                    if doc["exturl"] == "":
                        fn = join(PwicConst.DOCUMENTS_PATH % project, doc["filename"])
                        if isfile(fn):
                            content = b""
                            with open(fn, "rb") as f:
                                content = f.read()
                            if PwicLib.mime_compressed(PwicLib.file_ext(doc["filename"])):
                                archive.writestr(
                                    f'documents/{doc["filename"]}', content, compress_type=ZIP_STORED, compresslevel=0
                                )
                            else:
                                archive.writestr(f'documents/{doc["filename"]}', content)
                            del content
        except Exception as e:
            sql.close()
            raise web.HTTPInternalServerError() from e

        # Audit the action
        PwicLib.audit(
            sql,
            {
                "author": user,
                "event": "export-project",
                "project": project,
                "string": "zip-full" if with_revisions else "zip-latest",
            },
            request,
        )
        self._commit(sql, True)

        # Return the file
        buffer = inmemory.getvalue()
        inmemory.close()
        headers = {
            "Content-Type": str(PwicLib.mime("zip")),
            "Content-Disposition": 'attachment; filename="%s"' % PwicLib.attachment_name(project + ".zip"),
        }
        return web.Response(body=buffer, headers=MultiDict(headers))

    async def page(self, request: web.Request) -> web.Response:
        """Serve the pages"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Show the requested page
        project = PwicLib.safe_name(request.match_info.get("project"))
        page = PwicLib.safe_name(request.match_info.get("page", str(PwicConst.DEFAULTS["page"])))
        page_special = page == "special"
        revision = PwicLib.intval(request.match_info.get("revision", "0"))
        action = request.match_info.get("action", "view")
        pwic: Dict[str, Any] = {"project": project, "page": page, "revision": revision}

        # Fetch the name of the project or ask the user to pick a project
        sql = self.dbconn.cursor()
        if project == "":
            return await self._page_pick(sql, request, user, pwic)
        if not await self._page_prepare(sql, request, project, user, pwic):
            return await self._handle_output(
                sql, request, "project-access", pwic
            )  # Unauthorized users can request an access

        # Fetch the links of the header line
        sql.execute(
            """ SELECT a.page, a.title
                        FROM pages AS a
                        WHERE a.project = ?
                          AND a.latest  = 'X'
                          AND a.header  = 'X'
                        ORDER BY a.title""",
            (project,),
        )
        pwic["links"] = sql.fetchall()
        for i, row in enumerate(pwic["links"]):
            if row["page"] == PwicConst.DEFAULTS["page"]:
                pwic["links"].insert(0, pwic["links"].pop(i))  # Move to the top because it is the home page
                break

        # Verify that the page exists
        if not page_special:
            revision = self._redirect_revision(sql, project, user, page, revision)
            if revision == 0:
                if PwicLib.option(sql, project, "http_404") is not None:
                    sql.close()
                    raise web.HTTPNotFound()
                return await self._handle_output(sql, request, "page-404", pwic)  # Page not found

        # Show the requested page
        PwicExtension.on_api_page_requested(sql, request, action, project, page, revision)
        if action == "view":
            if page_special:
                return await self._page_view_special(sql, request, project, user, pwic)
            return await self._page_view(sql, request, project, user, page, revision, pwic)
        if action == "edit":
            return await self._page_edit(sql, request, project, page, revision, pwic)
        if action == "history":
            return await self._page_history(sql, request, project, page, pwic)
        if action == "move":
            return await self._page_move(sql, request, project, user, page, pwic)
        sql.close()
        raise web.HTTPNotFound()

    async def _page_pick(
        self,
        sql: sqlite3.Cursor,
        request: web.Request,
        user: str,
        pwic: Dict[str, Any],
    ) -> web.Response:
        # Projects joined
        dt = PwicLib.dt()
        sql.execute(
            """ SELECT a.project, a.description, a.date, c.last_activity
                        FROM projects AS a
                            INNER JOIN roles AS b
                                ON  b.project  = a.project
                                AND b.user     = ?
                                AND b.disabled = ''
                            LEFT OUTER JOIN (
                                SELECT project, MAX(date) AS last_activity
                                FROM audit.audit
                                WHERE date   >= ?
                                  AND author  = ?
                                GROUP BY project
                            ) AS c
                                ON c.project = a.project
                        ORDER BY c.last_activity DESC,
                                 a.date          DESC,
                                 a.description   ASC""",
            (user, dt["date-90d"], user),
        )
        pwic["projects"] = sql.fetchall()

        # Projects not joined yet
        sql.execute(
            """ SELECT a.project, c.description, c.date
                        FROM env AS a
                            LEFT OUTER JOIN roles AS b
                                ON  b.project = a.project
                                AND b.user    = ?
                            INNER JOIN projects AS c
                                ON c.project = a.project
                        WHERE a.project <> ''
                          AND a.key      = 'auto_join'
                          AND a.value   IN ('passive', 'active')
                          AND b.project IS NULL
                        ORDER BY c.date        DESC,
                                 c.description ASC""",
            (user,),
        )
        pwic["joinable_projects"] = sql.fetchall()

        # Output
        if (len(pwic["projects"]) == 1) and (len(pwic["joinable_projects"]) == 0):
            suffix = "?failed" if request.rel_url.query.get("failed", None) is not None else ""
            sql.close()
            raise web.HTTPTemporaryRedirect(f'/{pwic["projects"][0]["project"]}{suffix}')
        return await self._handle_output(sql, request, "project-select", pwic)

    async def _page_prepare(
        self,
        sql: sqlite3.Cursor,
        request: web.Request,
        project: str,
        user: str,
        pwic: Dict[str, Any],
    ) -> bool:
        # Verify if the project exists
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        row = sql.fetchone()
        if row is None:
            if PwicLib.option(sql, "", "http_404") is not None:
                sql.close()
                raise web.HTTPNotFound()
            sql.close()
            raise web.HTTPTemporaryRedirect("/")  # Project not found
        pwic["project_description"] = row["description"]
        pwic["title"] = row["description"]

        # Grant the default rights as a reader
        if (not PwicLib.reserved_user_name(user)) and (PwicLib.option(sql, project, "auto_join") == "passive"):
            if (
                sql.execute(
                    """ SELECT 1
                               FROM roles
                               WHERE project = ?
                                 AND user    = ?""",
                    (project, user),
                ).fetchone()
                is None
            ):
                sql.execute(
                    """ INSERT INTO roles (project, user, reader)
                                VALUES (?, ?, 'X')""",
                    (project, user),
                )
                if sql.rowcount > 0:
                    PwicLib.audit(
                        sql,
                        {
                            "author": PwicConst.USERS["system"],
                            "event": "grant-reader",
                            "project": project,
                            "user": user,
                            "string": "auto_join",
                        },
                        request,
                    )
                    self._commit(None, True)

        # Verify the access
        sql.execute(
            """ SELECT admin, manager, editor, validator, reader
                        FROM roles
                        WHERE project  = ?
                          AND user     = ?
                          AND disabled = '' """,
            (project, user),
        )
        row = sql.fetchone()
        if row is None:
            return False
        pwic.update(row)
        pwic["pure_reader"] = (
            not pwic["admin"]
            and not pwic["manager"]
            and not pwic["editor"]
            and not pwic["validator"]
            and pwic["reader"]
        )
        return True

    async def _page_view(
        self,
        sql: sqlite3.Cursor,
        request: web.Request,
        project: str,
        user: str,
        page: str,
        revision: int,
        pwic: Dict[str, Any],
    ) -> web.Response:
        # Content of the page
        sql.execute(
            """ SELECT revision, latest, draft, final, protection,
                               author, date, time, title, markdown,
                               tags, valuser, valdate, valtime
                        FROM pages
                        WHERE project  = ?
                          AND page     = ?
                          AND revision = ?""",
            (project, page, revision),
        )
        row = sql.fetchone()
        row["tags"] = PwicLib.list(row["tags"])
        pwic.update(row)

        # Read the HTML cache
        cache = (PwicLib.option(sql, project, "no_cache") is None) and PwicExtension.on_cache(
            sql, request, project, user, page, revision
        )
        if cache:
            sql.execute(
                """ SELECT html
                            FROM cache
                            WHERE project  = ?
                              AND page     = ?
                              AND revision = ?""",
                (project, page, revision),
            )
            row = sql.fetchone()
        else:
            row = None

        # Update the HTML cache if needed
        if row is not None:
            if isinstance(row["html"], bytes):
                html = str(gzip.decompress(row["html"]).decode())
            else:
                html = row["html"]
        else:
            row = {"project": project, "page": page, "revision": revision, "markdown": pwic["markdown"]}
            converter = PwicExporter(app["markdown"], user)
            html = converter.md2corehtml(sql, row, export_odt=False)
            del converter
            if cache:
                sql.execute(
                    """ INSERT OR REPLACE INTO cache (project, page, revision, html)
                                VALUES (?, ?, ?, ?)""",
                    (
                        project,
                        page,
                        revision,
                        gzip.compress(html.encode(), compresslevel=9) if app["options"]["compressed_cache"] else html,
                    ),
                )
                self._commit(None, True)

        # Enhance the page
        pwic["html"], pwic["tmap"] = PwicLib.extended_syntax(
            html,
            PwicLib.option(sql, project, "heading_mask"),
            headerNumbering=PwicLib.option(sql, project, "no_heading") is None,
        )
        pwic["hash"] = PwicLib.sha256(pwic["markdown"], salt=False)
        pwic["removable"] = (pwic["admin"] and not pwic["final"] and (pwic["valuser"] == "")) or (
            (pwic["author"] == user) and pwic["draft"]
        )
        pwic["file_formats"] = PwicExporter.get_allowed_extensions()
        pwic["canonical"] = f'{app["options"]["base_url"]}/{project}/{page}' + (
            "" if pwic["latest"] else f"/rev{revision}"
        )
        pwic["description"] = PwicExtension.on_html_description(sql, project, user, page, revision)
        pwic["keywords"] = PwicExtension.on_html_keywords(sql, project, user, page, revision)

        # File gallery
        query = """ SELECT id, filename, mime, size, author, date, time
                    FROM documents
                    WHERE project = ?
                      AND page    = ?
                      AND mime %s LIKE 'image/%%'
                    ORDER BY filename"""
        for cat, op in [("images", ""), ("documents", "NOT")]:
            sql.execute(query % op, (project, page))
            pwic[cat] = sql.fetchall()

        # Related links
        pwic["relations"] = []
        PwicExtension.on_related_pages(sql, request, project, user, page, pwic["relations"])
        pwic["relations"].sort(key=lambda x: x[1])
        return await self._handle_output(sql, request, "page", pwic)

    async def _page_view_special(
        self,
        sql: sqlite3.Cursor,
        request: web.Request,
        project: str,
        user: str,
        pwic: Dict[str, Any],
    ) -> web.Response:
        # Fetch the recently updated pages
        dt = PwicLib.dt()
        sql.execute(
            """ SELECT page, author, date, time, title, comment, milestone
                        FROM pages
                        WHERE project = ?
                          AND latest  = 'X'
                          AND date   >= ?
                        ORDER BY date DESC, time DESC""",
            (project, dt["date-30d"]),
        )
        pwic["recents"] = sql.fetchall()

        # Fetch the team members of the project
        pwic["admins"] = []
        pwic["managers"] = []
        pwic["editors"] = []
        pwic["validators"] = []
        pwic["readers"] = []
        show_members_max = PwicLib.intval(PwicLib.option(sql, project, "show_members_max", "-1"))
        sql.execute(
            """ SELECT COUNT(user) AS total
                        FROM roles
                        WHERE project  = ?
                          AND disabled = '' """,
            (project,),
        )
        restrict_members = (sql.fetchone()["total"] > show_members_max) and (show_members_max != -1)
        sql.execute(
            """ SELECT user, admin, manager, editor, validator, reader
                        FROM roles
                        WHERE project  = ?
                          AND disabled = ''
                        ORDER BY user""",
            (project,),
        )
        for row in sql.fetchall():
            for k in row:
                if (k != "user") and row[k]:
                    if not restrict_members or (k not in ["reader", "editor"]):
                        pwic[k + "s"].append(row["user"])

        # Fetch the pages of the project
        sql.execute(
            """ SELECT page, title, revision, draft, final, author,
                               date, time, milestone, valuser, valdate,
                               valtime
                        FROM pages
                        WHERE project = ?
                          AND latest  = 'X'
                        ORDER BY page ASC, revision DESC""",
            (project,),
        )
        pwic["pages"] = sql.fetchall()

        # Fetch the tags of the project
        sql.execute(
            """ SELECT tags
                        FROM pages
                        WHERE project = ?
                          AND latest  = 'X'
                          AND tags   <> '' """,
            (project,),
        )
        tags = ""
        for row in sql.fetchall():
            tags += " " + row["tags"]
        pwic["tags"] = sorted(PwicLib.list(tags.strip()))

        # Fetch the documents of the project
        sql.execute(
            """ SELECT b.id, b.project, b.page, b.filename, b.mime, b.size,
                               b.hash, b.author, b.date, b.time, b.exturl, c.occurrence
                        FROM roles AS a
                            INNER JOIN documents AS b
                                ON b.project = a.project
                            INNER JOIN (
                                SELECT hash, COUNT(hash) AS occurrence
                                FROM documents
                                GROUP BY hash
                                HAVING project = ?
                            ) AS c
                                ON c.hash = b.hash
                        WHERE a.project  = ?
                          AND a.user     = ?
                          AND a.disabled = ''
                        ORDER BY filename""",
            (project, project, user),
        )
        pwic["documents"] = sql.fetchall()
        used_size = 0
        for row in pwic["documents"]:
            used_size += row["size"]
            row["mime_icon"] = PwicLib.mime2icon(row["mime"])
            row["extension"] = PwicLib.file_ext(row["filename"])
        pmax = PwicLib.intval(PwicLib.option(sql, project, "project_size_max"))
        pwic["disk_space"] = {
            "used": used_size,
            "project_max": pmax,
            "percentage": min(100, float("%.2f" % (0 if pmax == 0 else 100.0 * used_size / pmax))),
        }
        return await self._handle_output(sql, request, "page-special", pwic)

    async def _page_edit(
        self,
        sql: sqlite3.Cursor,
        request: web.Request,
        project: str,
        page: str,
        revision: int,
        pwic: Dict[str, Any],
    ) -> web.Response:
        # Load the page
        sql.execute(
            """ SELECT revision, draft, final, header, protection,
                               title, markdown, tags, comment, milestone
                        FROM pages
                        WHERE project  = ?
                          AND page     = ?
                          AND revision = ?
                          AND latest   = 'X' """,
            (project, page, revision),
        )
        row = sql.fetchone()
        if row is None:
            sql.close()
            raise web.HTTPBadRequest()
        pwic.update(row)

        # Detects the emojis
        emojis = str(PwicLib.option(sql, project, "emojis", ""))
        if emojis == "*":
            emojis = " ".join([item[1].replace("&#x", "").replace(";", "") for item in PwicConst.EMOJIS.items()])
        pwic["emojis_toolbar"] = PwicLib.list_tags(emojis)

        return await self._handle_output(sql, request, "page-edit", pwic)

    async def _page_history(
        self,
        sql: sqlite3.Cursor,
        request: web.Request,
        project: str,
        page: str,
        pwic: Dict[str, Any],
    ) -> web.Response:
        # Redirect the pure reader if the history is disabled
        if pwic["pure_reader"] and (PwicLib.option(sql, project, "no_history") is not None):
            sql.close()
            raise web.HTTPTemporaryRedirect(f"/{project}/{page}")

        # Extract the revisions
        sql.execute(
            """ SELECT revision, latest, draft, final, author,
                               date, time, title, comment, milestone,
                               valuser, valdate, valtime
                        FROM pages
                        WHERE project = ?
                          AND page    = ?
                        ORDER BY revision DESC""",
            (project, page),
        )
        pwic["revisions"] = sql.fetchall()
        for row in pwic["revisions"]:
            if row["latest"]:
                pwic["title"] = row["title"]
        return await self._handle_output(sql, request, "page-history", pwic)

    async def _page_move(
        self,
        sql: sqlite3.Cursor,
        request: web.Request,
        project: str,
        user: str,
        page: str,
        pwic: Dict[str, Any],
    ) -> web.Response:
        # Check the current authorizations
        if not pwic["manager"]:
            sql.close()
            raise web.HTTPUnauthorized()

        # Select the possible target projects
        pwic["projects"] = []
        sql.execute(
            """ SELECT a.project, b.description
                        FROM roles AS a
                            INNER JOIN projects AS b
                                ON b.project = a.project
                        WHERE a.user     = ?
                          AND a.manager  = 'X'
                          AND a.disabled = ''
                        ORDER BY b.description""",
            (user,),
        )
        pwic["projects"] = sql.fetchall()

        # Related pages
        patterns = (
            f"%](/{project}/{page})%",
            f'%](/{project}/{page} "%',
            f"%](/{project}/{page}/%",
            f"%](/{project}/{page}#%",
            f"%](/{project}/{page}?%",
            f'%]({app["options"]["base_url"]}/{project}/{page})%',
            f'%]({app["options"]["base_url"]}/{project}/{page} "%',
            f'%]({app["options"]["base_url"]}/{project}/{page}/%',
            f'%]({app["options"]["base_url"]}/{project}/{page}#',
            f'%]({app["options"]["base_url"]}/{project}/{page}?',
        )
        sql.execute(
            """ SELECT a.project, a.page, a.date, a.title
                        FROM pages AS a
                            INNER JOIN roles AS b
                                ON  b.project  = a.project
                                AND b.user     = ?
                                AND b.disabled = ''
                        WHERE   latest      = 'X'
                          AND ( markdown LIKE ?
                             OR markdown LIKE ?
                             OR markdown LIKE ?
                             OR markdown LIKE ?
                             OR markdown LIKE ?
                             OR markdown LIKE ?
                             OR markdown LIKE ?
                             OR markdown LIKE ?
                             OR markdown LIKE ?
                             OR markdown LIKE ? )
                        ORDER BY a.project ASC,
                                 a.title   ASC""",
            (user,) + patterns,
        )
        pwic["relations"] = sql.fetchall()

        # Render the page
        sql.execute(
            """ SELECT title
                        FROM pages
                        WHERE project = ?
                          AND page    = ?
                          AND latest  = 'X' """,
            (project, page),
        )
        pwic["title"] = sql.fetchone()["title"]
        return await self._handle_output(sql, request, "page-move", pwic)

    async def page_random(self, request: web.Request) -> web.Response:
        """Serve a random page"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Check the authorizations
        project = PwicLib.safe_name(request.match_info.get("project"))
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT COUNT(*) AS total
                        FROM roles AS a
                            INNER JOIN pages AS b
                                ON  b.project = a.project
                                AND b.latest  = 'X'
                        WHERE a.project  = ?
                          AND a.user     = ?
                          AND a.disabled = '' """,
            (project, user),
        )
        n = sql.fetchone()["total"]
        if n == 0:
            sql.close()
            raise web.HTTPUnauthorized()

        # Show a random page
        n = randint(0, n - 1)  # nosec B311
        sql.execute(
            """ SELECT page
                        FROM pages
                        WHERE project = ?
                          AND latest  = 'X'
                        LIMIT 1
                        OFFSET ?""",
            (project, n),
        )
        row = sql.fetchone()
        if row is None:
            row = {"page": PwicConst.DEFAULTS["page"]}
        sql.close()
        raise web.HTTPTemporaryRedirect(f'/{project}/{row["page"]}')

    async def page_audit(self, request: web.Request) -> web.Response:
        """Serve the page to monitor the settings and the activities"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Fetch the parameters
        project = PwicLib.safe_name(request.match_info.get("project"))
        sql = self.dbconn.cursor()
        days = max(-1, PwicLib.intval(PwicLib.option(sql, project, "audit_range", "30")))
        dt = PwicLib.dt(days)

        # Fetch the name of the project
        if not self._check_roles(sql, project, user, admin=True):
            sql.close()
            raise web.HTTPTemporaryRedirect(
                f"/{project}/special"
            )  # Project not found, or user not authorized to view it
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        row = sql.fetchone()
        pwic = {
            "project": project,
            "project_description": row["description"],
            "range": days,
            "systime": PwicLib.dt(),
            "up": app["up"],
            "protocol": "IPv6" if ":" in PwicExtension.on_ip_header(request) else "IPv4",
        }

        # Read the audit data
        sql.execute(
            """ SELECT id, date, time, author, event, user,
                               project, page, reference, string
                        FROM audit.audit
                        WHERE project  = ?
                          AND date    >= ?
                        ORDER BY id DESC""",
            (project, dt["date-nd"]),
        )
        pwic["audits"] = sql.fetchall()
        for row in pwic["audits"]:
            del row["id"]
        return await self._handle_output(sql, request, "page-audit", pwic)

    async def page_help(self, request: web.Request) -> web.Response:
        """Serve the help page to any user"""
        pwic = {"project": "special", "page": "help", "title": "Help for Pwic.wiki"}
        return await self._handle_output(None, request, "help", pwic)

    async def page_create(self, request: web.Request) -> web.Response:
        """Serve the page to create a new page"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Fetch the projects where the user can add pages
        pwic: Dict[str, Any] = {
            "default_project": PwicLib.safe_name(request.match_info.get("project")),
            "default_page": PwicLib.safe_name(request.rel_url.query.get("page")),
        }
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT a.project, b.description
                        FROM roles AS a
                            INNER JOIN projects AS b
                                ON b.project = a.project
                        WHERE a.user     = ?
                          AND a.manager  = 'X'
                          AND a.disabled = ''
                        ORDER BY b.description""",
            (user,),
        )
        pwic["projects"] = sql.fetchall()

        # Show the page
        return await self._handle_output(sql, request, "page-create", pwic=pwic)

    async def page_user_create(self, request: web.Request) -> web.Response:
        """Serve the page to create a new user"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Fetch the projects where users can be created
        pwic: Dict[str, Any] = {"default_project": PwicLib.safe_name(request.match_info.get("project"))}
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT a.project, b.description
                        FROM roles AS a
                            INNER JOIN projects AS b
                                ON b.project = a.project
                        WHERE a.user     = ?
                          AND a.admin    = 'X'
                          AND a.disabled = ''
                        ORDER BY b.description""",
            (user,),
        )
        pwic["projects"] = sql.fetchall()

        # Show the page
        return await self._handle_output(sql, request, "user-create", pwic=pwic)

    async def page_user(self, request: web.Request) -> web.Response:
        """Serve the page to view the profile of a user"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Fetch the information of the user
        sql = self.dbconn.cursor()
        userpage = PwicLib.safe_user_name(request.match_info.get("userpage"))
        sql.execute(
            """ SELECT IIF(password == ?, 'X', '') AS oauth, initial, IIF(totp <> '', 'X', '') AS totp
                        FROM users
                        WHERE user = ?""",
            (PwicConst.MAGIC_OAUTH, userpage),
        )
        row = sql.fetchone()
        if row is None:
            sql.close()
            raise web.HTTPNotFound()
        pwic = {
            "user": user,
            "userpage": userpage,
            "password_oauth": PwicLib.xb(row["oauth"]),
            "password_initial": row["initial"],
            "password_totp": PwicLib.xb(row["totp"]),
        }

        # Fetch the commonly-accessible projects assigned to the user
        sql.execute(
            """ SELECT a.project, c.description
                        FROM roles AS a
                            INNER JOIN roles AS b
                                ON  b.project  = a.project
                                AND b.user     = ?
                                AND b.disabled = ''
                            INNER JOIN projects AS c
                                ON c.project = a.project
                        WHERE a.user     = ?
                          AND a.disabled = ''
                        ORDER BY c.description""",
            (user, userpage),
        )
        pwic["projects"] = sql.fetchall()

        # Fetch the own documents
        sql.execute(
            """ SELECT b.id, b.project, b.page, b.filename, b.mime, b.size,
                               b.hash, b.author, b.date, b.time, b.exturl, c.occurrence
                        FROM roles AS a
                            INNER JOIN documents AS b
                                ON  b.project = a.project
                                AND b.author  = ?
                            INNER JOIN (
                                SELECT project, hash, COUNT(*) AS occurrence
                                FROM documents
                                GROUP BY project, hash
                            ) AS c
                                ON  c.project = a.project
                                AND c.hash    = b.hash
                        WHERE a.user     = ?
                          AND a.disabled = ''
                        ORDER BY date DESC,
                                 time DESC""",
            (userpage, user),
        )
        pwic["documents"] = sql.fetchall()
        for row in pwic["documents"]:
            row["mime_icon"] = PwicLib.mime2icon(row["mime"])
            row["extension"] = PwicLib.file_ext(row["filename"])

        # Fetch the latest pages updated by the selected user
        dt = PwicLib.dt()
        sql.execute(
            """ SELECT u.project, u.page, p.revision, p.final,
                               p.date, p.time, p.title, p.milestone,
                               p.valuser, p.valdate, p.valtime
                        FROM (
                            SELECT DISTINCT project, page
                            FROM (
                                SELECT project, page
                                FROM pages
                                WHERE latest   = 'X'
                                  AND author   = ?
                                  AND date    >= ?
                            UNION
                                SELECT project, page
                                FROM pages
                                WHERE valuser  = ?
                                  AND valdate >= ?
                            )
                        ) AS u
                            INNER JOIN roles AS r
                                ON  r.project  = u.project
                                AND r.user     = ?
                                AND r.disabled = ''
                            INNER JOIN pages AS p
                                ON  p.project  = u.project
                                AND p.page     = u.page
                                AND p.latest   = 'X'
                        ORDER BY date DESC,
                                 time DESC""",
            (userpage, dt["date-90d"], userpage, dt["date-90d"], user),
        )
        pwic["pages"] = sql.fetchall()

        # Show the page
        return await self._handle_output(sql, request, "user", pwic=pwic)

    async def page_search(self, request: web.Request) -> web.Response:
        """Serve the search engine"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Parse the query
        sql = self.dbconn.cursor()
        project = PwicLib.safe_name(request.match_info.get("project"))
        case_sensitive = "cs" in request.rel_url.query
        if PwicLib.option(sql, project, "no_search") is not None:
            query = None
        else:
            query = PwicLib.search_parse(request.rel_url.query.get("q", ""), case_sensitive)
        if query is None:
            sql.close()
            raise web.HTTPTemporaryRedirect(f"/{project}")

        # Restrict the parameters
        pure_reader = self._check_reader_only(sql, project, user)
        if pure_reader is None:
            sql.close()
            raise web.HTTPUnauthorized()
        if pure_reader and (PwicLib.option(sql, project, "no_history") is not None):
            with_rev = False
        else:
            with_rev = "rev" in request.rel_url.query
        PwicExtension.on_search_terms(sql, request, project, user, query, with_rev)

        # Fetch the description of the project
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        pwic = {
            "project": project,
            "project_description": sql.fetchone()["description"],
            "terms": PwicLib.search2string(PwicLib.search_parse(request.rel_url.query.get("q", ""), True)),
            "pages": [],
            "documents": [],
            "with_rev": with_rev,
            "pure_reader": pure_reader,
        }

        # Search for a page
        if not PwicExtension.on_search_pages(sql, request, user, pwic, query):
            sql.execute(
                """ SELECT a.project, a.page, a.revision, a.latest, a.draft, a.final,
                                   a.author, a.date, a.time, a.title, a.markdown,
                                   a.tags, a.valuser, a.valdate, a.valtime, b.document_count
                            FROM pages AS a
                                LEFT JOIN (
                                    SELECT project, page, COUNT(id) AS document_count
                                    FROM documents
                                    GROUP BY project, page
                                    HAVING project = ?
                                ) AS b
                                    ON  b.project = a.project
                                    AND b.page    = a.page
                            WHERE a.project = ?
                              AND ( a.latest = 'X' OR 1 = ? )
                            ORDER BY a.date DESC,
                                     a.time DESC""",
                (project, project, int(with_rev)),
            )
            while True:
                row = sql.fetchone()
                if row is None:
                    break
                tmp_title = row["title"]
                if not case_sensitive:
                    row["markdown"] = row["markdown"].lower()  # sqlite.lower != python.lower
                    row["title"] = row["title"].lower()
                tagList = PwicLib.list(row["tags"])

                # Apply the filters
                ok = True
                score = 0
                for q in query["excluded"]:  # The first occurrence of an excluded term excludes the whole page
                    qlow = q.lower()
                    if (
                        (q == ":latest" and row["latest"])
                        or (q == ":draft" and row["draft"])
                        or (q == ":final" and row["final"])
                        or (q[:7] == "author:" and qlow[7:] in row["author"])
                        or (q[:6] == "title:" and q[6:] in row["title"])
                        or (q == ":validated" and row["valuser"] != "")
                        or (q[:10] == "validator:" and qlow[10:] in row["valuser"])
                        or (q == ":document" and PwicLib.intval(row["document_count"]) > 0)
                        or (q[1:] in tagList if q[:1] == "#" else False)
                        or (qlow == row["page"])
                        or (q in row["markdown"])
                    ):
                        ok = False
                        break
                if ok:
                    for q in query["included"]:  # The first non-occurrence of an included term excludes the whole page
                        qlow = q.lower()
                        if q == ":latest":
                            count = PwicLib.intval(row["latest"])
                        elif q == ":draft":
                            count = PwicLib.intval(row["draft"])
                        elif q == ":final":
                            count = PwicLib.intval(row["final"])
                        elif q[:7] == "author:":
                            count = row["author"].count(qlow[7:])
                        elif q[:6] == "title:":
                            count = row["title"].count(q[6:])
                        elif q == ":validated":
                            count = PwicLib.intval(row["valuser"] != "")
                        elif q[:10] == "validator:":
                            count = PwicLib.intval(qlow[10:] in row["valuser"])
                        elif q == ":document":
                            count = PwicLib.intval(PwicLib.intval(row["document_count"]) > 0)
                        elif q[1:] in tagList if q[:1] == "#" else False:
                            count = 5  # A tag counts more
                        else:
                            count = 5 * PwicLib.intval(qlow == row["page"]) + row["markdown"].count(q)
                        if count == 0:
                            ok = False
                            break
                        score += count
                if not ok:
                    continue

                # Save the found result
                row["title"] = tmp_title
                del row["markdown"]
                del row["tags"]
                del row["document_count"]
                row["score"] = score
                pwic["pages"].append(row)

        # Search for documents
        if not PwicExtension.on_search_documents(sql, request, user, pwic, query):
            sql.execute(
                """ SELECT id, project, page, filename, mime, size, author, date, time
                            FROM documents
                            WHERE project = ?
                            ORDER BY filename""",
                (project,),
            )
            while True:
                row = sql.fetchone()
                if row is None:
                    break

                # Apply the filters
                ok = True
                for q in query["excluded"]:
                    if ":" in q:
                        continue
                    q = q.lower()
                    if (q in row["page"]) or (q in row["filename"]) or (q in row["mime"]):
                        ok = False
                        break
                if ok:
                    for q in query["included"]:
                        if ":" in q:
                            continue
                        q = q.lower()
                        if (q not in row["page"]) and (q not in row["filename"]) and (q not in row["mime"]):
                            ok = False
                            break
                if not ok:
                    continue

                # Save the found document
                row["mime_icon"] = PwicLib.mime2icon(row["mime"])
                row["extension"] = PwicLib.file_ext(row["filename"])
                pwic["documents"].append(row)

        # Show the pages by score desc, date desc and time desc
        pwic["pages"].sort(key=lambda x: x["score"], reverse=True)
        return await self._handle_output(sql, request, "search", pwic=pwic)

    async def page_env(self, request: web.Request) -> web.Response:
        """Serve the project-dependent settings that can be modified online
        without critical, technical or legal impact on the server"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Verify that the user is an administrator
        project = PwicLib.safe_name(request.match_info.get("project"))
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user, admin=True):
            sql.close()
            raise web.HTTPUnauthorized()

        # Show the page
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        row = sql.fetchone()
        pwic = {
            "project": project,
            "project_description": row["description"],
            "changeable_vars": sorted([k for k in PwicConst.ENV if PwicConst.ENV[k].pdep and PwicConst.ENV[k].online]),
        }
        return await self._handle_output(sql, request, "page-env", pwic=pwic)

    async def page_roles(self, request: web.Request) -> web.Response:
        """Serve the form to change the authorizations of the users"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Fetch the name of the project
        project = PwicLib.safe_name(request.match_info.get("project"))
        sql = self.dbconn.cursor()
        pwic: Dict[str, Any] = {"project": project, "roles": []}

        # Fetch the roles
        dt = PwicLib.dt()
        sql.execute(
            """ SELECT a.user, c.initial, c.password AS oauth,
                               a.admin, a.manager, a.editor, a.validator,
                               a.reader, a.disabled, d.activity
                        FROM roles AS a
                            INNER JOIN roles AS b
                                ON  b.project  = a.project
                                AND b.user     = ?
                                AND b.admin    = 'X'
                                AND b.disabled = ''
                            INNER JOIN users AS c
                                ON c.user = a.user
                            LEFT OUTER JOIN (
                                SELECT author, MAX(date) AS activity
                                FROM audit.audit
                                WHERE project  = ?
                                  AND date    >= ?
                                GROUP BY author
                            ) AS d
                                ON d.author = a.user
                        WHERE a.project = ?
                        ORDER BY a.admin     DESC,
                                 a.manager   DESC,
                                 a.editor    DESC,
                                 a.validator DESC,
                                 a.reader    DESC,
                                 a.user      ASC""",
            (user, project, dt["date-90d"], project),
        )
        pwic["roles"] = sql.fetchall()
        for row in pwic["roles"]:
            row["oauth"] = row["oauth"] == PwicConst.MAGIC_OAUTH
            if row["activity"] is None:
                row["activity"] = "-"

        # Display the page
        if len(pwic["roles"]) == 0:
            sql.close()
            raise web.HTTPUnauthorized()  # Or project not found
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        pwic["project_description"] = sql.fetchone()["description"]
        return await self._handle_output(sql, request, "user-roles", pwic=pwic)

    async def page_links(self, request: web.Request) -> web.Response:
        """Serve the check of the links"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Verify the authorizations
        project = PwicLib.safe_name(request.match_info.get("project"))
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user, manager=True):
            sql.close()
            raise web.HTTPUnauthorized()
        if PwicLib.option(sql, project, "no_link_review") is not None:
            sql.close()
            raise web.HTTPForbidden()

        # Fetch the documents
        sql.execute(
            """ SELECT CAST(id AS TEXT) AS id
                        FROM documents
                        ORDER BY id"""
        )
        docids = [row["id"] for row in sql.fetchall()]

        # Fetch the pages
        sql.execute(
            """ SELECT page, header, markdown
                        FROM pages
                        WHERE project = ?
                          AND latest  = 'X'
                        ORDER BY page""",
            (project,),
        )

        # Extract the links between the pages
        linkmap: Dict[str, List[str]] = {PwicConst.DEFAULTS["page"]: []}
        broken_docs: Dict[str, List[int]] = {}
        while True:
            row = sql.fetchone()
            if row is None:
                break

            page = row["page"]
            if page not in linkmap:
                linkmap[page] = []

            # Generate a fake link at the home page for all the bookmarked pages
            if row["header"] and (page not in linkmap[PwicConst.DEFAULTS["page"]]):
                linkmap[PwicConst.DEFAULTS["page"]].append(page)

            # Find the links to the other pages
            subpages = PwicConst.REGEXES["page"].findall(row["markdown"])
            if subpages is not None:
                for sp in subpages:
                    if ((sp[0][1:] or project) == project) and (sp[1] not in linkmap[page]):
                        linkmap[page].append(sp[1])

            # Looks for the linked documents
            subdocs = PwicConst.REGEXES["document"].findall(row["markdown"])
            if subdocs is not None:
                for sd in subdocs:
                    if sd[0] not in docids:
                        if page not in broken_docs:
                            broken_docs[page] = []
                        broken_docs[page].append(PwicLib.intval(sd[0]))

        # Find the orphaned and broken links
        allpages = list(linkmap)  # Keys
        orphans = allpages.copy()
        orphans.remove(PwicConst.DEFAULTS["page"])
        broken = []
        for link in linkmap:
            for page in linkmap[link]:
                if page in orphans:
                    orphans.remove(page)
                if page not in allpages:
                    broken.append((link, page))

        # Show the values
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        row = sql.fetchone()
        pwic = {
            "project": project,
            "project_description": row["description"],
            "orphans": orphans,
            "broken": broken,
            "broken_docs": broken_docs,
        }
        return await self._handle_output(sql, request, "page-links", pwic=pwic)

    async def page_graph(self, request: web.Request) -> web.Response:
        """Serve the visual representation of the links"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Fetch the parameters
        project = PwicLib.safe_name(request.match_info.get("project"))

        # Check the authorizations
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user, manager=True):
            sql.close()
            raise web.HTTPUnauthorized()
        if PwicLib.option(sql, project, "no_graph") is not None:
            sql.close()
            raise web.HTTPForbidden()

        # Show the page
        sql.execute(
            """ SELECT description
                        FROM projects
                        WHERE project = ?""",
            (project,),
        )
        row = sql.fetchone()
        pwic = {"project": project, "project_description": row["description"]}
        return await self._handle_output(sql, request, "page-graph", pwic=pwic)

    async def page_compare(self, request: web.Request) -> web.Response:
        """Serve the page that compares two revisions"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            return await self._handle_login(request)

        # Fetch the parameters
        sql = self.dbconn.cursor()
        project = PwicLib.safe_name(request.match_info.get("project"))
        page = PwicLib.safe_name(request.match_info.get("page"))
        new_revision = PwicLib.intval(request.match_info.get("new_revision", ""))
        old_revision = PwicLib.intval(request.match_info.get("old_revision", ""))

        # Fetch the pages
        if (PwicLib.option(sql, project, "no_history") is not None) and self._check_reader_only(sql, project, user):
            sql.close()
            raise web.HTTPUnauthorized()
        sql.execute(
            """ SELECT d.description,
                               b.title,
                               b.markdown AS new_markdown,
                               c.markdown AS old_markdown
                        FROM roles AS a
                            INNER JOIN pages AS b
                                ON  b.project  = a.project
                                AND b.page     = ?
                                AND b.revision = ?
                            INNER JOIN pages AS c
                                ON  c.project  = b.project
                                AND c.page     = b.page
                                AND c.revision = ?
                            INNER JOIN projects AS d
                                ON  d.project  = a.project
                        WHERE a.project  = ?
                          AND a.user     = ?
                          AND a.disabled = '' """,
            (page, new_revision, old_revision, project, user),
        )
        row = sql.fetchone()
        if row is None:
            sql.close()
            raise web.HTTPUnauthorized()

        # Show the page
        def _diff(tfrom: str, tto: str) -> str:
            diff = HtmlDiff()
            tfrom2 = tfrom.split("\n")
            tto2 = tto.split("\n")
            return (
                diff.make_table(tfrom2, tto2)
                .replace("&nbsp;", " ")
                .replace(' nowrap="nowrap"', "")
                .replace(' cellpadding="0"', "")
            )

        pwic = {
            "title": row["title"],
            "project": project,
            "project_description": row["description"],
            "page": page,
            "new_revision": new_revision,
            "old_revision": old_revision,
            "diff": _diff(row["old_markdown"], row["new_markdown"]),
        }
        return await self._handle_output(sql, request, "page-compare", pwic=pwic)

    async def document_get(self, request: web.Request) -> Union[web.Response, web.FileResponse]:
        """Download a document fully or partially"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Read the properties of the requested document
        docid = PwicLib.intval(request.match_info.get("id", 0))
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT a.project, a.filename, a.mime, a.size, a.exturl
                        FROM documents AS a
                            INNER JOIN roles AS b
                                ON  b.project  = a.project
                                AND b.user     = ?
                                AND b.disabled = ''
                        WHERE a.id = ?""",
            (user, docid),
        )
        row = sql.fetchone()
        if row is None:
            sql.close()
            raise web.HTTPNotFound()

        # Checks
        filename = join(PwicConst.DOCUMENTS_PATH % row["project"], row["filename"])
        if row["exturl"] == "":
            if not isfile(filename):
                sql.close()
                raise web.HTTPNotFound()
            if getsize(filename) != row["size"]:
                sql.close()
                raise web.HTTPConflict()  # Size mismatch causes an infinite download time
        else:
            if PwicConst.REGEXES["protocol"].match(row["exturl"]) is None:
                sql.close()
                raise web.HTTPNotFound()
        if not PwicExtension.on_document_get(
            sql, request, row["project"], user, row["filename"], row["mime"], row["size"]
        ):
            sql.close()
            raise web.HTTPUnauthorized()

        # Transfer the remote file
        if row["exturl"] != "":
            sql.close()
            return web.HTTPFound(row["exturl"])
        # ... or the local file
        headers = MultiDict({"Content-Type": row["mime"], "Content-Length": str(row["size"])})
        if request.rel_url.query.get("attachment", None) is not None:
            headers["Content-Disposition"] = 'attachment; filename="%s"' % PwicLib.attachment_name(row["filename"])
        PwicExtension.on_http_headers(sql, request, headers, row["project"], None)
        sql.close()
        return web.FileResponse(path=filename, chunk_size=512 * 1024, status=200, headers=headers)

    async def document_all_get(self, request: web.Request) -> web.Response:
        """Download all the local documents assigned to a page"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Read the properties of the requested document
        project = PwicLib.safe_name(request.match_info.get("project"))
        page = PwicLib.safe_name(request.match_info.get("page"))
        if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE):
            raise web.HTTPBadRequest()

        # Fetch the documents
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT b.filename, b.mime, b.size
                        FROM roles AS a
                            INNER JOIN documents AS b
                                ON  b.project = a.project
                                AND b.page    = ?
                        WHERE a.project  = ?
                          AND a.user     = ?
                          AND a.disabled = ''
                          AND b.exturl   = '' """,
            (page, project, user),
        )
        data = sql.fetchall()

        # Compress the documents
        counter = 0
        inmemory = BytesIO()
        with ZipFile(inmemory, mode="w", compression=ZIP_DEFLATED) as archive:
            for row in data:
                if PwicExtension.on_document_get(
                    sql, request, project, user, row["filename"], row["mime"], row["size"]
                ):
                    fn = join(PwicConst.DOCUMENTS_PATH % project, row["filename"])
                    if isfile(fn):
                        content = b""
                        with open(fn, "rb") as f:
                            content = f.read()
                        if PwicLib.mime_compressed(PwicLib.file_ext(row["filename"])):
                            archive.writestr(row["filename"], content, compress_type=ZIP_STORED, compresslevel=0)
                        else:
                            archive.writestr(row["filename"], content)
                        del content
                        counter += 1

        # Return the file
        sql.close()
        buffer = inmemory.getvalue()
        inmemory.close()
        if counter == 0:
            raise web.HTTPNotFound()
        headers = {
            "Content-Type": str(PwicLib.mime("zip")),
            "Content-Disposition": 'attachment; filename="%s"' % PwicLib.attachment_name(f"{project}_{page}.zip"),
        }
        return web.Response(body=buffer, headers=MultiDict(headers))

    def _auto_join(self, sql: sqlite3.Cursor, request: web.Request, user: str, categories: List[str]) -> bool:
        """Assign a user to the projects that require a forced membership"""
        ok = False
        if not PwicLib.reserved_user_name(user):
            query = """ SELECT a.project
                        FROM env AS a
                            LEFT OUTER JOIN roles AS b
                                ON  b.project = a.project
                                AND b.user    = ?
                        WHERE a.project <> ''
                          AND a.key      = 'auto_join'
                          AND a.value   IN ('%s')
                          AND b.project IS NULL"""
            sql.execute(query % ("', '".join(categories)), (user,))
            for row in sql.fetchall():
                sql.execute(
                    """ INSERT OR IGNORE INTO roles (project, user, reader)
                                VALUES (?, ?, 'X')""",
                    (row["project"], user),
                )
                if sql.rowcount > 0:
                    ok = True
                    PwicLib.audit(
                        sql,
                        {
                            "author": PwicConst.USERS["system"],
                            "event": "grant-reader",
                            "project": row["project"],
                            "user": user,
                            "string": "auto_join",
                        },
                        request,
                    )
        return ok

    async def api_login(self, request: web.Request) -> web.Response:
        """API to log in people"""

        def _cache_totp(user: str, pin: str) -> bool:
            # Note: the cache is not shared across multiple processes
            curtime = PwicLib.timestamp()
            for k in G_TOTP_CACHE:
                if G_TOTP_CACHE[k] < curtime - 3660:
                    del G_TOTP_CACHE[k]
            key = f"{user}@{pin}"
            if key in G_TOTP_CACHE:
                return False
            G_TOTP_CACHE[key] = curtime
            return True

        # Checks
        ip = PwicExtension.on_ip_header(request)
        self._check_ip(ip)

        # Fetch the submitted data
        session = await self._get_session(request)
        post = await self._handle_post(request)
        user = PwicLib.safe_user_name(post.get("user"))
        pwd = "" if user == PwicConst.USERS["anonymous"] else PwicLib.sha256(post.get("password", ""))
        pin = str(PwicLib.intval(post.get("pin")))
        lang = post.get("language", session.get("language", ""))
        if lang not in app["langs"]:
            lang = PwicConst.DEFAULTS["language"]

        # Login with the credentials
        ok_pwd = False
        ok_totp = True
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT totp
                        FROM users
                        WHERE user     = ?
                        AND password = ?""",
            (user, pwd),
        )
        row = sql.fetchone()
        if row is not None:
            # 2FA TOTP and custom checks
            if (row["totp"] != "") and (PwicLib.option(sql, "", "no_totp") is None):
                if not TOTP(row["totp"]).verify(pin):
                    ok_totp = False
                else:
                    ok_totp = _cache_totp(user, pin)
                del row["totp"]
            if ok_totp:
                ok_pwd = PwicExtension.on_login(sql, request, user, lang, ip)

            # Open the session
            if ok_pwd:
                self._auto_join(sql, request, user, ["active"])
                session = await new_session(request)
                session["user"] = user
                session["language"] = lang
                session["user_secret"] = PwicLib.random_hash()
                session["ip"] = ip
                session["timestamp"] = PwicLib.timestamp()
                if user != PwicConst.USERS["anonymous"]:
                    PwicLib.audit(sql, {"author": user, "event": "login"}, request)
                self._commit(None, True)
        sql.close()

        # Final redirection (do not use "raise")
        if "redirect" in request.rel_url.query:
            return web.HTTPFound("/" if ok_pwd and ok_totp else "/special/login?failed")
        if not ok_totp:
            return web.HTTPRequestTimeout()
        if not ok_pwd:
            raise web.HTTPUnauthorized()
        return web.HTTPOk()

    async def api_oauth(self, request: web.Request) -> web.Response:
        """Manage the federated authentication"""

        def _oauth_failed(parent_exception: Optional[Exception] = None) -> None:
            raise web.HTTPTemporaryRedirect("/?failed") from parent_exception

        async def _fetch_token(url: str, query: Dict[str, Any]) -> Tuple[str, str]:
            try:
                async with ClientSession() as client:
                    async with client.post(url=url, data=query, headers={"Accept": PwicLib.mime("json")}) as response:
                        data = await response.json()
                token = data.get("access_token")
                assert token is not None
            except Exception as e:
                _oauth_failed(e)
            return data.get("token_type", "Bearer"), token

        async def _call_api(url: str, token_type: str, token: str) -> Dict:
            try:
                async with ClientSession() as client:
                    async with client.get(url=url, headers={"Authorization": f"{token_type} {token}"}) as response:
                        data = await response.json()
                assert data is not None
            except Exception as e:
                _oauth_failed(e)
            return data

        # Checks
        ip = PwicExtension.on_ip_header(request)
        self._check_ip(ip)

        # Get the callback parameters
        error = request.rel_url.query.get("error", "")
        code = request.rel_url.query.get("code", None)
        state = request.rel_url.query.get("state", None)
        if (error != "") or (None in [code, state]):
            _oauth_failed()

        # Check the state
        session = await self._get_session(request)
        state_current = session.get("user_secret", "")
        if state != state_current:
            session["user_secret"] = PwicLib.random_hash()
            _oauth_failed()

        # Call the provider
        oauth = app["oauth"]
        no_domain = len(oauth["domains"]) == 0
        emails = []
        if oauth["provider"] == "github":
            # Fetch an authentication token
            query = {
                "client_id": oauth["identifier"],
                "client_secret": oauth["server_secret"],
                "code": code,
                "state": state,
            }
            _, token = await _fetch_token("https://github.com/login/oauth/access_token", query)

            # Fetch the emails of the user
            data = await _call_api("https://api.github.com/user/emails", "token", token)
            for entry in data:
                if entry.get("verified", False) is True:
                    if no_domain and not entry.get(
                        "primary", False
                    ):  # If the domain is not verified, only the primary email is targeted
                        continue
                    item = entry.get("email", "")
                    if "@" in item:
                        emails.append(item.strip().lower())
                        if no_domain:  # If the domain is not verified, the primary email is found
                            break

        elif oauth["provider"] == "google":
            # Fetch an authentication token
            query = {
                "client_id": oauth["identifier"],
                "grant_type": "authorization_code",
                "code": code,
                "redirect_uri": app["options"]["base_url"] + "/api/oauth",
                "client_secret": oauth["server_secret"],
            }
            token_type, token = await _fetch_token("https://oauth2.googleapis.com/token", query)

            # Fetch the email of the user
            data = await _call_api("https://www.googleapis.com/userinfo/v2/me", token_type, token)
            if data.get("verified_email", False) is True:
                item = data.get("email", "").strip().lower()
                if "@" in item and "+" not in item:
                    emails.append(item)

        elif oauth["provider"] == "microsoft":
            # Fetch an authentication token
            query = {
                "client_id": oauth["identifier"],
                "grant_type": "authorization_code",
                "scope": "https://graph.microsoft.com/User.Read",
                "code": code,
                "redirect_uri": app["options"]["base_url"] + "/api/oauth",
                "client_secret": oauth["server_secret"],
            }
            token_type, token = await _fetch_token(
                f'https://login.microsoftonline.com/{oauth["tenant"]}/oauth2/v2.0/token', query
            )

            # Fetch the email of the user
            data = await _call_api("https://graph.microsoft.com/v1.0/me/", token_type, token)
            item = data.get("mail", "").strip().lower()
            if "@" in item:
                emails.append(item)

        else:
            raise web.HTTPNotImplemented()

        # Select the authorized email
        sql = self.dbconn.cursor()
        PwicExtension.on_oauth(sql, request, emails)
        if len(emails) == 0:
            _oauth_failed()
        if no_domain:
            user = emails[0]
        else:
            user = ""
            cursor = len(oauth["domains"])
            for item in emails:
                domain = item[item.find("@") + 1 :]
                try:
                    index = oauth["domains"].index(domain)
                except ValueError:
                    continue
                if index < cursor:
                    user = item
                    cursor = index
        user = PwicLib.safe_user_name(user)
        if PwicLib.reserved_user_name(user) or ("@" not in user):
            _oauth_failed()

        # Create the default user account
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        dt = PwicLib.dt()
        sql.execute(
            """ INSERT OR IGNORE INTO users (user, password, initial, password_date, password_time)
                        VALUES (?, ?, '', ?, ?)""",
            (user, PwicConst.MAGIC_OAUTH, dt["date"], dt["time"]),
        )
        if sql.rowcount > 0:
            # - PwicConst.DEFAULTS['password'] is not set because the user will forget to change it
            # - The user cannot change the internal password because the current password will not be hashed correctly
            # - The password can be reset from the administration console only
            # - Then the two authentications methods can coexist
            PwicLib.audit(
                sql,
                {
                    "author": PwicConst.USERS["system"],
                    "event": "create-user",
                    "user": user,
                    "string": PwicConst.MAGIC_OAUTH,
                },
                request,
            )
        self._auto_join(sql, request, user, ["active", "sso"])

        # Register the session
        session = await new_session(request)
        session["user"] = user
        session["language"] = PwicLib.detect_language(request, app["langs"], sso=True)
        session["user_secret"] = PwicLib.random_hash()
        session["ip"] = ip
        session["timestamp"] = PwicLib.timestamp()
        PwicLib.audit(sql, {"author": user, "event": "login", "string": PwicConst.MAGIC_OAUTH}, request)
        self._commit(sql, True)

        # Final redirection (do not use "raise")
        return web.HTTPFound("/")

    async def api_server_env_get(self, request: web.Request) -> web.Response:
        """API to return the defined environment variables"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user in ["", PwicConst.USERS["anonymous"]]:
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))

        # Verify that the user is an administrator of the/a project
        sql = self.dbconn.cursor()
        if project != "":
            if not self._check_roles(sql, project, user, admin=True):
                project = ""
        if project == "":
            if not self._check_roles(sql, None, user, admin=True):
                sql.close()
                raise web.HTTPUnauthorized()

        # Fetch the environment variables
        sql.execute(
            """ SELECT project, key, value
                        FROM env
                        WHERE ( project = ?
                             OR project = '' )
                          AND   value   <> ''
                        ORDER BY key     ASC,
                                 project DESC""",
            (project,),
        )

        # Formatting
        data = {}
        for row in sql.fetchall():
            if row["key"] not in PwicConst.ENV:
                continue
            (global_, key, value) = (row["project"] == "", row["key"], row["value"])
            if PwicConst.ENV[key].private or (key in data):
                continue
            data[key] = {
                "value": value,
                "global": global_,
                "project_independent": PwicConst.ENV[key].pindep,
                "project_dependent": PwicConst.ENV[key].pdep,
                "changeable": PwicConst.ENV[key].pdep and PwicConst.ENV[key].online,
            }

        # Final result
        sql.close()
        return web.Response(text=json.dumps(data), content_type=PwicLib.mime("json"))

    async def api_server_headers_get(self, request: web.Request) -> web.Response:
        """Return the received headers for a request"""
        # Verify that the user is connected
        user = await self._suser(request)
        if PwicLib.reserved_user_name(user):
            raise web.HTTPUnauthorized()

        # JSON serialization of the object of type CIMultiDictProxy
        data: Dict[str, Any] = {}
        for k, v in iter(request.headers.items()):
            if k != "Cookie":
                if k not in data:
                    data[k] = []
                data[k].append(v)
        data = {"ip": PwicExtension.on_ip_header(request), "headers": data}
        return web.Response(text=json.dumps(data), content_type=PwicLib.mime("json"))

    async def api_server_ping(self, request: web.Request) -> web.Response:
        """Notify if the session is still alive"""
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()
        return web.Response(text="OK", content_type=PwicLib.mime("txt"))

    async def api_server_shutdown(self, request: web.Request) -> None:
        """Stop the server from localhost"""
        # Check the remote IP address
        ip = PwicExtension.on_ip_header(request)
        if not ip_address(ip).is_loopback:
            raise web.HTTPForbidden()  # Must be from localhost only

        # Shutdown the server
        if self.dbconn.in_transaction:
            raise web.HTTPServiceUnavailable()
        sql = self.dbconn.cursor()
        PwicLib.audit(sql, {"author": PwicConst.USERS["anonymous"], "event": "shutdown-server"}, request)
        self._commit(sql, True)
        sys.exit(0)

    async def api_server_unlock(self, request: web.Request) -> web.Response:
        """Release the lock in the database from localhost"""
        # Check the remote IP address
        ip = PwicExtension.on_ip_header(request)
        if not ip_address(ip).is_loopback:
            raise web.HTTPForbidden()  # Must be from localhost only

        # Release the locks after an internal failure
        if not self.dbconn.in_transaction:
            raise web.HTTPBadRequest()  # Not locked
        self.dbconn.interrupt()
        self._commit(None, False)  # Unlock

        # Event
        sql = self.dbconn.cursor()
        PwicLib.audit(sql, {"author": PwicConst.USERS["anonymous"], "event": "unlock-db"}, request)
        self._commit(sql, True)
        return web.HTTPOk()

    async def api_project_list(self, request: web.Request) -> web.Response:
        """API to list the authorized projects for a user if you belong to these projects"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        account = PwicLib.safe_user_name(post.get("user"))
        if account == "":
            account = user

        # Select the projects
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT a.project, b.description, a.admin, a.manager,
                               a.editor, a.validator, a.reader
                        FROM roles AS a
                            INNER JOIN projects AS b
                                ON b.project = a.project
                            INNER JOIN roles AS c
                                ON  c.project  = a.project
                                AND c.user     = ?
                                AND c.disabled = ''
                        WHERE a.user     = ?
                          AND a.disabled = ''
                        ORDER BY a.project ASC""",
            (user, account),
        )
        data = sql.fetchall()
        sql.close()
        return web.Response(text=json.dumps(data), content_type=PwicLib.mime("json"))

    async def api_project_get(self, request: web.Request) -> web.Response:
        """API to fetch the metadata of the project"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        if project == "":
            raise web.HTTPBadRequest()
        page = PwicLib.safe_name(post.get("page"))  # Optional
        allrevs = PwicLib.xb(PwicLib.x(post.get("all")))
        no_markdown = PwicLib.xb(PwicLib.x(post.get("no_markdown")))
        no_document = PwicLib.xb(PwicLib.x(post.get("no_document")))
        data: Dict[str, Dict[str, List[Dict[str, Any]]]] = {}

        # Restriction of the API
        sql = self.dbconn.cursor()
        pure_reader = self._check_reader_only(sql, project, user)
        if pure_reader is None:
            sql.close()
            raise web.HTTPUnauthorized()  # No access to the project
        if pure_reader:
            if PwicLib.option(sql, project, "api_restrict") is not None:
                sql.close()
                raise web.HTTPUnauthorized()
            if PwicLib.option(sql, project, "no_history") is not None:
                if PwicLib.option(sql, project, "validated_only") is not None:
                    sql.close()
                    raise web.HTTPNotImplemented()
                allrevs = False
        sql_ext = self.dbconn.cursor()

        # Fetch the pages
        api_expose_markdown = PwicLib.option(sql, project, "api_expose_markdown") is not None
        query = """ SELECT page, revision, latest, draft, final,
                           header, protection, author, date, time,
                           title, %s markdown, tags, comment, milestone,
                           valuser, valdate, valtime
                    FROM pages
                    WHERE   project = ?
                      AND ( page    = ?   OR '' = ? )
                      AND ( latest  = 'X' OR 1  = ? )
                    ORDER BY page     ASC,
                             revision DESC"""
        sql.execute(query % ("" if api_expose_markdown else "'' AS "), (project, page, page, int(allrevs)))
        while True:
            row = sql.fetchone()
            if row is None:
                break
            if row["page"] not in data:
                data[row["page"]] = {"revisions": [], "documents": []}
            item: Dict[str, Any] = {}
            for k in row:
                if k == "markdown":
                    if api_expose_markdown and not no_markdown:
                        item[k] = PwicExtension.on_markdown_pre(sql_ext, project, row["page"], row["revision"], row[k])
                    item["hash"] = PwicLib.sha256(row[k], salt=False)
                elif k == "tags":
                    if row[k] != "":
                        item[k] = PwicLib.list(row[k])
                elif k != "page":
                    if (not isinstance(row[k], str)) or (row[k] != ""):
                        item[k] = row[k]
            item["url"] = f'{app["options"]["base_url"]}/{project}/{row["page"]}/rev{row["revision"]}'
            data[row["page"]]["revisions"].append(item)

        # Fetch the documents
        if not no_document:
            sql.execute(
                """ SELECT id, page, filename, mime, size, hash, author, date, time
                            FROM documents
                            WHERE project = ?
                              AND (page = ? OR '' = ?)
                            ORDER BY page, filename""",
                (project, page, page),
            )
            while True:
                row = sql.fetchone()
                if row is None:
                    break

                row["url"] = f'{app["options"]["base_url"]}/special/document/{row["id"]}/{row["filename"]}'
                k = row["page"]
                del row["page"]
                if k in data:
                    data[k]["documents"].append(row)

        # Final result
        PwicExtension.on_api_project_info_get(sql_ext, request, project, user, page, data)
        sql_ext.close()
        sql.close()
        return web.Response(text=json.dumps(data), content_type=PwicLib.mime("json"))

    async def api_project_env_set(self, request: web.Request) -> web.Response:
        """API to modify some of the project-dependent settings"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        key = PwicLib.safe_name(post.get("key"))
        value = post.get("value", "").strip()
        if (
            (project == "")
            or (key not in PwicConst.ENV)
            or not PwicConst.ENV[key].pdep
            or not PwicConst.ENV[key].online
        ):
            raise web.HTTPBadRequest()

        # Verify that the user is administrator and has changed his password
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT 1
                        FROM roles AS a
                            INNER JOIN users AS b
                                ON b.user = a.user
                        WHERE a.project  = ?
                          AND a.user     = ?
                          AND a.admin    = 'X'
                          AND a.disabled = ''
                          AND b.initial  = '' """,
            (project, user),
        )
        if sql.fetchone() is None:
            sql.close()
            raise web.HTTPUnauthorized()

        # Update the variable
        value = str(PwicExtension.on_api_project_env_set(sql, request, project, user, key, value))
        if value == "":
            sql.execute(
                """ DELETE FROM env
                            WHERE project = ?
                              AND key     = ?""",
                (project, key),
            )
        else:
            sql.execute(
                """ INSERT OR REPLACE INTO env (project, key, value)
                            VALUES (?, ?, ?)""",
                (project, key, value),
            )
        PwicLib.audit(
            sql,
            {
                "author": user,
                "event": "%sset-%s" % ("un" if value == "" else "", key),
                "project": project,
                "string": "" if PwicConst.ENV[key].private else value,
            },
            request,
        )
        self._commit(sql, True)
        return web.HTTPOk()

    async def api_project_users_get(self, request: web.Request) -> web.Response:
        """API to fetch the active users of a project based on their roles"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        admin = PwicLib.xb(PwicLib.x(post.get("admin")))
        manager = PwicLib.xb(PwicLib.x(post.get("manager")))
        editor = PwicLib.xb(PwicLib.x(post.get("editor")))
        validator = PwicLib.xb(PwicLib.x(post.get("validator")))
        reader = PwicLib.xb(PwicLib.x(post.get("reader")))
        operator = post.get("operator", "")
        if (
            (project == "")
            or not (admin or manager or editor or validator or reader)
            or (operator not in ["or", "and", "exact"])
        ):
            raise web.HTTPBadRequest()

        # Verify that the user belongs to the project
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user):
            sql.close()
            raise web.HTTPUnauthorized()

        # List the users
        data = []
        if operator == "or":
            # The user has one of the selected roles
            sql.execute(
                """ SELECT user
                            FROM roles
                            WHERE project = ?
                              AND ((1 = ? AND admin     = 'X')
                                OR (1 = ? AND manager   = 'X')
                                OR (1 = ? AND editor    = 'X')
                                OR (1 = ? AND validator = 'X')
                                OR (1 = ? AND reader    = 'X'))
                              AND disabled = '' """,
                (project, int(admin), int(manager), int(editor), int(validator), int(reader)),
            )
        elif operator == "and":
            # The user has all the selected roles at least
            sql.execute(
                """ SELECT user
                            FROM roles
                            WHERE project = ?
                              AND (0 = ? OR admin     = 'X')
                              AND (0 = ? OR manager   = 'X')
                              AND (0 = ? OR editor    = 'X')
                              AND (0 = ? OR validator = 'X')
                              AND (0 = ? OR reader    = 'X')
                              AND disabled = '' """,
                (project, int(admin), int(manager), int(editor), int(validator), int(reader)),
            )
        else:
            # The user has all the selected roles only
            sql.execute(
                """ SELECT user
                            FROM roles
                            WHERE project   = ?
                              AND admin     = ?
                              AND manager   = ?
                              AND editor    = ?
                              AND validator = ?
                              AND reader    = ?
                              AND disabled  = '' """,
                (
                    project,
                    PwicLib.x(admin),
                    PwicLib.x(manager),
                    PwicLib.x(editor),
                    PwicLib.x(validator),
                    PwicLib.x(reader),
                ),
            )
        data = [row["user"] for row in sql.fetchall()]
        data.sort()
        sql.close()
        return web.Response(text=json.dumps(data), content_type=PwicLib.mime("json"))

    async def api_project_progress_get(self, request: web.Request) -> web.Response:
        """API to analyze the progress of the project"""
        # Verify that the user is connected
        user = await self._suser(request)
        if PwicLib.reserved_user_name(user):
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        tags = PwicLib.list(PwicLib.list_tags(post.get("tags", "")))
        combined = PwicLib.xb(PwicLib.x(post.get("combined")))
        if (project in PwicConst.NOT_PROJECT) or (len(tags) == 0):
            raise web.HTTPBadRequest()

        # Verify that the user is authorized for the project
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user):
            sql.close()
            raise web.HTTPUnauthorized()

        # Initialize each tag
        data = {}
        for t in tags:
            data[t] = {"draft": 0, "step": 0, "final": 0, "validated": 0, "total": 0}

        # Calculate the statistics
        sql.execute(
            """ SELECT draft, final, tags, valuser
                        FROM pages
                        WHERE project = ?
                          AND latest = 'X' """,
            (project,),
        )
        for row in sql.fetchall():
            row["tags"] = PwicLib.list(row["tags"])

            # Verify the tags
            ok = combined
            for t in tags:
                if combined:
                    ok = ok and (t in row["tags"])
                    if not ok:
                        continue
                else:
                    ok = ok or (t in row["tags"])
                    if ok:
                        break

            # Save the stats
            if ok:
                for t in tags:
                    if t in row["tags"]:
                        if row["valuser"] != "":
                            data[t]["validated"] += 1
                        elif row["final"]:
                            data[t]["final"] += 1
                        elif row["draft"]:
                            data[t]["draft"] += 1
                        else:
                            data[t]["step"] += 1
                        data[t]["total"] += 1

        # Final result
        sql.close()
        return web.Response(text=json.dumps(data), content_type=PwicLib.mime("json"))

    async def api_project_graph_get(self, request: web.Request) -> web.Response:
        """Draw the directed graph of the project
        http://graphviz.org/pdf/dotguide.pdf
        http://graphviz.org/Gallery/directed/go-package.html
        http://viz-js.com
        """
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the posted values
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        if project == "":
            raise web.HTTPBadRequest()

        # Verify the feature
        sql = self.dbconn.cursor()
        if PwicLib.option(sql, project, "no_graph") is not None:
            sql.close()
            raise web.HTTPForbidden()

        # Mapping of the pages
        pages: List[Tuple[str, str]] = []
        maps: List[Tuple[str, str, str, str]] = []

        def _make_link(fromProject: str, fromPage: str, toProject: str, toPage: str) -> None:
            if (fromProject, fromPage) != (toProject, toPage):
                tup = (toProject, toPage, fromProject, fromPage)
                pos = bisect_left(maps, tup)
                if (pos >= len(maps)) or (maps[pos] != tup):
                    insort(maps, tup)

        def _get_node_id(project: str, page: str) -> str:
            tup = (project, page)
            pos = bisect_left(pages, tup)
            if (pos >= len(pages)) or (pages[pos] != tup):
                insort(pages, tup)
                return _get_node_id(project, page)
            return f"n{pos + 1}"

        # Fetch the pages
        sql.execute(
            """ SELECT b.project, b.page, b.header, b.markdown
                        FROM roles AS a
                            INNER JOIN pages AS b
                                ON  b.project = a.project
                                AND b.latest  = 'X'
                        WHERE a.project  = ?
                          AND a.user     = ?
                          AND a.manager  = 'X'
                          AND a.disabled = ''
                        ORDER BY b.project,
                                 b.page""",
            (project, user),
        )
        while True:
            row = sql.fetchone()
            if row is None:
                break

            # Reference the processed page
            _get_node_id(row["project"], row["page"])
            _make_link("", "", row["project"], row["page"])

            # Assign the bookmarks to the home page
            if row["header"]:
                _make_link(row["project"], PwicConst.DEFAULTS["page"], row["project"], row["page"])

            # Find the links to the other pages
            subpages = PwicConst.REGEXES["page"].findall(row["markdown"].replace(app["options"]["base_url"], ""))
            if subpages is not None:
                for sp in subpages:
                    sp = [sp[0][1:] or project, sp[1]]
                    if (sp[0] not in PwicConst.NOT_PROJECT) and (sp[1] not in PwicConst.NOT_PAGE):
                        _get_node_id(sp[0], sp[1])
                        _make_link(row["project"], row["page"], sp[0], sp[1])
        if len(maps) == 0:
            sql.close()
            raise web.HTTPUnauthorized()

        # Authorized projects of the user
        sql.execute(
            """ SELECT project
                        FROM roles
                        WHERE user     = ?
                          AND disabled = '' """,
            (user,),
        )
        authorized_projects = [row["project"] for row in sql.fetchall()]

        # Build the file for GraphViz
        def _get_node_infos(project: str, page: str) -> Tuple[str, Optional[str]]:
            # Read the page
            sql.execute(
                """ SELECT draft, final, title, valuser
                            FROM pages
                            WHERE project = ?
                              AND page    = ?
                              AND latest  = 'X' """,
                (project, page),
            )
            row = sql.fetchone()
            if row is None:
                return ("", None)

            # Define the background color
            color: Optional[str] = None
            if row["valuser"] != "":
                color = "C0FFC0"  # Light green
            elif row["final"]:
                color = "FFFFC0"  # Light yellow
            elif row["draft"]:
                color = "FFE0E0"  # Light red
            return (row["title"], color)

        viz = "digraph PWIC_WIKI {\n"
        lastProject = ""
        maps.sort(key=lambda tup: 0 if tup[0] == project else 1)  # Main project in first position
        for toProject, toPage, fromProject, fromPage in maps:
            # Detection of a new project
            if toProject != lastProject:
                if lastProject != "":
                    viz += "}\n"
                lastProject = toProject
                viz += "subgraph cluster_%s {\n" % toProject
                viz += f'label="{toProject}";\n'
                if toProject in authorized_projects:
                    viz += f'URL="/{toProject}";\n'

                # Define all the nodes of the cluster
                for project, page in pages:
                    if project == toProject:
                        title, bgcolor = _get_node_infos(project, page)
                        if (title != "") and (project not in authorized_projects):
                            title = "[No authorization]"
                        viz += '%s [label="%s"; tooltip="%s"%s%s%s];\n' % (
                            _get_node_id(project, page),
                            page,
                            title.replace('"', '\\"') if title != "" else "[The page does not exist]",
                            (f'; URL="/{project}/{page}"' if project in authorized_projects and title != "" else ""),
                            ('; color="red"' if title == "" else ""),
                            (f'; style="filled"; fillcolor="#{bgcolor}"' if bgcolor is not None else ""),
                        )

            # Create the links in the cluster of the targeted node (else there is no box)
            if "" not in [fromProject, fromPage]:
                viz += "%s -> %s;\n" % (_get_node_id(fromProject, fromPage), _get_node_id(toProject, toPage))

        # Final output
        if len(maps) > 0:
            viz += "}\n"
        viz += "}"
        sql.close()
        return web.Response(text=viz, content_type=PwicLib.mime("gv"))

    async def api_page_create(self, request: web.Request) -> web.Response:
        """API to create a new page"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        kb = PwicLib.xb(PwicLib.x(post.get("kb")))
        page = PwicLib.safe_name(post.get("page"))
        tags = PwicLib.list_tags(post.get("tags", ""))
        milestone = post.get("milestone", "").strip()
        ref_project = PwicLib.safe_name(post.get("ref_project"))
        ref_page = PwicLib.safe_name(post.get("ref_page"))
        ref_tags = PwicLib.xb(PwicLib.x(post.get("ref_tags")))
        if (
            (project in PwicConst.NOT_PROJECT)
            or (page in PwicConst.NOT_PAGE)
            or ((ref_page != "") and (ref_project == ""))
        ):
            raise web.HTTPBadRequest()

        # Verify that the user is a manager of the provided project
        sql = self.dbconn.cursor()
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        if not self._check_roles(sql, project, user, manager=True):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()

        # Consume a KBid
        if PwicLib.option(sql, project, "no_space_page") is not None:
            page = page.replace(" ", "_")
        if kb:
            sql.execute(
                """ SELECT page
                            FROM pages
                            WHERE project       = ?
                              AND page         >= ?
                              AND page         <= ?
                              AND LENGTH(page)  = ?
                              AND latest        = 'X'
                            ORDER BY page DESC""",
                (
                    project,
                    page + int(PwicConst.DEFAULTS["kb_length"]) * "0",
                    page + int(PwicConst.DEFAULTS["kb_length"]) * "9",
                    len(page) + int(PwicConst.DEFAULTS["kb_length"]),
                ),
            )
            kbid = 1
            while True:
                row = sql.fetchone()
                if row is None:
                    break
                s = row["page"][len(page) :]
                if s.isdigit():
                    kbid = PwicLib.intval(s) + 1
                    break
            if kbid >= 10 ** int(PwicConst.DEFAULTS["kb_length"]):
                self._commit(sql, False)
                raise web.HTTPLengthRequired()
            page = page + (f'%0{PwicConst.DEFAULTS["kb_length"]}d' % (kbid,))

        # Check the availability of the renamed page
        sql.execute(
            """ SELECT 1
                        FROM pages
                        WHERE project = ?
                          AND page    = ?
                          AND latest  = 'X' """,
            (project, page),
        )
        if sql.fetchone() is not None:
            self._commit(sql, False)
            raise web.HTTPBadRequest()

        # Check the maximal number of pages per project
        page_count_max = PwicLib.intval(PwicLib.option(sql, project, "page_count_max"))
        if page_count_max > 0:
            sql.execute(
                """ SELECT COUNT(page) AS total
                            FROM pages
                            WHERE project = ?
                              AND latest  = 'X' """,
                (project,),
            )
            if sql.fetchone()["total"] >= page_count_max:
                self._commit(sql, False)
                raise web.HTTPForbidden()

        # Fetch the default markdown if the page is created in reference to another one
        default_title = page
        default_markdown = f"# {page}"
        default_tags = ""
        if (ref_project != "") and (ref_page != ""):
            sql.execute(
                """ SELECT b.title, b.markdown, b.tags
                            FROM roles AS a
                                INNER JOIN pages AS b
                                    ON  b.project = a.project
                                    AND b.page    = ?
                                    AND b.latest  = 'X'
                            WHERE a.project  = ?
                              AND a.user     = ?
                              AND a.disabled = '' """,
                (ref_page, ref_project, user),
            )
            row = sql.fetchone()
            if row is None:
                self._commit(sql, False)
                raise web.HTTPNotFound()
            default_title = row["title"]
            default_markdown = row["markdown"]
            if ref_tags:
                default_tags = row["tags"]

        # Custom check
        if not PwicExtension.on_api_page_create(sql, request, project, user, page, kb, tags, milestone):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()

        # Handle the creation of the page
        dt = PwicLib.dt()
        revision = 1
        sql.execute(
            """ INSERT INTO pages (project, page, revision, latest, draft, author, date, time, title, markdown, tags, comment, milestone)
                        VALUES (?, ?, ?, 'X', 'X', ?, ?, ?, ?, ?, ?, 'Initial', ?)""",
            (
                project,
                page,
                revision,
                user,
                dt["date"],
                dt["time"],
                default_title,
                default_markdown,
                PwicLib.list_tags(tags + " " + default_tags),
                milestone,
            ),
        )
        PwicLib.audit(
            sql,
            {"author": user, "event": "create-revision", "project": project, "page": page, "reference": revision},
            request,
        )
        self._commit(sql, True)

        # Result
        data = {"project": project, "page": page, "revision": revision, "url": f"/{project}/{page}"}
        return web.Response(text=json.dumps(data), content_type=PwicLib.mime("json"))

    async def api_page_edit(self, request: web.Request) -> web.Response:
        """API to update an existing page"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        page = PwicLib.safe_name(post.get("page"))
        title = post.get("title", "").strip()
        markdown = post.get("markdown", "")  # No strip()
        tags = PwicLib.list_tags(post.get("tags", ""))
        comment = post.get("comment", "").strip()
        milestone = post.get("milestone", "").strip()
        draft = PwicLib.xb(PwicLib.x(post.get("draft")))
        final = PwicLib.xb(PwicLib.x(post.get("final")))
        if final:
            draft = False
        header = PwicLib.xb(PwicLib.x(post.get("header")))
        protection = PwicLib.xb(PwicLib.x(post.get("protection")))
        no_quick_fix = PwicLib.xb(PwicLib.x(post.get("no_quick_fix")))
        if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE) or ("" in [user, title, comment]):
            raise web.HTTPBadRequest()
        dt = PwicLib.dt()

        # Check the maximal size of a revision
        sql = self.dbconn.cursor()
        if PwicLib.option(sql, project, "rstrip") is not None:
            markdown = re.sub(r"[ \t]+\n", "\n", markdown)
            markdown = re.sub(r"[ \t]+$", "", markdown)
        revision_size_max = PwicLib.intval(PwicLib.option(sql, project, "revision_size_max"))
        if 0 < revision_size_max < len(markdown):
            sql.close()
            raise web.HTTPBadRequest()

        # Fetch the last revision of the page and the profile of the user
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT b.revision, b.final, b.header, b.protection,
                               b.markdown, b.valuser, a.manager
                        FROM roles AS a
                            INNER JOIN pages AS b
                                ON  b.project = a.project
                                AND b.page    = ?
                                AND b.latest  = 'X'
                        WHERE a.project   = ?
                          AND a.user      = ?
                          AND ( a.manager = 'X'
                             OR a.editor  = 'X' )
                          AND a.disabled  = '' """,
            (page, project, user),
        )
        row = sql.fetchone()
        if row is None:
            self._commit(sql, False)
            raise web.HTTPUnauthorized()  # Or not found which is normally unlikely
        revision = row["revision"]
        quick_fix_candidate = (markdown == row["markdown"]) and not row["final"] and (row["valuser"] == "")
        manager = row["manager"]
        if not manager:
            if row["protection"]:  # The protected pages can be updated by the managers only
                self._commit(sql, False)
                raise web.HTTPUnauthorized()
            protection = False  # This field cannot be set by the non-managers
            header = row["header"]  # This field is reserved to the managers, so we keep the existing value

        # Check the maximal number of revisions per page
        revision_count_max = PwicLib.intval(PwicLib.option(sql, project, "revision_count_max"))
        if revision_count_max > 0:
            sql.execute(
                """ SELECT COUNT(revision) AS total
                            FROM pages
                            WHERE project = ?
                              AND page    = ?""",
                (project, page),
            )
            if sql.fetchone()["total"] >= revision_count_max:
                self._commit(sql, False)
                raise web.HTTPBadRequest()

        # Check the minimal edit time
        if not manager:
            edit_time_min = PwicLib.intval(PwicLib.option(sql, project, "edit_time_min"))
            if edit_time_min > 0:
                sql.execute(
                    """ SELECT MAX(date || ' ' || time) AS last_dt
                                FROM pages
                                WHERE project = ?
                                  AND author  = ?
                                  AND latest  = 'X' """,
                    (project, user),
                )
                last_dt = sql.fetchone()["last_dt"]
                if last_dt is not None:
                    d1 = datetime.strptime(last_dt, PwicConst.DEFAULTS["dt_mask"])
                    d2 = datetime.strptime(f'{dt["date"]} {dt["time"]}', PwicConst.DEFAULTS["dt_mask"])
                    if (d2 - d1).total_seconds() < edit_time_min:
                        self._commit(sql, False)
                        raise web.HTTPServiceUnavailable()

        # Custom check
        if not PwicExtension.on_api_page_edit(
            sql,
            request,
            project,
            user,
            page,
            title,
            markdown,
            tags,
            comment,
            milestone,
            draft,
            final,
            header,
            protection,
        ):
            self._commit(sql, False)
            raise web.HTTPBadRequest()

        # Update an existing entry in the terms of quick_fix
        if (
            quick_fix_candidate
            and manager
            and not no_quick_fix
            and (PwicLib.option(sql, project, "quick_fix") is not None)
        ):
            sql.execute(
                """ UPDATE pages
                            SET draft      = ?,
                                final      = ?,
                                header     = ?,
                                protection = ?,
                                title      = ?,
                                tags       = ?,
                                comment    = ?,
                                milestone  = ?
                            WHERE project  = ?
                              AND page     = ?
                              AND revision = ?""",
                (
                    PwicLib.x(draft),
                    PwicLib.x(final),
                    PwicLib.x(header),
                    PwicLib.x(protection),
                    title,
                    tags,
                    comment,
                    milestone,
                    project,
                    page,
                    revision,
                ),
            )
            PwicLib.audit(
                sql,
                {
                    "author": user,
                    "event": "update-revision",
                    "project": project,
                    "page": page,
                    "reference": revision,
                    "string": "quick_fix",
                },
                request,
            )
        else:
            # Create a new revision
            sql.execute(
                """ INSERT INTO pages
                                (project, page, revision, draft, final, header,
                                 protection, author, date, time, title,
                                 markdown, tags, comment, milestone)
                            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
                (
                    project,
                    page,
                    revision + 1,
                    PwicLib.x(draft),
                    PwicLib.x(final),
                    PwicLib.x(header),
                    PwicLib.x(protection),
                    user,
                    dt["date"],
                    dt["time"],
                    title,
                    markdown,
                    tags,
                    comment,
                    milestone,
                ),
            )
            if sql.rowcount > 0:
                PwicLib.audit(
                    sql,
                    {
                        "author": user,
                        "event": "create-revision",
                        "project": project,
                        "page": page,
                        "reference": revision + 1,
                    },
                    request,
                )

                # Remove the own drafts
                if final and (PwicLib.option(sql, project, "keep_drafts") is None):
                    sql.execute(
                        """ SELECT revision
                                    FROM pages
                                    WHERE project   = ?
                                      AND page      = ?
                                      AND revision <= ?
                                      AND author    = ?
                                      AND draft     = 'X'
                                      AND final     = ''
                                      AND valuser   = '' """,
                        (project, page, revision, user),
                    )
                    for row in sql.fetchall():
                        sql.execute(
                            """ DELETE FROM cache
                                        WHERE project  = ?
                                          AND page     = ?
                                          AND revision = ?""",
                            (project, page, row["revision"]),
                        )
                        sql.execute(
                            """ DELETE FROM pages
                                        WHERE project  = ?
                                          AND page     = ?
                                          AND revision = ?""",
                            (project, page, row["revision"]),
                        )
                        PwicLib.audit(
                            sql,
                            {
                                "author": user,
                                "event": "delete-revision",
                                "project": project,
                                "page": page,
                                "reference": row["revision"],
                                "string": "Draft",
                            },
                            request,
                        )

                # Purge the old flags
                sql.execute(
                    """ UPDATE pages
                                SET header = '',
                                    latest = ''
                                WHERE project   = ?
                                  AND page      = ?
                                  AND revision <= ?""",
                    (project, page, revision),
                )
        self._commit(sql, True)
        return web.HTTPOk()

    async def api_page_validate(self, request: web.Request) -> web.Response:
        """Validate the revision of a page"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the revision to validate
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        page = PwicLib.safe_name(post.get("page"))
        revision = PwicLib.intval(post.get("revision", 0))
        if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE) or (revision == 0):
            raise web.HTTPBadRequest()

        # Verify that it is possible to validate the page
        sql = self.dbconn.cursor()
        if not PwicExtension.on_api_page_validate(sql, request, project, user, page, revision):
            sql.close()
            raise web.HTTPUnauthorized()
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT b.page
                        FROM roles AS a
                            INNER JOIN pages AS b
                                ON  b.project  = a.project
                                AND b.page     = ?
                                AND b.revision = ?
                                AND b.final    = 'X'
                                AND b.valuser  = ''
                            INNER JOIN users AS c
                                ON  c.user     = a.user
                                AND c.initial  = ''
                        WHERE a.project   = ?
                          AND a.user      = ?
                          AND a.validator = 'X'
                          AND a.disabled  = '' """,
            (page, revision, project, user),
        )
        if sql.fetchone() is None:
            self._commit(sql, False)
            raise web.HTTPUnauthorized()

        # Update the page
        dt = PwicLib.dt()
        sql.execute(
            """ UPDATE pages
                        SET valuser = ?,
                            valdate = ?,
                            valtime = ?
                        WHERE project  = ?
                          AND page     = ?
                          AND revision = ?""",
            (user, dt["date"], dt["time"], project, page, revision),
        )
        PwicLib.audit(
            sql,
            {"author": user, "event": "validate-revision", "project": project, "page": page, "reference": revision},
            request,
        )
        self._commit(sql, True)
        return web.HTTPOk()

    async def api_page_move(self, request: web.Request) -> web.Response:
        """Move a page and its attachments to another location"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the page to move
        post = await self._handle_post(request)
        srcproj = PwicLib.safe_name(post.get("ref_project"))
        srcpage = PwicLib.safe_name(post.get("ref_page"))
        dstproj = PwicLib.safe_name(post.get("project"))
        dstpage = PwicLib.safe_name(post.get("page"))
        ignore_file_errors = PwicLib.xb(PwicLib.x(post.get("ignore_file_errors", "X")))
        if dstpage == "":
            dstpage = srcpage
        if (
            (srcproj in PwicConst.NOT_PROJECT)
            or (srcpage in PwicConst.NOT_PAGE)
            or (dstproj in PwicConst.NOT_PROJECT)
            or (dstpage in PwicConst.NOT_PAGE)
        ):
            raise web.HTTPBadRequest()

        # Verify that the user is a manager of the 2 projects (no need to check the protection of the page)
        sql = self.dbconn.cursor()
        if (dstproj != srcproj) and (PwicLib.option(sql, "", "maintenance") is not None):
            sql.close()
            raise web.HTTPServiceUnavailable()
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        for p in [srcproj, dstproj]:
            if not self._check_roles(sql, p, user, manager=True):
                self._commit(sql, False)
                raise web.HTTPUnauthorized()

        # Verify that the source page exists
        sql.execute(
            """ SELECT 1
                        FROM pages
                        WHERE project = ?
                          AND page    = ?""",
            (srcproj, srcpage),
        )
        if sql.fetchone() is None:
            self._commit(sql, False)
            raise web.HTTPNotFound()

        # Verify that the target page does not exist
        if PwicLib.option(sql, dstproj, "no_space_page") is not None:
            dstpage = dstpage.replace(" ", "_")
        sql.execute(
            """ SELECT 1
                        FROM pages
                        WHERE project = ?
                          AND page    = ?""",
            (dstproj, dstpage),
        )
        if sql.fetchone() is not None:
            self._commit(sql, False)
            raise web.HTTPForbidden()

        # Verify the files
        files = []
        if dstproj != srcproj:
            # Verify the folders
            for p in [srcproj, dstproj]:
                if not isdir(PwicConst.DOCUMENTS_PATH % p):
                    self._commit(sql, False)
                    raise web.HTTPInternalServerError()

            # Check the files in conflict (no automatic rename)
            sql.execute(
                """ SELECT filename
                            FROM documents
                            WHERE project = ?
                              AND page    = ?
                              AND exturl  = '' """,
                (srcproj, srcpage),
            )
            for row in sql.fetchall():
                if isfile(join(PwicConst.DOCUMENTS_PATH % dstproj, row["filename"])):
                    self._commit(sql, False)
                    raise web.HTTPConflict()
                files.append(row["filename"])

        # Custom check
        if not PwicExtension.on_api_page_move(sql, request, srcproj, user, srcpage, dstproj, dstpage):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()

        # Move the files physically
        ok = True
        if len(files) > 0:
            if dstproj != srcproj:
                for f in files:
                    try:
                        os.rename(
                            join(PwicConst.DOCUMENTS_PATH % srcproj, f), join(PwicConst.DOCUMENTS_PATH % dstproj, f)
                        )
                    except OSError:
                        ok = False
                if not ok and not ignore_file_errors:
                    self._commit(sql, False)
                    raise web.HTTPInternalServerError()

        # Update the index of the files
        sql.execute(
            """ UPDATE documents
                        SET project   = ?,
                            page      = ?
                        WHERE project = ?
                          AND page    = ?""",
            (dstproj, dstpage, srcproj, srcpage),
        )

        # Update the index of the pages
        sql.execute(
            """ UPDATE pages
                        SET project   = ?,
                            page      = ?
                        WHERE project = ?
                          AND page    = ?""",
            (dstproj, dstpage, srcproj, srcpage),
        )
        sql.execute(
            """ DELETE FROM cache
                        WHERE project = ?
                          AND page    = ?""",
            (srcproj, srcpage),
        )

        # Audit
        PwicLib.audit(
            sql,
            {
                "author": user,
                "event": "delete-page",
                "project": srcproj,
                "page": srcpage,
                "string": f"/{dstproj}/{dstpage}",
            },
            request,
        )
        sql.execute(
            """ SELECT revision
                        FROM pages
                        WHERE project = ?
                          AND page    = ?
                          AND latest  = 'X' """,
            (dstproj, dstpage),
        )
        PwicLib.audit(
            sql,
            {
                "author": user,
                "event": "create-revision",
                "project": dstproj,
                "page": dstpage,
                "reference": sql.fetchone()["revision"],
                "string": f"/{srcproj}/{srcpage}",
            },
            request,
        )
        self._commit(sql, True)
        return web.HTTPFound(f"/{dstproj}/{dstpage}?" + ("success" if ok else "failed"))

    async def api_page_delete(self, request: web.Request) -> web.Response:
        """Delete a revision of a page"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the revision to delete
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        page = PwicLib.safe_name(post.get("page"))
        revision = PwicLib.intval(post.get("revision", 0))
        if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE) or (revision == 0):
            raise web.HTTPBadRequest()

        # Verify that the deletion is possible
        sql = self.dbconn.cursor()
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT COUNT(revision) AS total
                        FROM pages
                        WHERE project = ?
                          AND page    = ?""",
            (project, page),
        )
        num_revs = sql.fetchone()["total"]
        if (num_revs == 1) and (PwicLib.option(sql, "", "maintenance") is not None):
            self._commit(sql, False)
            raise web.HTTPServiceUnavailable()  # During a maintenance, the last revision can't be deleted because the all the files would be deleted
        sql.execute(
            """ SELECT a.header
                        FROM pages AS a
                            INNER JOIN roles AS b
                                ON  b.project  = a.project
                                AND b.user     = ?
                                AND b.disabled = ''
                        WHERE a.project  = ?
                          AND a.page     = ?
                          AND a.revision = ?
                          AND ((    b.admin   = 'X'
                                AND a.final   = ''
                                AND a.valuser = ''
                            ) OR (  b.user    = a.author
                                AND a.draft   = 'X'
                            ))""",
            (user, project, page, revision),
        )
        row = sql.fetchone()
        if row is None:
            self._commit(sql, False)
            raise web.HTTPUnauthorized()
        if not PwicExtension.on_api_page_delete(sql, request, project, user, page, revision):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()
        header = row["header"]

        # Delete the revision
        sql.execute(
            """ DELETE FROM cache
                        WHERE project  = ?
                          AND page     = ?
                          AND revision = ?""",
            (project, page, revision),
        )
        sql.execute(
            """ DELETE FROM pages
                        WHERE project  = ?
                          AND page     = ?
                          AND revision = ?""",
            (project, page, revision),
        )
        num_revs -= 1
        PwicLib.audit(
            sql,
            {"author": user, "event": "delete-revision", "project": project, "page": page, "reference": revision},
            request,
        )
        if revision > 1:
            # Find the latest revision that is not necessarily "revision - 1"
            sql.execute(
                """ SELECT MAX(revision) AS revision
                            FROM pages
                            WHERE project   = ?
                              AND page      = ?
                              AND revision <> ?""",
                (project, page, revision),
            )
            row = sql.fetchone()
            if row["revision"] is not None:
                if row["revision"] < revision:  # If we have already deleted the latest revision
                    sql.execute(
                        """ UPDATE pages
                                    SET latest = 'X',
                                        header = ?
                                    WHERE project  = ?
                                      AND page     = ?
                                      AND revision = ?""",
                        (PwicLib.x(header), project, page, row["revision"]),
                    )

        # Delete the attached documents when the page doesn't exist anymore
        docKO = 0
        if num_revs == 0:
            sql.execute(
                """ SELECT id, filename, exturl
                            FROM documents
                            WHERE project = ?
                              AND page    = ?""",
                (project, page),
            )
            for row in sql.fetchall():
                ko = False

                # Attempt to delete the file
                if not PwicExtension.on_api_document_delete(
                    sql, request, project, user, page, row["id"], row["filename"], row["exturl"]
                ):
                    ko = True
                else:
                    if row["exturl"] == "":
                        fn = join(PwicConst.DOCUMENTS_PATH % project, row["filename"])
                        try:
                            os.remove(fn)
                        except OSError:
                            if isfile(fn):
                                ko = True

                # Handle the result of the deletion
                if ko:
                    docKO += 1
                else:
                    sql.execute(
                        """ DELETE FROM documents
                                    WHERE id = ?""",
                        (row["id"],),
                    )
                    PwicLib.audit(
                        sql,
                        {
                            "author": user,
                            "event": "delete-document",
                            "project": project,
                            "page": page,
                            "string": row["filename"],
                        },
                        request,
                    )

        # Final
        if docKO > 0:
            self._commit(sql, False)  # Possible partial deletion
            raise web.HTTPInternalServerError()
        self._commit(sql, True)
        return web.HTTPOk()

    async def api_page_export(self, request: web.Request) -> web.Response:
        """API to export a page"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Read the parameters
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        page = PwicLib.safe_name(post.get("page"))
        revision = PwicLib.intval(post.get("revision", 0))
        extension = post.get("format", "").strip().lower()
        if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE) or (extension == ""):
            raise web.HTTPBadRequest()

        # Apply the options on the parameters
        sql = self.dbconn.cursor()
        revision = self._redirect_revision(sql, project, user, page, revision)
        if revision == 0:
            sql.close()
            raise web.HTTPForbidden()
        file_formats_disabled = PwicLib.list(PwicLib.option(sql, project, "file_formats_disabled"))
        if (extension in file_formats_disabled) or ("*" in file_formats_disabled):
            sql.close()
            raise web.HTTPForbidden()

        # Handle the own file formats
        endname = PwicLib.attachment_name(f"{project}_{page}_rev{revision}.{extension}")
        done, newbody, newheaders = PwicExtension.on_api_page_export(
            sql, request, project, user, page, revision, extension, endname
        )
        if done:
            sql.close()
            if newbody is None:
                raise web.HTTPNotFound()
            return web.Response(body=newbody, headers=MultiDict(newheaders))

        # Convert the page (md2md, md2html, md2odt)
        converter = PwicExporter(app["markdown"], user)
        data = converter.convert(sql, project, page, revision, extension)
        del converter
        sql.close()
        if data is None:
            raise web.HTTPUnsupportedMediaType()
        headers = {
            "Content-Type": str(PwicLib.mime(extension) or PwicLib.mime("")),
            "Content-Disposition": f'attachment; filename="{endname}"',
        }
        return web.Response(body=data, headers=MultiDict(headers))

    async def api_markdown(self, request: web.Request) -> web.Response:
        """Return the HTML corresponding to the posted Markdown"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the parameters
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        markdown = post.get("markdown", "")
        if "" in [project, markdown]:
            raise web.HTTPBadRequest()

        # Verify that the user is able to write on the project
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user, manager=True, editor=True):
            sql.close()
            raise web.HTTPUnauthorized()

        # Return the converted output (md2html)
        converter = PwicExporter(app["markdown"], user)
        row = {"project": project, "page": None, "revision": 0, "markdown": markdown}
        html = converter.md2corehtml(sql, row, export_odt=False)
        sql.close()
        return web.Response(text=html, content_type=PwicLib.mime("txt"))

    async def api_user_create(self, request: web.Request) -> web.Response:
        """API to create a new user"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Fetch the submitted data
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        wisheduser = post.get("user", "").strip().lower()
        newuser = PwicLib.safe_user_name(post.get("user"))
        if (project in PwicConst.NOT_PROJECT) or (wisheduser != newuser) or PwicLib.reserved_user_name(newuser):
            raise web.HTTPBadRequest()

        # Verify that the user is administrator and has changed his password
        sql = self.dbconn.cursor()
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT 1
                        FROM roles AS a
                            INNER JOIN users AS b
                                ON b.user = a.user
                        WHERE a.project  = ?
                          AND a.user     = ?
                          AND a.admin    = 'X'
                          AND a.disabled = ''
                          AND b.initial  = '' """,
            (project, user),
        )
        if (sql.fetchone() is None) or not PwicExtension.on_api_user_create(sql, request, project, user, newuser):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()

        # Create the new user
        if PwicLib.option(sql, project, "no_new_user") is not None:
            sql.execute(
                """ SELECT user
                            FROM users
                            WHERE user = ?""",
                (newuser,),
            )
            if sql.fetchone() is None:
                self._commit(sql, False)
                raise web.HTTPForbidden()
        else:
            dt = PwicLib.dt()
            sql.execute(
                """ INSERT OR IGNORE INTO users (user, password, initial, password_date, password_time)
                            VALUES (?, ?, '', ?, ?)""",
                (newuser, PwicLib.sha256(PwicConst.DEFAULTS["password"]), dt["date"], dt["time"]),
            )
            if sql.rowcount > 0:
                PwicLib.audit(sql, {"author": user, "event": "create-user", "user": newuser}, request)

        # Grant the default rights as reader
        sql.execute(
            """ INSERT INTO roles (project, user, reader)
                        SELECT ?, ?, 'X'
                        WHERE NOT EXISTS ( SELECT 1 FROM roles WHERE project = ? AND user = ? )""",
            (project, newuser, project, newuser),
        )
        if sql.rowcount > 0:
            PwicLib.audit(sql, {"author": user, "event": "grant-reader", "project": project, "user": newuser}, request)
        self._commit(sql, True)
        return web.HTTPOk()

    async def api_user_language_set(self, request: web.Request) -> web.Response:
        """API to change the language of the user interface"""
        # Fetch the submitted data
        post = await self._handle_post(request)
        language = post.get("language", "")
        if language not in app["langs"]:
            raise web.HTTPBadRequest()

        # Change the language
        session = await self._get_session(request)
        session["language"] = language
        return web.HTTPOk()

    async def api_user_password_change(self, request: web.Request) -> web.Response:
        """Change the password of the current user"""
        # Verify that the user is connected
        user = await self._suser(request)
        if PwicLib.reserved_user_name(user):
            raise web.HTTPUnauthorized()

        # Get the posted values
        post = await self._handle_post(request)
        current = post.get("password_current", "").strip()
        new1 = post.get("password_new1", "").strip()
        new2 = post.get("password_new2", "").strip()
        if (
            ("" in [current, new1, new2])
            or (new1 != new2)
            or (new1 in [current, PwicConst.DEFAULTS["password"]])
            or (user in new1.lower())
        ):
            raise web.HTTPBadRequest()

        # Verify the format of the new password
        sql = self.dbconn.cursor()
        password_regex = str(PwicLib.option(sql, "", "password_regex", ""))
        if password_regex != "":  # nosec B105
            try:
                if re.compile(password_regex).match(new1) is None:
                    sql.close()
                    raise web.HTTPBadRequest()
            except Exception as e:
                sql.close()
                raise web.HTTPInternalServerError() from e
        if not PwicExtension.on_api_user_password_change(sql, request, user, new1):
            sql.close()
            raise web.HTTPUnauthorized()

        # Verify the current password
        ok = False
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT user
                        FROM users
                        WHERE user     = ?
                          AND password = ?""",
            (user, PwicLib.sha256(current)),
        )
        if sql.fetchone() is not None:
            # Update the password
            dt = PwicLib.dt()
            sql.execute(
                """ UPDATE users
                            SET password      = ?,
                                initial       = '',
                                password_date = ?,
                                password_time = ?
                            WHERE user = ?""",
                (PwicLib.sha256(new1), dt["date"], dt["time"], user),
            )
            if sql.rowcount > 0:
                PwicLib.audit(sql, {"author": user, "event": "change-password", "user": user}, request)
                ok = True
        self._commit(sql, True)
        if not ok:
            raise web.HTTPBadRequest()
        return web.HTTPOk()

    async def api_user_roles_set(self, request: web.Request) -> web.Response:
        """Change the roles of a user"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the posted values
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        userpost = PwicLib.safe_user_name(post.get("name"))
        roles = ["admin", "manager", "editor", "validator", "reader", "disabled", "delete"]
        try:
            roleid = roles.index(post.get("role", ""))
            delete = roles[roleid] == "delete"
        except ValueError as e:
            raise web.HTTPBadRequest() from e
        if (
            (project in PwicConst.NOT_PROJECT)
            or (userpost == "")
            or (PwicLib.reserved_user_name(userpost) and (roles in ["admin", "delete"]))
        ):
            raise web.HTTPBadRequest()

        # Select the current rights of the user
        sql = self.dbconn.cursor()
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT a.user, a.admin, a.manager, a.editor,
                               a.validator, a.reader, a.disabled, c.initial
                        FROM roles AS a
                            INNER JOIN roles AS b
                                ON  b.project  = a.project
                                AND b.user     = ?
                                AND b.admin    = 'X'
                                AND b.disabled = ''
                            INNER JOIN users AS c           -- The modified user
                                ON  c.user     = a.user
                            INNER JOIN users AS d           -- The administrator must have changed its password already
                                ON  d.user     = b.user
                                AND d.initial  = ''
                        WHERE a.project = ?
                          AND a.user    = ?""",
            (user, project, userpost),
        )
        row = sql.fetchone()
        if row is None or (not delete and row["initial"]):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()

        # Delete a user
        if delete:
            if not PwicExtension.on_api_user_roles_set(sql, request, project, user, userpost, "delete", None):
                self._commit(sql, False)
                raise web.HTTPUnauthorized()
            sql.execute(
                """ DELETE FROM roles
                            WHERE project = ?
                              AND user    = ?
                              AND user   <> ?""",
                (project, userpost, user),
            )
            if sql.rowcount == 0:
                self._commit(sql, False)
                raise web.HTTPBadRequest()
            PwicLib.audit(sql, {"author": user, "event": "delete-user", "project": project, "user": userpost}, request)
            self._commit(sql, True)
            return web.Response(text="OK", content_type=PwicLib.mime("txt"))

        # New role
        newvalue = not row[roles[roleid]]
        if (roles[roleid] == "admin") and not newvalue and (user == userpost):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()  # Cannot self-ungrant admin, so there is always at least one admin on the project
        if not PwicExtension.on_api_user_roles_set(sql, request, project, user, userpost, roles[roleid], newvalue):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()
        try:
            query = """ UPDATE roles
                        SET %s = ?
                        WHERE project = ?
                          AND user    = ?"""
            sql.execute(query % roles[roleid], (PwicLib.x(newvalue), project, userpost))
        except sqlite3.IntegrityError as e:
            self._commit(sql, False)
            raise web.HTTPUnauthorized() from e
        if sql.rowcount == 0:
            self._commit(sql, False)
            raise web.HTTPBadRequest()
        PwicLib.audit(
            sql,
            {
                "author": user,
                "event": "%s-%s" % ("grant" if newvalue else "ungrant", roles[roleid]),
                "project": project,
                "user": userpost,
            },
            request,
        )
        self._commit(sql, True)
        return web.Response(text=PwicLib.x(newvalue), content_type=PwicLib.mime("txt"))

    async def api_document_create(self, request: web.Request) -> web.Response:
        """API to create a new document"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Verify that there is no maintenance message that may prevent the file from being saved
        sql = self.dbconn.cursor()
        if PwicLib.option(sql, "", "maintenance") is not None:
            sql.close()
            raise web.HTTPServiceUnavailable()

        # Parse the submitted multipart/form-data
        try:
            regex_name = re.compile(r'[^file]name="([^"]+)"')
            regex_filename = re.compile(r'filename="([^"]+)"')
            doc: Dict[str, Any] = {"project": "", "page": "", "filename": "", "mime": "", "content": None}
            multipart = MultipartReader.from_response(request)
            while True:
                part = await multipart.next()
                if part is None:
                    break

                # Read the type of entry
                disposition = part.headers.get(hdrs.CONTENT_DISPOSITION, "")
                if disposition[:10] != "form-data;":
                    continue

                # Read the name of the field
                name_re = regex_name.search(disposition)
                if name_re is None:
                    continue
                name = name_re.group(1)

                # Assign the value
                if name in ["project", "page"]:
                    doc[name] = PwicLib.safe_name(await part.text())
                elif name == "content":
                    fn_re = regex_filename.search(disposition)
                    if fn_re is None:
                        continue
                    fn = PwicLib.safe_file_name(fn_re.group(1))
                    if (fn == "") or (len(fn) > PwicLib.intval(PwicConst.DEFAULTS["limit_filename"])):
                        continue
                    doc["filename"] = fn
                    doc["mime"] = part.headers.get(hdrs.CONTENT_TYPE, "").strip().lower()
                    doc[name] = await part.read(decode=False)
        except Exception as e:
            sql.close()
            raise web.HTTPBadRequest() from e
        doc["project"] = PwicLib.safe_name(doc["project"])
        doc["page"] = PwicLib.safe_name(doc["page"])
        doc["filename"] = PwicLib.safe_file_name(doc["filename"])
        if (doc["content"] in [None, "", b""]) or (
            "" in [doc["project"], doc["page"], doc["filename"]]
        ):  # The mime is checked later
            sql.close()
            raise web.HTTPBadRequest()
        if not PwicExtension.on_api_document_create_start(sql, request, doc):
            sql.close()
            raise web.HTTPUnauthorized()

        # Verify that the project and folder exist
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT project
                        FROM projects
                        WHERE project = ?""",
            (doc["project"],),
        )
        if sql.fetchone() is None:
            self._commit(sql, False)
            raise web.HTTPBadRequest()
        if not isdir(PwicConst.DOCUMENTS_PATH % doc["project"]):
            self._commit(sql, False)
            raise web.HTTPInternalServerError()

        # Verify the authorizations
        sql.execute(
            """ SELECT 1
                        FROM roles AS a
                            INNER JOIN pages AS b
                                ON  b.project = a.project
                                AND b.page    = ?
                                AND b.latest  = 'X'
                        WHERE   a.project  = ?
                          AND   a.user     = ?
                          AND ( a.manager  = 'X'
                             OR a.editor   = 'X' )
                          AND   a.disabled = '' """,
            (doc["page"], doc["project"], user),
        )
        if sql.fetchone() is None:
            self._commit(sql, False)
            raise web.HTTPUnauthorized()

        # Verify the consistency of the filename
        document_name_regex = PwicLib.option(sql, doc["project"], "document_name_regex")
        if document_name_regex is not None:
            try:
                regex_doc = re.compile(document_name_regex, re.VERBOSE)
            except Exception as e:
                self._commit(sql, False)
                raise web.HTTPInternalServerError() from e
            if regex_doc.search(doc["filename"]) is None:
                self._commit(sql, False)
                raise web.HTTPBadRequest()

        # Verify the file type
        if PwicLib.option(sql, "", "magic_bytes") is not None:
            if not self._check_mime(doc):
                self._commit(sql, False)
                raise web.HTTPUnsupportedMediaType()
        if PwicConst.REGEXES["mime"].match(doc["mime"]) is None:
            self._commit(sql, False)
            raise web.HTTPBadRequest()

        # Verify the maximal document size
        document_size_max = PwicLib.intval(PwicLib.option(sql, doc["project"], "document_size_max", "-1"))
        if 0 <= document_size_max < len(doc["content"]):
            self._commit(sql, False)
            raise web.HTTPRequestEntityTooLarge(document_size_max, len(doc["content"]))

        # Verify the maximal project size
        # ... is there a check ?
        project_size_max = PwicLib.intval(PwicLib.option(sql, doc["project"], "project_size_max", "-1"))
        if project_size_max >= 0:
            # ... current size of the project
            current_project_size = PwicLib.intval(
                sql.execute(
                    """ SELECT SUM(size) AS total
                                                                  FROM documents
                                                                  WHERE project = ?""",
                    (doc["project"],),
                ).fetchone()["total"]
            )
            # ... current size of the file if it exists already
            current_file_size = PwicLib.intval(
                sql.execute(
                    """ SELECT SUM(size) AS total
                                                               FROM documents
                                                               WHERE project  = ?
                                                                 AND filename = ?""",
                    (doc["project"], doc["filename"]),
                ).fetchone()["total"]
            )
            # ... verify the size
            if current_project_size - current_file_size + len(doc["content"]) > project_size_max:
                self._commit(sql, False)
                raise web.HTTPRequestEntityTooLarge(
                    project_size_max - current_project_size + current_file_size, len(doc["content"])
                )  # HTTPInsufficientStorage has no hint

        # At last, verify that the document doesn't exist yet (not related to a given page)
        forcedId = None
        sql.execute(
            """ SELECT id, page, exturl
                        FROM documents
                        WHERE project  = ?
                          AND filename = ?""",
            (doc["project"], doc["filename"]),
        )
        row = sql.fetchone()
        if row is not None:
            if row["page"] != doc["page"]:  # Existing document = Delete + Keep same ID (replace it)
                self._commit(sql, False)
                raise web.HTTPConflict()  # Existing document on another page = do nothing
            if row["exturl"] == "":
                # Local file
                try:
                    fn = join(PwicConst.DOCUMENTS_PATH % doc["project"], doc["filename"])
                    os.remove(fn)
                except OSError as e:
                    if isfile(fn):
                        self._commit(sql, False)
                        raise web.HTTPInternalServerError() from e
            else:
                # External file
                if not PwicExtension.on_api_document_delete(
                    sql, request, doc["project"], user, doc["page"], row["id"], doc["filename"], doc["exturl"]
                ):
                    self._commit(sql, False)
                    raise web.HTTPInternalServerError()
            sql.execute(
                """ DELETE FROM documents
                            WHERE id = ?""",
                (row["id"],),
            )
            forcedId = row["id"]

        # Verify the content of the zipped files
        if (PwicLib.option(sql, doc["project"], "zip_no_exec") is not None) and PwicLib.mime_zipped(
            PwicLib.file_ext(doc["filename"])
        ):
            magics = PwicLib.magic_bytes("zip")
            if (magics is not None) and (doc["content"][: len(magics[0])] == PwicLib.str2bytearray(magics[0])):
                # Read the file names
                try:
                    inmemory = BytesIO(doc["content"])
                    with ZipFile(inmemory, mode="r") as archive:
                        zipfiles = archive.infolist()
                    inmemory.close()
                except BadZipFile as e:
                    self._commit(sql, False)
                    raise web.HTTPUnsupportedMediaType() from e

                # Check the file names
                for zf in zipfiles:
                    if not zf.is_dir():
                        if (PwicLib.file_ext(zf.filename) in PwicConst.EXECS) or (
                            (zf.external_attr >> 16) & 0o111 != 0
                        ):  # +x flags
                            self._commit(sql, False)
                            raise web.HTTPForbidden()

        # Find the dimensions of the loaded picture
        width, height = 0, 0
        if doc["mime"][:6] == "image/":
            try:
                inmemory = BytesIO(doc["content"])
                width, height = imagesize.get(inmemory)
                inmemory.close()
            except ValueError:
                pass

            # Check the maximal size
            document_pixels_max = PwicLib.intval(PwicLib.option(sql, doc["project"], "document_pixels_max", "-1"))
            if 0 <= document_pixels_max < width * height:
                self._commit(sql, False)
                raise web.HTTPRequestEntityTooLarge(document_pixels_max, width * height)

        # Upload the file on the server
        try:
            filename = join(PwicConst.DOCUMENTS_PATH % doc["project"], doc["filename"])
            with open(filename, "wb") as f:  # Rewrite any existing file
                f.write(doc["content"])
        except OSError as e:
            self._commit(sql, False)
            raise web.HTTPInternalServerError() from e

        # Create the document in the database
        dt = PwicLib.dt()
        newdoc = forcedId is None
        sql.execute(
            """ INSERT INTO documents (id, project, page, filename, mime, size, width,
                                               height, hash, author, date, time, exturl)
                        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '')""",
            (
                forcedId,
                doc["project"],
                doc["page"],
                doc["filename"],
                doc["mime"],
                len(doc["content"]),
                width,
                height,
                PwicLib.sha256(doc["content"], salt=False),
                user,
                dt["date"],
                dt["time"],
            ),
        )
        if newdoc:
            forcedId = sql.lastrowid
        PwicLib.audit(
            sql,
            {
                "author": user,
                "event": "%s-document" % ("create" if newdoc else "update"),
                "project": doc["project"],
                "page": doc["page"],
                "reference": PwicLib.intval(forcedId),
                "string": doc["filename"],
            },
            request,
        )
        self._commit(None, True)

        # Forward the notification of the created file
        sql.execute(
            """ SELECT *
                        FROM documents
                        WHERE id = ?""",
            (forcedId,),
        )
        row = sql.fetchone()
        if row is not None:
            row["path"] = join(PwicConst.DOCUMENTS_PATH % row["project"], row["filename"])
            PwicExtension.on_api_document_create_end(sql, request, row)
        sql.close()
        return web.HTTPOk()

    async def api_document_get(self, request: web.Request) -> web.Response:
        """Download a file by redirecting to the right location"""
        # Fetch the parameters
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        page = PwicLib.safe_name(post.get("page"))
        docid = PwicLib.intval(post.get("id", "0"))
        attachment = PwicLib.xb(PwicLib.x(post.get("attachment")))

        # Redirect to the file
        if docid > 0:
            return web.HTTPFound(f"/special/document/{docid}" + ("?attachment" if attachment else ""))
        if "" not in [project, page]:
            return web.HTTPFound(f"/{project}/special/documents/{page}")
        raise web.HTTPBadRequest()

    async def api_document_list(self, request: web.Request) -> web.Response:
        """Return the list of the attached documents"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Read the parameters
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        page = PwicLib.safe_name(post.get("page"))
        if (project in PwicConst.NOT_PROJECT) or (page in PwicConst.NOT_PAGE):
            raise web.HTTPBadRequest()

        # Read the documents
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT markdown
                        FROM pages
                        WHERE project = ?
                          AND page    = ?
                          AND latest  = 'X' """,
            (project, page),
        )
        row = sql.fetchone()
        if row is None:
            sql.close()
            raise web.HTTPNotFound()
        markdown = row["markdown"]
        conversion_allowed = PwicLib.option(sql, project, "no_document_conversion") is None
        convertible_exts = PwicImporter.get_allowed_extensions()
        sql.execute(
            """ SELECT b.id, b.filename, b.mime, b.size, b.hash, b.author, b.date, b.time, b.exturl
                        FROM roles AS a
                            INNER JOIN documents AS b
                                ON  b.project = a.project
                                AND b.page    = ?
                        WHERE a.project  = ?
                          AND a.user     = ?
                          AND a.disabled = ''
                        ORDER BY b.filename""",
            (page, project, user),
        )
        documents = sql.fetchall()
        for row in documents:
            row["mime_icon"] = PwicLib.mime2icon(row["mime"])
            row["size_str"] = PwicLib.size2str(row["size"])
            row["used"] = (
                (f'(/special/document/{row["id"]})' in markdown)
                or (f'(/special/document/{row["id"]}?' in markdown)
                or (f'(/special/document/{row["id"]}#' in markdown)
                or (f'(/special/document/{row["id"]} "' in markdown)
            )
            row["url"] = f'{app["options"]["base_url"]}/special/document/{row["id"]}/{row["filename"]}'
            row["extension"] = PwicLib.file_ext(row["filename"])
            row["convertible"] = conversion_allowed and (row["extension"] in convertible_exts)
        PwicExtension.on_api_document_list(sql, request, project, page, documents)
        sql.close()
        return web.Response(text=json.dumps(documents), content_type=PwicLib.mime("json"))

    async def api_document_rename(self, request: web.Request) -> web.Response:
        """Rename a file"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Read the parameters
        post = await self._handle_post(request)
        docid = PwicLib.intval(post.get("id", ""))
        project = PwicLib.safe_name(post.get("project"))
        filename = PwicLib.safe_file_name(post.get("filename", ""))
        if (docid == 0) or (filename == ""):
            raise web.HTTPBadRequest()

        # Read the document to be renamed
        sql = self.dbconn.cursor()
        if PwicLib.option(sql, "", "maintenance") is not None:
            sql.close()
            raise web.HTTPServiceUnavailable()
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT a.id, a.project, a.page, a.filename
                        FROM documents AS a
                            INNER JOIN roles AS b
                                ON    b.project  = a.project
                                AND   b.user     = ?
                                AND ( b.manager  = 'X'
                                  OR  b.editor   = 'X' )
                                AND   b.disabled = ''
                        WHERE a.id      = ?
                          AND a.project = ?
                          AND a.exturl  = '' """,
            (user, docid, project),
        )
        row = sql.fetchone()
        if row is None:
            self._commit(sql, False)
            raise web.HTTPUnauthorized()

        # Rename the file
        ext = PwicLib.file_ext(row["filename"])
        if PwicLib.file_ext(filename) != ext:
            filename += f".{ext}"
        if filename == row["filename"]:
            self._commit(sql, False)
            raise web.HTTPBadRequest()
        if not PwicExtension.on_api_document_rename(
            sql, request, project, user, row["page"], docid, row["filename"], filename
        ):
            self._commit(sql, False)
            raise web.HTTPUnauthorized()
        try:
            os.rename(
                join(PwicConst.DOCUMENTS_PATH % project, row["filename"]),
                join(PwicConst.DOCUMENTS_PATH % project, filename),
            )
        except OSError as e:
            self._commit(sql, False)
            raise web.HTTPInternalServerError() from e

        # Update the database
        sql.execute(
            """ UPDATE documents
                        SET filename = ?
                        WHERE id = ?""",
            (filename, docid),
        )
        PwicLib.audit(
            sql,
            {
                "author": user,
                "event": "rename-document",
                "project": project,
                "page": row["page"],
                "reference": docid,
                "string": f'{row["filename"]} -> {filename}',
            },
            request,
        )
        self._commit(sql, True)
        return web.HTTPOk()

    async def api_document_delete(self, request: web.Request) -> web.Response:
        """Delete a document"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the file to delete
        post = await self._handle_post(request)
        docid = PwicLib.intval(post.get("id", 0))
        project = PwicLib.safe_name(post.get("project"))
        if (project == "") or (docid == 0):
            raise web.HTTPBadRequest()

        # Verify that the deletion is possible
        sql = self.dbconn.cursor()
        if PwicLib.option(sql, "", "maintenance") is not None:
            sql.close()
            raise web.HTTPServiceUnavailable()
        if not self._lock(sql):
            raise web.HTTPServiceUnavailable()
        sql.execute(
            """ SELECT b.page, b.filename, b.exturl
                        FROM roles AS a
                            INNER JOIN documents AS b
                                ON  b.id       = ?
                                AND b.project  = a.project
                        WHERE   a.project  = ?
                          AND   a.user     = ?
                          AND ( a.manager  = 'X'
                             OR a.editor   = 'X' )
                          AND   a.disabled = '' """,
            (docid, project, user),
        )
        row = sql.fetchone()
        if row is None:
            self._commit(sql, False)
            raise web.HTTPUnauthorized()  # Or not found
        if not PwicExtension.on_api_document_delete(
            sql, request, project, user, row["page"], docid, row["filename"], row["exturl"]
        ):
            self._commit(sql, False)
            raise web.HTTPUnauthorized() if row["exturl"] == "" else web.HTTPInternalServerError()

        # Delete the local file
        if row["exturl"] == "":
            fn = join(PwicConst.DOCUMENTS_PATH % project, row["filename"])
            try:
                os.remove(fn)
            except OSError as e:
                if isfile(fn):
                    self._commit(sql, False)
                    raise web.HTTPInternalServerError() from e

        # Delete the index
        sql.execute(""" DELETE FROM documents WHERE id = ?""", (docid,))
        PwicLib.audit(
            sql,
            {
                "author": user,
                "event": "delete-document",
                "project": project,
                "page": row["page"],
                "reference": docid,
                "string": row["filename"],
            },
            request,
        )
        self._commit(sql, True)
        return web.HTTPOk()

    async def api_document_convert(self, request: web.Request) -> web.Response:
        """Convert a local document to Markown"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the parameters
        post = await self._handle_post(request)
        docid = PwicLib.intval(post.get("id", 0))
        if docid <= 0:
            raise web.HTTPBadRequest()

        # Processing of an internal file
        sql = self.dbconn.cursor()
        sql.execute(
            """ SELECT b.project, b.filename, b.mime, b.exturl
                        FROM roles AS a
                            INNER JOIN documents AS b
                                ON  b.id      = ?
                                AND b.project = a.project
                        WHERE   a.user     = ?
                          AND ( a.manager  = 'X'
                             OR a.editor   = 'X' )
                          AND   a.disabled = '' """,
            (docid, user),
        )
        row = sql.fetchone()
        if row is None:
            sql.close()
            raise web.HTTPUnauthorized()  # Or not found
        if row["exturl"] != "":
            sql.close()
            raise web.HTTPUnprocessableEntity()
        if PwicLib.option(sql, row["project"], "no_document_conversion") is not None:
            sql.close()
            raise web.HTTPForbidden()

        # Convert a local document to Markdown
        converter = PwicImporter()
        data = converter.convert(sql, user, docid)
        sql.close()
        if data in [None, "", b""]:
            raise web.HTTPUnprocessableEntity()
        return web.Response(text=str(data), content_type=PwicLib.mime("md"))

    async def api_document_remote_convert(self, request: web.Request) -> web.Response:
        """Convert a remote document to Markown"""
        # Verify that the user is connected
        user = await self._suser(request)
        if user == "":
            raise web.HTTPUnauthorized()

        # Get the parameters
        post = await self._handle_post(request)
        project = PwicLib.safe_name(post.get("project"))
        url = post.get("url", "").strip()
        if "" in [project, url]:
            raise web.HTTPBadRequest()

        # Verify that the user is able to write on the project
        sql = self.dbconn.cursor()
        if not self._check_roles(sql, project, user, manager=True, editor=True):
            sql.close()
            raise web.HTTPUnauthorized()
        if PwicLib.option(sql, project, "remote_url") is None:
            sql.close()
            raise web.HTTPForbidden()

        # Audit the action before the download
        PwicLib.audit(
            sql,
            {"author": user, "event": "fetch-url", "project": f"*{project}", "string": url},  # Declarative value
            request,
        )
        self._commit(None, True)

        # Convert the data
        data = await PwicLib.download_str(url, "text/")
        if data is None:
            raise web.HTTPUnprocessableEntity()
        data = PwicImporterHtml().get_md_memory(data)
        data = PwicExtension.on_api_document_convert(sql, project, user, None, 0, url, data)
        sql.close()
        return web.Response(text=str(data), content_type=PwicLib.mime("md"))

    async def api_odata(self, request: web.Request) -> web.Response:
        # Check the IP address
        self._check_ip(PwicExtension.on_ip_header(request))

        # Verify the availability of the service
        sql = self.dbconn.cursor()
        if PwicLib.option(sql, "", "odata") is None:
            sql.close()
            raise web.HTTPServiceUnavailable()

        # Content
        base_url = str(PwicLib.option(sql, "", "base_url", ""))
        fn = "odata_service.xml"
        with open(f"./static/api/{fn}", mode="r", encoding="UTF-8") as f:
            content = f.read()
        content = PwicExtension.on_odata_xml_definition(sql, request, fn, content)
        content = content.replace("\t", "").replace("\r", "").replace("\n", "").replace("{base_url}", base_url).strip()
        sql.close()
        return web.Response(text=content, content_type="application/xml", headers={"OData-Version": "4.0"})

    async def api_odata_metadata(self, request: web.Request) -> web.Response:
        # Check the IP address
        self._check_ip(PwicExtension.on_ip_header(request))

        # Verify the availability of the service
        sql = self.dbconn.cursor()
        if PwicLib.option(sql, "", "odata") is None:
            sql.close()
            raise web.HTTPServiceUnavailable()

        # Content
        fn = "odata_meta.edmx"
        with open(f"./static/api/{fn}", mode="r", encoding="UTF-8") as f:
            content = f.read()
        content = PwicExtension.on_odata_xml_definition(sql, request, fn, content)
        content = content.replace("\t", "").replace("\r", "").replace("\n", "").strip()
        sql.close()
        return web.Response(text=content, content_type="application/xml", headers={"OData-Version": "4.0"})

    async def api_odata_content(self, request: web.Request) -> web.Response:
        # Check the IP address
        self._check_ip(PwicExtension.on_ip_header(request))

        # Verify the availability of the service
        sql = self.dbconn.cursor()
        if PwicLib.option(sql, "", "odata") is None:
            sql.close()
            raise web.HTTPServiceUnavailable()

        # Fetch the user and its password
        auth = request.headers.get("Authorization", "")
        if auth == "":
            sql.close()
            return web.Response(status=401, headers={"WWW-Authenticate": "Basic"})
        if auth[:6] != "Basic ":
            sql.close()
            raise web.HTTPBadRequest()
        try:
            auth = b64decode(auth[6:]).decode()
        except binascii.Error as e:
            sql.close()
            raise web.HTTPBadRequest() from e
        if ":" not in auth:
            sql.close()
            raise web.HTTPBadRequest()
        user, passwd = auth.split(":", 1)

        # Verify the user and password
        if PwicLib.reserved_user_name(user):
            sql.close()
            raise web.HTTPUnauthorized()
        sql.execute(
            """ SELECT password
                        FROM users
                        WHERE user    = ?
                          AND initial = '' """,
            (user,),
        )
        row = sql.fetchone()
        if row is None:
            sql.close()
            raise web.HTTPUnauthorized()
        if row["password"] == PwicConst.MAGIC_OAUTH:
            sql.close()
            raise web.HTTPNotImplemented()
        if row["password"] != PwicLib.sha256(passwd):
            sql.close()
            raise web.HTTPUnauthorized()
        del row["password"], passwd, auth

        # Extension
        if not PwicExtension.on_odata_content_pre(sql, request, user):
            sql.close()
            raise web.HTTPUnauthorized()

        # Prepare the query
        base_url = str(PwicLib.option(sql, "", "base_url", ""))
        table = PwicLib.safe_name(request.match_info.get("table"))
        if table == "env":
            if not self._check_roles(sql, None, user, admin=True):
                sql.close()
                raise web.HTTPUnauthorized()
            sql.execute(
                """ SELECT a.project, a.key, a.value
                            FROM env AS a
                                INNER JOIN (
                                    SELECT project
                                    FROM roles
                                    WHERE user     = ?
                                      AND admin    = 'X'
                                      AND disabled = ''
                                  UNION
                                    SELECT ''
                                ) AS b
                                    ON b.project = a.project
                            WHERE a.value <> ''
                            ORDER BY a.key     ASC,
                                     a.project ASC""",
                (user,),
            )
        elif table == "projects":
            sql.execute(
                """ SELECT a.project, a.description, a.date
                            FROM projects AS a
                                INNER JOIN roles AS b
                                    ON  b.project  = a.project
                                    AND b.user     = ?
                                    AND b.disabled = ''
                        UNION
                            SELECT project, description, date
                            FROM projects
                            WHERE project = '' """,
                (user,),
            )
        elif table == "pages":
            sql.execute(
                """ SELECT a.project, a.page, a.revision, a.draft, a.final,
                                   a.header, a.protection, a.author, a.date, a.time,
                                   a.title, a.tags, a.comment, a.milestone, a.valuser,
                                   a.valdate, a.valtime
                            FROM pages AS a
                                INNER JOIN roles AS b
                                    ON  b.project  = a.project
                                    AND b.user     = ?
                                    AND b.disabled = ''
                            WHERE a.latest = 'X' """,
                (user,),
            )
        elif table == "documents":
            sql.execute(
                """ SELECT a.id, a.project, a.page, a.filename, a.mime,
                                   a.size, a.width, a.height, a.hash, a.author,
                                   a.date, a.time, a.exturl
                            FROM documents AS a
                                INNER JOIN roles AS b
                                    ON  b.project  = a.project
                                    AND b.user     = ?
                                    AND b.disabled = '' """,
                (user,),
            )
        elif table == "users":
            sql.execute(
                """ SELECT DISTINCT a.user, IIF(a.password == ?, 'X', '') AS oauth,
                                            a.initial, IIF(a.totp <> '', 'X', '') AS totp,
                                            a.password_date, a.password_time
                            FROM users AS a
                                INNER JOIN roles AS b
                                    ON  b.user     = a.user
                                    AND b.disabled = ''
                                INNER JOIN (
                                    SELECT project
                                    FROM roles
                                    WHERE user     = ?
                                      AND disabled = ''
                                ) AS c
                                    ON c.project = b.project
                        UNION
                            SELECT user, '' AS oauth, initial, '' AS totp, password_date, password_time
                            FROM users
                            WHERE user = '' """,
                (PwicConst.MAGIC_OAUTH, user),
            )
        elif table == "roles":
            sql.execute(
                """ SELECT a.project, a.user, a.admin, a.manager, a.editor,
                                   a.validator, a.reader
                            FROM roles AS a
                                INNER JOIN (
                                    SELECT project
                                    FROM roles
                                    WHERE user     = ?
                                      AND disabled = ''
                                ) AS b
                                    ON b.project = a.project""",
                (user,),
            )
        elif not PwicExtension.on_odata_custom_content(sql, request, user, table):
            sql.close()
            raise web.HTTPBadRequest()

        # Fetch the data
        data: Dict[str, Any] = {"@odata.context": f"{base_url}/api/odata/$metadata#{table}", "value": []}
        for row in sql.fetchall():
            # Fix the formats to comply with OData
            if table == "env":
                if (row["key"] not in PwicConst.ENV) or PwicConst.ENV[row["key"]].private:
                    continue
            elif table == "pages":
                row["valdate"] = row["valdate"] or "1970-01-01"
                row["valtime"] = row["valtime"] or "00:00:00"
            elif table == "users":
                row["oauth"] = PwicLib.xb(row["oauth"])
                row["totp"] = PwicLib.xb(row["totp"])
            data["value"].append(row)

        # Result
        PwicExtension.on_odata_content(sql, request, user, data)
        sql.close()
        return web.Response(
            text=json.dumps(data, separators=(",", ":")),
            content_type="application/json; odata.metadata=none; odata.streaming=false; IEEE754Compatible=false",
            headers={"OData-Version": "4.0", "Cache-Control": "no-cache, must-revalidate"},
        )

    async def api_swagger(self, request: web.Request) -> web.Response:
        """Display the features of the API"""
        return await self._handle_output(None, request, "page-swagger", {})


# =====================
#  Program entry point
# =====================

G_TOTP_CACHE: Dict[str, int] = {}
app = web.Application()


def main() -> bool:
    """Program entry point"""

    # Check root
    try:
        if os.geteuid() == 0:
            print("Error: Pwic.wiki should not be started with the root account")
            return False
    except AttributeError:
        pass  # No check on Windows

    # Check the databases
    if not isfile(PwicConst.DB_SQLITE) or not isfile(PwicConst.DB_SQLITE_AUDIT):
        print('Error: the databases are not initialized by the admin command "init-db"')
        return False

    # Command line
    parser = argparse.ArgumentParser(description=f"Pwic.wiki Server version {PwicConst.VERSION}")
    parser.add_argument("--host", default=PwicConst.DEFAULTS["host"], help="Listening host")
    parser.add_argument("--port", type=int, default=PwicLib.intval(PwicConst.DEFAULTS["port"]), help="Listening port")
    parser.add_argument(
        "--new-session",
        action="store_true",
        help="Generate a new secret key for the session (it will disconnect all the users)",
    )
    parser.add_argument(
        "--sql-trace", action="store_true", help="Display the SQL queries in the console for debugging purposes"
    )
    args = parser.parse_args()

    # Modules
    # ... launch time
    app["up"] = PwicLib.dt()
    # ... SQLite
    app["sql"], sql = PwicLib.connect(trace=args.sql_trace, vacuum=True)
    # ... languages
    app["langs"] = sorted(
        ["en"] + [f for f in listdir(PwicConst.LOCALE_PATH) if (len(f) == 2) and isdir(join(PwicConst.LOCALE_PATH, f))]
    )
    # ... i18n templates
    app["jinja"] = {}
    for lang in app["langs"]:
        entry = Environment(
            loader=FileSystemLoader(PwicConst.TEMPLATES_PATH),
            autoescape=False,
            auto_reload=PwicLib.option(sql, "", "fixed_templates") is None,
            lstrip_blocks=True,
            trim_blocks=True,
            extensions=["jinja2.ext.i18n"],
        )
        if lang == "en":
            entry.install_null_translations()
        else:
            entry.install_gettext_translations(translation("pwic", localedir="locale", languages=[lang]))
        entry.filters["is_hex"] = PwicLib.is_hex
        entry.filters["no_html"] = PwicLib.no_html
        entry.filters["reserved_user_name"] = PwicLib.reserved_user_name
        entry.filters["size2str"] = PwicLib.size2str
        entry.filters["slash"] = lambda v: v.replace("'", "\\'")
        app["jinja"][lang] = entry
    # ... client size
    app._client_max_size = max(app._client_max_size, PwicLib.intval(PwicLib.option(sql, "", "client_size_max")))
    # ... PWIC
    app["pwic"] = PwicServer(app["sql"])
    app.on_response_prepare.append(app["pwic"]._handle_headers)
    # ... session
    keep_sessions = PwicLib.option(sql, "", "keep_sessions") is not None
    if not keep_sessions or args.new_session:
        sql.execute(
            """ DELETE FROM env
                        WHERE key = 'pwic_session' """
        )
    skey: Union[Optional[str], bytes] = PwicLib.option(sql, "", "pwic_session")
    if skey is None:
        skey = urandom(32)
        if keep_sessions:
            sql.execute(
                """ INSERT OR REPLACE INTO env (project, key, value)
                            VALUES ('', 'pwic_session', ?)""",
                (skey,),
            )  # Possible BLOB into TEXT explained at sqlite.org/faq.html#q3
    setup(
        app,
        EncryptedCookieStorage(
            skey, httponly=True, samesite="Strict" if PwicLib.option(sql, "", "strict_cookies") is not None else "Lax"
        ),
    )
    del skey
    # ... Markdown parser
    app["markdown"] = PwicLib.init_markdown(sql)

    # Routes
    app.router.add_static("/static/", path="./static/", append_version=False)
    app.add_routes(PwicExtension.load_custom_routes(app["pwic"]))
    app.add_routes(
        [
            web.get("/robots.txt", app["pwic"].static_robots),
            web.post("/api/login", app["pwic"].api_login),
            web.get("/api/oauth", app["pwic"].api_oauth),
            web.post("/api/server/env/get", app["pwic"].api_server_env_get),
            web.get("/api/server/headers/get", app["pwic"].api_server_headers_get),
            web.post("/api/server/ping", app["pwic"].api_server_ping),
            web.post("/api/server/shutdown", app["pwic"].api_server_shutdown),
            web.post("/api/server/unlock", app["pwic"].api_server_unlock),
            web.post("/api/project/list", app["pwic"].api_project_list),
            web.post("/api/project/get", app["pwic"].api_project_get),
            web.post("/api/project/env/set", app["pwic"].api_project_env_set),
            web.post("/api/project/users/get", app["pwic"].api_project_users_get),
            web.post("/api/project/progress/get", app["pwic"].api_project_progress_get),
            web.post("/api/project/graph/get", app["pwic"].api_project_graph_get),
            web.post("/api/page/create", app["pwic"].api_page_create),
            web.post("/api/page/edit", app["pwic"].api_page_edit),
            web.post("/api/page/validate", app["pwic"].api_page_validate),
            web.post("/api/page/move", app["pwic"].api_page_move),
            web.post("/api/page/delete", app["pwic"].api_page_delete),
            web.post("/api/page/export", app["pwic"].api_page_export),
            web.post("/api/markdown/convert", app["pwic"].api_markdown),
            web.post("/api/user/create", app["pwic"].api_user_create),
            web.post("/api/user/language/set", app["pwic"].api_user_language_set),
            web.post("/api/user/password/change", app["pwic"].api_user_password_change),
            web.post("/api/user/roles/set", app["pwic"].api_user_roles_set),
            web.post("/api/document/create", app["pwic"].api_document_create),
            web.post("/api/document/get", app["pwic"].api_document_get),
            web.post("/api/document/list", app["pwic"].api_document_list),
            web.post("/api/document/rename", app["pwic"].api_document_rename),
            web.post("/api/document/delete", app["pwic"].api_document_delete),
            web.post("/api/document/remote/convert", app["pwic"].api_document_remote_convert),
            web.post("/api/document/convert", app["pwic"].api_document_convert),
            web.get("/api/odata/$metadata", app["pwic"].api_odata_metadata),
            web.get("/api/odata/{table:[a-z]+}", app["pwic"].api_odata_content),
            web.get("/api/odata", app["pwic"].api_odata),
            web.get("/api", app["pwic"].api_swagger),
            web.get("/special/login", app["pwic"]._handle_login),
            web.get("/special/logout", app["pwic"]._handle_logout),
            web.get("/special/help", app["pwic"].page_help),
            web.get("/special/user/{userpage}", app["pwic"].page_user),
            web.get(r"/special/document/{id:[0-9]+}/{dummy:[^\/]+}", app["pwic"].document_get),
            web.get(r"/special/document/{id:[0-9]+}", app["pwic"].document_get),
            web.get(r"/{project:[^\/]+}/special/search", app["pwic"].page_search),
            web.get(r"/{project:[^\/]+}/special/searchlink", app["pwic"].project_searchlink),
            web.get(r"/{project:[^\/]+}/special/sitemap", app["pwic"].project_sitemap),
            web.get(r"/{project:[^\/]+}/special/user", app["pwic"].page_user_create),
            web.get(r"/{project:[^\/]+}/special/roles", app["pwic"].page_roles),
            web.get(r"/{project:[^\/]+}/special/audit", app["pwic"].page_audit),
            web.get(r"/{project:[^\/]+}/special/env", app["pwic"].page_env),
            web.get(r"/{project:[^\/]+}/special/page", app["pwic"].page_create),
            web.get(r"/{project:[^\/]+}/special/feed/{format:json|atom|rss}", app["pwic"].project_feed),
            web.get(r"/{project:[^\/]+}/special/manifest", app["pwic"].project_manifest),
            web.get(r"/{project:[^\/]+}/special/links", app["pwic"].page_links),
            web.get(r"/{project:[^\/]+}/special/graph", app["pwic"].page_graph),
            web.get(r"/{project:[^\/]+}/special/export/{format:zip}", app["pwic"].project_export),
            web.get(r"/{project:[^\/]+}/special/random", app["pwic"].page_random),
            web.get(r"/{project:[^\/]+}/special/documents/{page:[^\/]+}", app["pwic"].document_all_get),
            web.get(
                r"/{project:[^\/]+}/{page:[^\/]+}/rev{new_revision:[0-9]+}/compare/rev{old_revision:[0-9]+}",
                app["pwic"].page_compare,
            ),
            web.get(r"/{project:[^\/]+}/{page:[^\/]+}/rev{revision:[0-9]+}", app["pwic"].page),
            web.get(r"/{project:[^\/]+}/{page:[^\/]+}/{action:view|edit|history|move}", app["pwic"].page),
            web.get(r"/{project:[^\/]+}/{page:[^\/]+}", app["pwic"].page),
            web.get(r"/{project:[^\/]+}", app["pwic"].page),
            web.get("/", app["pwic"].page),
        ]
    )

    # CORS
    origins = PwicLib.list(PwicLib.option(sql, "", "api_cors"))
    if len(origins) == 0:
        app["cors"] = None
    else:
        import aiohttp_cors

        app["cors"] = aiohttp_cors.setup(app)
        for route in list(app.router.routes()):
            if (route.method in ["GET", "POST"]) and (route.get_info().get("path", "")[:4] == "/api"):
                options = {}
                for k in origins:
                    options[k] = aiohttp_cors.ResourceOptions(
                        allow_methods=[route.method], allow_headers="*", expose_headers="*"
                    )
                app["cors"].add(route, options)

    # HTTPS
    if PwicLib.option(sql, "", "https") is None:
        https = None
    else:
        try:
            import ssl

            https = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
            https.load_cert_chain(PwicConst.PUBLIC_KEY, PwicConst.PRIVATE_KEY)
        except FileNotFoundError:
            print("Error: SSL certificates not found")
            return False
        except Exception as e:
            print(f"Error: {e}")
            return False

    # General options of the server
    base_url = str(PwicLib.option(sql, "", "base_url", ""))
    app["options"] = {
        "base_url": base_url,
        "compressed_cache": PwicLib.option(sql, "", "compressed_cache") is not None,
        "http_referer": (base_url != "") and (PwicLib.option(sql, "", "http_referer") is not None),
        "ip_filter": [],
        "no_login": PwicLib.option(sql, "", "no_login") is not None,
        "session_expiry": PwicLib.intval(PwicLib.option(sql, "", "session_expiry", "0")),
    }
    if app["options"]["base_url"] == "":
        print('Warning: defining the option "base_url" is highly recommended')
    app["oauth"] = {
        "provider": PwicLib.option(sql, "", "oauth_provider", None),
        "tenant": PwicLib.option(sql, "", "oauth_tenant", ""),
        "identifier": PwicLib.option(sql, "", "oauth_identifier", ""),
        "server_secret": PwicLib.option(sql, "", "oauth_secret", ""),
        "domains": PwicLib.list(str(PwicLib.option(sql, "", "oauth_domains", ""))),
    }

    # Compile the IP filters
    for mask in PwicLib.list(PwicLib.option(sql, "", "ip_filter")):
        item: List[Any] = [IPR_EQ, None, None]  # Type, Negated, Mask object

        # Suspension flag
        if mask[:1] == "#":
            continue

        # Negation flag
        item[1] = mask[:1] == "~"
        if item[1]:
            mask = mask[1:]

        # Condition types
        # ... networks
        if "/" in mask:
            item[0] = IPR_NET
            item[2] = ip_network(mask)
        # ... mask for IP
        elif ("*" in mask) or ("?" in mask):
            item[0] = IPR_REG
            item[2] = re.compile(mask.replace(".", "\\.").replace("?", ".").replace("*", ".*"))
        # ... raw IP
        else:
            item[2] = mask
        app["options"]["ip_filter"].append(item)

    # Load the bots
    app["bots"] = []
    with open("./static/robots.txt", "r") as f:
        bots = f.read().replace("\r", "").split("\n")
    for buffer in bots:
        if buffer[:11] == "User-agent:":
            buffer = buffer[12:].strip().lower()
            if len(buffer) > 4:
                app["bots"].append(buffer)

    # Logging
    http_log_file = PwicLib.option(sql, "", "http_log_file", "")
    http_log_format = str(PwicLib.option(sql, "", "http_log_format", PwicConst.DEFAULTS["logging_format"]))
    if http_log_file != "":
        import logging

        logging.basicConfig(filename=http_log_file, datefmt="%d/%m/%Y %H:%M:%S", level=logging.INFO)

    # Launch the server
    if not PwicExtension.on_server_ready(app, sql):
        return False
    sql.execute(
        """ SELECT MAX(id) AS id
                    FROM audit.audit
                    WHERE event = 'start-server' """
    )
    row = sql.fetchone()
    if row["id"] is not None:
        sql.execute(
            """ SELECT date, time
                        FROM audit.audit
                        WHERE id = ?""",
            (row["id"],),
        )
        row = sql.fetchone()
        print(f'Last started on {row["date"]} {row["time"]}.')
    PwicLib.audit(
        sql, {"author": PwicConst.USERS["system"], "event": "start-server", "string": f"{args.host}:{args.port}"}
    )
    app["sql"].commit()
    sql.close()
    del sql
    web.run_app(app, host=args.host, port=args.port, ssl_context=https, access_log_format=http_log_format)
    return True


if __name__ == "__main__":
    main()
