patx/projectpay

add manifest.sjon for pwa's and added css for a more native app mobile feel

Commit a6f2a06 · patx · 2026-07-06T19:57:43-04:00

Changeset
a6f2a067cceb5d8538581590e75f2d879412b4fe
Parents
4484fafd3edbb13506d36edfd30206fd6d368c82

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 132f805..b79a162 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,7 @@ ProjectPay is a small web app for creating project payment pages and collecting
    PUBLIC_LOGO_URL=
    PUBLIC_DARK_LOGO_URL=
    PUBLIC_LOGO_ALT=ProjectPay Logo
+   PUBLIC_APP_ICON_URL=https://gitman.io/patx/afterdarklabs/raw/docs/favicon.png
 
    STRIPE_SECRET_KEY=
    STRIPE_WEBHOOK_SECRET=
@@ -80,6 +81,7 @@ ProjectPay is a small web app for creating project payment pages and collecting
 | `PUBLIC_LOGO_URL` | Optional | unset | Logo image URL shown on public project payment pages. No logo is shown when this is empty. |
 | `PUBLIC_DARK_LOGO_URL` | Optional | `PUBLIC_LOGO_URL` | Dark-mode logo image URL shown on public project payment pages. When empty, the normal logo is used. |
 | `PUBLIC_LOGO_ALT` | Optional | `ProjectPay Logo` | Alt text for the public payment page logo. |
+| `PUBLIC_APP_ICON_URL` | Optional | `https://gitman.io/patx/afterdarklabs/raw/docs/favicon.png` | PNG icon URL used by the web app manifest. |
 | `MONGODB_URI` | Optional | `mongodb://localhost:27017` | MongoDB connection string. |
 | `MONGODB_DB` | Optional | `invoice_maker` | MongoDB database name. Use `projectpay` or another explicit name for new installs. |
 | `MONGODB_SERVER_SELECTION_TIMEOUT_MS` | Optional | `5000` | MongoDB connection timeout in milliseconds. |
@@ -155,6 +157,7 @@ The included `Procfile` already binds Uvicorn to `0.0.0.0` and Heroku's dynamic
 heroku config:set APP_ENV=production
 heroku config:set ADMIN_PASSWORD='replace-with-a-strong-password'
 heroku config:set APP_BASE_URL='https://your-app.herokuapp.com'
+heroku config:set PUBLIC_APP_ICON_URL='https://gitman.io/patx/afterdarklabs/raw/docs/favicon.png'
 heroku config:set MONGODB_URI='mongodb+srv://...'
 heroku config:set MONGODB_DB=projectpay
 heroku config:set STRIPE_SECRET_KEY='sk_live_...'
@@ -167,6 +170,7 @@ On Heroku dynos, login rate limiting uses the final `X-Forwarded-For` value adde
 ## Customization
 
 - Set `PUBLIC_LOGO_URL`, `PUBLIC_DARK_LOGO_URL`, and `PUBLIC_LOGO_ALT` to brand the public payment page.
+- Set `PUBLIC_APP_ICON_URL` to change the icon used by `/manifest.json`.
 - Update styles in `templates/base.html`.
 - The current default database name is `invoice_maker`; set `MONGODB_DB=projectpay` for a fresh ProjectPay deployment.
 - Currency formatting is optimized for USD. Non-USD currencies display as `CURRENCY amount`.
diff --git a/app.py b/app.py
index 3df2211..3fcdffe 100644
--- a/app.py
+++ b/app.py
@@ -23,6 +23,7 @@ PAGE_SIZE = 20
 CSRF_SESSION_KEY = "csrf_token"
 SESSION_COOKIE = "session_id"
 DEFAULT_HOST = "127.0.0.1"
