Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
FAIL=0

# Allow legitimate locations (Impressum, privacy, terms, COMMERCIAL-LICENSE, gdpr-design, self-hosting).
ALLOW_RE='^(app/templates/(impressum|privacy|terms)\.html|COMMERCIAL-LICENSE\.md|docs/gdpr-account-deletion-design\.md|docs/api-usage-guide\.md|docs/self-hosting\.md|docs-internal/.*|\.githooks/.*|\.github/workflows/scope-guard\.yml|CHANGELOG\.md)$'
# locale/ catalogs are mechanically extracted from the impressum/privacy/terms templates above —
# they cannot avoid carrying the same address/email strings. Treating them as public is consistent
# with the source templates being public.
ALLOW_RE='^(app/templates/(impressum|privacy|terms)\.html|COMMERCIAL-LICENSE\.md|docs/gdpr-account-deletion-design\.md|docs/api-usage-guide\.md|docs/self-hosting\.md|docs-internal/.*|\.githooks/.*|\.github/workflows/scope-guard\.yml|CHANGELOG\.md|locale/.*\.(po|pot|mo))$'

# Personal/operational identifiers that should never land in public code.
PATTERNS='lennart\.seidel@icloud\.com|lennart@filemorph\.io|Reetwerder|21029 Hamburg'
Expand Down
2 changes: 1 addition & 1 deletion .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ set -e
ZERO=0000000000000000000000000000000000000000

