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
Comments
No comments yet.
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")