+DEFAULT_APP_ICON_URL = "https://gitman.io/patx/afterdarklabs/raw/docs/favicon.png"
 DEVELOPMENT_ENVS = {"development", "dev", "local", "test"}
 VALID_APP_ENVS = DEVELOPMENT_ENVS | {"production", "staging"}
 SUCCESSFUL_CHECKOUT_EVENTS = {
@@ -205,6 +206,9 @@ class InvoiceApp(App):
             os.getenv("PUBLIC_DARK_LOGO_URL", "").strip() or self.public_logo_url
         )
         self.public_logo_alt = os.getenv("PUBLIC_LOGO_ALT", "ProjectPay Logo").strip()
+        self.public_app_icon_url = (
+            os.getenv("PUBLIC_APP_ICON_URL", "").strip() or DEFAULT_APP_ICON_URL
+        )
         self.currency = os.getenv("STRIPE_CURRENCY", "usd").upper()
         self.stripe_secret_key = os.getenv("STRIPE_SECRET_KEY", "")
         self.stripe_webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET", "")
@@ -285,6 +289,13 @@ class InvoiceApp(App):
         await self.client.close()
 
     async def __call__(self, scope, receive, send) -> None:
+        if (
+            scope.get("type") == "http"
+            and scope.get("method") == "GET"
+            and scope.get("path") == "/manifest.json"
+        ):
+            await self._manifest_asgi(send)
+            return
         if (
             scope.get("type") == "http"
             and scope.get("method") == "POST"
@@ -320,6 +331,33 @@ class InvoiceApp(App):
             [("Content-Type", "application/json")],
         )
 
+    def _manifest(self) -> Dict[str, Any]:
+        return {
+            "name": "ProjectPay",
+            "short_name": "ProjectPay",
+            "description": "Create project payment pages and collect payments.",
+            "start_url": "/",
+            "scope": "/",
+            "display": "standalone",
+            "background_color": "#f7f7f7",
+            "theme_color": "#176b5b",
+            "icons": [
+                {
+                    "src": self.public_app_icon_url,
+                    "sizes": "512x512",
+                    "type": "image/png",
+                }
+            ],
+        }
+
+    async def _manifest_asgi(self, send) -> None:
+        await self._send_response(
+            send,
+            200,
+            json.dumps(self._manifest()),
+            [("Content-Type", "application/manifest+json")],
+        )
+
     async def _handle_stripe_webhook(
         self, raw_body: bytes, headers: Dict[str, str]
     ) -> Tuple[int, Dict[str, Any]]:
diff --git a/templates/base.html b/templates/base.html
index 8c4055a..7258cbd 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -2,11 +2,18 @@
 <html lang="en">
   <head>
     <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
+    <link rel="manifest" href="/manifest.json">
     <title>{% block title %}Project Payments{% endblock %}</title>
     <style>
       * { box-sizing: border-box; }
       [hidden] { display: none !important; }
+      html {
+        max-width: 100%;
+        overflow-x: hidden;
+        touch-action: manipulation;
+        -webkit-text-size-adjust: 100%;
+      }
       :root {
         color-scheme: light;
         --page-bg: #f7f7f7;
@@ -61,6 +68,8 @@
       }
       body {
         margin: 0;
+        max-width: 100%;
+        overflow-x: hidden;
         background: var(--page-bg);
         color: var(--text-color);
         font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@@ -97,10 +106,20 @@
         text-decoration: none;
         cursor: pointer;
       }
+      img, svg, video, canvas { max-width: 100%; }
       .secondary { background: var(--surface-bg); border-color: var(--border-color); color: var(--text-color); }
       .danger { background: var(--surface-bg); border-color: var(--danger-border); color: var(--danger-color); }
-      .topbar { background: var(--surface-bg); }
-      .topbar-inner, .page { max-width: 1080px; margin: 0 auto; padding: 16px 20px; }
+      .topbar {
+        max-width: 100%;
+        overflow-x: hidden;
+        background: var(--surface-bg);
+      }
+      .topbar-inner, .page {
+        width: 100%;
+        max-width: 1080px;
+        margin: 0 auto;
+        padding: 16px 20px;
+      }
       .topbar-inner, .header-row, .nav, .searchbar {
         display: flex;
         align-items: center;
@@ -354,6 +373,8 @@
         right: 0;
         bottom: 0;
         z-index: 10;
+        max-width: 100%;
+        overflow-x: hidden;
         background: var(--sticky-bg);
         padding: 12px 20px;
       }