# Same patterns as pre-commit — keep in sync.
ALLOW_RE='^(app/templates/(impressum|privacy|terms)\.html|COMMERCIAL-LICENSE\.md|docs/gdpr-account-deletion-design\.md|docs/api-usage-guide\.md|docs/self-hosting\.md|docs-internal/.*|\.githooks/.*|\.github/workflows/scope-guard\.yml|CHANGELOG\.md)$'
ALLOW_RE='^(app/templates/(impressum|privacy|terms)\.html|COMMERCIAL-LICENSE\.md|docs/gdpr-account-deletion-design\.md|docs/api-usage-guide\.md|docs/self-hosting\.md|docs-internal/.*|\.githooks/.*|\.github/workflows/scope-guard\.yml|CHANGELOG\.md|locale/.*\.(po|pot|mo))$'
PATTERNS='lennart\.seidel@icloud\.com|lennart@filemorph\.io|Reetwerder|21029 Hamburg'
OPS_PATTERNS='/opt/filemorph(/|$|[[:space:]])|/var/log/filemorph|/home/deploy([[:space:]]|/)|Hetzner CX|HETZNER_HOST|HETZNER_SSH_USER|HETZNER_SSH_KEY|OPS_REPO_DISPATCH_PAT|GHCR_PAT|appleboy/ssh-action'
SECRET_ASSIGN='(JWT_SECRET|SMTP_PASSWORD|STRIPE_SECRET_KEY|STRIPE_WEBHOOK_SECRET|DATABASE_URL|API_KEY|POSTGRES_PASSWORD|GHCR_PAT|OPS_REPO_DISPATCH_PAT|HETZNER_SSH_KEY)[[:space:]]*=[[:space:]]*[^[:space:]$]'
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ FileMorph runs in three editions, all built from this repository:
|---|---|---|
| **Community** | Self-hosted (Docker, source) | File conversion + compression, REST API, single-user API-key auth |
| **Cloud SaaS** | [filemorph.io](https://filemorph.io) | Community features + user accounts (JWT), tier quotas, Stripe billing, admin cockpit |
| **Compliance** | Self-hosted with commercial licence | Cloud-Edition features + tamper-evident audit log (SHA-256 hash chain), `X-Output-SHA256` integrity header, PDF/A-2b output (veraPDF-validated), default-on EXIF/XMP/IPTC strip, `X-Data-Classification` header, self-service account deletion, signed images (cosign) + signed releases (GPG). For DACH Behörden, Krankenhäuser, and Anwaltskanzleien. |
| **Compliance** | Self-hosted with commercial licence | Cloud-Edition features + tamper-evident audit log (SHA-256 hash chain), `X-Output-SHA256` integrity header, PDF/A-2b output (CI gate validated against veraPDF for a worst-case fixture), default-on EXIF/XMP/IPTC strip, `X-Data-Classification` header, self-service account deletion, signed images (cosign) + cryptographically signed releases. For DACH Behörden, Krankenhäuser, and Anwaltskanzleien. |

The README and `docs/` are written for the **Community** edition. The
Cloud-Edition features (account registration, Stripe checkout, admin
Expand Down
26 changes: 25 additions & 1 deletion app/api/routes/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.api.routes.auth import get_current_user
from app.core.audit import record_event
from app.core.config import settings
from app.db.base import get_db
from app.db.models import TierEnum, User
from app.models.schemas import CheckoutRequest

logger = logging.getLogger(__name__)

Expand All @@ -34,14 +36,27 @@ def _stripe_enabled() -> None:
@router.post("/checkout/{tier}")
async def create_checkout_session(
tier: str,
body: CheckoutRequest,
request: Request,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Create a Stripe Checkout session for the given tier (pro | business)."""
"""Create a Stripe Checkout session for the given tier (pro | business).

Requires `withdrawal_waiver_acknowledged: true` in the request body so the
user has explicitly waived their 14-day §312g BGB / §356 (5) BGB right of
withdrawal — the consent is recorded as a SHA-256 hash-chained audit event
so it can be reproduced at dispute time.
"""
_stripe_enabled()
price_id = _TIER_TO_PRICE.get(tier, "")
if not price_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid tier.")
if not body.withdrawal_waiver_acknowledged:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="withdrawal_waiver_required",
)

stripe.api_key = settings.stripe_secret_key

Expand All @@ -55,6 +70,15 @@ async def create_checkout_session(
await db.commit()
customer_id = customer.id

actor_ip = request.client.host if request.client else None
await record_event(
event_type="billing.checkout.withdrawal_waiver_recorded",
actor_user_id=user.id,
actor_ip=actor_ip,
payload={"tier": tier},
db=db,
)

session = stripe.checkout.Session.create(
customer=customer_id,
payment_method_types=["card"],
Expand Down
2 changes: 1 addition & 1 deletion app/core/jsonld.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def build_site_jsonld(app_base_url: str) -> tuple[str, str]:
"operatingSystem": "Any",
"offers": {"@type": "Offer", "price": "0", "priceCurrency": "EUR"},
"description": (
"Privacy-first file converter & compressor — open-source and self-hostable."
"Privacy-respecting file converter & compressor — open-source and self-hostable."
),
},
{
Expand Down
13 changes: 13 additions & 0 deletions app/models/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,16 @@ class FormatsResponse(BaseModel):

class ErrorResponse(BaseModel):
detail: str


class CheckoutRequest(BaseModel):
"""Body schema for POST /billing/checkout/{tier}.

The user must explicitly waive their 14-day right of withdrawal under
§312g BGB / §356(5) BGB before paid-tier API access can be activated
immediately on Stripe checkout completion. Without this acknowledgement
the request is rejected with HTTP 400 — the standard 14-day withdrawal
protection then applies and immediate activation is deferred.
"""

withdrawal_waiver_acknowledged: bool = False
31 changes: 27 additions & 4 deletions app/static/js/pricing.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
document.addEventListener('DOMContentLoaded', () => {
// Wire withdrawal-waiver checkboxes to enable/disable their target buttons.
// The checkbox + button pair represents an explicit two-step §356 (5) BGB
// consent: the button stays disabled until the user actively ticks the
// waiver box. The `data-target` attribute names the button id.
document.querySelectorAll('.withdrawal-waiver').forEach((cb) => {
const targetId = cb.dataset.target;
const btn = document.getElementById(targetId);
if (!btn) return;
cb.addEventListener('change', () => {
btn.disabled = !cb.checked;
});
});

const proBtn = document.getElementById('pro-btn');
const bizBtn = document.getElementById('business-btn');
if (proBtn) proBtn.addEventListener('click', () => upgrade('pro'));
Expand All @@ -12,18 +25,28 @@ async function upgrade(tier) {
window.location.href = '/register?next=pricing';
return;
}
var btn = document.getElementById(tier + '-btn');
const waiver = document.getElementById(tier + '-waiver');
if (!waiver || !waiver.checked) {
// The button shouldn't be clickable without the checkbox, but guard
// anyway in case the markup or DOM is altered.
return;
}
const btn = document.getElementById(tier + '-btn');
btn.disabled = true;
btn.textContent = 'Redirecting\u2026';
btn.textContent = 'Redirecting';
try {
var res = await window.FM.authFetch('/api/v1/billing/checkout/' + tier, { method: 'POST' });
const res = await window.FM.authFetch('/api/v1/billing/checkout/' + tier, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ withdrawal_waiver_acknowledged: true }),
});
if (res.status === 503) {
btn.disabled = false;
btn.textContent = tier === 'pro' ? 'Upgrade to Pro' : 'Upgrade to Business';
alert('Payments are not yet active. Please check back soon.');
return;
}
var data = await res.json();
const data = await res.json();
if (data.url) window.location.href = data.url;
} catch (e) {
btn.disabled = false;
Expand Down
2 changes: 1 addition & 1 deletion app/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<!-- TODO: add favicon.ico (32x32) and apple-touch-icon.png (180x180) to app/static/ -->

<!-- B-3: Meta tags and Open Graph -->
<meta name="description" content="{{ _('FileMorph — Free online file converter & compressor. Convert images, documents, audio and video. Fast, private, no account required.') }}" />
<meta name="description" content="{{ _('FileMorph — Free online file converter & compressor. Convert images, documents, audio and video. Privacy-respecting, no account required.') }}" />
<link rel="canonical" href="{{ app_base_url }}{{ request.url.path }}" />
<!-- i18n: hreflang alternates so Google indexes each language as its own URL.
x-default points at the unprefixed route, which serves the operator's
Expand Down
20 changes: 10 additions & 10 deletions app/templates/dashboard.html
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
{% extends "base.html" %}
{% import "_components/card.html" as uc %}
{% import "_components/button.html" as ub %}
{% block title %}Dashboard — FileMorph{% endblock %}
{% block title %}{{ _('Dashboard') }} — FileMorph{% endblock %}
{% block content %}
<main class="flex-1 max-w-3xl mx-auto w-full px-4 sm:px-6 py-8 space-y-6">

<h1 class="text-h-sect text-ink">Dashboard</h1>
<h1 class="text-h-sect text-ink">{{ _('Dashboard') }}</h1>

<!-- User info card -->
{% call uc.card() %}
<div class="flex items-center gap-5">
<div id="user-avatar"
class="w-14 h-14 rounded-full bg-brand flex items-center justify-center text-2xl font-bold text-white shrink-0">?</div>
<div class="min-w-0">
<p id="user-email" class="font-semibold truncate">Loading…</p>
<p id="user-email" class="font-semibold truncate">{{ _('Loading…') }}</p>
<span id="user-tier"
class="inline-block mt-1 text-xs px-2 py-0.5 rounded-full bg-brand/20 text-brand capitalize">—</span>
<p class="text-xs text-ink-faint mt-1">Member since <span id="user-since">—</span></p>
<p class="text-xs text-ink-faint mt-1">{{ _('Member since') }} <span id="user-since">—</span></p>
</div>
</div>
{% endcall %}
Expand All @@ -26,28 +26,28 @@ <h1 class="text-h-sect text-ink">Dashboard</h1>
<div class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h2 class="font-semibold">API Keys</h2>
<p class="text-xs text-ink-faint mt-0.5">Use these to authenticate programmatic API requests.</p>
<h2 class="font-semibold">{{ _('API Keys') }}</h2>
<p class="text-xs text-ink-faint mt-0.5">{{ _('Use these to authenticate programmatic API requests.') }}</p>
</div>
{{ ub.button('+ New Key', id='create-key-btn', size='sm') }}
{{ ub.button(_('+ New Key'), id='create-key-btn', size='sm') }}
</div>

<!-- New key reveal box -->
<div id="new-key-box" class="hidden bg-emerald-950 border border-emerald-800 rounded-xl p-4 space-y-2">
<p class="text-xs text-emerald-400 font-semibold">Copy this key now — it will not be shown again.</p>
<p class="text-xs text-emerald-400 font-semibold">{{ _('Copy this key now — it will not be shown again.') }}</p>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<code id="new-key-value"
class="flex-1 text-xs text-emerald-300 break-all font-mono bg-black/30 rounded-lg px-3 py-2"></code>
<button id="copy-key-btn"
class="shrink-0 text-xs px-3 py-2 rounded-lg border border-emerald-700 text-emerald-400 hover:bg-emerald-900 transition-colors">
Copy
{{ _('Copy') }}
</button>
</div>
</div>

<!-- Keys list -->
<div id="keys-list" class="space-y-2">
<p class="text-sm text-ink-faint">Loading…</p>
<p class="text-sm text-ink-faint">{{ _('Loading…') }}</p>
</div>
</div>
{% endcall %}
Expand Down
Loading
Loading