feat: add catalogue check by creating a small service runner
This commit is contained in:
97
API_SERVICE.md
Normal file
97
API_SERVICE.md
Normal file
@@ -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)
|
||||||
67
api_service.py
Normal file
67
api_service.py
Normal file
@@ -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)
|
||||||
@@ -24,5 +24,8 @@ define('MAIL_TO', getenv('MAIL_TO') ?: 'semesterapparate@ph-freiburg.de');
|
|||||||
define('BASE_PATH', __DIR__);
|
define('BASE_PATH', __DIR__);
|
||||||
define('STATIC_PATH', '/static');
|
define('STATIC_PATH', '/static');
|
||||||
|
|
||||||
|
// Signature validation API (optional Python service)
|
||||||
|
define('SIGNATURE_API_URL', getenv('SIGNATURE_API_URL') ?: 'http://localhost:8001');
|
||||||
|
|
||||||
// Email regex pattern
|
// Email regex pattern
|
||||||
define('EMAIL_REGEX', '/^[^@\s]+@[^@\s]+\.[^@\s]+$/');
|
define('EMAIL_REGEX', '/^[^@\s]+@[^@\s]+\.[^@\s]+$/');
|
||||||
|
|||||||
200
php/elsa.php
200
php/elsa.php
@@ -34,7 +34,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="submit_elsa.php" class="request-form">
|
<form method="post" action="submit_elsa.php" class="request-form" data-api-url="<?php echo SIGNATURE_API_URL; ?>">
|
||||||
<h2>Allgemeine Informationen</h2>
|
<h2>Allgemeine Informationen</h2>
|
||||||
<div class="grid-form">
|
<div class="grid-form">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
@@ -137,6 +137,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let sectionCounter = 0;
|
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
|
// Theme toggle functionality
|
||||||
(function() {
|
(function() {
|
||||||
@@ -271,6 +281,7 @@
|
|||||||
'<td><input type="number" name="monografie_pages_from[]" data-section="' + sectionId + '" required min="1"></td>' +
|
'<td><input type="number" name="monografie_pages_from[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||||
'<td><input type="number" name="monografie_pages_to[]" data-section="' + sectionId + '" required min="1"></td>' +
|
'<td><input type="number" name="monografie_pages_to[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||||
'<td><button type="button" class="btn-icon" onclick="removeRow(\'' + rowId + '\')" title="Zeile entfernen"><span class="mdi mdi-delete"></span></button></td>';
|
'<td><button type="button" class="btn-icon" onclick="removeRow(\'' + rowId + '\')" title="Zeile entfernen"><span class="mdi mdi-delete"></span></button></td>';
|
||||||
|
attachSignatureListeners(row);
|
||||||
} else if (type === 'zeitschriftenartikel') {
|
} else if (type === 'zeitschriftenartikel') {
|
||||||
row.innerHTML = '<td><input type="text" name="zeitschrift_author[]" data-section="' + sectionId + '" required></td>' +
|
row.innerHTML = '<td><input type="text" name="zeitschrift_author[]" data-section="' + sectionId + '" required></td>' +
|
||||||
'<td><input type="text" name="zeitschrift_year[]" data-section="' + sectionId + '" required></td>' +
|
'<td><input type="text" name="zeitschrift_year[]" data-section="' + sectionId + '" required></td>' +
|
||||||
@@ -291,6 +302,7 @@
|
|||||||
'<td><input type="text" name="herausgeber_signature[]" data-section="' + sectionId + '" placeholder="Optional"></td>' +
|
'<td><input type="text" name="herausgeber_signature[]" data-section="' + sectionId + '" placeholder="Optional"></td>' +
|
||||||
'<td><input type="number" name="herausgeber_pages_from[]" data-section="' + sectionId + '" required min="1"></td>' +
|
'<td><input type="number" name="herausgeber_pages_from[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||||
'<td><input type="number" name="herausgeber_pages_to[]" data-section="' + sectionId + '" required min="1"></td>' +
|
'<td><input type="number" name="herausgeber_pages_to[]" data-section="' + sectionId + '" required min="1"></td>' +
|
||||||
|
attachSignatureListeners(row);
|
||||||
'<td><button type="button" class="btn-icon" onclick="removeRow(\'' + rowId + '\')" title="Zeile entfernen"><span class="mdi mdi-delete"></span></button></td>';
|
'<td><button type="button" class="btn-icon" onclick="removeRow(\'' + rowId + '\')" title="Zeile entfernen"><span class="mdi mdi-delete"></span></button></td>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,21 +310,203 @@
|
|||||||
function removeRow(rowId) {
|
function removeRow(rowId) {
|
||||||
const row = document.getElementById(rowId);
|
const row = document.getElementById(rowId);
|
||||||
if (row) {
|
if (row) {
|
||||||
|
const signatureInput = row.querySelector('input[name*="_signature"]');
|
||||||
|
if (signatureInput) {
|
||||||
|
cleanupSignatureTracking(signatureInput);
|
||||||
|
}
|
||||||
row.remove();
|
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) {
|
function removeMediaSection(sectionId) {
|
||||||
const section = document.getElementById(sectionId);
|
const section = document.getElementById(sectionId);
|
||||||
if (section) {
|
if (section) {
|
||||||
if (confirm('Möchten Sie diese Sektion wirklich entfernen?')) {
|
if (const rows = section.querySelectorAll('tr[id]');
|
||||||
const type = section.getAttribute('data-type');
|
rows.forEach(row => {
|
||||||
|
const signatureInput = row.querySelector('input[name*="_signature"]');
|
||||||
|
if (signatureInput) {
|
||||||
|
cleanupSignatureTracking(signatureInput);
|
||||||
|
}
|
||||||
|
});
|
||||||
section.remove();
|
section.remove();
|
||||||
const btn = document.getElementById('btn-' + type);
|
const btn = document.getElementById('btn-' + type);
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.title = 'Sektion hinzufügen';
|
btn.title = 'Sektion hinzufügen';
|
||||||
}
|
}
|
||||||
|
updateSubmitButton(); btn.disabled = false;
|
||||||
|
btn.title = 'Sektion hinzufügen';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user