From 74c8eacbf29b191dd82966fd572c763c71166e9c Mon Sep 17 00:00:00 2001 From: WorldTeacher Date: Thu, 20 Nov 2025 13:04:42 +0100 Subject: [PATCH] feat: add catalogue check by creating a small service runner --- API_SERVICE.md | 97 ++++++++++++++++++++++++ api_service.py | 67 +++++++++++++++++ php/config.php | 3 + php/elsa.php | 200 ++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 API_SERVICE.md create mode 100644 api_service.py diff --git a/API_SERVICE.md b/API_SERVICE.md new file mode 100644 index 0000000..b98b6fe --- /dev/null +++ b/API_SERVICE.md @@ -0,0 +1,97 @@ +# Signature Validation API Service + +This is a lightweight Python service that provides signature validation for the PHP application. + +## Why a separate service? + +The `bibapi` library is Python-only and provides access to your library catalog. Rather than rewriting this in PHP, we keep a small Python service running just for signature validation. + +## Running the Service + +### Option 1: Direct Python +```bash +python api_service.py +``` + +### Option 2: With uvicorn +```bash +uvicorn api_service:app --host 0.0.0.0 --port 8001 +``` + +### Option 3: Docker (if you can run containers internally) +```dockerfile +FROM python:3.13-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY api_service.py . +EXPOSE 8001 +CMD ["uvicorn", "api_service:app", "--host", "0.0.0.0", "--port", "8001"] +``` + +```bash +docker build -t signature-api . +docker run -d -p 8001:8001 signature-api +``` + +## Configuration + +Set the API endpoint in your PHP config. Update `php/config.php`: + +```php +// Signature validation API endpoint (optional) +define('SIGNATURE_API_URL', getenv('SIGNATURE_API_URL') ?: 'http://localhost:8001'); +``` + +## Testing + +```bash +# Health check +curl http://localhost:8001/health + +# Validate a signature +curl "http://localhost:8001/api/validate-signature?signature=ABC123" +``` + +## Production Deployment + +1. **Same server**: Run on a different port (8001) alongside your PHP application +2. **Separate server**: Run on internal network, update `SIGNATURE_API_URL` in PHP config +3. **Systemd service** (Linux): + +Create `/etc/systemd/system/signature-api.service`: +```ini +[Unit] +Description=Signature Validation API +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/var/www/signature-api +Environment="PATH=/var/www/signature-api/.venv/bin" +ExecStart=/var/www/signature-api/.venv/bin/uvicorn api_service:app --host 0.0.0.0 --port 8001 +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +Then: +```bash +sudo systemctl enable signature-api +sudo systemctl start signature-api +``` + +## Security + +- In production, update CORS `allow_origins` to only your PHP server domain +- Consider adding API key authentication if exposed to public network +- Run behind reverse proxy (nginx/Apache) with SSL + +## Notes + +- The service is stateless and lightweight +- No data persistence required +- Can be scaled horizontally if needed +- Falls back gracefully if unavailable (ELSA form fields just won't have validation hints) diff --git a/api_service.py b/api_service.py new file mode 100644 index 0000000..543fcb7 --- /dev/null +++ b/api_service.py @@ -0,0 +1,67 @@ +""" +Lightweight Python API service for signature validation +This can run independently to support the PHP application +""" +from fastapi import FastAPI, Query +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from bibapi import catalogue +import re +import os + +app = FastAPI(title="Signature Validation API") + +# Allow PHP application to call this API +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, restrict to your PHP server domain + allow_credentials=True, + allow_methods=["GET"], + allow_headers=["*"], +) + +# Initialize catalogue for signature validation +cat = catalogue.Catalogue() + +@app.get("/api/validate-signature") +async def validate_signature(signature: str = Query(...)): + """Validate a book signature and return total pages""" + try: + book_result = cat.get_book_with_data(signature) + if book_result and hasattr(book_result, "pages") and book_result.pages: + # Try to extract numeric page count + pages_str = str(book_result.pages) + # Extract first number from pages string (e.g., "245 S." -> 245) + match = re.search(r"(\d+)", pages_str) + if match: + total_pages = int(match.group(1)) + return JSONResponse( + {"valid": True, "total_pages": total_pages, "signature": signature} + ) + + return JSONResponse( + { + "valid": False, + "error": "Signatur nicht gefunden oder keine Seitenzahl verfügbar", + "signature": signature, + } + ) + except Exception as e: + return JSONResponse( + { + "valid": False, + "error": f"Fehler bei der Validierung: {str(e)}", + "signature": signature, + }, + status_code=500 + ) + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "ok", "service": "signature-validation"} + +if __name__ == "__main__": + import uvicorn + port = int(os.getenv("API_PORT", "8001")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/php/config.php b/php/config.php index 56db04c..4db6933 100644 --- a/php/config.php +++ b/php/config.php @@ -24,5 +24,8 @@ define('MAIL_TO', getenv('MAIL_TO') ?: 'semesterapparate@ph-freiburg.de'); define('BASE_PATH', __DIR__); define('STATIC_PATH', '/static'); +// Signature validation API (optional Python service) +define('SIGNATURE_API_URL', getenv('SIGNATURE_API_URL') ?: 'http://localhost:8001'); + // Email regex pattern define('EMAIL_REGEX', '/^[^@\s]+@[^@\s]+\.[^@\s]+$/'); diff --git a/php/elsa.php b/php/elsa.php index 9be4905..b70b41b 100644 --- a/php/elsa.php +++ b/php/elsa.php @@ -34,7 +34,7 @@

