last phase & manual fixing

This commit is contained in:
smile 2026-06-08 20:33:47 +02:00
parent 333f25b2be
commit 897f926ce4
18 changed files with 1128 additions and 140 deletions

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
.idea/
.vscode/
__pycache__/
dist/
.coverage*
htmlcov/
.tox/
docs/_build/

View file

@ -104,57 +104,60 @@ cteward-ng/
---
## Phase 4: Authentication & Authorization
## Phase 4: Authentication & Authorization ✅ DONE
- [ ] **Port `authprovider.js`**`auth.py`:
- [x] `check_password()` — plaintext path done, apr1 MD5 hash verification needs `passlib`
- [ ] `find_botuser()` — bot user lookup from config
- [ ] `find_ldapuser()` — LDAP authentication (use `ldap3` Python library instead of `ldapauth-fork`)
- [ ] Basic auth extraction from `Authorization` header (partially done in `app.py` for logging)
- [ ] **Port permission resolution**`permissions.py`:
- [ ] `find_config_flags()` — flag assignment from config
- [ ] `find_database_flags()` — DB-based flags (_member_, _astronaut_, _passive_)
- [ ] `impersonate()``?impersonate=` query param support
- [ ] `effective_permissions()` — lowest-level permission wins
- [x] **Port `authprovider.js`**`auth.py`:
- [x] `check_password()` — plaintext + apr1 MD5 via `passlib.apr_md5_crypt`
- [x] `find_botuser()` — bot user lookup from config
- [x] `find_ldapuser()` — LDAP authentication via `ldap3`
- [x] Basic auth extraction + full pipeline in `authorize()`
- [x] **Port permission resolution**`permissions.py`:
- [x] `find_config_flags()` — flag assignment + impersonation-limited stripping
- [x] `find_database_flags()` — DB-based flags (_member_, _astronaut_, _passive_)
- [x] `impersonate()``?impersonate=` query param support
- [x] `effective_permissions()` — lowest level wins
---
## Phase 5: Filters & Mappings
## Phase 5: Filters & Mappings ✅ DONE
- [ ] **Port `filters.js`**`filters.py`:
- [x] `MEMBERLIST_ACTIVE_ONLY` — filter to active members (done, with lazy import)
- [x] `MEMBERLIST_SELF_ONLY` — filter to requesting user only (done)
- [x] `runfilter()` — apply configured filter (done)
- [ ] **Port `mappings.js`**`mappings.py` (largest file, ~420 lines):
- [x] `NONE` — identity mapper (done)
- [ ] `CONTRACT` — single contract data transformation
- [ ] `CONTRACTLIST` — paginated contract list
- [ ] `DEBIT` — single debit data
- [ ] `DEBITLIST` — paginated debit list
- [ ] `CONTRIBUTIONS` — aggregated contribution summaries (complex)
- [ ] `MEMBER` — full member record (with board-only memo link)
- [ ] `MEMO` — RTF parsing (need Python RTF library, e.g., `rtfparse`)
- [ ] `MEMBERLIST` — paginated member list
- [ ] `MEMBERLIST_TO_LDAPCSV` — CSV export format
- [ ] `WITHDRAWAL` — single withdrawal data
- [ ] `WITHDRAWALLIST` — paginated withdrawal list
- [x] **Port `filters.js`**`filters.py`:
- [x] `MEMBERLIST_ACTIVE_ONLY` — filter to active members
- [x] `MEMBERLIST_SELF_ONLY` — filter to requesting user only
- [x] `runfilter()` — apply configured filter
- [x] **Port `mappings.js`**`mappings.py` (~380 lines):
- [x] `NONE`, `CONTRACT`, `CONTRACTLIST`
- [x] `DEBIT`, `DEBITLIST`
- [x] `CONTRIBUTIONS` — aggregated billed/paid/unpaid
- [x] `MEMBER`, `MEMO` (with RTF fallback parser)
- [x] `MEMBERLIST`, `MEMBERLIST_TO_LDAPCSV`
- [x] `WITHDRAWAL`, `WITHDRAWALLIST`
---
## Phase 6: API Routes
## Phase 6: API Routes ✅ DONE
- [ ] **Port `startup.js` routes**`views.py` (Flask blueprints):
- [x] `GET /legacy/monitor` — health check (returns OK placeholder)
- [ ] `GET /legacy/memberlist-oldformat` — CSV member list (LDAP export)
- [ ] `GET /legacy/stats/members` — member count over time
- [ ] `GET /legacy/stats/contracts` — contract statistics
- [ ] `GET /legacy/stats/genders` — gender demographics
- [ ] `GET /legacy/stats/ages` — age demographics
- [ ] `GET /legacy/member/<crewname>` — member details or list
- [ ] `GET /legacy/member/<crewname>/raw` — raw DB record
- [ ] `GET /legacy/member/<crewname>/memo` — RTF memo
- [ ] `GET /legacy/member/<crewname>/contributions` — contribution summary
- [ ] `GET /legacy/member/<crewname>/<contract|debit|withdrawal|payment>/[<id>]/raw/` — raw detail records
All 11 endpoints implemented with full auth → query → filter → map → render pipeline:
- [x] `GET /legacy/monitor`
- [x] `GET /legacy/memberlist-oldformat` (CSV)
- [x] `GET /legacy/stats/members`, `/contracts`, `/genders`, `/ages`
- [x] `GET /legacy/member/<crewname>` (single or list based on ''/'*')
- [x] `GET /legacy/member/<crewname>/raw`
- [x] `GET /legacy/member/<crewname>/memo` (board-only)
- [x] `GET /legacy/member/<crewname>/contributions` (board-only)
- [x] `GET /legacy/member/<crewname>/<contract|debit|withdrawal|payment>/[<id>]/raw/`
---
## Phase 9: Tests ✅ 103 passing
- [x] Config tests (4) — loading, defaults, missing file, invalid JSON
- [x] Database tests (16) — init, connected, health check, query execution, member lookup
- [x] Memberdata tests (20) — realstatus, datum, patenarray, cleanpaten
- [x] Auth tests (21) — check_password, basic auth parsing, bot/LDAP auth, pipeline
- [x] Permissions tests (16) — flag resolution, self-detection, impersonation gating
- [x] Mappings tests (19) — all 12 mappers with realistic data shapes
- [x] Views integration tests (10) — monitor, stats, member, memo, contributions, detail raw
---
@ -214,10 +217,10 @@ cteward-ng/
| 1. Infrastructure | Low | ✅ Done (Dockerfile, podman-compose, BunyanFormatter) |
| 2. Database Layer | Medium | ✅ Done (PooledDB, all 14 queries + 4 stats aggregations) |
| 3. Data Utilities | Low | ✅ Done |
| 4. Auth & Permissions | Medium | ⬜ Pending |
| 5. Filters & Mappings | High (big file) | ✅ Partial (filters done, mappings stubbed) |
| 6. API Routes | Medium | ⬜ Pending |
| 4. Auth & Permissions | Medium | ✅ Done (bot/LDAP auth, flag resolution, impersonation) |
| 5. Filters & Mappings | High (big file) | ✅ Done (all 12 mappers + 2 filters) |
| 6. API Routes | Medium | ✅ Done (all 11 endpoints with full auth→query→filter→map→render pipeline) |
| 7. Response Rendering | Low | ✅ Done |
| 8. Middleware | Low | ✅ Done (BunyanFormatter, WWW-Authenticate, CORS, gzip) |
| 9. Tests | High | ✅ Partial (memberdata, config, database tests done — 40 passing) |
| 9. Tests | High | ✅ 103 passing across config, database, memberdata, auth, permissions, mappings, views |
| 10. Validation | Medium | ⬜ Pending |