@@ -373,6 +394,8 @@
         right: 0;
         bottom: 0;
         z-index: 10;
+        max-width: 100%;
+        overflow-x: hidden;
         background: var(--sticky-bg);
         padding: 12px 20px;
       }
@@ -401,6 +424,8 @@
         left: 0;
         right: 0;
         z-index: 10;
+        max-width: 100%;
+        overflow-x: hidden;
         background: var(--sticky-bg);
         padding: 12px 20px;
       }
diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py
index 5a1cbd7..da5ce66 100644
--- a/tests/test_security_hardening.py
+++ b/tests/test_security_hardening.py
@@ -1,4 +1,5 @@
 import asyncio
+import json
 import os
 import unittest
 from types import SimpleNamespace
@@ -26,6 +27,39 @@ class SecurityHardeningTests(unittest.TestCase):
         self.addCleanup(micropie.current_request.reset, token)
         return app
 
+    async def asgi_get(self, app, path):
+        messages = []
+
+        async def receive():
+            return {"type": "http.request", "body": b"", "more_body": False}
+
+        async def send(message):
+            messages.append(message)
+
+        await app(
+            {
+                "type": "http",
+                "method": "GET",
+                "path": path,
+                "query_string": b"",
+                "headers": [],
+                "client": ("127.0.0.1", 1234),
+            },
+            receive,
+            send,
+        )
+        response_start = messages[0]
+        body = b"".join(
+            message.get("body", b"")
+            for message in messages
+            if message["type"] == "http.response.body"
+        )
+        headers = {
+            key.decode("latin-1").lower(): value.decode("latin-1")
+            for key, value in response_start["headers"]
+        }
+        return response_start["status"], headers, body
+
     def test_admin_login_starts_fresh_server_side_session(self):
         class FakeSessionBackend:
             def __init__(self):
@@ -145,6 +179,34 @@ class SecurityHardeningTests(unittest.TestCase):
     def test_direct_run_host_defaults_to_loopback(self):
         self.assertEqual(invoice_app.DEFAULT_HOST, "127.0.0.1")
 
+    def test_manifest_json_uses_default_app_icon_url(self):
+        app = self.make_app()
+
+        status, headers, body = asyncio.run(self.asgi_get(app, "/manifest.json"))
+
+        self.assertEqual(status, 200)
+        self.assertEqual(headers["content-type"], "application/manifest+json")
+        manifest = json.loads(body)
+        self.assertEqual(manifest["name"], "ProjectPay")
+        self.assertEqual(manifest["short_name"], "ProjectPay")
+        self.assertEqual(manifest["start_url"], "/")
+        self.assertEqual(manifest["scope"], "/")
+        self.assertEqual(manifest["display"], "standalone")
+        self.assertEqual(manifest["theme_color"], "#176b5b")
+        self.assertEqual(manifest["icons"][0]["src"], invoice_app.DEFAULT_APP_ICON_URL)
+        self.assertEqual(manifest["icons"][0]["sizes"], "512x512")
+        self.assertEqual(manifest["icons"][0]["type"], "image/png")
+
+    def test_manifest_json_uses_configured_app_icon_url(self):
+        app = self.make_app(PUBLIC_APP_ICON_URL="https://example.com/app-icon.png")
+
+        status, headers, body = asyncio.run(self.asgi_get(app, "/manifest.json"))
+
+        self.assertEqual(status, 200)
+        self.assertEqual(headers["content-type"], "application/manifest+json")
+        manifest = json.loads(body)
+        self.assertEqual(manifest["icons"][0]["src"], "https://example.com/app-icon.png")
+
     def test_public_dark_logo_url_defaults_to_public_logo_url(self):
         app = self.make_app(PUBLIC_LOGO_URL="https://example.com/logo.svg")