-
+

Allgemeine Informationen

@@ -137,6 +137,16 @@ }; let sectionCounter = 0; + + // Track signature validations + let signatureTracking = {}; + let validationTimers = {}; + + // Get API URL from form data attribute + const getApiUrl = () => { + const form = document.querySelector('.request-form'); + return form ? form.dataset.apiUrl : 'http://localhost:8001'; + }; // Theme toggle functionality (function() { @@ -271,6 +281,7 @@ '' + '' + ''; + attachSignatureListeners(row); } else if (type === 'zeitschriftenartikel') { row.innerHTML = '' + '' + @@ -291,6 +302,7 @@ '' + '' + '' + + attachSignatureListeners(row); ''; } } @@ -298,21 +310,203 @@ function removeRow(rowId) { const row = document.getElementById(rowId); if (row) { + const signatureInput = row.querySelector('input[name*="_signature"]'); + if (signatureInput) { + cleanupSignatureTracking(signatureInput); + } row.remove(); + updateSubmitButton(); + } + } + + function validateSignature(signatureInput, pagesFromInput, pagesToInput) { + const signature = signatureInput.value.trim(); + + if (!signature) { + signatureInput.classList.remove('signature-validating', 'signature-valid', 'signature-invalid'); + return; + } + + const inputId = signatureInput.id || signatureInput.name; + if (validationTimers[inputId]) { + clearTimeout(validationTimers[inputId]); + } + + signatureInput.classList.add('signature-validating'); + signatureInput.classList.remove('signature-valid', 'signature-invalid'); + + validationTimers[inputId] = setTimeout(async () => { + try { + const apiUrl = getApiUrl(); + const response = await fetch(apiUrl + '/api/validate-signature?signature=' + encodeURIComponent(signature)); + const data = await response.json(); + + if (data.valid) { + if (!signatureTracking[signature]) { + signatureTracking[signature] = { + totalPages: data.total_pages, + inputs: [] + }; + } else { + signatureTracking[signature].totalPages = data.total_pages; + } + + const existingIndex = signatureTracking[signature].inputs.findIndex( + item => item.signature === signatureInput + ); + if (existingIndex === -1) { + signatureTracking[signature].inputs.push({ + signature: signatureInput, + pagesFrom: pagesFromInput, + pagesTo: pagesToInput + }); + } + + signatureInput.classList.remove('signature-validating'); + signatureInput.classList.add('signature-valid'); + signatureInput.title = 'Signatur gefunden: ' + data.total_pages + ' Seiten'; + + checkSignatureThreshold(signature); + } else { + signatureInput.classList.remove('signature-validating'); + signatureInput.classList.add('signature-invalid'); + signatureInput.title = data.error || 'Signatur nicht gefunden'; + updateSubmitButton(); + } + } catch (error) { + signatureInput.classList.remove('signature-validating'); + signatureInput.classList.add('signature-invalid'); + signatureInput.title = 'Validierungsfehler - API nicht erreichbar'; + updateSubmitButton(); + } + }, 800); + } + + function checkSignatureThreshold(signature) { + const tracking = signatureTracking[signature]; + if (!tracking) return; + + let totalRequestedPages = 0; + const threshold = Math.ceil(tracking.totalPages * 0.15); + + tracking.inputs.forEach(item => { + const from = parseInt(item.pagesFrom.value) || 0; + const to = parseInt(item.pagesTo.value) || 0; + if (from > 0 && to > 0 && to >= from) { + totalRequestedPages += (to - from + 1); + } + }); + + const isOverThreshold = totalRequestedPages > threshold; + + tracking.inputs.forEach(item => { + const row = item.signature.closest('tr'); + if (row) { + if (isOverThreshold) { + row.classList.add('threshold-exceeded'); + item.signature.title = 'Warnung: Gesamtanzahl der Seiten (' + totalRequestedPages + ') überschreitet 15% (' + threshold + ' Seiten)'; + } else { + row.classList.remove('threshold-exceeded'); + item.signature.title = 'Signatur gefunden: ' + tracking.totalPages + ' Seiten (Aktuell: ' + totalRequestedPages + '/' + threshold + ')'; + } + } + }); + + updateSubmitButton(); + } + + function cleanupSignatureTracking(signatureInput) { + const signature = signatureInput.value.trim(); + if (signature && signatureTracking[signature]) { + signatureTracking[signature].inputs = signatureTracking[signature].inputs.filter( + item => item.signature !== signatureInput + ); + + if (signatureTracking[signature].inputs.length === 0) { + delete signatureTracking[signature]; + } else { + checkSignatureThreshold(signature); + } + } + } + + function updateSubmitButton() { + const submitButton = document.querySelector('button[type="submit"]'); + const hasInvalidSignatures = document.querySelectorAll('.signature-invalid').length > 0; + const hasThresholdExceeded = document.querySelectorAll('.threshold-exceeded').length > 0; + + if (hasInvalidSignatures || hasThresholdExceeded) { + submitButton.disabled = true; + if (hasThresholdExceeded) { + submitButton.title = 'Formular kann nicht abgesendet werden: 15%-Grenze überschritten'; + } else { + submitButton.title = 'Formular kann nicht abgesendet werden: Ungültige Signaturen'; + } + } else { + submitButton.disabled = false; + submitButton.title = ''; + } + } + + function attachSignatureListeners(row) { + const signatureInput = row.querySelector('input[name*="_signature"]'); + const pagesFromInput = row.querySelector('input[name*="_pages_from"]'); + const pagesToInput = row.querySelector('input[name*="_pages_to"]'); + + if (signatureInput && pagesFromInput && pagesToInput) { + if (!signatureInput.id) { + signatureInput.id = 'sig-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); + } + + signatureInput.addEventListener('input', () => { + const oldSignature = signatureInput.dataset.lastSignature || ''; + const newSignature = signatureInput.value.trim(); + + if (oldSignature && oldSignature !== newSignature) { + const tempInput = document.createElement('input'); + tempInput.value = oldSignature; + cleanupSignatureTracking(tempInput); + } + + signatureInput.dataset.lastSignature = newSignature; + validateSignature(signatureInput, pagesFromInput, pagesToInput); + }); + + pagesFromInput.addEventListener('input', () => { + const signature = signatureInput.value.trim(); + if (signature && signatureTracking[signature]) { + checkSignatureThreshold(signature); + } + }); + + pagesToInput.addEventListener('input', () => { + const signature = signatureInput.value.trim(); + if (signature && signatureTracking[signature]) { + checkSignatureThreshold(signature); + } + }); } } function removeMediaSection(sectionId) { const section = document.getElementById(sectionId); if (section) { - if (confirm('Möchten Sie diese Sektion wirklich entfernen?')) { - const type = section.getAttribute('data-type'); + if (const rows = section.querySelectorAll('tr[id]'); + rows.forEach(row => { + const signatureInput = row.querySelector('input[name*="_signature"]'); + if (signatureInput) { + cleanupSignatureTracking(signatureInput); + } + }); section.remove(); const btn = document.getElementById('btn-' + type); if (btn) { btn.disabled = false; btn.title = 'Sektion hinzufügen'; } + updateSubmitButton(); btn.disabled = false; + btn.title = 'Sektion hinzufügen'; + } } } }