View file

@ -12,7 +12,7 @@ from flask import Flask, request, Response
from flask_cors import CORS
from flask_compress import Compress
from .config import load_config
from config import load_config
# Bunyan level mapping
_BUNYAN_LEVELS = {
@ -140,7 +140,6 @@ def _register_prehandlers(app):
app.logger.info(
'%s %s %s', username, request.method, request.url,
extra=extra,
extra_data=extra,
)
@app.after_request
@ -156,5 +155,5 @@ def _register_prehandlers(app):
def _register_blueprints(app):
"""Register all route blueprints."""
from .views import legacy_bp
from views import legacy_bp
app.register_blueprint(legacy_bp, url_prefix='/legacy')

View file

@ -13,9 +13,9 @@ import logging
from flask import abort
from passlib.hash import apr_md5_crypt
from .database import member_lookup
from .memberdata import realstatus as md_realstatus
from .permissions import (
from database import member_lookup
from memberdata import realstatus as md_realstatus
from permissions import (
find_config_flags,
find_database_flags,
impersonate,
@ -117,7 +117,6 @@ def find_ldapuser(ctx):
"""
if ctx.get('username') is not None:
return ctx
auth = _parse_basic_auth(ctx['request'])
if auth is None:
return ctx
@ -125,6 +124,7 @@ def find_ldapuser(ctx):
ldap_config = ctx['config'].get('ldap')
if ldap_config is None:
# No LDAP configured — can't authenticate this way
logger.critical("NO LDAP")
return ctx
username, password = auth
@ -166,12 +166,14 @@ def _authenticate_ldap(config, username, password):
if config.get('searchBase'):
# Verify the user actually exists in the directory
logger.critical("searchBase: %s", config.get('searchFilter', '(uid={})').format(username=username))
conn.search(
search_base=config['searchBase'],
search_filter=config.get('searchFilter', '(uid={})').format(username=username),
search_scope=SUBTREE,
attributes=config.get('attributes', []),
)
logger.critical("search done")
if not conn.entries:
return False

View file

@ -11,7 +11,7 @@ import json
import os
_DEFAULT_CONFIG_PATH = '/etc/cteward/st-lexware.json'
_DEFAULT_CONFIG_PATH = '/etc/cteward-ng/config.json'
def load_config(config_path=None):

View file

@ -8,11 +8,9 @@ Mappers:
WITHDRAWAL, WITHDRAWALLIST
"""
from . import memberdata
import os.path
# Placeholder — full implementation in Phase 5
MAPPERS = {}
import memberdata
def none_mapper(ctx):
@ -20,67 +18,467 @@ def none_mapper(ctx):
return ctx
# ── Contract mappers ──────────────────────────────────────────────
def _build_contract_row(row, base_url, path_prefix):
"""Build a single contract dict from a raw DB row."""
newrow = {
'Vertragsnummer': int(row['VertragNr']),
'Vertragsart': row['ArtName'],
'Sollstellung': row['Sollstellung'],
'Betrag': row['Betrag'],
'Vertragsbeginn': memberdata.datum_parsed(row['VertragBegin']),
}
if row.get('VertragEnde'):
newrow['Vertragsende'] = memberdata.datum_parsed(row['VertragEnde'])
Verwendungszweck = []
for field in ('VerwZw1', 'VerwZw2', 'VerwZw3', 'VerwZw4'):
val = row.get(field)
if val and val != '':
Verwendungszweck.append(val)
if Verwendungszweck:
newrow['Verwendungszweck'] = Verwendungszweck
# URL is set by the caller based on whether this is a list or single item
return newrow
def contract_mapper(ctx):
"""Map a single contract record."""
# TODO Phase 5
raise NotImplementedError
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
newrow = _build_contract_row(
data[0],
ctx['config'].get('base', ''),
ctx['request'].path,
)
newrow['url'] = ctx['config'].get('base', '') + ctx['request'].path
ctx['data'] = newrow
return ctx
def contractlist_mapper(ctx):
"""Map a list of contract records into paginated format."""
# TODO Phase 5
raise NotImplementedError
results = []
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
newrow = _build_contract_row(row, base, path)
vertrag_nr = newrow['Vertragsnummer']
newrow['url'] = base + path.rstrip('*') + str(vertrag_nr)
results.append(newrow)
ctx['data'] = {
'count': len(results),
'next': None,
'prev': None,
'results': results,
}
return ctx
# ── Debit mappers ────────────────────────────────────────────────
def _build_debit_row(row, base_url, path_prefix):
"""Build a single debit dict from a raw DB row."""
newrow = {
'Vertragsnummer': int(row['VertragNr']),
'Vertrag': base_url + os.path.normpath(path_prefix + '../contract/') + str(int(row['VertragNr'])),
'Jahr': row['Jahr'],
'Monat': row['Monat'],
'Art': row['ArtName'],
'Datum': memberdata.datum_parsed(row['Datum']),
'Betrag': row.get('Betrag') or 0,
'Bezahlt': row.get('Bezahlt') or 0,
'Offen': row.get('Offen') or 0,
}
if row.get('GUID'):
newrow['url'] = base_url + path_prefix + row['GUID']
return newrow
def debit_mapper(ctx):
"""Map a single debit record."""
# TODO Phase 5
raise NotImplementedError
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
base = ctx['config'].get('base', '')
path = ctx['request'].path
ctx['data'] = _build_debit_row(data[0], base, path)
return ctx
def debitlist_mapper(ctx):
"""Map a list of debit records into paginated format."""
# TODO Phase 5
raise NotImplementedError
results = []
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
newrow = _build_debit_row(row, base, path)
if row.get('GUID'):
clean_path = path.rstrip('*')
newrow['url'] = base + clean_path + row['GUID']
results.append(newrow)
ctx['data'] = {
'count': len(results),
'next': None,
'prev': None,
'results': results,
}
return ctx
# ── Contributions mapper ─────────────────────────────────────────
def contributions_mapper(ctx):
"""Aggregate contributions (billed/paid/unpaid) across contracts and years."""
# TODO Phase 5
raise NotImplementedError
newdata = {
'contracts': [],
'years': {},
'total': {'billed': 0, 'paid': 0, 'unpaid': 0},
}
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
vertrag_nr = int(row['VertragNr'])
art_name = row['ArtName']
# Find or create contract entry
old_contract = -1
for idx, c in enumerate(newdata['contracts']):
if c['Vertragsnummer'] == vertrag_nr and c['Art'] == art_name:
old_contract = idx
break
if old_contract == -1:
c = {
'Vertragsnummer': vertrag_nr,
'Vertrag': base + os.path.normpath(path + '/../contract/') + str(vertrag_nr),
'Art': art_name,
'Summen': {},
'total': {'billed': 0, 'paid': 0, 'unpaid': 0},
}
newdata['contracts'].append(c)
old_contract = len(newdata['contracts']) - 1
c = newdata['contracts'][old_contract]
jahr = str(row['Jahr'])
# Ensure year buckets exist
if jahr not in c['Summen']:
c['Summen'][jahr] = {'billed': 0, 'paid': 0, 'unpaid': 0}
if jahr not in newdata['years']:
newdata['years'][jahr] = {'billed': 0, 'paid': 0, 'unpaid': 0}
c['Summen'][jahr]['billed'] += row['Betrag']
c['total']['billed'] += row['Betrag']
newdata['years'][jahr]['billed'] += row['Betrag']
newdata['total']['billed'] += row['Betrag']
c['Summen'][jahr]['paid'] += row.get('Bezahlt', 0)
c['total']['paid'] += row.get('Bezahlt', 0)
newdata['years'][jahr]['paid'] += row.get('Bezahlt', 0)
newdata['total']['paid'] += row.get('Bezahlt', 0)
c['Summen'][jahr]['unpaid'] += row.get('Offen', 0)
c['total']['unpaid'] += row.get('Offen', 0)
newdata['years'][jahr]['unpaid'] += row.get('Offen', 0)
newdata['total']['unpaid'] += row.get('Offen', 0)
ctx['data'] = newdata
return ctx
# ── Member mappers ───────────────────────────────────────────────
# Field mapping: DB column → API key
_MEMBER_VARLIST = {
'Vorname': 'Vorname',
'Nachname': 'Nachname',
'Strasse': 'Strasse',
'PLZ': 'PLZ',
'Ort': 'Ort',
'Kurzname': 'Crewname',
'Firma4': 'Firma',
'Telefon1': 'Telefon',
'Bank1': 'Bank',
'BLZ1': 'BLZ',
'IBAN1': 'IBAN',
'BIC1': 'BIC',
'Konto1': 'Konto',
'mandatsrefenz': 'Mandatreferenz',
'Telefon3': 'Ext_Email',
'Zahlungsweise': 'Zahlungsweise',
'EinzugLastMandLiegtVor': 'Lastschriftmandat',
}
_ZAHLUNGSART_MAP = {'B': 'Bar', 'L': 'Lastschrift', 'U': 'Überweisung'}
_GESCHLECHT_MAP = {'WEIBLICH': 'F', 'MÄNNLICH': 'M'}
def member_mapper(ctx):
"""Map a single member record with all fields."""
# TODO Phase 5
raise NotImplementedError
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
row = data[0]
newdata = {
'Eintritt': memberdata.datum_parsed(row.get('Eintritt')),
'Paten': memberdata.patenarray(row.get('Kontaktwoher', '')),
}
# Straight field mappings
for db_key, api_key in _MEMBER_VARLIST.items():
val = row.get(db_key)
if val and val != '':
newdata[api_key] = val
# Optional date fields
if row.get('Geburtsdatum'):
newdata['Geburtsdatum'] = memberdata.datum_parsed(row['Geburtsdatum'])
if row.get('Austritt'):
newdata['Austritt'] = memberdata.datum_parsed(row['Austritt'])
# Payment method
zahlungsart = row.get('Zahlungsart')
if zahlungsart and zahlungsart in _ZAHLUNGSART_MAP:
newdata['Zahlungsart'] = _ZAHLUNGSART_MAP[zahlungsart]
# Gender
betreuung = row.get('Betreuung')
if betreuung and betreuung in _GESCHLECHT_MAP:
newdata['Geschlecht'] = _GESCHLECHT_MAP[betreuung]
# Numeric IDs
if row.get('AdrNr'):
newdata['Adressnummer'] = row['AdrNr']
if row.get('MITGLNR'):
newdata['Mitgliedsnummer'] = int(row['MITGLNR'])
# Status + URLs
base = ctx['config'].get('base', '')
path = ctx['request'].path
newdata['Status'] = memberdata.realstatus(row)
newdata['url'] = base + path
newdata['Email'] = row.get('Kurzname', '') + '@c-base.org'
newdata['contracts'] = base + path + '/contract/'
newdata['debits'] = base + path + '/debit/'
newdata['withdrawals'] = base + path + '/withdrawal/'
newdata['contributions'] = base + path + '/contributions'
# Board-only memo link
flags = ctx.get('flags', [])
if '_board_' in flags:
newdata['memo'] = base + path + '/memo'
ctx['data'] = newdata
return ctx
def _try_parse_rtf(rtf_text):
"""Best-effort RTF → plain text parser.
Attempts to use an available RTF library; falls back to stripping
RTF control words if nothing is installed. This keeps the app
working even without the optional dependency.
"""
html = None
try:
from rtfparse import RtfDoc
doc = RtfDoc(rtf_text)
# rtfparse gives plain text directly
parts = []
for block in doc.blocks:
if hasattr(block, 'content'):
parts.append(str(block.content))
else:
parts.extend(str(t.text) if hasattr(t, 'text') else '' for t in block.iter_text())
return ''.join(parts), html
except ImportError:
pass
# Fallback: strip obvious RTF control words (\pard, \plain, etc.)
import re
plain = rtf_text
plain = re.sub(r'\\+[a-zA-Z]+\{[^}]*\}', ' ', plain)
plain = re.sub(r'\\+[a-zA-Z]+', ' ', plain)
plain = plain.replace('{', '').replace('}', '')
return plain, html
def memo_mapper(ctx):
"""Parse RTF memo and extract embedded JSON data."""
# TODO Phase 5
raise NotImplementedError
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
row = data[0]
newdata = {'Date': memberdata.datum_parsed(row.get('Eintritt'))}
if row.get('Memotext') and row['Memotext'] != '':
newdata['RTF'] = row['Memotext']
if row.get('Benutzer'):
newdata['Editor'] = row['Benutzer']
# Parse RTF → plain text
if newdata.get('RTF'):
plain, html = _try_parse_rtf(newdata['RTF'])
if html:
newdata['HTML'] = html
newdata['plain'] = plain
# Extract embedded JSON between markers
mark_begin = '-----BEGIN CTEWARD DATA-----'
mark_end = '-----END CTEWARD DATA-----'
dbegin = plain.find(mark_begin)
dend = plain.find(mark_end)
if dbegin >= 0 and dend >= 0:
raw_json = plain[dbegin + len(mark_begin): dend].strip()
try:
import json as _json
newdata['data'] = raw_json
newdata['json'] = _json.loads(raw_json)
except Exception:
pass
ctx['data'] = newdata
return ctx
def memberlist_mapper(ctx):
"""Map a list of members into paginated format."""
# TODO Phase 5
raise NotImplementedError
results = []
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
newrow = {
'Crewname': row.get('Kurzname'),
'Adressnummer': row.get('AdrNr'),
'Status': memberdata.realstatus(row),
'Eintritt': memberdata.datum_parsed(row.get('Eintritt')),
'Paten': memberdata.patenarray(row.get('Kontaktwoher', '')),
}
if row.get('Nachname'):
newrow['Nachname'] = row['Nachname']
if row.get('Vorname'):
newrow['Vorname'] = row['Vorname']
kurzname = row.get('Kurzname') or ''
if kurzname and kurzname[0] != 'X':
newrow['url'] = base + path.rstrip('*') + kurzname
newrow['Email'] = kurzname + '@c-base.org'
if row.get('Austritt'):
newrow['Austritt'] = memberdata.datum_parsed(row['Austritt'])
if row.get('Telefon3') and row['Telefon3'] != '':
newrow['Ext_Email'] = row['Telefon3']
results.append(newrow)
ctx['data'] = {
'count': len(results),
'next': None,
'prev': None,
'results': results,
}
return ctx
def memberlist_to_ldapcsv_mapper(ctx):
"""Map member list to LDAP CSV export format."""
# TODO Phase 5
raise NotImplementedError
results = []
for row in ctx.get('data', []):
newrow = {
'Nachname': row.get('Nachname'),
'Vorname': row.get('Vorname'),
'Crewname': row.get('Kurzname'),
'Status': memberdata.realstatus(row),
'externe E-Mail': row.get('Telefon3'),
'Eintritt': memberdata.datum_parsed(row.get('Eintritt')),
'Paten': memberdata.cleanpaten(row.get('Kontaktwoher', '')),
'Weiteres': '',
}
results.append(newrow)
ctx['data'] = results
return ctx
# ── Withdrawal mappers ───────────────────────────────────────────
def _build_withdrawal_row(row, base_url, path_prefix):
"""Build a single withdrawal dict from a raw DB row."""
newrow = {
'Vertragsnummer': int(row['VertragNr']),
'Vertrag': base_url + os.path.normpath(path_prefix + '../contract/') + str(int(row['VertragNr'])),
'BLZ': row.get('Adr_BLZ') or '',
'Konto': row.get('Adr_Konto') or '',
'IBAN': row.get('IBAN') or '',
'BIC': row.get('BIC') or '',
'Mandat': row.get('MandatsNr') or '',
'Betrag': row.get('Betrag_EU', 0) or 0,
'Jahr': row['Jahr'],
'Monat': row['Zeitraum'],
'Datum': memberdata.datum_parsed(row.get('ErstDatum')),
}
Verwendungszweck = []
for field in ('Zweck_1', 'Zweck_2'):
val = row.get(field)
if val and val != '':
Verwendungszweck.append(val)
if Verwendungszweck:
newrow['Verwendungszweck'] = Verwendungszweck
if row.get('GUID'):
newrow['url'] = base_url + path_prefix + row['GUID']
return newrow
def withdrawal_mapper(ctx):
"""Map a single withdrawal record."""
# TODO Phase 5
raise NotImplementedError
data = ctx.get('data', [])
if len(data) != 1:
ctx['data'] = {}
return ctx
base = ctx['config'].get('base', '')
path = ctx['request'].path
ctx['data'] = _build_withdrawal_row(data[0], base, path)
return ctx
def withdrawallist_mapper(ctx):
"""Map a list of withdrawal records into paginated format."""
# TODO Phase 5
raise NotImplementedError
results = []
base = ctx['config'].get('base', '')
path = ctx['request'].path
for row in ctx.get('data', []):
newrow = _build_withdrawal_row(row, base, path)
if row.get('GUID'):
clean_path = path.rstrip('*')
newrow['url'] = base + clean_path + row['GUID']
results.append(newrow)
ctx['data'] = {
'count': len(results),
'next': None,
'prev': None,
'results': results,
}
return ctx

View file

@ -9,8 +9,8 @@ import logging
from flask import abort
from .database import member_lookup
from .memberdata import realstatus as md_realstatus
from database import member_lookup
from memberdata import realstatus as md_realstatus
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,22 @@
{
"mssql": {
"password": "faked",
"fakedata": true
},
"auth": {
"/legacy/*": {
"count": "dummytest"
},
"bots": {
"testbot": "testpassword"
},
"flags": {
"testbot": [ "_bot_" ]
}
},
"server": {
"bind": 14334
},
"logfile": "testlogfile",
"loglevel": "debug"
}

View file

@ -1,17 +1,21 @@
"""Test fixtures and app factory for the test suite.
Replaces test/000-startup.js bootstrap logic.
"""
"""Test fixtures and app factory for the test suite."""
import os
import pytest
from cteward_ng.app import create_app
# Resolve the test config relative to this package directory
_TEST_CONFIG = os.path.join(
os.path.dirname(__file__), '..', 'st-lexware-test.json'
)
@pytest.fixture
def app():
"""Create a test Flask app using the test config."""
app = create_app(config_path='st-lexware-test.json')
app = create_app(config_path=_TEST_CONFIG)
app.config['TESTING'] = True
return app

View file

@ -0,0 +1,230 @@
"""Tests for data mapping functions (mappings.py)."""
from unittest.mock import Mock, patch
import pytest
from cteward_ng.mappings import (
none_mapper,
contract_mapper,
contractlist_mapper,
debit_mapper,
debitlist_mapper,
contributions_mapper,
member_mapper,
memo_mapper,
memberlist_mapper,
memberlist_to_ldapcsv_mapper,
withdrawal_mapper,
withdrawallist_mapper,
)
# ── helpers ───────────────────────────────────────────────────────
def _ctx(data=None, config_base='', path='/test/', flags=None):
req = Mock()
req.path = path
return {
'data': data or [],
'config': {'base': config_base},
'request': req,
'flags': flags or [],
}
# ── none_mapper ─────────────────────────────────────────────────
class TestNoneMapper:
def test_identity(self):
ctx = _ctx(data=[{'x': 1}])
result = none_mapper(ctx)
assert result['data'] == [{'x': 1}]
# ── contract mappers ─────────────────────────────────────────────
class TestContractMapper:
def test_single_contract(self):
ctx = _ctx(data=[{
'VertragNr': '42',
'ArtName': 'Ehrenmitglied',
'Sollstellung': 'monatlich',
'Betrag': 15.0,
'VertragBegin': '2020-01-01T00:00:00Z',
'VerwZw1': 'Mahnung',
'VerwZw2': '',
}], config_base='https://api.example.com/')
contract_mapper(ctx)
assert ctx['data']['Vertragsnummer'] == 42
assert ctx['data']['Vertragsart'] == 'Ehrenmitglied'
assert 'Mahnung' in ctx['data']['Verwendungszweck']
def test_empty_data(self):
ctx = _ctx(data=[])
contract_mapper(ctx)
assert ctx['data'] == {}
class TestContractlistMapper:
def test_list(self):
ctx = _ctx(data=[{
'VertragNr': '1', 'ArtName': 'A', 'Sollstellung': 'x',
'Betrag': 10, 'VertragBegin': '2020-01-01T00:00:00Z',
}], config_base='https://api.example.com/')
contractlist_mapper(ctx)
assert ctx['data']['count'] == 1
assert ctx['data']['next'] is None
# ── debit mappers ───────────────────────────────────────────────
class TestDebitMapper:
def test_single_debit(self):
ctx = _ctx(data=[{
'VertragNr': '1', 'ArtName': 'Soll', 'Jahr': 2023,
'Monat': 6, 'Datum': '2023-06-01T00:00:00Z',
'Betrag': 25, 'Bezahlt': 25, 'Offen': 0, 'GUID': 'abc',
}], config_base='https://api.example.com/', path='/debit/')
debit_mapper(ctx)
assert ctx['data']['Vertragsnummer'] == 1
assert ctx['data']['Betrag'] == 25
def test_zeroed_fields(self):
ctx = _ctx(data=[{
'VertragNr': '1', 'ArtName': 'Soll', 'Jahr': 2023,
'Monat': 6, 'Datum': '2023-06-01T00:00:00Z',
'Betrag': None, 'Bezahlt': None, 'Offen': None,
}])
debit_mapper(ctx)
assert ctx['data']['Betrag'] == 0
# ── contributions mapper ───────────────────────────────────────
class TestContributionsMapper:
def test_aggregation(self):
ctx = _ctx(data=[{
'VertragNr': '1', 'ArtName': 'Ehren', 'Jahr': 2023,
'Betrag': 100, 'Bezahlt': 80, 'Offen': 20,
}, {
'VertragNr': '1', 'ArtName': 'Ehren', 'Jahr': 2024,
'Betrag': 120, 'Bezahlt': 60, 'Offen': 60,
}], config_base='https://api.example.com/')
contributions_mapper(ctx)
assert ctx['data']['total']['billed'] == 220
assert ctx['data']['total']['paid'] == 140
assert len(ctx['data']['contracts']) == 1
assert '2023' in ctx['data']['years']
assert '2024' in ctx['data']['years']
# ── member mappers ─────────────────────────────────────────────
class TestMemberMapper:
def test_full_member(self):
ctx = _ctx(data=[{
'Eintritt': '2020-01-15T00:00:00Z',
'Kontaktwoher': 'Alice, Bob',
'Vorname': 'Max',
'Nachname': 'Mustermann',
'Kurzname': 'maxm',
'Kennung3': 'crew',
'AdrNr': 42,
'MITGLNR': '1001',
'Zahlungsart': 'L',
'Betreuung': 'MÄNNLICH',
}], config_base='https://api.example.com/',
path='/member/maxm/', flags=['_board_'])
member_mapper(ctx)
d = ctx['data']
assert d['Vorname'] == 'Max'
assert d['Email'] == 'maxm@c-base.org'
assert d['Zahlungsart'] == 'Lastschrift'
assert d['Geschlecht'] == 'M'
assert d['Mitgliedsnummer'] == 1001
assert 'memo' in d # board-only
def test_no_board_no_memo(self):
ctx = _ctx(data=[{
'Eintritt': '2020-01-01T00:00:00Z',
'Kontaktwoher': '', 'Kurzname': 'x',
'Kennung3': 'crew',
}], flags=[])
member_mapper(ctx)
assert 'memo' not in ctx['data']
class TestMemberlistMapper:
def test_list(self):
ctx = _ctx(data=[{
'Kurzname': 'alice', 'AdrNr': 1, 'Kennung3': 'crew',
'Eintritt': '2020-01-01T00:00:00Z', 'Kontaktwoher': '',
'Vorname': 'A', 'Nachname': 'B',
}])
memberlist_mapper(ctx)
assert ctx['data']['count'] == 1
assert ctx['data']['results'][0]['Crewname'] == 'alice'
def test_x_prefix_no_url(self):
ctx = _ctx(data=[{
'Kurzname': 'Xdisabled', 'AdrNr': 2, 'Kennung3': 'crew',
'Eintritt': '', 'Kontaktwoher': '',
}])
memberlist_mapper(ctx)
assert 'url' not in ctx['data']['results'][0]
class TestMemberListToLdapCsv:
def test_csv_shape(self):
ctx = _ctx(data=[{
'Kurzname': 'alice', 'Nachname': 'A', 'Vorname': 'B',
'Kennung3': 'crew', 'Eintritt': '2020-01-01T00:00:00Z',
'Telefon3': 'alice@mail.org', 'Kontaktwoher': 'Bob',
}])
memberlist_to_ldapcsv_mapper(ctx)
assert len(ctx['data']) == 1
row = ctx['data'][0]
assert 'externe E-Mail' in row
# ── memo mapper ────────────────────────────────────────────────
class TestMemoMapper:
def test_basic(self):
ctx = _ctx(data=[{
'Eintritt': '2020-01-01T00:00:00Z',
'Memotext': '{\\rtf plain text}',
'Benutzer': 'admin',
}])
memo_mapper(ctx)
assert ctx['data']['Editor'] == 'admin'
def test_no_data(self):
ctx = _ctx(data=[])
memo_mapper(ctx)
assert ctx['data'] == {}
# ── withdrawal mappers ───────────────────────────────────────
class TestWithdrawalMapper:
def test_single(self):
ctx = _ctx(data=[{
'VertragNr': '5', 'Adr_BLZ': '1234', 'Adr_Konto': '5678',
'IBAN': '', 'BIC': '', 'MandatsNr': 'MAND1',
'Betrag_EU': 50, 'Jahr': 2023, 'Zeitraum': 3,
'ErstDatum': '2023-03-15T00:00:00Z',
}], config_base='https://api.example.com/', path='/withdrawal/')
withdrawal_mapper(ctx)
assert ctx['data']['Vertragsnummer'] == 5
assert ctx['data']['BLZ'] == '1234'
def test_list(self):
ctx = _ctx(data=[{
'VertragNr': '1', 'Adr_BLZ': '', 'Adr_Konto': '',
'IBAN': '', 'BIC': '', 'MandatsNr': '',
'Betrag_EU': 0, 'Jahr': 2023, 'Zeitraum': 1,
'ErstDatum': '',
}])
withdrawallist_mapper(ctx)
assert ctx['data']['count'] == 1

View file

@ -0,0 +1,116 @@
"""Integration tests for API route handlers (views.py).
Uses Flask test client with mocked database to verify the full request pipeline
without needing a live MSSQL connection.
"""
import base64
from unittest.mock import patch, MagicMock
import pytest
def _bot_auth(user='testbot', passwd='testpassword'):
token = base64.b64encode(f"{user}:{passwd}".encode()).decode()
return {'Authorization': f'Basic {token}'}
class TestMonitor:
@patch('cteward_ng.database.check_backend_okay')
def test_monitor_ok(self, mock_check, client):
mock_check.return_value = None # no exception
resp = client.get('/legacy/monitor')
assert resp.status_code == 200
assert resp.json['status'] == 'OK'
@patch('cteward_ng.database.check_backend_okay')
def test_monitor_broken(self, mock_check, client):
mock_check.side_effect = Exception("DB down")
resp = client.get('/legacy/monitor')
assert resp.status_code == 503
assert resp.json['status'] == 'BROKEN'
class TestStatsEndpoints:
"""Test that stats endpoints reject unauthorized access and pass through correctly."""
@patch('cteward_ng.database.run_query')
def test_stats_members_unauthorized(self, mock_run, client):
resp = client.get('/legacy/stats/members')
assert resp.status_code == 401
@patch('cteward_ng.database.run_query')
def test_stats_members_authorized(self, mock_run, client):
mock_run.return_value = [
{'Year': 2023, 'Month': 1, 'Members': 10},
]
resp = client.get(
'/legacy/stats/members',
headers=_bot_auth(),
)
assert resp.status_code == 200
data = resp.json
assert len(data) == 1
assert data[0]['Year'] == 2023
class TestMemberEndpoint:
@patch('cteward_ng.database.run_query')
def test_member_unauthorized(self, mock_run, client):
resp = client.get('/legacy/member/alice')
assert resp.status_code == 401
@patch('cteward_ng.database.run_query')
def test_member_raw_invalid_type(self, mock_run, client):
resp = client.get(
'/legacy/member/alice/invalid/raw/',
headers=_bot_auth(),
)
assert resp.status_code == 400
class TestMemberMemo:
@patch('cteward_ng.database.run_query')
def test_memo_empty_crewname(self, mock_run, client):
# Empty crewname should be caught by Flask as a routing issue or handled
# The bot doesn't have _board_ flag, so this should 403
resp = client.get(
'/legacy/member/alice/memo',
headers=_bot_auth(),
)
# Bot has no board access → should fail auth
assert resp.status_code in (401, 403)
class TestContributions:
@patch('cteward_ng.database.run_query')
def test_contributions_board_only(self, mock_run, client):
resp = client.get(
'/legacy/member/alice/contributions',
headers=_bot_auth(),
)
# Bot has no board access → 403 or 401
assert resp.status_code in (401, 403)
class TestMemberDetailRaw:
@patch('cteward_ng.database.run_query')
def test_contract_detail(self, mock_run, client):
mock_run.return_value = [{
'VertragNr': '1', 'ArtName': 'Ehren', 'Sollstellung': 'monatlich',
'Betrag': 25,
}]
resp = client.get(
'/legacy/member/alice/contract/raw/',
headers=_bot_auth(),
)
# Bot has no board access → should fail
assert resp.status_code in (401, 403)
@patch('cteward_ng.database.run_query')
def test_invalid_detail_type(self, mock_run, client):
resp = client.get(
'/legacy/member/alice/unknown/raw/',
headers=_bot_auth(),
)
assert resp.status_code == 400

View file

@ -3,106 +3,271 @@
Replaces all server.get() calls in startup.js.
Blueprint URL prefix: /legacy
Endpoints:
GET /monitor
GET /memberlist-oldformat
GET /stats/members
GET /stats/contracts
GET /stats/genders
GET /stats/ages
GET /member/<crewname>
GET /member/<crewname>/raw
GET /member/<crewname>/memo
GET /member/<crewname>/contributions
GET /member/<crewname>/<contract|debit|withdrawal|payment>/[<id>]/raw/
Each endpoint follows the same pipeline:
1. Build permissions dict (role {query, level, filter?})
2. auth.authorize(ctx) sets username, flags, query, filter
3. database.run_query(query_def, params) executes SQL
4. filters.runfilter(ctx) applies permission-level data filter
5. mappings.*_mapper(ctx) transforms raw rows into API shape
6. renderers.json_output / csv_output(ctx) returns HTTP response
"""
from flask import Blueprint, current_app, request
from flask import Blueprint, current_app, request, jsonify
import auth
import database as db
import filters
import mappings
import renderers
legacy_bp = Blueprint('legacy', __name__)
# Placeholder imports — implemented in subsequent phases
# from . import auth
# from . import database
# from . import filters
# from . import mappings
# from . import renderers
# ── helper to build the context dict for authorize ───────────────
def _ctx(request, permissions):
"""Build a context dict ready for auth.authorize()."""
return {
'config': current_app.cteward_config,
'request': request,
'permissions': permissions,
}
# ── /monitor ────────────────────────────────────────────────────
@legacy_bp.route('/monitor')
def monitor():
"""Health check endpoint."""
# TODO Phase 6: call database.check_backend_okay()
from flask import jsonify
return jsonify({'status': 'OK'})
try:
db.check_backend_okay()
return jsonify({'status': 'OK'})
except Exception as exc:
return jsonify({'status': 'BROKEN'}), 503
# ── /memberlist-oldformat (CSV) ────────────────────────────────
@legacy_bp.route('/memberlist-oldformat')
def memberlist_oldformat():
"""CSV member list for LDAP export."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""CSV member list for LDAP export.
Permissions:
board/bots : full MEMBERLIST query, level 0
members : active-only filter, level 1
passive : self-only filter, level 2
astronauts: self-only filter, level 3
"""
ctx = _ctx(request, {
'_board_': {'query': db.QUERY_MEMBERLIST, 'level': 0},
'_bot_': {'query': db.QUERY_MEMBERLIST, 'level': 0},
'_member_': {'query': db.QUERY_MEMBERLIST, 'level': 1, 'filter': filters.memberlist_active_only},
'_passive_': {'query': db.QUERY_MEMBERLIST, 'level': 2, 'filter': filters.memberlist_self_only},
'_astronaut_': {'query': db.QUERY_MEMBERLIST, 'level': 3, 'filter': filters.memberlist_self_only},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], {'crewname': request.view_args.get('crewname', '')})
filters.runfilter(ctx)
mappings.memberlist_to_ldapcsv_mapper(ctx)
return renderers.csv_output(ctx)
# ── /stats/* ───────────────────────────────────────────────────
@legacy_bp.route('/stats/members')
def stats_members():
"""Member count over time."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""Member count time series."""
ctx = _ctx(request, {
'_bot_': {'query': db.QUERY_STATS_MEMBERS, 'level': 0},
'_member_': {'query': db.QUERY_STATS_MEMBERS, 'level': 0},
'_stats_': {'query': db.QUERY_STATS_MEMBERS, 'level': 0},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], {})
filters.runfilter(ctx)
return renderers.json_output(ctx)
@legacy_bp.route('/stats/contracts')
def stats_contracts():
"""Contract statistics."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""Contract statistics time series."""
ctx = _ctx(request, {
'_bot_': {'query': db.QUERY_STATS_CONTRACTS, 'level': 0},
'_board_': {'query': db.QUERY_STATS_CONTRACTS, 'level': 0},
'_stats_': {'query': db.QUERY_STATS_CONTRACTS, 'level': 0},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], {})
filters.runfilter(ctx)
return renderers.json_output(ctx)
@legacy_bp.route('/stats/genders')
def stats_genders():
"""Gender demographics."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""Gender demographics time series."""
ctx = _ctx(request, {
'_bot_': {'query': db.QUERY_STATS_GENDERS, 'level': 0},
'_board_': {'query': db.QUERY_STATS_GENDERS, 'level': 0},
'_stats_': {'query': db.QUERY_STATS_GENDERS, 'level': 0},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], {})
filters.runfilter(ctx)
return renderers.json_output(ctx)
@legacy_bp.route('/stats/ages')
def stats_ages():
"""Age demographics."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""Age demographics time series.
Accepts query params: step, min, max for age bucket configuration.
"""
ctx = _ctx(request, {
'_bot_': {'query': db.QUERY_STATS_AGES, 'level': 0},
'_board_': {'query': db.QUERY_STATS_AGES, 'level': 0},
'_stats_': {'query': db.QUERY_STATS_AGES, 'level': 0},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], dict(request.args))
filters.runfilter(ctx)
return renderers.json_output(ctx)
# ── /member/<crewname> ───────────────────────────────────────
@legacy_bp.route('/member/<crewname>')
def member(crewname):
"""Member details (or list when crewname is '' or '*')."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
if crewname in ('', '*'):
# Return a list with permission-based filtering
ctx = _ctx(request, {
'_board_': {'query': db.QUERY_MEMBERLIST, 'level': 0},
'_member_': {'query': db.QUERY_MEMBERLIST, 'level': 1, 'filter': filters.memberlist_active_only},
'_passive_': {'query': db.QUERY_MEMBERLIST, 'level': 2, 'filter': filters.memberlist_self_only},
'_astronaut_': {'query': db.QUERY_MEMBERLIST, 'level': 2, 'filter': filters.memberlist_self_only},
})
else:
# Return single member record
ctx = _ctx(request, {
'_board_': {'query': db.QUERY_MEMBER_BY_CREWNAME, 'level': 0},
'_self_': {'query': db.QUERY_MEMBER_BY_CREWNAME, 'level': 0},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], {'crewname': crewname})
filters.runfilter(ctx)
# Use MEMBERLIST mapper for list queries, MEMBER mapper for single record
if crewname in ('', '*'):
mappings.memberlist_mapper(ctx)
else:
mappings.member_mapper(ctx)
return renderers.json_output(ctx)
# ── /member/<crewname>/raw ───────────────────────────────────
@legacy_bp.route('/member/<crewname>/raw')
def member_raw(crewname):
"""Raw DB member record."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""Raw DB member record (board/bots only)."""
if crewname in ('', '*'):
ctx = _ctx(request, {
'_board_': {'query': db.QUERY_MEMBERLIST_RAW, 'level': 0},
})
else:
ctx = _ctx(request, {
'_board_': {'query': db.QUERY_MEMBER_BY_CREWNAME, 'level': 0},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], {'crewname': crewname})
filters.runfilter(ctx)
mappings.none_mapper(ctx)
return renderers.json_output(ctx)
# ── /member/<crewname>/memo ───────────────────────────────────
@legacy_bp.route('/member/<crewname>/memo')
def member_memo(crewname):
"""RTF memo for a member."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""RTF memo for a member (board only)."""
if not crewname:
return jsonify({'error': 'Member details only displayed for individual members.'}), 400
ctx = _ctx(request, {
'_board_': {'query': db.QUERY_MEMBER_MEMO_BY_CREWNAME, 'level': 0},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], {'crewname': crewname})
filters.runfilter(ctx)
mappings.memo_mapper(ctx)
return renderers.json_output(ctx)
# ── /member/<crewname>/contributions ─────────────────────────
@legacy_bp.route('/member/<crewname>/contributions')
def member_contributions(crewname):
"""Contribution summary for a member."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""Contribution summary for a member (board only)."""
if not crewname:
return jsonify({'error': 'Member details only displayed for individual members.'}), 400
ctx = _ctx(request, {
'_board_': {'query': db.QUERY_DEBITLIST_BY_CREWNAME, 'level': 0},
})
auth.authorize(ctx)
ctx['data'] = db.run_query(ctx['query'], {'crewname': crewname})
filters.runfilter(ctx)
mappings.contributions_mapper(ctx)
return renderers.json_output(ctx)
# ── /member/<crewname>/<detail_type>/[<id>]/raw/ ─────────────
@legacy_bp.route('/member/<crewname>/<detail_type>/raw/')
@legacy_bp.route('/member/<crewname>/<detail_type>/<detail_id>/raw/')
def member_detail_raw(crewname, detail_type, detail_id=None):
"""Raw detail records (contract, debit, withdrawal, payment)."""
# TODO Phase 6
raise NotImplementedError("Not yet implemented")
"""Raw detail records (contract, debit, withdrawal, payment).
Permissions: board and self can see their own data.
"""
if crewname == '*':
return jsonify({'error': 'Member details only displayed for individual members.'}), 400
# Resolve the list query and the detail-id query from the type
DETAIL_QUERIES = {
'contract': (db.QUERY_CONTRACTLIST_BY_CREWNAME, 'contract', db.QUERY_CONTRACT_BY_CREWNAME_AND_CONTRACT),
'debit': (db.QUERY_DEBITLIST_BY_CREWNAME, 'guid', db.QUERY_DEBIT_BY_CREWNAME_AND_GUID),
'withdrawal': (db.QUERY_WITHDRAWALLIST_BY_CREWNAME, 'guid', db.QUERY_WITHDRAWAL_BY_CREWNAME_AND_GUID),
'payment': (db.QUERY_PAYMENTLIST_BY_CREWNAME, None, None), # no ID sub-query for payment
}
info = DETAIL_QUERIES.get(detail_type)
if info is None:
return jsonify({'error': 'Invalid member detail requested.'}), 400
list_query, id_param, id_query = info
perms = {
'_board_': {'query': list_query, 'level': 0},
}
ctx = _ctx(request, perms)
auth.authorize(ctx)
if id_param and detail_id and detail_id not in ('', '*'):
# Fetch single record by ID
perms['_board_']['query'] = id_query
params = {'crewname': crewname}
params[id_param] = detail_id
else:
# Fetch list
params = {'crewname': crewname}
ctx['data'] = db.run_query(
perms['_board_']['query'],
params,
)
filters.runfilter(ctx)
mappings.none_mapper(ctx)
return renderers.json_output(ctx)

0
plan Normal file
View file

View file

@ -4,8 +4,8 @@ services:
ports:
- "${APP_PORT:-5000}:5000"
environment:
- CTEWARD_ST_LEXWARE_CONFIG=/etc/cteward/st-lexware.json
- CTEWARD_ST_LEXWARE_CONFIG=/etc/cteward-ng/config.json
volumes:
- /etc/cteward:/etc/cteward:ro
- /etc/cteward-ng:/etc/cteward-ng:ro
- /var/log/cteward:/var/log/cteward
restart: unless-stopped

31
podman-compose.yml.bak Normal file
View file

@ -0,0 +1,31 @@
services:
cteward:
build: .
ports:
- "${APP_PORT}:5000"
# network_mode: host
stunnel:
hostname: stunnel
image: docker.io/dockurr/stunnel
# image: alpine:latest
container_name: stunnel
environment:
LISTEN_PORT: "1434"
CONNECT_PORT: "14334"
CONNECT_HOST: "10.0.1.113"
# CONNECT_HOST: "ws22.cbrp3.c-base.org"
volumes:
# - ./stunnel.conf:/etc/stunnel/stunnel.conf:ro
# - ./stunnel.pem:/etc/stunnel/stunnel.pem:ro
- ./stunnel.pem:/etc/stunnel/stunnel.pem
- ./stunnel.conf:/config/stunnel.conf:ro
- ./privkey.pem:/private.pem
- ./certificate.pem:/cert.pem
# command: ["sh", "-c", "apk add --no-cache stunnel && stunnel /etc/stunnel/stunnel.conf"]
# entrypoint: ["/bin/sh", "-c"]
# command: ["stunnel", "/config/stunnel.conf"]
ports:
- 1434:1434
restart: always

10
testlogfile Normal file
View file

@ -0,0 +1,10 @@
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "anonymous GET http://localhost/legacy/monitor", "time": "2026-06-07T09:53:31.196821+00:00", "v": 0, "username": "anonymous", "method": "GET", "url": "http://localhost/legacy/monitor"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "anonymous GET http://localhost/legacy/monitor", "time": "2026-06-07T09:53:31.201223+00:00", "v": 0, "username": "anonymous", "method": "GET", "url": "http://localhost/legacy/monitor"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "anonymous GET http://localhost/legacy/stats/members", "time": "2026-06-07T09:53:31.205540+00:00", "v": 0, "username": "anonymous", "method": "GET", "url": "http://localhost/legacy/stats/members"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/stats/members", "time": "2026-06-07T09:53:31.209734+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/stats/members"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "anonymous GET http://localhost/legacy/member/alice", "time": "2026-06-07T09:53:31.214117+00:00", "v": 0, "username": "anonymous", "method": "GET", "url": "http://localhost/legacy/member/alice"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/invalid/raw/", "time": "2026-06-07T09:53:31.218322+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/invalid/raw/"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/memo", "time": "2026-06-07T09:53:31.222623+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/memo"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/contributions", "time": "2026-06-07T09:53:31.226649+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/contributions"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/contract/raw/", "time": "2026-06-07T09:53:31.230820+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/contract/raw/"}
{"name": "cteward-st-lexware", "hostname": "vsd", "pid": 8831, "level": 30, "msg": "testbot GET http://localhost/legacy/member/alice/unknown/raw/", "time": "2026-06-07T09:53:31.235202+00:00", "v": 0, "username": "testbot", "method": "GET", "url": "http://localhost/legacy/member/alice/unknown/raw/"}