CMS 2.0 - Theme engine, logging, admin improvements

Major changes:
- New ThemeManager with Twig templating and SCSS compilation
- Dynamic themes system (themes/default, themes/demo)
- LogManager with SQLite storage and syslog forwarding
- RequestLogger with static helper methods
- Admin UI overhaul (Bootstrap 5, dark mode)
- Admin config page with logging and theme settings
- Admin logs page with filters and search
- Removed legacy Mustache templates
- Removed test plugin and theme
- Composer dependencies: Twig, scssphp, CommonMark, MaxMind GeoIP
This commit is contained in:
2026-08-08 18:02:14 +02:00
parent d453b8073f
commit 6333bc410f
833 changed files with 108974 additions and 1386 deletions
+3
View File
@@ -20,6 +20,9 @@ admin/storage/cache/
admin/storage/geoip/
admin/storage/stats.json
# Runtime-compiled theme assets
public/themes/
# Temporary files
*.tmp
*.temp
+20 -14
View File
@@ -7,7 +7,7 @@
## Build & Run
- **Run Server**: `php -S localhost:8080 cms/router.php` (router nodig voor clean URLs)
- **Lint PHP**: `find . -name "*.php" -not -path "./vendor/*" -exec php -l {} \;`
- **Dependencies**: Composer vereist voor CommonMark. Geen NPM.
- **Dependencies**: Composer vereist voor CommonMark, Twig en scssphp. Geen NPM.
- **Admin Console**: Toegankelijk op `/admin.php` (standaard login: `admin` / `admin`)
## Project Structuur
@@ -17,24 +17,29 @@ codepress/
│ ├── core/
│ │ ├── class/
│ │ │ ├── CodePressCMS.php # Hoofd CMS class
│ │ │ ├── ThemeManager.php # Thema-resolver + Twig render + SCSS compile
│ │ │ ├── Logger.php # Logging systeem
│ │ │ └── SimpleTemplate.php # Mustache-style template engine
│ │ │ └── SimpleTemplate.php # Legacy Mustache-style engine (niet meer gebruikt)
│ │ ├── plugin/
│ │ │ ├── PluginManager.php # Plugin loader
│ │ │ └── CMSAPI.php # API voor plugins
│ │ ├── config.php # Config loader (leest config.json)
│ │ └── index.php # Bootstrap (autoloader, requires)
│ ├── lang/ # Taalbestanden (nl.php, en.php)
── templates/ # Mustache templates
├── layout.mustache # Hoofd layout (bevat inline CSS)
│ ├── assets/
│ │ ├── header.mustache
│ │ │ ├── navigation.mustache
│ │ │ └── footer.mustache
│ │ ├── markdown_content.mustache
│ │ ├── php_content.mustache
│ │ ── html_content.mustache
└── router.php # PHP dev server router
── router.php # PHP dev server router (serveert ook /themes/)
├── themes/ # Dynamische thema's (volledig zelfstandig)
│ ├── default/ # Standaard thema
│ │ ├── theme.json # { title, default_layout, layout→.twig mapping, kleuren }
│ │ ├── base.twig # Hoofd layout (head, header, nav, footer)
│ │ ├── full_content.twig # Layout: volledige breedte
│ │ ├── left_sidebar.twig # Layout: sidebar links
│ │ ├── right_sidebar.twig # Layout: sidebar rechts
│ │ ── custom1.twig # Layout: custom
│ ├── partials/ # header.twig, navigation.twig, footer.twig
│ │ ├── css/theme.scss # SCSS bron (runtime gecompileerd)
│ │ └── js/theme.js # Thema JavaScript
│ ├── demo/ # Demo thema (zelfde structuur, andere look)
│ └── test/ # Test thema
├── admin/ # Admin paneel
│ ├── config/
│ │ ├── app.php # Admin app configuratie
@@ -53,6 +58,7 @@ codepress/
│ │ ├── config.php
│ │ ├── plugins.php
│ │ ├── plugin-config.php
│ │ ├── theme.php
│ │ └── users.php
│ └── storage/logs/ # Admin logs
├── cli/ # CLI scripts & tests
@@ -86,7 +92,7 @@ codepress/
- Admin entry point + routing: `public/admin.php`
- Admin authenticatie: `admin/src/AdminAuth.php`
- **Content**: Stored in `content/`. Supports `.md` (Markdown), `.php` (Dynamic), `.html` (Static).
- **Templating**: Mustache-style `{{placeholder}}` in `templates/layout.mustache` via `SimpleTemplate.php`.
- **Templating**: Twig templates in `themes/<naam>/`. `ThemeManager` rendert via Twig en compileert `css/theme.scss` runtime naar `public/themes/<naam>/theme.css`. Layout gekozen via frontmatter `layout:` key; onbekende layouts vallen terug op `default_layout` in `theme.json`.
- **Navigation**: Auto-generated from directory structure. Folders require an index file to be clickable in breadcrumbs.
- **Security**:
- Always use `htmlspecialchars()` for outputting user/content data
@@ -115,5 +121,5 @@ codepress/
## Bekende aandachtspunten
- LSP errors over "Undefined function" in PHP files zijn vals-positief (standaard PHP functies worden niet herkend door de LSP). Negeer deze.
- Zie `TODO.md` voor alle openstaande verbeteringen en nieuwe features.
- `vendor/` map bevat Composer dependencies (CommonMark, Mustache). Niet handmatig wijzigen.
- `vendor/` map bevat Composer dependencies (CommonMark, Twig, scssphp, Mustache). Niet handmatig wijzigen.
- `admin/config/admin.json` bevat wachtwoord-hashes. Niet committen met echte productie-wachtwoorden.
+14 -8
View File
@@ -49,13 +49,20 @@ codepress/
│ │ ├── config.php # Configuration loader
│ │ └── index.php # Bootstrap (autoloader)
│ ├── lang/ # Language files (nl.php, en.php)
── templates/ # Mustache templates
│ │ ├── layout.mustache
│ ├── assets/ (header, navigation, footer)
│ │ ├── markdown_content.mustache
│ │ ├── php_content.mustache
│ │ ── html_content.mustache
└── router.php # PHP dev server router
── router.php # PHP dev server router (also serves /themes/)
├── themes/ # Dynamic themes (fully self-contained)
│ ├── default/ # Default theme
│ │ ├── theme.json # { title, default_layout, layout→.twig mapping, colors }
│ │ ├── base.twig # Main layout
│ │ ── full_content.twig # Layout: full width
│ ├── left_sidebar.twig # Layout: sidebar left
│ │ ├── right_sidebar.twig # Layout: sidebar right
│ │ ├── custom1.twig # Layout: custom
│ │ ├── partials/ # header, navigation, footer
│ │ ├── css/theme.scss # SCSS source (compiled at runtime)
│ │ └── js/theme.js # Theme JavaScript
│ ├── demo/ # Demo theme
│ └── test/ # Test theme
├── admin/ # Admin panel
│ ├── config/
│ │ ├── app.php # Admin configuration
@@ -96,7 +103,6 @@ codepress/
{
"site_title": "CodePress",
"content_dir": "content",
"templates_dir": "cms/templates",
"default_page": "index",
"active_theme": "default",
"language": {
+14 -8
View File
@@ -49,13 +49,20 @@ codepress/
│ │ ├── config.php # Config loader
│ │ └── index.php # Bootstrap (autoloader)
│ ├── lang/ # Taalbestanden (nl.php, en.php)
── templates/ # Mustache templates
│ │ ├── layout.mustache
│ ├── assets/ (header, navigation, footer)
│ │ ├── markdown_content.mustache
│ │ ├── php_content.mustache
│ │ ── html_content.mustache
└── router.php # PHP dev server router
── router.php # PHP dev server router (serveert ook /themes/)
├── themes/ # Dynamische thema's (volledig zelfstandig)
│ ├── default/ # Standaard thema
│ │ ├── theme.json # { title, default_layout, layout→.twig mapping, kleuren }
│ │ ├── base.twig # Hoofd layout
│ │ ── full_content.twig # Layout: volledige breedte
│ ├── left_sidebar.twig # Layout: sidebar links
│ │ ├── right_sidebar.twig # Layout: sidebar rechts
│ │ ├── custom1.twig # Layout: custom
│ │ ├── partials/ # header, navigation, footer
│ │ ├── css/theme.scss # SCSS bron (runtime gecompileerd)
│ │ └── js/theme.js # Thema JavaScript
│ ├── demo/ # Demo thema
│ └── test/ # Test thema
├── admin/ # Admin paneel
│ ├── config/
│ │ ├── app.php # Admin configuratie
@@ -96,7 +103,6 @@ codepress/
{
"site_title": "CodePress",
"content_dir": "content",
"templates_dir": "cms/templates",
"default_page": "index",
"active_theme": "default",
"language": {
+5
View File
@@ -171,6 +171,11 @@ $layoutSidebarColor = $layoutThemeConfig['header_color'] ?? '#0a369d';
case 'theme':
require __DIR__ . '/pages/theme.php';
break;
case 'theme-new':
require __DIR__ . '/pages/theme-new.php';
break;
case 'plugins':
require __DIR__ . '/pages/plugins.php';
break;
+81 -3
View File
@@ -95,9 +95,9 @@
value="<?= htmlspecialchars($configData['author']['name'] ?? '') ?>">
</div>
<div class="col-md-6">
<label for="author_website" class="form-label">Website</label>
<input type="url" class="form-control" id="author_website" name="author_website"
value="<?= htmlspecialchars($configData['author']['website'] ?? '') ?>">
<label for="author_website" class="form-label">Website URL</label>
<input type="text" class="form-control" id="author_website" name="author_website"
value="<?= htmlspecialchars($configData['author']['website'] ?? '') ?>" placeholder="bijv. noorlander.info">
</div>
</div>
</div>
@@ -140,6 +140,84 @@
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header">
<i class="bi bi-journal-text"></i> Logging
</div>
<div class="card-body">
<?php $logging = $configData['logging'] ?? []; ?>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="logging_enabled" name="logging_enabled" value="1" <?= !empty($logging['enabled']) ? 'checked' : '' ?>>
<label class="form-check-label" for="logging_enabled">Logging inschakelen</label>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="logging_driver" class="form-label">Opslag</label>
<select class="form-select" id="logging_driver" name="logging_driver">
<option value="sqlite" <?= ($logging['driver'] ?? 'sqlite') === 'sqlite' ? 'selected' : '' ?>>SQLite (standaard)</option>
<option value="syslog" <?= ($logging['driver'] ?? '') === 'syslog' ? 'selected' : '' ?>>Syslog</option>
</select>
<div class="form-text">Kies <strong>Syslog</strong> om logregels naar een externe syslog-server te sturen. Logregels worden altijd ook lokaal opgeslagen.</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="syslog_host" class="form-label">Syslog server (host)</label>
<input type="text" class="form-control font-monospace" id="syslog_host" name="syslog_host"
value="<?= htmlspecialchars($logging['syslog_host'] ?? '') ?>" placeholder="bijv. 192.168.1.10">
<div class="form-text">Vul de host van de syslog-server in (bijv. <code>192.168.1.10</code> of <code>logs.example.com</code>).</div>
</div>
<div class="col-md-3">
<label for="syslog_port" class="form-label">Poort</label>
<input type="number" class="form-control" id="syslog_port" name="syslog_port" min="1" max="65535"
value="<?= (int)($logging['syslog_port'] ?? 514) ?>">
</div>
<div class="col-md-3">
<label for="syslog_facility" class="form-label">Facility</label>
<select class="form-select" id="syslog_facility" name="syslog_facility">
<?php foreach (['local0', 'local1', 'local2', 'local3', 'local4', 'local5', 'local6', 'local7', 'daemon', 'user', 'auth'] as $fac): ?>
<option value="<?= $fac ?>" <?= ($logging['syslog_facility'] ?? 'local0') === $fac ? 'selected' : '' ?>><?= $fac ?></option>
<?php endforeach; ?>
</select>
<div class="form-text">Categorie van de logbron (bijv. <code>local0</code><code>local7</code> voor eigen applicaties).</div>
</div>
</div>
<div class="mb-3">
<label for="syslog_ident" class="form-label">Syslog ident</label>
<input type="text" class="form-control font-monospace" id="syslog_ident" name="syslog_ident"
value="<?= htmlspecialchars($logging['syslog_ident'] ?? 'codepress') ?>">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Te registreren gebeurtenissen</label>
<?php $events = $logging['events'] ?? []; ?>
<div class="row">
<?php
$eventLabels = [
'admin' => 'Admin activiteiten',
'requests' => 'Requests (pagina bezoeken)',
'errors' => 'Fouten & waarschuwingen',
'security' => 'Beveiliging',
'content' => 'Content wijzigingen',
'system' => 'Systeem',
];
foreach ($eventLabels as $key => $label):
?>
<div class="col-md-4">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="logging_event_<?= $key ?>" name="logging_events[]" value="<?= $key ?>" <?= !empty($events[$key]) ? 'checked' : '' ?>>
<label class="form-check-label" for="logging_event_<?= $key ?>"><?= $label ?></label>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check-lg"></i> Configuratie opslaan
</button>
+6
View File
@@ -18,11 +18,17 @@
<div class="col-md-4">
<label for="layout" class="form-label">Sjabloon / Layout</label>
<select class="form-select" id="layout" name="layout">
<?php if (empty($themeLayouts)): ?>
<option value="sidebar-content" <?= $currentLayout === 'sidebar-content' ? 'selected' : '' ?>>Sidebar + Inhoud (standaard)</option>
<option value="content" <?= $currentLayout === 'content' ? 'selected' : '' ?>>Alleen inhoud (full-width)</option>
<option value="sidebar" <?= $currentLayout === 'sidebar' ? 'selected' : '' ?>>Alleen sidebar (full-width)</option>
<option value="content-sidebar" <?= $currentLayout === 'content-sidebar' ? 'selected' : '' ?>>Inhoud links + sidebar rechts</option>
<option value="content-sidebar-reverse" <?= $currentLayout === 'content-sidebar-reverse' ? 'selected' : '' ?>>Inhoud rechts + sidebar links</option>
<?php else: ?>
<?php foreach ($themeLayouts as $key => $file): ?>
<option value="<?= htmlspecialchars($key) ?>" <?= $currentLayout === $key ? 'selected' : '' ?>><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $key))) ?></option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<?php if (!empty($availablePlugins)): ?>
+6
View File
@@ -28,11 +28,17 @@
<div class="col-md-6">
<label for="layout" class="form-label">Sjabloon / Layout</label>
<select class="form-select" id="layout" name="layout">
<?php if (empty($themeLayouts)): ?>
<option value="sidebar-content">Sidebar + Inhoud (standaard)</option>
<option value="content">Alleen inhoud (full-width)</option>
<option value="sidebar">Alleen sidebar (full-width)</option>
<option value="content-sidebar">Inhoud links + sidebar rechts</option>
<option value="content-sidebar-reverse">Inhoud rechts + sidebar links</option>
<?php else: ?>
<?php foreach ($themeLayouts as $key => $file): ?>
<option value="<?= htmlspecialchars($key) ?>"><?= htmlspecialchars(ucfirst(str_replace('_', ' ', $key))) ?></option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<?php if (!empty($availablePlugins)): ?>
+71 -109
View File
@@ -1,117 +1,79 @@
<h2 class="mb-4"><i class="bi bi-journal-text"></i> Logs</h2>
<ul class="nav nav-tabs mb-3" id="logTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link <?= $activeTab === 'admin' ? 'active' : '' ?>" id="admin-log-tab" data-bs-toggle="tab" data-bs-target="#admin-log" type="button" role="tab">Activiteiten log</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link <?= $activeTab === 'requests' ? 'active' : '' ?>" id="request-log-tab" data-bs-toggle="tab" data-bs-target="#request-log" type="button" role="tab">Requests log</button>
</li>
</ul>
<div class="tab-content">
<!-- Admin activity log -->
<div class="tab-pane fade <?= $activeTab === 'admin' ? 'show active' : '' ?>" id="admin-log" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="text-muted"><?= count($adminLogs) ?> regels</small>
<div>
<a href="/admin/logs?tab=admin&download=1" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download"></i> Download</a>
<a href="/admin/logs?tab=admin&clear=1" class="btn btn-sm btn-outline-danger" onclick="return confirm('Activiteiten log wissen?')"><i class="bi bi-trash"></i> Wissen</a>
<div class="card shadow-sm mb-4">
<div class="card-body">
<form method="GET" action="/admin/logs" class="row g-3 align-items-end">
<div class="col-md-3">
<label for="filter_event" class="form-label">Gebeurtenis</label>
<select class="form-select" id="filter_event" name="event">
<option value="">Alle</option>
<?php foreach ($eventTypes as $ev): ?>
<option value="<?= $ev ?>" <?= $filterEvent === $ev ? 'selected' : '' ?>><?= ucfirst($ev) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<?php if (empty($adminLogs)): ?>
<p class="text-muted p-3 mb-0">Geen activiteiten geregistreerd.</p>
<?php else: ?>
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Tijd</th>
<th>Niveau</th>
<th>IP</th>
<th>Bericht</th>
</tr>
</thead>
<tbody>
<?php foreach ($adminLogs as $log): ?>
<tr>
<td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td>
<td><span class="badge bg-<?= $log['level'] === 'warning' ? 'warning text-dark' : ($log['level'] === 'error' ? 'danger' : 'info') ?>"><?= htmlspecialchars($log['level']) ?></span></td>
<td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td>
<td><?= htmlspecialchars($log['message']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<div class="col-md-3">
<label for="filter_level" class="form-label">Niveau</label>
<select class="form-select" id="filter_level" name="level">
<option value="">Alle</option>
<?php foreach ($levelTypes as $lv): ?>
<option value="<?= $lv ?>" <?= $filterLevel === $lv ? 'selected' : '' ?>><?= ucfirst($lv) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
</div>
<!-- Request log -->
<div class="tab-pane fade <?= $activeTab === 'requests' ? 'show active' : '' ?>" id="request-log" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="text-muted"><?= count($requestLogs) ?> regels</small>
<div>
<a href="/admin/logs?tab=requests&download=1" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download"></i> Download</a>
<a href="/admin/logs?tab=requests&clear=1" class="btn btn-sm btn-outline-danger" onclick="return confirm('Requestlog wissen?')"><i class="bi bi-trash"></i> Wissen</a>
<div class="col-md-4">
<label for="filter_search" class="form-label">Zoeken</label>
<input type="text" class="form-control" id="filter_search" name="search" value="<?= htmlspecialchars($filterSearch) ?>" placeholder="Zoek in bericht...">
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
<?php if (empty($requestLogs)): ?>
<p class="text-muted p-3 mb-0">Geen requests geregistreerd.</p>
<?php else: ?>
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Tijd</th>
<th>IP</th>
<th>Land</th>
<th>Pagina</th>
<th>Type / Gebruiker</th>
<th>Status</th>
<th>Taal</th>
<th>User agent</th>
<th>Referrer</th>
</tr>
</thead>
<tbody>
<?php foreach ($requestLogs as $log): ?>
<tr class="<?= str_starts_with($log['status'] ?? 'ok', 'blocked') ? 'table-danger' : '' ?>">
<td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td>
<td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td>
<td class="text-nowrap small" title="<?= htmlspecialchars(GeoIP::getCountryName($log['country'] ?: null)) ?>">
<?= GeoIP::getCountryFlagEmoji($log['country'] ?: null) ?>
<span class="text-muted"><?= htmlspecialchars($log['country'] ?: '—') ?></span>
</td>
<td><?= htmlspecialchars($log['page']) ?></td>
<td>
<span class="badge bg-<?= $log['visitor_info']['badge'] ?>">
<i class="bi <?= $log['visitor_info']['icon'] ?>"></i> <?= $log['visitor_info']['label'] ?>
</span>
</td>
<td>
<?php if (str_starts_with($log['status'] ?? 'ok', 'blocked')): ?>
<span class="badge bg-danger" title="<?= htmlspecialchars($log['status']) ?>">
<i class="bi bi-shield-x"></i> Geblokkeerd
</span>
<?php else: ?>
<span class="badge bg-success" title="Toegestaan">
<i class="bi bi-check-circle"></i> OK
</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($log['lang']) ?></td>
<td class="small text-muted" title="<?= htmlspecialchars($log['ua']) ?>"><?= htmlspecialchars(substr($log['ua'], 0, 50)) ?><?= strlen($log['ua']) > 50 ? '…' : '' ?></td>
<td class="small text-muted" title="<?= htmlspecialchars($log['referrer']) ?>"><?= htmlspecialchars(substr($log['referrer'], 0, 30)) ?><?= strlen($log['referrer']) > 30 ? '…' : '' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<div class="col-md-2">
<label for="filter_limit" class="form-label">Aantal</label>
<select class="form-select" id="filter_limit" name="limit">
<?php foreach ([50, 100, 200, 500, 1000] as $n): ?>
<option value="<?= $n ?>" <?= $limit === $n ? 'selected' : '' ?>><?= $n ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="col-12 d-flex justify-content-between">
<button type="submit" class="btn btn-primary"><i class="bi bi-funnel"></i> Filteren</button>
<div>
<a href="/admin/logs?download=1<?= $filterEvent ? '&event=' . urlencode($filterEvent) : '' ?><?= $filterLevel ? '&level=' . urlencode($filterLevel) : '' ?><?= $filterSearch ? '&search=' . urlencode($filterSearch) : '' ?>" class="btn btn-sm btn-outline-secondary"><i class="bi bi-download"></i> Download</a>
<a href="/admin/logs?clear=1" class="btn btn-sm btn-outline-danger" onclick="return confirm('Log wissen?')"><i class="bi bi-trash"></i> Wissen</a>
</div>
</div>
</form>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-2">
<small class="text-muted"><?= count($logEntries) ?> regels</small>
</div>
<div class="card shadow-sm">
<div class="card-body p-0" style="max-height: 600px; overflow-y: auto;">
<?php if (empty($logEntries)): ?>
<p class="text-muted p-3 mb-0">Geen logregels gevonden.</p>
<?php else: ?>
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Tijd</th>
<th>Gebeurtenis</th>
<th>Niveau</th>
<th>IP</th>
<th>Bericht</th>
</tr>
</thead>
<tbody>
<?php foreach ($logEntries as $log): ?>
<tr>
<td class="text-nowrap small text-muted"><?= htmlspecialchars($log['time']) ?></td>
<td><span class="badge bg-secondary"><?= htmlspecialchars($log['event']) ?></span></td>
<td><span class="badge bg-<?= $log['level'] === 'warning' ? 'warning text-dark' : ($log['level'] === 'error' ? 'danger' : 'info') ?>"><?= htmlspecialchars($log['level']) ?></span></td>
<td><code class="small"><?= htmlspecialchars($log['ip']) ?></code></td>
<td><?= htmlspecialchars($log['message']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</div>
+38
View File
@@ -0,0 +1,38 @@
<h2 class="mb-4"><i class="bi bi-palette"></i> Nieuw thema aanmaken</h2>
<form method="post" class="card shadow-sm">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<div class="card-body">
<?php if (!empty($message)): ?>
<div class="alert alert-<?= $messageType ?>"><?= htmlspecialchars($message) ?></div>
<?php endif; ?>
<div class="mb-3">
<label for="theme_name" class="form-label">Thema naam</label>
<input type="text" class="form-control font-monospace" id="theme_name" name="theme_name"
value="<?= htmlspecialchars($_POST['theme_name'] ?? '') ?>" required
placeholder="bijv. MijnThema">
<div class="form-text">Alleen letters, cijfers, streepjes en underscores. Begin met een letter. Wordt gebruikt als <strong>mapnaam</strong>.</div>
</div>
<div class="alert alert-info mb-0">
<strong><i class="bi bi-lightbulb"></i> Wat wordt er aangemaakt?</strong>
<ul class="mb-0 mt-2">
<li><code>themes/ThemaNaam/theme.json</code> — Configuratie (default template + template mapping)</li>
<li><code>themes/ThemaNaam/base.twig</code> — Hoofd layout</li>
<li><code>themes/ThemaNaam/*.twig</code> — Layout-sjablonen</li>
<li><code>themes/ThemaNaam/partials/</code> — header, navigation, footer</li>
<li><code>themes/ThemaNaam/css/theme.scss</code> — Styling (kleuren, hoogtes, achtergrond)</li>
<li><code>themes/ThemaNaam/js/theme.js</code> — Thema JavaScript</li>
<li><code>themes/ThemaNaam/theme.png</code> — Voorbeeldafbeelding</li>
</ul>
<p class="text-muted mb-0 mt-2">Het nieuwe thema wordt gekopieerd van het standaard thema. Pas daarna de SCSS-kleuren en sjablonen aan naar wens.</p>
</div>
</div>
<div class="card-footer text-end">
<a href="/admin/theme" class="btn btn-secondary">Annuleren</a>
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Aanmaken</button>
</div>
</form>
+81 -260
View File
@@ -1,283 +1,104 @@
<?php if ($editTheme): ?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-palette"></i> Thema's</h2>
<a href="/admin/theme-new" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg"></i> Nieuwe thema
</a>
</div>
<!-- Edit theme -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-palette"></i> Bewerk thema: <?= htmlspecialchars($editTheme['name'] ?? $editThemeName) ?></h2>
<a href="/admin/theme" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Terug
</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="/admin/theme" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="save">
<input type="hidden" name="theme" value="<?= htmlspecialchars($editThemeName) ?>">
<div class="row mb-3">
<div class="col-md-6">
<label for="theme_name" class="form-label">Themanaam</label>
<input type="text" class="form-control" id="theme_name" name="theme_name" value="<?= htmlspecialchars($editTheme['name'] ?? $editThemeName) ?>">
<div class="card shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title"><i class="bi bi-info-circle"></i> Thema-ontwikkelaarshandleiding</h5>
<p class="text-muted mb-2">Een thema is een volledig zelfstandige map en moet aan de volgende eisen voldoen:</p>
<div class="row g-3">
<div class="col-md-4">
<div class="d-flex align-items-start">
<i class="bi bi-folder2-open text-primary me-2 mt-1"></i>
<div>
<strong>Mapstructuur</strong><br>
<code class="small">themes/Naam/theme.json</code>
<small class="text-muted d-block">Elke thema-map moet een <code>theme.json</code> bevatten.</small>
</div>
</div>
<div class="row g-4">
<div class="col-md-4">
<div class="card">
<div class="card-header">Header</div>
<div class="card-body">
<div class="mb-3">
<label for="header_color" class="form-label">Achtergrond</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="header_color" name="header_color" value="<?= htmlspecialchars($editTheme['header_color'] ?? '#0a369d') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['header_color'] ?? '#0a369d') ?>" maxlength="7" data-target="header_color">
</div>
</div>
<div class="mb-0">
<label for="header_font_color" class="form-label">Tekst</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="header_font_color" name="header_font_color" value="<?= htmlspecialchars($editTheme['header_font_color'] ?? '#ffffff') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['header_font_color'] ?? '#ffffff') ?>" maxlength="7" data-target="header_font_color">
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">Navigatie</div>
<div class="card-body">
<div class="mb-3">
<label for="navigation_color" class="form-label">Achtergrond</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="navigation_color" name="navigation_color" value="<?= htmlspecialchars($editTheme['navigation_color'] ?? '#2754b4') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['navigation_color'] ?? '#2754b4') ?>" maxlength="7" data-target="navigation_color">
</div>
</div>
<div class="mb-0">
<label for="navigation_font_color" class="form-label">Tekst</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="navigation_font_color" name="navigation_font_color" value="<?= htmlspecialchars($editTheme['navigation_font_color'] ?? '#ffffff') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['navigation_font_color'] ?? '#ffffff') ?>" maxlength="7" data-target="navigation_font_color">
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">Sidebar</div>
<div class="card-body">
<div class="mb-3">
<label for="sidebar_background" class="form-label">Achtergrond</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="sidebar_background" name="sidebar_background" value="<?= htmlspecialchars($editTheme['sidebar_background'] ?? '#f8f9fa') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['sidebar_background'] ?? '#f8f9fa') ?>" maxlength="7" data-target="sidebar_background">
</div>
</div>
<div class="mb-0">
<label for="sidebar_border" class="form-label">Rand</label>
<div class="input-group">
<input type="color" class="form-control form-control-color" id="sidebar_border" name="sidebar_border" value="<?= htmlspecialchars($editTheme['sidebar_border'] ?? '#dee2e6') ?>">
<input type="text" class="form-control form-control-color-value" value="<?= htmlspecialchars($editTheme['sidebar_border'] ?? '#dee2e6') ?>" maxlength="7" data-target="sidebar_border">
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="d-flex align-items-start">
<i class="bi bi-file-earmark-code text-primary me-2 mt-1"></i>
<div>
<strong>Sjablonen (Twig)</strong><br>
<code class="small">base.twig + *.twig</code>
<small class="text-muted d-block">Layout-sjablonen die via <code>config.default_template</code> en <code>template</code> worden gekozen.</small>
</div>
</div>
<div class="row g-4 mt-2">
<div class="col-md-4">
<div class="card">
<div class="card-header"><i class="bi bi-arrows-vertical"></i> Hoogte balken</div>
<div class="card-body">
<div class="mb-3">
<label for="header_height" class="form-label">Header hoogte (px)</label>
<input type="number" class="form-control" id="header_height" name="header_height" value="<?= htmlspecialchars($editTheme['header_height'] ?? '56') ?>" min="32" max="200">
</div>
<div class="mb-0">
<label for="nav_height" class="form-label">Navigatie hoogte (px)</label>
<input type="number" class="form-control" id="nav_height" name="nav_height" value="<?= htmlspecialchars($editTheme['nav_height'] ?? '42') ?>" min="24" max="200">
</div>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header"><i class="bi bi-image"></i> Header achtergrond afbeelding</div>
<div class="card-body">
<div class="mb-3">
<label for="bg_image" class="form-label">Upload afbeelding</label>
<input type="file" class="form-control" id="bg_image" name="bg_image" accept="image/jpeg,image/png,image/gif,image/webp,image/svg+xml">
<small class="form-text text-muted">Toegestaan: JPG, PNG, GIF, WebP, SVG</small>
</div>
<div class="mb-0">
<label for="background_image_url" class="form-label">Of URL naar afbeelding</label>
<div class="input-group">
<input type="text" class="form-control" id="background_image_url" name="background_image_url" placeholder="https://..." value="<?= htmlspecialchars(str_starts_with($editTheme['background_image'] ?? '', 'http') ? $editTheme['background_image'] : '') ?>">
</div>
<?php if (!empty($editTheme['background_image'])): ?>
<div class="mt-2 d-flex align-items-center gap-3">
<div>
<small class="text-muted">Huidig:</small>
<img src="<?= htmlspecialchars(str_starts_with($editTheme['background_image'], 'http') ? $editTheme['background_image'] : '/themes/' . $editTheme['background_image']) ?>" style="max-height: 60px; max-width: 200px;" class="img-thumbnail mt-1 d-block">
</div>
<div class="form-check">
<input class="btn-check" type="checkbox" id="bg_image_remove" name="bg_image_remove" value="1" autocomplete="off">
<label class="btn btn-outline-danger btn-sm" for="bg_image_remove">
<i class="bi bi-trash3"></i> Verwijder afbeelding
</label>
</div>
</div>
<?php endif; ?>
</div>
<div class="mb-3 mt-3">
<label for="background_image_opacity" class="form-label">Doorzichtigheid (%)</label>
<div class="d-flex align-items-center gap-2">
<input type="range" class="form-range" style="max-width: 200px;" id="background_image_opacity" name="background_image_opacity" min="0" max="100" value="<?= htmlspecialchars($editTheme['background_image_opacity'] ?? '100') ?>" oninput="this.nextElementSibling.textContent=this.value+'%'">
<span class="badge bg-secondary"><?= htmlspecialchars($editTheme['background_image_opacity'] ?? '100') ?>%</span>
</div>
<small class="form-text text-muted">100% = volledig zichtbaar, 50% = half doorzichtig, 0% = onzichtbaar</small>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="d-flex align-items-start">
<i class="bi bi-palette text-primary me-2 mt-1"></i>
<div>
<strong>Styling & voorbeeld</strong><br>
<code class="small">css/theme.scss + theme.png</code>
<small class="text-muted d-block">Kleuren/hoogtes in SCSS, een <code>theme.png</code> voorbeeld voor de selectie.</small>
</div>
</div>
<div class="mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Opslaan
</button>
<a href="/admin/theme" class="btn btn-outline-secondary">Annuleren</a>
</div>
</form>
</div>
</div>
<?php else: ?>
<!-- Theme list -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-palette"></i> Thema's</h2>
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="collapse" data-bs-target="#newThemeForm">
<i class="bi bi-plus-lg"></i> Nieuw thema
</button>
</div>
<div class="collapse mb-4" id="newThemeForm">
<div class="card shadow-sm">
<div class="card-body">
<form method="POST" action="/admin/theme" class="row g-3 align-items-end">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="create">
<div class="col-md-6">
<label for="new_name" class="form-label">Naam nieuw thema</label>
<input type="text" class="form-control" id="new_name" name="new_name" placeholder="bijv. donker" required>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-success w-100">
<i class="bi bi-plus-lg"></i> Aanmaken
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="row g-4">
<?php foreach ($themes as $themeName => $themeData): ?>
<?php $isActive = $themeName === $activeTheme; ?>
<div class="col-md-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0"><?= htmlspecialchars($themeData['name'] ?? $themeName) ?></h5>
<?php if ($themeName === $activeTheme): ?>
<span class="badge bg-success">Actief</span>
<?php endif; ?>
<div class="card shadow-sm h-100 theme-card">
<div class="card-body text-center">
<div class="mb-2">
<?php $preview = '/themes/' . htmlspecialchars($themeName) . '/theme.png'; ?>
<img src="<?= $preview ?>" alt="Voorbeeld thema <?= htmlspecialchars($themeData['title'] ?? $themeData['name'] ?? $themeName) ?>" class="img-fluid rounded theme-preview">
</div>
<!-- Color preview swatches -->
<div class="mb-3">
<div class="d-flex align-items-center gap-1 mb-2">
<span class="small text-muted" style="width: 70px;">Header:</span>
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['header_color'] ?? '#0a369d') ?>; border: 1px solid #dee2e6;"></span>
<span class="small text-muted"><?= htmlspecialchars($themeData['header_color'] ?? '#0a369d') ?></span>
</div>
<div class="d-flex align-items-center gap-1 mb-2">
<span class="small text-muted" style="width: 70px;">Navigatie:</span>
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['navigation_color'] ?? '#2754b4') ?>; border: 1px solid #dee2e6;"></span>
<span class="small text-muted"><?= htmlspecialchars($themeData['navigation_color'] ?? '#2754b4') ?></span>
</div>
<div class="d-flex align-items-center gap-1">
<span class="small text-muted" style="width: 70px;">Sidebar:</span>
<span class="d-inline-block rounded" style="width: 24px; height: 24px; background: <?= htmlspecialchars($themeData['sidebar_background'] ?? '#f8f9fa') ?>; border: 1px solid #dee2e6;"></span>
<span class="small text-muted"><?= htmlspecialchars($themeData['sidebar_background'] ?? '#f8f9fa') ?></span>
</div>
</div>
<!-- Heights and background image -->
<div class="mb-3 small">
<div class="text-muted mb-1">
<i class="bi bi-arrows-vertical"></i> Header: <?= htmlspecialchars($themeData['header_height'] ?? '56') ?>px &middot; Navigatie: <?= htmlspecialchars($themeData['nav_height'] ?? '42') ?>px
</div>
<?php if (!empty($themeData['background_image'])): ?>
<div class="text-muted">
<i class="bi bi-image"></i> Achtergrond: <?= htmlspecialchars($themeData['background_image_opacity'] ?? '100') ?>% zichtbaar
<?php if (!str_starts_with($themeData['background_image'], 'http')): ?>
<img src="/themes/<?= htmlspecialchars($themeData['background_image']) ?>" style="max-height: 30px; max-width: 80px;" class="img-thumbnail ms-1 align-middle">
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<div class="d-flex gap-2">
<a href="/admin/theme?edit=<?= urlencode($themeName) ?>" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil"></i> Bewerken
</a>
<?php if ($themeName !== $activeTheme): ?>
<form method="POST" action="/admin/theme">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="activate">
<input type="hidden" name="theme" value="<?= htmlspecialchars($themeName) ?>">
<button type="submit" class="btn btn-outline-success btn-sm">
<i class="bi bi-check-circle"></i> Activeren
</button>
</form>
<?php endif; ?>
<h5 class="card-title mb-0">
<?= htmlspecialchars($themeData['title'] ?? $themeData['name'] ?? $themeName) ?>
</h5>
<?php if ($isActive): ?>
<span class="badge bg-success mt-1">Actief</span>
<?php endif; ?>
</div>
<div class="card-footer text-center py-2">
<?php if ($isActive): ?>
<span class="text-muted small">Dit thema is actief</span>
<?php else: ?>
<form method="POST" action="/admin/theme" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="activate">
<input type="hidden" name="theme" value="<?= htmlspecialchars($themeName) ?>">
<button type="submit" class="btn btn-sm btn-outline-success" title="Activeren">
<i class="bi bi-check-circle"></i> Activeren
</button>
</form>
<?php if ($themeName !== 'default'): ?>
<form method="POST" action="/admin/theme">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="theme" value="<?= htmlspecialchars($themeName) ?>">
<button type="submit" class="btn btn-outline-danger btn-sm" onclick="return confirm('Weet je zeker dat je thema &#39;<?= htmlspecialchars($themeData['name'] ?? $themeName) ?>&#39; wilt verwijderen?')">
<i class="bi bi-trash"></i> Verwijderen
</button>
</form>
<form method="POST" action="/admin/theme-delete?theme=<?= urlencode($themeName) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je thema &#39;<?= htmlspecialchars($themeData['title'] ?? $themeName) ?>&#39; wilt verwijderen? Alle bestanden worden permanent verwijderd.')">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Verwijderen">
<i class="bi bi-trash"></i>
</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<script>
document.querySelectorAll('.form-control-color-value').forEach(function(input) {
input.addEventListener('input', function() {
var target = document.getElementById(this.dataset.target);
if (target && /^#[0-9a-fA-F]{6}$/.test(this.value)) {
target.value = this.value;
}
});
});
document.querySelectorAll('.form-control-color').forEach(function(input) {
input.addEventListener('input', function() {
var target = document.querySelector('[data-target="' + this.id + '"]');
if (target) target.value = this.value;
});
});
</script>
<style>
.theme-card {
transition: box-shadow 0.2s ease, transform 0.2s ease;
overflow: hidden;
}
.theme-card:hover {
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);
transform: translateY(-2px);
}
.theme-preview {
border: 1px solid #dee2e6;
background: #ffffff;
}
</style>
+50 -47
View File
@@ -591,7 +591,7 @@ class CodePressCMS {
}
}
$authorWebsite = $this->config['author']['website'] ?? '';
$authorWebsite = $this->normalizeUrl($this->config['author']['website'] ?? '');
if ($authorWebsite !== '') {
$authorHost = parse_url($authorWebsite, PHP_URL_HOST);
if ($authorHost) {
@@ -602,6 +602,25 @@ class CodePressCMS {
return array_values(array_unique(array_filter($hosts)));
}
/**
* Normalize a URL: if no scheme is present, prepend https://.
* Handles hostnames stored without protocol (e.g. "noorlander.info").
*
* @param string $url Raw URL or hostname
* @return string Normalized absolute URL, or '' if empty
*/
private function normalizeUrl(string $url): string
{
$url = trim($url);
if ($url === '') {
return '';
}
if (!preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $url)) {
return 'https://' . $url;
}
return $url;
}
/**
* Parse Markdown content to HTML using League CommonMark
*
@@ -1140,7 +1159,7 @@ class CodePressCMS {
'is_guide_page' => isset($_GET['guide']),
'lang_switch_url' => '',
'author_name' => $this->config['author']['name'] ?? 'CodePress Developer',
'author_website' => $this->config['author']['website'] ?? '#',
'author_website' => $this->normalizeUrl($this->config['author']['website'] ?? '') ?: '#',
'author_git' => 'https://git.noorlander.info/E.Noorlander',
'seo_description' => $this->config['seo']['description'] ?? 'CodePress CMS - Lightweight file-based content management system',
'seo_keywords' => $this->config['seo']['keywords'] ?? 'cms, php, content management, file-based',
@@ -1156,8 +1175,6 @@ class CodePressCMS {
'nav_height' => $this->config['theme']['nav_height'] ?? '42',
'sidebar_background' => $this->config['theme']['sidebar_background'] ?? '#f8f9fa',
'sidebar_border' => $this->config['theme']['sidebar_border'] ?? '#dee2e6',
'background_image_css' => $this->getBackgroundImageCss(),
'background_image_opacity' => $this->getBackgroundImageOpacity(),
// Language
'current_lang' => $this->currentLanguage,
'current_lang_upper' => strtoupper($this->currentLanguage),
@@ -1216,33 +1233,18 @@ class CodePressCMS {
// Don't show site title link on guide page
$templateData['show_site_link'] = !$this->isContentDirEmpty() && !isset($_GET['guide']);
// Load and render all templates with data
$layoutTemplate = file_get_contents($this->config['templates_dir'] . '/layout.mustache');
$headerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/header.mustache');
$navigationTemplate = file_get_contents($this->config['templates_dir'] . '/assets/navigation.mustache');
$footerTemplate = file_get_contents($this->config['templates_dir'] . '/assets/footer.mustache');
// Map legacy frontmatter layout values to theme template keys
$layoutKey = $this->mapLayoutToThemeKey($layout);
// Determine content type and load appropriate template
$contentType = $this->getContentType($page);
$contentTemplateFile = $this->config['templates_dir'] . '/' . $contentType . '_content.mustache';
$contentTemplate = file_exists($contentTemplateFile) ? file_get_contents($contentTemplateFile) : '<div class="content">{{{content}}}</div>';
// Add theme asset URLs to template data
$themeManager = new ThemeManager($this->config);
$templateData['theme_title'] = $themeManager->getTitle();
$templateData['theme_css_url'] = $themeManager->getCssUrl();
$templateData['theme_js_url'] = $themeManager->getJsUrl();
$templateData['theme_config'] = $themeManager->getConfig();
// Render all templates with data
$renderedHeader = SimpleTemplate::render($headerTemplate, $templateData);
$renderedNavigation = SimpleTemplate::render($navigationTemplate, $templateData);
$renderedFooter = SimpleTemplate::render($footerTemplate, $templateData);
$renderedContent = SimpleTemplate::render($contentTemplate, $templateData);
// Replace partials in layout
$finalTemplate = str_replace('{{>header}}', $renderedHeader, $layoutTemplate);
$finalTemplate = str_replace('{{>navigation}}', $renderedNavigation, $finalTemplate);
$finalTemplate = str_replace('{{>footer}}', $renderedFooter, $finalTemplate);
$finalTemplate = str_replace('{{>content_template}}', $renderedContent, $finalTemplate);
// Render the final layout with all template data
$renderedLayout = SimpleTemplate::render($finalTemplate, $templateData);
// Render the page through the active theme
$renderedLayout = $themeManager->render($layoutKey, $templateData);
echo $renderedLayout;
@@ -1371,6 +1373,25 @@ class CodePressCMS {
return $html;
}
/**
* Map a frontmatter layout value to a theme template key.
*
* Legacy values are translated to the new theme keys. Unknown values
* are passed through so ThemeManager can fall back to default_layout.
*
* @param string $layout Layout value from page metadata
* @return string Theme template key
*/
private function mapLayoutToThemeKey(string $layout): string {
return match ($layout) {
'content' => 'full_content',
'sidebar-content', 'content-sidebar' => 'left_sidebar',
'content-sidebar-reverse' => 'right_sidebar',
'sidebar' => 'custom1',
default => $layout,
};
}
/**
* Determine content type for current page
*
@@ -1540,24 +1561,6 @@ class CodePressCMS {
return false;
}
private function getBackgroundImageCss(): string
{
$bg = $this->config['theme']['background_image'] ?? '';
if (empty($bg)) {
return 'none';
}
if (str_starts_with($bg, 'http')) {
return 'url(' . $bg . ')';
}
return 'url(/themes/' . $bg . ')';
}
private function getBackgroundImageOpacity(): int
{
$opacity = intval($this->config['theme']['background_image_opacity'] ?? 100);
return max(0, min(100, $opacity));
}
private function processContent(string $content): string
{
return str_replace('-/assets/', '/-assets/', $content);
+365
View File
@@ -0,0 +1,365 @@
<?php
/**
* LogManager - Dynamic logging for CodePress CMS
*
* Supports three drivers:
* - syslog: Send log entries to a remote syslog server (UDP) or local syslog.
* - sqlite: Store log entries in a SQLite database (default when no syslog
* server is configured and SQLite is available).
* - file: Fallback to plain text files when SQLite is unavailable.
*
* Which event types are recorded is controlled dynamically via the
* "logging.events" config section (admin, requests, errors, security,
* content, system).
*/
class LogManager {
const EVENT_ADMIN = 'admin';
const EVENT_REQUESTS = 'requests';
const EVENT_ERRORS = 'errors';
const EVENT_SECURITY = 'security';
const EVENT_CONTENT = 'content';
const EVENT_SYSTEM = 'system';
private static $config = null;
private static $pdo = null;
private static $dbPath = null;
/**
* Initialize the log manager with the logging config section.
*
* @param array $loggingConfig The "logging" section from config.json
*/
public static function init(array $loggingConfig): void
{
self::$config = $loggingConfig;
self::$dbPath = dirname(__DIR__, 3) . '/admin/storage/logs/codepress.sqlite';
}
/**
* Whether logging is enabled at all.
*/
public static function isEnabled(): bool
{
return !empty(self::$config['enabled']);
}
/**
* Whether a given event type should be recorded.
*
* @param string $event One of the EVENT_* constants
*/
public static function isEventEnabled(string $event): bool
{
if (!self::isEnabled()) {
return false;
}
$events = self::$config['events'] ?? [];
return !empty($events[$event]);
}
/**
* Get the local storage driver: 'sqlite' (falling back to 'file' if
* SQLite is unavailable). Syslog is an additional output, not a storage
* driver, so it never replaces local storage.
*/
public static function getDriver(): string
{
$driver = self::$config['driver'] ?? 'sqlite';
if ($driver === 'sqlite') {
return self::sqliteAvailable() ? 'sqlite' : 'file';
}
return 'file';
}
/**
* Record a log entry if the event type is enabled.
*
* @param string $event Event type (EVENT_* constant)
* @param string $level Log level (info, warning, error, debug)
* @param string $message Log message
* @param array $context Additional structured context
*/
public static function log(string $event, string $level, string $message, array $context = []): void
{
if (!self::isEventEnabled($event)) {
return;
}
$entry = [
'time' => date('Y-m-d H:i:s'),
'event' => $event,
'level' => $level,
'message' => $message,
'ip' => $context['ip'] ?? (class_exists('RequestLogger') ? RequestLogger::getClientIp() : ''),
'context' => $context,
];
$driver = self::getDriver();
// Always store locally (sqlite or file fallback) so the dynamic log
// in the admin always has entries.
if ($driver === 'sqlite') {
self::writeSqlite($entry);
} else {
self::writeFile($entry);
}
// Additionally forward to a remote syslog server if one is configured.
$syslogHost = trim(self::$config['syslog_host'] ?? '');
if ($syslogHost !== '') {
self::writeSyslog($entry);
}
}
/**
* Send a log entry to a remote syslog server over UDP.
*/
private static function writeSyslog(array $entry): void
{
$host = trim(self::$config['syslog_host'] ?? '');
$port = (int)(self::$config['syslog_port'] ?? 514);
$facility = self::syslogFacility(self::$config['syslog_facility'] ?? 'local0');
$ident = self::$config['syslog_ident'] ?? 'codepress';
$severity = self::syslogSeverity($entry['level']);
$pri = ($facility * 8) + $severity;
$msg = '<' . $pri . '>' . date('M d H:i:s') . ' ' . $ident . '[' . getmypid() . ']: '
. '[' . $entry['event'] . '] [' . $entry['level'] . '] ' . $entry['message'];
$sock = @fsockopen('udp://' . $host, $port, $errno, $errstr, 2);
if ($sock) {
@fwrite($sock, $msg . "\n");
@fclose($sock);
}
}
/**
* Store a log entry in the SQLite database.
*/
private static function writeSqlite(array $entry): void
{
$pdo = self::getPdo();
if ($pdo === null) {
// SQLite failed -> fall back to file
self::writeFile($entry);
return;
}
try {
$stmt = $pdo->prepare(
'INSERT INTO logs (time, event, level, message, ip, context) VALUES (:time, :event, :level, :message, :ip, :context)'
);
$stmt->execute([
':time' => $entry['time'],
':event' => $entry['event'],
':level' => $entry['level'],
':message' => $entry['message'],
':ip' => $entry['ip'],
':context' => json_encode($entry['context']),
]);
} catch (\Throwable $e) {
self::writeFile($entry);
}
}
/**
* Append a log entry to a plain text file (fallback driver).
*/
private static function writeFile(array $entry): void
{
$dir = dirname(self::$dbPath);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$file = $dir . '/codepress.log';
$line = '[' . $entry['time'] . '] [' . $entry['event'] . '] [' . $entry['level'] . '] ['
. $entry['ip'] . '] ' . $entry['message'] . "\n";
@file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
/**
* Get (and lazily create) the PDO connection to the SQLite database.
*/
private static function getPdo(): ?\PDO
{
if (self::$pdo !== null) {
return self::$pdo;
}
if (!self::sqliteAvailable()) {
return null;
}
$dir = dirname(self::$dbPath);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
try {
self::$pdo = new \PDO('sqlite:' . self::$dbPath);
self::$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
self::$pdo->exec(
'CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
time TEXT NOT NULL,
event TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
ip TEXT,
context TEXT
)'
);
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_logs_event ON logs (event)');
self::$pdo->exec('CREATE INDEX IF NOT EXISTS idx_logs_time ON logs (time)');
return self::$pdo;
} catch (\Throwable $e) {
self::$pdo = null;
return null;
}
}
/**
* Whether the SQLite PDO driver is available.
*/
private static function sqliteAvailable(): bool
{
return class_exists('PDO') && in_array('sqlite', \PDO::getAvailableDrivers(), true);
}
/**
* Map a facility name to its syslog numeric value.
*/
private static function syslogFacility(string $facility): int
{
$map = [
'kern' => 0, 'user' => 1, 'mail' => 2, 'daemon' => 3,
'auth' => 4, 'syslog' => 5, 'lpr' => 6, 'news' => 7,
'uucp' => 8, 'cron' => 9, 'authpriv' => 10, 'ftp' => 11,
'local0' => 16, 'local1' => 17, 'local2' => 18, 'local3' => 19,
'local4' => 20, 'local5' => 21, 'local6' => 22, 'local7' => 23,
];
return $map[$facility] ?? 16;
}
/**
* Map a log level to its syslog severity value.
*/
private static function syslogSeverity(string $level): int
{
$map = [
'debug' => 7,
'info' => 6,
'notice' => 5,
'warning' => 4,
'error' => 3,
'critical' => 2,
'alert' => 1,
'emergency' => 0,
];
return $map[strtolower($level)] ?? 6;
}
/**
* Query recent log entries from the active store.
*
* @param int $limit Number of entries to return
* @param string|null $event Optional event filter
* @param string|null $level Optional level filter (info, warning, error, ...)
* @param string|null $search Optional text search on the message
* @return array List of log entries (newest first)
*/
public static function getLogs(int $limit = 200, ?string $event = null, ?string $level = null, ?string $search = null): array
{
$driver = self::getDriver();
if ($driver === 'sqlite') {
$pdo = self::getPdo();
if ($pdo !== null) {
try {
$sql = 'SELECT time, event, level, message, ip FROM logs';
$conds = [];
$params = [];
if ($event !== null && $event !== '') {
$conds[] = 'event = :event';
$params[':event'] = $event;
}
if ($level !== null && $level !== '') {
$conds[] = 'level = :level';
$params[':level'] = $level;
}
if ($search !== null && $search !== '') {
$conds[] = 'message LIKE :search';
$params[':search'] = '%' . $search . '%';
}
if (!empty($conds)) {
$sql .= ' WHERE ' . implode(' AND ', $conds);
}
$sql .= ' ORDER BY id DESC LIMIT ' . (int)$limit;
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(\PDO::FETCH_ASSOC);
} catch (\Throwable $e) {
return [];
}
}
}
// File fallback
$dir = dirname(self::$dbPath);
$file = $dir . '/codepress.log';
if (!file_exists($file)) {
return [];
}
$lines = file($file);
$lines = array_slice($lines, -$limit);
$logs = [];
foreach ($lines as $line) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
if ($event !== null && $event !== '' && $m[2] !== $event) {
continue;
}
if ($level !== null && $level !== '' && $m[3] !== $level) {
continue;
}
if ($search !== null && $search !== '' && stripos($m[5], $search) === false) {
continue;
}
$logs[] = [
'time' => $m[1],
'event' => $m[2],
'level' => $m[3],
'ip' => $m[4],
'message' => $m[5],
];
}
}
return array_reverse($logs);
}
/**
* Clear all stored log entries.
*/
public static function clear(): void
{
$driver = self::getDriver();
if ($driver === 'sqlite') {
$pdo = self::getPdo();
if ($pdo !== null) {
try {
$pdo->exec('DELETE FROM logs');
return;
} catch (\Throwable $e) {
// fall through to file
}
}
}
$dir = dirname(self::$dbPath);
$file = $dir . '/codepress.log';
if (file_exists($file)) {
@file_put_contents($file, '');
}
}
}
+8
View File
@@ -98,6 +98,14 @@ class Logger {
// Write to file with error suppression (graceful degradation)
@file_put_contents(self::$logFile, $line, FILE_APPEND | LOCK_EX);
// Route through the dynamic log manager (errors/system events)
if (class_exists('LogManager')) {
$event = ($level === self::ERROR || $level === self::WARNING)
? LogManager::EVENT_ERRORS
: LogManager::EVENT_SYSTEM;
LogManager::log($event, strtolower($level), $message, $context);
}
}
/**
+73
View File
@@ -44,6 +44,79 @@ class RequestLogger
return $ip;
}
/**
* Check whether an IP matches any entry in a list of IPs/CIDR ranges.
*
* Supports exact IPv4/IPv6 addresses and CIDR notation (e.g. 192.168.0.0/16).
*
* @param string $ip The client IP to test
* @param array $list List of IPs and/or CIDR ranges
* @return bool True if the IP matches any entry
*/
public static function ipMatchesList(string $ip, array $list): bool
{
$ip = trim($ip);
if ($ip === '') {
return false;
}
$isV6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
$packed = $isV6 ? inet_pton($ip) : inet_pton($ip);
foreach ($list as $entry) {
$entry = trim((string)$entry);
if ($entry === '') {
continue;
}
// Exact match
if ($entry === $ip) {
return true;
}
// CIDR notation
if (strpos($entry, '/') !== false) {
[$subnet, $bits] = array_pad(explode('/', $entry, 2), 2, null);
$subnet = trim($subnet);
$subnetPacked = inet_pton($subnet);
if ($subnetPacked === false || $packed === false) {
continue;
}
// Ensure both are the same address family
if (strlen($subnetPacked) !== strlen($packed)) {
continue;
}
$maxBits = strlen($packed) * 8;
$bits = (int)$bits;
if ($bits < 0 || $bits > $maxBits) {
continue;
}
if ($bits === 0) {
return true;
}
$fullBytes = intdiv($bits, 8);
$remainingBits = $bits % 8;
$match = true;
for ($i = 0; $i < $fullBytes; $i++) {
if ($subnetPacked[$i] !== $packed[$i]) {
$match = false;
break;
}
}
if ($match && $remainingBits > 0) {
$mask = 0xFF << (8 - $remainingBits);
if ((ord($subnetPacked[$fullBytes]) & $mask) !== (ord($packed[$fullBytes]) & $mask)) {
$match = false;
}
}
if ($match) {
return true;
}
}
}
return false;
}
public static function getClientIp(): string
{
$headerKeys = [
+203
View File
@@ -0,0 +1,203 @@
<?php
use ScssPhp\ScssPhp\Compiler;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
/**
* ThemeManager - Resolves and renders the active theme
*
* Responsibilities:
* - Resolve the active theme directory from config
* - Load theme.json (title, default_layout, template mapping, colors)
* - Build a Twig environment rooted at the theme directory
* - Compile theme SCSS to CSS at runtime (cached by mtime)
* - Map a requested layout to a concrete .twig template, falling back
* to the theme's default_layout when the layout is unknown
*/
class ThemeManager {
private $config;
private $themeDir;
private $themeConfig;
private $twig;
private $compiledCssDir;
/**
* @param array $config Full CMS config (must contain 'theme_dir' and 'theme')
*/
public function __construct(array $config) {
$this->config = $config;
$this->themeDir = $config['theme_dir'] ?? (__DIR__ . '/../../../themes/' . ($config['active_theme'] ?? 'default'));
$this->themeConfig = $config['theme'] ?? [];
$this->compiledCssDir = __DIR__ . '/../../../public/themes';
$loader = new FilesystemLoader($this->themeDir);
$this->twig = new Environment($loader, [
'cache' => false,
'autoescape' => false,
]);
}
/**
* Get the absolute path of the active theme directory
*/
public function getThemeDir(): string {
return $this->themeDir;
}
/**
* Get the raw theme.json config array
*/
public function getThemeConfig(): array {
return $this->themeConfig;
}
/**
* Get the theme title (from theme.json 'title' or 'name')
*/
public function getTitle(): string {
return $this->themeConfig['title'] ?? $this->themeConfig['name'] ?? basename($this->themeDir);
}
/**
* Get the theme config section (default_template, background settings, etc.)
*/
public function getConfig(): array {
return $this->themeConfig['config'] ?? [];
}
/**
* Get the theme template section (layout key => .twig file mapping)
*/
public function getTemplates(): array {
return $this->themeConfig['template'] ?? [];
}
/**
* Resolve the .twig template file for a requested layout.
*
* Templates are defined in theme.json under the "template" section:
* { "template": { "full_content": "full_content.twig", ... } }
* The default template is defined in the "config" section:
* { "config": { "default_template": "full_content", ... } }
*
* Priority:
* 1. If the layout is a known key in the "template" section, use its mapped file.
* 2. Otherwise fall back to config.default_template.
* 3. Final safety net: full_content.twig.
*
* @param string $layout Requested layout key (e.g. 'left_sidebar')
* @return string Template name usable by the Twig loader
*/
public function getTemplateForLayout(string $layout): string {
$layout = trim($layout);
$templates = $this->getTemplates();
if ($layout !== '' && isset($templates[$layout])) {
$file = $templates[$layout];
if ($this->templateExists($file)) {
return $file;
}
}
$config = $this->getConfig();
$default = $config['default_template'] ?? 'full_content';
if (isset($templates[$default])) {
$file = $templates[$default];
if ($this->templateExists($file)) {
return $file;
}
}
return 'full_content.twig';
}
/**
* Get the list of available layout keys defined in the "template" section.
*
* @return array List of layout keys
*/
public function getLayouts(): array {
return array_keys($this->getTemplates());
}
/**
* Check whether a template file exists in the theme directory
*/
private function templateExists(string $file): bool {
$path = $this->themeDir . '/' . ltrim($file, '/');
return is_file($path);
}
/**
* Render a layout template with the given data.
*
* @param string $layout Requested layout key
* @param array $data Template variables
* @return string Rendered HTML
*/
public function render(string $layout, array $data): string {
$template = $this->getTemplateForLayout($layout);
return $this->twig->render($template, $data);
}
/**
* Compile the theme's SCSS to CSS (cached by source mtime).
*
* @return string|null Absolute path to the compiled CSS, or null if none
*/
public function compileCss(): ?string {
$scssFile = $this->themeDir . '/css/theme.scss';
if (!is_file($scssFile)) {
return null;
}
$themeName = basename($this->themeDir);
$outDir = $this->compiledCssDir . '/' . $themeName;
$outFile = $outDir . '/theme.css';
$cacheFile = $outDir . '/.mtime';
$mtime = filemtime($scssFile);
if (is_file($outFile) && is_file($cacheFile) && (int)file_get_contents($cacheFile) === $mtime) {
return $outFile;
}
if (!is_dir($outDir)) {
mkdir($outDir, 0755, true);
}
try {
$compiler = new Compiler();
$compiler->setImportPaths($this->themeDir . '/css');
$css = $compiler->compileString(file_get_contents($scssFile))->getCss();
file_put_contents($outFile, $css);
file_put_contents($cacheFile, (string)$mtime);
return $outFile;
} catch (\Throwable $e) {
error_log('ThemeManager SCSS compile error: ' . $e->getMessage());
return null;
}
}
/**
* Get the public URL for the compiled theme CSS, or null if unavailable.
*/
public function getCssUrl(): ?string {
$compiled = $this->compileCss();
if ($compiled === null) {
return null;
}
return '/themes/' . basename($this->themeDir) . '/theme.css';
}
/**
* Get the public URL for the theme JS, or null if unavailable.
*/
public function getJsUrl(): ?string {
$jsFile = $this->themeDir . '/js/theme.js';
if (!is_file($jsFile)) {
return null;
}
return '/themes/' . basename($this->themeDir) . '/js/theme.js';
}
}
+53 -8
View File
@@ -12,7 +12,6 @@ if (!file_exists($configJsonPath)) {
$defaultConfig = [
'site_title' => 'CodePress',
'content_dir' => 'content',
'templates_dir' => 'cms/templates',
'active_theme' => 'default',
'default_page' => 'auto',
'language' => [
@@ -25,7 +24,7 @@ if (!file_exists($configJsonPath)) {
],
'author' => [
'name' => 'E. Noorlander',
'website' => 'https://noorlander.info'
'website' => 'noorlander.info'
],
'show_version' => true,
'enabled_plugins' => ['MQTTTracker', 'HTMLBlock'],
@@ -54,7 +53,29 @@ if (!file_exists($configJsonPath)) {
'geoip_api_url' => '',
'geoip_api_key' => '',
'retention_days' => 400,
'excluded_ips' => []
'excluded_ips' => [
'192.168.0.0/16',
'10.0.0.0/8',
'172.16.0.0/12',
'127.0.0.1',
'::1'
]
],
'logging' => [
'enabled' => true,
'driver' => 'sqlite',
'syslog_host' => '',
'syslog_port' => 514,
'syslog_facility' => 'local0',
'syslog_ident' => 'codepress',
'events' => [
'admin' => true,
'requests' => true,
'errors' => true,
'security' => true,
'content' => true,
'system' => true
]
]
];
@file_put_contents($configJsonPath, json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
@@ -88,25 +109,50 @@ if (file_exists($configJsonPath)) {
'geoip_api_url' => '',
'geoip_api_key' => '',
'retention_days' => 400,
'excluded_ips' => [],
'excluded_ips' => [
'192.168.0.0/16',
'10.0.0.0/8',
'172.16.0.0/12',
'127.0.0.1',
'::1'
],
],
'logging' => [
'enabled' => true,
'driver' => 'sqlite',
'syslog_host' => '',
'syslog_port' => 514,
'syslog_facility' => 'local0',
'syslog_ident' => 'codepress',
'events' => [
'admin' => true,
'requests' => true,
'errors' => true,
'security' => true,
'content' => true,
'system' => true
],
],
];
foreach ($sectionDefaults as $section => $defaults) {
$config[$section] = array_merge($defaults, is_array($config[$section] ?? null) ? $config[$section] : []);
}
// Ensure the private/loopback IP defaults are present when the list is empty
if (empty($config['analytics']['excluded_ips'])) {
$config['analytics']['excluded_ips'] = $sectionDefaults['analytics']['excluded_ips'];
}
// Convert relative paths to absolute
$projectRoot = __DIR__ . '/../../';
if (isset($config['content_dir']) && strpos($config['content_dir'], '/') !== 0) {
$config['content_dir'] = $projectRoot . $config['content_dir'];
}
if (isset($config['templates_dir']) && strpos($config['templates_dir'], '/') !== 0) {
$config['templates_dir'] = $projectRoot . $config['templates_dir'];
}
// Load active theme
$activeTheme = $config['active_theme'] ?? 'default';
$themeDir = __DIR__ . '/../../themes/' . $activeTheme;
$config['theme_dir'] = $themeDir;
$themeFile = $themeDir . '/theme.json';
if (file_exists($themeFile)) {
$themeConfig = json_decode(file_get_contents($themeFile), true);
@@ -123,6 +169,5 @@ if (file_exists($configJsonPath)) {
return [
'site_title' => 'CodePress',
'content_dir' => __DIR__ . '/../../content',
'templates_dir' => __DIR__ . '/../templates',
'default_page' => 'auto'
];
+2
View File
@@ -40,9 +40,11 @@ require_once 'class/RequestLogger.php';
require_once 'class/GeoIP.php';
require_once 'class/Analytics.php';
require_once 'class/SimpleTemplate.php';
require_once 'class/ThemeManager.php';
// Load Logger class - structured logging with log levels
require_once 'class/Logger.php';
require_once 'class/LogManager.php';
// Load Plugin system
require_once 'plugin/CMSAPI.php';
+20
View File
@@ -30,6 +30,26 @@ if (is_file($filePath)) {
return true;
}
// Serve theme assets from the themes/ directory (e.g. /themes/default/js/theme.js)
if (preg_match('#^/themes/([^/]+)/(.+)$#', $path, $m)) {
$themeName = $m[1];
$themeRel = $m[2];
$themesDir = __DIR__ . '/../themes';
$themeFile = $themesDir . '/' . $themeName . '/' . $themeRel;
$realThemes = realpath($themesDir);
$realFile = realpath($themeFile);
if ($realFile && $realThemes && strpos($realFile, $realThemes) === 0 && is_file($realFile)) {
$ext = strtolower(pathinfo($realFile, PATHINFO_EXTENSION));
if (isset($mimeTypes[$ext])) {
header('Content-Type: ' . $mimeTypes[$ext]);
}
readfile($realFile);
return true;
}
http_response_code(404);
return true;
}
// Admin routes: /admin/login → admin.php?route=login
if (preg_match('#^/admin(?:/(.+))?$#', $path, $m)) {
$_GET['route'] = $m[1] ?? 'dashboard';
-5
View File
@@ -1,5 +0,0 @@
<div class="html-content">
<article class="content-body" role="main">
{{{content}}}
</article>
</div>
-552
View File
@@ -1,552 +0,0 @@
<!DOCTYPE html>
<html lang="{{current_lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{page_title}} - {{site_title}}</title>
<!-- Skip to content link for accessibility -->
<a href="#main-content" class="skip-link sr-only sr-only-focusable">Skip to main content</a>
<!-- CMS Meta Tags -->
<meta name="generator" content="{{site_title}} CMS">
<meta name="application-name" content="{{site_title}}">
<meta name="author" content="{{author_name}}">
<meta name="creator" content="{{author_name}}">
<meta name="publisher" content="{{author_name}}">
<!-- SEO Meta Tags -->
<meta name="description" content="{{seo_description}}">
<meta name="keywords" content="{{seo_keywords}}">
{{#block_ai_bots}}
<meta name="robots" content="noai, noimageai">
<meta name="tdm-reservation" content="1">
{{/block_ai_bots}}
{{#block_search_engines}}
<meta name="robots" content="noindex, nofollow">
{{/block_search_engines}}
<!-- Author Links -->
<link rel="author" href="{{author_website}}">
<link rel="me" href="{{author_git}}">
<!-- Favicon and PWA -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0a369d">
<!-- Styles -->
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/css/bootstrap-icons.css" rel="stylesheet">
<link href="/assets/css/style.css" rel="stylesheet">
<link href="/assets/css/mobile.css" rel="stylesheet">
<!-- Accessibility styles -->
<style>
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 6px;
outline: 3px solid #0056b3;
outline-offset: 2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
</style>
<!-- Dynamic theme colors -->
<style>
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
#site-header {
background-image: {{background_image_css}};
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
}
#site-header::before {
content: '';
position: absolute;
inset: 0;
background-color: var(--header-bg);
opacity: calc((100 - {{background_image_opacity}}) / 100);
pointer-events: none;
z-index: 0;
}
#site-header > * {
position: relative;
z-index: 1;
}
:root {
--header-bg: {{header_color}};
--header-font: {{header_font_color}};
--header-height: {{header_height}}px;
--nav-bg: {{navigation_color}};
--nav-font: {{navigation_font_color}};
--nav-height: {{nav_height}}px;
--sidebar-bg: {{sidebar_background}};
--sidebar-border: {{sidebar_border}};
}
/* Header styles */
.navbar {
background-color: var(--header-bg) !important;
min-height: var(--header-height);
}
.navbar .navbar-brand,
.navbar .navbar-text,
.navbar .form-control,
.navbar .btn {
color: var(--header-font) !important;
}
.navbar .form-control::placeholder {
color: rgba(255,255,255,0.7) !important;
}
.navbar .btn-outline-light {
border-color: var(--header-font) !important;
}
/* Language dropdown styling */
.dropdown-menu {
background-color: var(--header-bg) !important;
border: 1px solid var(--header-font) !important;
}
.dropdown-item {
color: var(--header-font) !important;
}
.dropdown-item:hover {
background-color: rgba(255,255,255,0.1) !important;
color: var(--header-font) !important;
}
/* Hide Bootstrap dropdown arrow and use custom icon */
.dropdown-toggle::after {
display: none !important;
}
.btn-outline-light {
color: var(--header-font) !important;
border-color: var(--header-font) !important;
}
.btn-outline-light:hover {
background-color: rgba(255,255,255,0.1) !important;
color: var(--header-font) !important;
}
/* Fix button color when dropdown is open */
.btn-outline-light:focus,
.btn-outline-light:active,
.show > .btn-outline-light.dropdown-toggle {
background-color: rgba(255,255,255,0.1) !important;
color: var(--header-font) !important;
border-color: var(--header-font) !important;
box-shadow: none !important;
}
.bi-chevron-down {
font-size: 0.75em;
margin-left: 0.25rem;
}
/* Remove Bootstrap default breadcrumb separators */
.breadcrumb-item + .breadcrumb-item::before {
content: "" !important;
padding: 0 !important;
}
/* Custom breadcrumb styling */
.breadcrumb {
--bs-breadcrumb-divider: "";
}
.breadcrumb-item {
color: var(--nav-font) !important;
}
.breadcrumb-item a {
color: var(--nav-font) !important;
text-decoration: none;
}
.breadcrumb-item a:hover {
text-decoration: underline;
}
/* Sidebar toggle button in breadcrumb */
.sidebar-toggle-item {
display: flex;
align-items: center;
margin-right: 0.5rem;
}
.sidebar-toggle-btn {
padding: 0;
line-height: 1;
font-size: 1.1rem;
color: var(--header-bg) !important;
border: none !important;
background: transparent !important;
box-shadow: none !important;
cursor: pointer;
}
.sidebar-toggle-btn:hover {
opacity: 0.7;
}
/* Sidebar hide/show transition */
.sidebar-column {
transition: all 0.3s ease;
}
.sidebar-hidden {
display: none !important;
}
/* Navigation section background */
.navigation-section {
background-color: var(--nav-bg) !important;
color: var(--nav-font) !important;
min-height: var(--nav-height);
}
/* Enhanced accessibility styles */
.focus-visible:focus,
.btn:focus,
.form-control:focus,
.nav-link:focus {
outline: 3px solid #0056b3 !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 1px #ffffff, 0 0 0 4px #0056b3 !important;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
:root {
--text-color: #000000;
--bg-color: #ffffff;
--border-color: #000000;
--focus-color: #000000;
}
.btn-primary {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
}
.btn-outline-light {
color: #000000 !important;
border-color: #000000 !important;
}
.text-muted {
color: #000000 !important;
}
.navbar {
background-color: #ffffff !important;
border-bottom: 1px solid #000000 !important;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Remove nav-tabs background so it inherits from parent */
.nav-tabs {
background-color: transparent !important;
border: none !important;
}
.nav-tabs .nav-link {
background-color: transparent !important;
border: none !important;
color: var(--nav-font) !important;
}
.nav-tabs .nav-link:hover {
background-color: rgba(255,255,255,0.1) !important;
}
.nav-tabs .nav-link.active {
background-color: rgba(255,255,255,0.2) !important;
border-bottom: 2px solid var(--nav-font) !important;
}
/* Sidebar styling */
.sidebar-column {
background-color: var(--sidebar-bg) !important;
border-right: 1px solid var(--sidebar-border) !important;
position: sticky;
top: 0;
min-height: calc(100vh - var(--header-height) - var(--nav-height) - 42px);
}
.sidebar {
padding: 1.5rem;
height: 100%;
overflow-y: auto;
}
.content-column {
background-color: #ffffff;
}
.content-wrapper {
padding: 2rem;
padding-bottom: 80px !important;
}
/* Ensure full height layout */
.main-content {
flex: 1;
}
/* Mobile responsive */
@media (max-width: 767.98px) {
.sidebar-column {
border-right: none !important;
border-top: 1px solid var(--sidebar-border) !important;
min-height: auto;
margin-top: 1rem;
}
.content-column {
background-color: #ffffff;
}
.content-wrapper {
min-height: auto;
padding-bottom: 2rem !important;
}
}
/* Tablet and mobile: sidebar below content */
@media (max-width: 991.98px) {
.sidebar-column {
order: 2 !important;
}
.content-column {
order: 1 !important;
}
}
/* Code block styling */
pre {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
border: 1px solid #dee2e6;
margin-bottom: 1rem;
}
pre code {
background: none;
padding: 0;
color: #333;
font-size: 0.85rem;
line-height: 1.5;
}
code {
background: #e8e8e8;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.9em;
color: #d63384;
}
/* Footer icon hover effects */
.footer-icon {
color: #6c757d;
text-decoration: none;
transition: all 0.2s ease-in-out;
display: inline-block;
padding: 2px;
}
.footer-icon:hover {
color: #0d6efd;
transform: translateY(-1px);
}
.footer-icon:active {
transform: translateY(0);
}
/* Specific icon hover colors */
.footer-icon.guide:hover {
color: #198754;
}
.footer-icon.cms:hover {
color: #dc3545;
}
.footer-icon.git:hover {
color: #6f42c1;
}
.footer-icon.website:hover {
color: #fd7e14;
}
</style>
</head>
<body>
{{>header}}
<nav role="navigation" aria-label="Main navigation" id="site-navigation">
{{>navigation}}
</nav>
<nav id="site-breadcrumb" class="breadcrumb-section bg-light border-bottom" aria-label="Breadcrumb navigation">
<div class="container-fluid">
<div class="row">
<div class="col-12 py-2">
<h2 class="sr-only">Breadcrumb Navigation</h2>
{{{breadcrumb}}}
</div>
</div>
</div>
</nav>
<main role="main" id="main-content" class="main-content" style="padding: 0;">
{{#sidebar_content}}
{{#equal layout "sidebar-content"}}
<div class="row g-0">
<aside role="complementary" aria-label="Sidebar content" id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2 order-md-1">
<div class="sidebar h-100">
{{{sidebar_content}}}
</div>
</aside>
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1 order-md-2">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
</div>
{{/equal}}
{{#equal layout "content"}}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
</div>
{{/equal}}
{{#equal layout "sidebar"}}
<div class="container-fluid">
<aside id="site-sidebar" class="col-12 sidebar-column">
<div class="sidebar">
{{{sidebar_content}}}
</div>
</aside>
</div>
{{/equal}}
{{#equal layout "content-sidebar"}}
<div class="row g-0">
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
<aside id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2">
<div class="sidebar h-100">
{{{sidebar_content}}}
</div>
</aside>
</div>
{{/equal}}
{{#equal layout "content-sidebar-reverse"}}
<div class="row g-0 flex-row-reverse">
<section id="site-content" class="col-lg-9 col-md-8 content-column">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
<aside id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column">
<div class="sidebar h-100">
{{{sidebar_content}}}
</div>
</aside>
</div>
{{/equal}}
{{/sidebar_content}}
{{^sidebar_content}}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{>content_template}}
</div>
</section>
</div>
{{/sidebar_content}}
</main>
<footer role="contentinfo" id="site-footer">
{{>footer}}
</footer>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
</body>
</html>
-5
View File
@@ -1,5 +0,0 @@
<div class="markdown-content">
<article class="content-body" role="main">
{{{content}}}
</article>
</div>
-5
View File
@@ -1,5 +0,0 @@
<div class="php-content">
<article class="content-body" role="main">
{{{content}}}
</article>
</div>
+3 -1
View File
@@ -3,6 +3,8 @@
"mustache/mustache": "^3.0",
"league/commonmark": "^2.7",
"php-mqtt/client": "^2.0",
"geoip2/geoip2": "^2.13"
"geoip2/geoip2": "^2.13",
"twig/twig": "^3.28",
"scssphp/scssphp": "^2.1"
}
}
Generated
+986 -1
View File
File diff suppressed because it is too large Load Diff
+25 -3
View File
@@ -1,7 +1,6 @@
{
"site_title": "CodePress",
"content_dir": "content",
"templates_dir": "cms/templates",
"active_theme": "default",
"default_page": "auto",
"language": {
@@ -17,7 +16,7 @@
},
"author": {
"name": "E. Noorlander",
"website": "https://noorlander.info"
"website": "noorlander.info"
},
"show_version": true,
"enabled_plugins": [
@@ -48,6 +47,29 @@
"geoip_mmdb_path": "",
"geoip_api_url": "",
"geoip_api_key": "",
"retention_days": 400
"retention_days": 400,
"excluded_ips": [
"192.168.0.0/16",
"10.0.0.0/8",
"172.16.0.0/12",
"127.0.0.1",
"::1"
]
},
"logging": {
"enabled": true,
"driver": "sqlite",
"syslog_host": "",
"syslog_port": 514,
"syslog_facility": "local0",
"syslog_ident": "codepress",
"events": {
"admin": true,
"requests": true,
"errors": true,
"security": true,
"content": true,
"system": true
}
}
}
+2 -2
View File
@@ -27,7 +27,7 @@
- [x] **Breadcrumb titels ongeescaped** - `$title` direct in HTML zonder `htmlspecialchars()` (`CodePressCMS.php:1197`)
- [x] **Zoekresultaat-URLs missen `&lang=`** - Taalparameter ontbreekt (`CodePressCMS.php:264`)
- [x] **Operator precedence bug** - `!$x ?? true` evalueert als `(!$x) ?? true` (`MQTTTracker.php:131`)
- [ ] **Taalwisselaar verliest pagina** - Wisselen van taal navigeert altijd naar homepage (`header.mustache:22`)
- [ ] **Taalwisselaar verliest pagina** - Wisselen van taal navigeert altijd naar homepage (`partials/header.twig`)
- [ ] **ctime is geen creatietijd op Linux** - `stat()` ctime is inode-wijzigingstijd (`CodePressCMS.php:400`)
- [ ] **getGuidePage() dupliceert markdown parsing** - Zelfde CommonMark setup als `parseMarkdown()` (`CodePressCMS.php:854`)
- [ ] **HTMLBlock ontbrekende `</div>`** - Niet-gesloten tags bij null-check (`HTMLBlock.php:68`)
@@ -36,7 +36,7 @@
## Laag
- [x] **Hardcoded 'Ga naar'** - Niet vertaalbaar in `autoLinkPageTitles()` (`CodePressCMS.php:587`)
- [x] **HTML lang attribuut** - `<html lang="en">` hardcoded i.p.v. dynamisch (`layout.mustache:2`)
- [x] **HTML lang attribuut** - `<html lang="en">` hardcoded i.p.v. dynamisch (`base.twig`)
- [x] **console.log in productie** - Debug log in app.js (`app.js:54`)
- [x] **Event listener leak** - N globale click listeners in forEach loop (`app.js:85`)
- [x] **Sidebar toggle aria** - Ontbrekende `aria-label` en `aria-expanded` (`CodePressCMS.php:1171`)
+103 -39
View File
@@ -58,8 +58,8 @@ codepress/
│ ├── core/
│ │ ├── class/
│ │ │ ├── CodePressCMS.php # Main CMS class (content, navigation, search)
│ │ │ ├── ThemeManager.php # Theme resolver + Twig render + SCSS compile
│ │ │ ├── Logger.php # Structured logging system
│ │ │ ├── SimpleTemplate.php # Mustache-style template engine
│ │ │ ├── Analytics.php # Visitor statistics
│ │ │ ├── BotGuard.php # Bot/AI/scraper detection
│ │ │ ├── GeoIP.php # Country lookup by IP
@@ -73,13 +73,21 @@ codepress/
│ ├── lang/ # Language files
│ │ ├── nl.php # Dutch translations
│ │ └── en.php # English translations
── templates/ # Mustache templates
├── layout.mustache # Main layout (CSS, structure)
│ ├── assets/ # Header, navigation, footer partials
│ │ ├── markdown_content.mustache
│ │ ├── php_content.mustache
│ │ ── html_content.mustache
└── router.php # PHP dev server router
── router.php # PHP dev server router (also serves /themes/)
├── themes/ # Dynamic themes (fully self-contained)
│ ├── default/ # Default theme
│ │ ├── theme.json # { title, config.default_template, template→.twig mapping }
│ │ ├── base.twig # Main layout (head, header, nav, footer)
│ │ ── full_content.twig # Layout: full width
│ ├── left_sidebar.twig # Layout: sidebar left
│ │ ├── right_sidebar.twig # Layout: sidebar right
│ │ ├── custom1.twig # Layout: custom
│ │ ├── partials/ # header.twig, navigation.twig, footer.twig
│ │ ├── css/theme.scss # Colors, heights, background (compiled at runtime)
│ │ ├── js/theme.js # Theme JavaScript
│ │ └── theme.png # Preview image
│ ├── demo/ # Demo theme (same structure, different look)
│ └── test/ # Test theme
├── admin/ # Admin panel
│ ├── config/
│ │ ├── app.php # Admin app configuration (paths, timezone)
@@ -124,12 +132,19 @@ codepress/
│ ├── assets/ # CSS, JS, favicons
│ │ ├── codemirror/ # CodeMirror editor (minified JS/CSS)
│ │ └── css/js/ # Bootstrap, icons, app CSS/JS
│ ├── themes/ # Uploaded theme backgrounds
│ ├── themes/ # Runtime compiled theme CSS (public/themes)
│ └── manifest.json / sw.js # PWA support
├── themes/ # Theme definitions
├── themes/ # Dynamic themes (fully self-contained)
│ ├── default/ # Default theme
│ │ ── theme.json # Colors, heights, background
└── ... # Other themes
│ │ ── theme.json # Title, default template, template mapping
│ ├── base.twig # Main layout
│ │ ├── *.twig # Layout templates (full_content, left_sidebar, ...)
│ │ ├── partials/ # header, navigation, footer
│ │ ├── css/theme.scss # Colors, heights, background
│ │ ├── js/theme.js # Theme JavaScript
│ │ └── theme.png # Preview image
│ ├── demo/ # Demo theme
│ └── test/ # Test theme
├── config.json # Site configuration
├── version.php # Version information
└── vendor/ # Composer dependencies
@@ -301,7 +316,6 @@ This is useful for your own IP address or internal monitoring tools.
{
"site_title": "CodePress",
"content_dir": "content",
"templates_dir": "cms\/templates",
"default_page": "index",
"active_theme": "default",
"language": {
@@ -336,32 +350,64 @@ This is useful for your own IP address or internal monitoring tools.
### Themes
Themes are managed via the admin panel at `/admin/theme`. You can create, activate, adjust colors, upload background images, and delete themes.
Themes are managed via the admin panel at `/admin/theme`. This is a selection page: pick the active theme and click "Activate theme". Each theme is a fully self-contained folder in `themes/` with its own Twig templates, SCSS and JavaScript.
#### Theme structure (`themes/<name>/`)
```
themes/<name>/
├── theme.json # Title, default template, template mapping
├── base.twig # Main layout (head, header, nav, footer)
├── full_content.twig # Layout: full width
├── left_sidebar.twig # Layout: sidebar left
├── right_sidebar.twig # Layout: sidebar right
├── custom1.twig # Layout: custom
├── partials/ # header.twig, navigation.twig, footer.twig
├── css/theme.scss # Colors, heights, background (compiled at runtime)
├── js/theme.js # Theme JavaScript
└── theme.png # Preview image (shown in admin)
```
#### Theme Configuration (`themes/<name>/theme.json`)
```json
{
"name": "Default",
"header_color": "#0a369d",
"header_font_color": "#ffffff",
"header_height": "56",
"navigation_color": "#2754b4",
"navigation_font_color": "#ffffff",
"nav_height": "42",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6",
"background_image": "",
"background_image_opacity": "100"
"title": "default",
"config": {
"default_template": "full_content"
},
"template": {
"full_content": "full_content.twig",
"left_sidebar": "left_sidebar.twig",
"right_sidebar": "right_sidebar.twig",
"custom1": "custom1.twig"
}
}
```
- **`config.default_template`**: the default template used when a page requests an unknown layout.
- **`template`**: the layout-key → `.twig` file mapping. A theme can have multiple template pages.
Colors, heights and background are **not** set in `theme.json` but in `css/theme.scss`:
```scss
$header-bg: #0a369d;
$header-font: #ffffff;
$header-height: 56px;
$nav-bg: #2754b4;
$nav-font: #ffffff;
$nav-height: 42px;
$sidebar-bg: #f8f9fa;
$sidebar-border: #dee2e6;
$header-bg-image: none; // optional header background
$header-bg-opacity: 1;
```
The SCSS is compiled at runtime into `public/themes/<name>/theme.css`.
#### How to create a new theme
1. Go to `/admin/theme`
2. Enter a name and click "Create"
3. Adjust colors, heights and background
4. Activate the theme
Themes are created manually: copy the `themes/default/` folder to `themes/<name>/`, adjust the SCSS colors and templates, and add a `theme.png` preview. The theme is then available on `/admin/theme` to activate.
### Security
@@ -418,10 +464,23 @@ Country detection via three sources:
### Logging
The admin console maintains two logs, viewable at `/admin/logs`:
The admin console maintains logs, viewable at `/admin/logs`:
- **Activity log** (`admin/storage/logs/admin.log`) — admin actions like creating, editing, deleting pages, enabling/disabling plugins, changing configuration.
- **Request log** (`admin/storage/logs/requests.log`) — every page view on the website, including IP, page, domain, language, user agent, and referrer.
- **Dynamic log** — structured log entries via `LogManager`, with event type, level, IP, and message.
#### Configuring dynamic logging
Via `/admin/config`**Logging** you can configure how and what is recorded:
- **Storage**: `SQLite` (default) or `Syslog`.
- **Syslog server**: if a host is provided, log entries are sent to that server over UDP. Leave empty to use SQLite.
- **Facility**: the category of the log source in syslog. `local0``local7` are for your own applications; `daemon`, `user`, and `auth` are standard system categories.
- **Syslog ident**: the name that appears in the log message (e.g. `codepress`).
- **Events**: choose which types are recorded — `admin`, `requests`, `errors`, `security`, `content`, `system`.
If no syslog server is configured, SQLite is always used (with a file fallback if SQLite is unavailable).
The dashboard shows the last 20 entries of each log. Click "View all →" for the full list, where you can also download or clear.
@@ -523,6 +582,8 @@ This guide is also built into the admin panel via `/admin/guide`, with support f
### Templates
Templates are Twig files per theme in `themes/<name>/`. `ThemeManager` renders them and compiles `css/theme.scss` at runtime into `public/themes/<name>/theme.css`.
#### Template Variables
**Site Info** - `site_title`, `author_name`, `author_website`, `author_git`
@@ -531,36 +592,39 @@ This guide is also built into the admin panel via `/admin/guide`, with support f
**Navigation** - `menu`, `breadcrumb`, `homepage`
**Theme (from theme.json)** - `header_color`, `header_font_color`, `header_height`, `navigation_color`, `navigation_font_color`, `nav_height`, `sidebar_background`, `sidebar_border`, `background_image_css`, `background_image_opacity`
**Theme** - `theme_title`, `theme_css_url`, `theme_js_url`, `theme_config` (config from theme.json)
**Language** - `current_lang`, `current_lang_upper`, `t_*` (translated strings)
#### Layout Options
Use YAML frontmatter to select layout:
Use YAML frontmatter to select the template. The layout key references a template in the active theme:
```yaml
---
title: My Page
layout: sidebar-content
layout: left_sidebar
plugins: HTMLBlock
---
```
#### Available Layouts
- `sidebar-content` - Sidebar left, content right (default)
- `content` - Content only (full width)
- `sidebar` - Sidebar only
- `content-sidebar` - Content left, sidebar right
- `content-sidebar-reverse` - Content right, sidebar left
The available layouts are defined by the `template` section of the active theme (`themes/<name>/theme.json`). The default theme includes:
- `full_content` - Content only (full width)
- `left_sidebar` - Sidebar left, content right
- `right_sidebar` - Content left, sidebar right
- `custom1` - Custom layout
If a page requests an unknown layout, the `default_template` from the theme's `config` is used.
#### Meta Data
```yaml
---
title: Page Title
layout: content-sidebar
layout: left_sidebar
description: Page description
author: Author Name
date: 2025-11-26
+103 -39
View File
@@ -58,8 +58,8 @@ codepress/
│ ├── core/
│ │ ├── class/
│ │ │ ├── CodePressCMS.php # Hoofd CMS class (content, navigatie, search)
│ │ │ ├── ThemeManager.php # Thema-resolver + Twig render + SCSS compile
│ │ │ ├── Logger.php # Gestructureerd logging systeem
│ │ │ ├── SimpleTemplate.php # Mustache-style template engine
│ │ │ ├── Analytics.php # Bezoekersstatistieken
│ │ │ ├── BotGuard.php # Bot/AI/scraper detectie
│ │ │ ├── GeoIP.php # Landbepaling op basis van IP
@@ -73,13 +73,21 @@ codepress/
│ ├── lang/ # Taalbestanden
│ │ ├── nl.php # Nederlandse vertalingen
│ │ └── en.php # Engelse vertalingen
── templates/ # Mustache templates
├── layout.mustache # Hoofd layout (CSS, structuur)
│ ├── assets/ # Header, navigation, footer partials
│ │ ├── markdown_content.mustache
│ │ ├── php_content.mustache
│ │ ── html_content.mustache
└── router.php # PHP dev server router
── router.php # PHP dev server router (serveert ook /themes/)
├── themes/ # Dynamische thema's (volledig zelfstandig)
│ ├── default/ # Standaard thema
│ │ ├── theme.json # { title, config.default_template, template→.twig mapping }
│ │ ├── base.twig # Hoofd layout (head, header, nav, footer)
│ │ ── full_content.twig # Layout: volledige breedte
│ ├── left_sidebar.twig # Layout: sidebar links
│ │ ├── right_sidebar.twig # Layout: sidebar rechts
│ │ ├── custom1.twig # Layout: custom
│ │ ├── partials/ # header.twig, navigation.twig, footer.twig
│ │ ├── css/theme.scss # Kleuren, hoogtes, achtergrond (runtime gecompileerd)
│ │ ├── js/theme.js # Thema JavaScript
│ │ └── theme.png # Voorbeeldafbeelding
│ ├── demo/ # Demo thema (zelfde structuur, andere look)
│ └── test/ # Test thema
├── admin/ # Admin paneel
│ ├── config/
│ │ ├── app.php # Admin app configuratie (paden, timezone)
@@ -124,12 +132,19 @@ codepress/
│ ├── assets/ # CSS, JS, favicons
│ │ ├── codemirror/ # CodeMirror editor (minified JS/CSS)
│ │ └── css/js/ # Bootstrap, icons, app CSS/JS
│ ├── themes/ # Geuploade theme achtergronden
│ ├── themes/ # Runtime gecompileerde thema CSS (public/themes)
│ └── manifest.json / sw.js # PWA ondersteuning
├── themes/ # Thema definities
├── themes/ # Dynamische thema's (volledig zelfstandig)
│ ├── default/ # Standaard thema
│ │ ── theme.json # Kleuren, hoogtes, achtergrond
└── ... # Andere thema's
│ │ ── theme.json # Titel, default template, template mapping
│ ├── base.twig # Hoofd layout
│ │ ├── *.twig # Layout-sjablonen (full_content, left_sidebar, ...)
│ │ ├── partials/ # header, navigation, footer
│ │ ├── css/theme.scss # Kleuren, hoogtes, achtergrond
│ │ ├── js/theme.js # Thema JavaScript
│ │ └── theme.png # Voorbeeldafbeelding
│ ├── demo/ # Demo thema
│ └── test/ # Test thema
├── config.json # Site configuratie
├── version.php # Versie informatie
└── vendor/ # Composer dependencies
@@ -302,7 +317,6 @@ Dit is handig voor je eigen IP-adres of dat van interne monitoring tools.
{
"site_title": "CodePress",
"content_dir": "content",
"templates_dir": "cms\/templates",
"default_page": "index",
"active_theme": "default",
"language": {
@@ -337,32 +351,64 @@ Dit is handig voor je eigen IP-adres of dat van interne monitoring tools.
### Thema's
Thema's worden beheerd via het admin paneel op `/admin/theme`. Je kunt thema's aanmaken, activeren, kleuren aanpassen, achtergrondafbeeldingen uploaden en verwijderen.
Thema's worden beheerd via het admin paneel op `/admin/theme`. Dit is een selectiepagina: kies het actieve thema en klik "Thema activeren". Elk thema is een volledig zelfstandige map in `themes/` met eigen Twig-sjablonen, SCSS en JavaScript.
#### Thema-structuur (`themes/<naam>/`)
```
themes/<naam>/
├── theme.json # Titel, default template, template mapping
├── base.twig # Hoofd layout (head, header, nav, footer)
├── full_content.twig # Layout: volledige breedte
├── left_sidebar.twig # Layout: sidebar links
├── right_sidebar.twig # Layout: sidebar rechts
├── custom1.twig # Layout: custom
├── partials/ # header.twig, navigation.twig, footer.twig
├── css/theme.scss # Kleuren, hoogtes, achtergrond (runtime gecompileerd)
├── js/theme.js # Thema JavaScript
└── theme.png # Voorbeeldafbeelding (tonen in admin)
```
#### Thema Configuratie (`themes/<naam>/theme.json`)
```json
{
"name": "Standaard",
"header_color": "#0a369d",
"header_font_color": "#ffffff",
"header_height": "56",
"navigation_color": "#2754b4",
"navigation_font_color": "#ffffff",
"nav_height": "42",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6",
"background_image": "",
"background_image_opacity": "100"
"title": "default",
"config": {
"default_template": "full_content"
},
"template": {
"full_content": "full_content.twig",
"left_sidebar": "left_sidebar.twig",
"right_sidebar": "right_sidebar.twig",
"custom1": "custom1.twig"
}
}
```
- **`config.default_template`**: de standaard sjabloon die gebruikt wordt wanneer een pagina een onbekende layout vraagt.
- **`template`**: de layout-sleutel → `.twig`-bestand koppeling. Zo kan een thema meerdere template-pagina's hebben.
Kleuren, hoogtes en achtergrond worden **niet** in `theme.json` gezet, maar in `css/theme.scss`:
```scss
$header-bg: #0a369d;
$header-font: #ffffff;
$header-height: 56px;
$nav-bg: #2754b4;
$nav-font: #ffffff;
$nav-height: 42px;
$sidebar-bg: #f8f9fa;
$sidebar-border: #dee2e6;
$header-bg-image: none; // optionele header-achtergrond
$header-bg-opacity: 1;
```
De SCSS wordt runtime gecompileerd naar `public/themes/<naam>/theme.css`.
#### Een nieuw thema maken
1. Ga naar `/admin/theme`
2. Voer een naam in en klik "Aanmaken"
3. Pas kleuren, hoogtes en achtergrond aan
4. Activeer het thema
Thema's zijn handmatig aan te maken: kopieer de `themes/default/` map naar `themes/<naam>/`, pas de SCSS-kleuren en sjablonen aan, en voeg een `theme.png` preview toe. Daarna is het thema beschikbaar op `/admin/theme` om te activeren.
### Beveiliging
@@ -419,10 +465,23 @@ Landbepaling kan via drie bronnen:
### Logging
De admin console houdt twee logs bij, te bekijken via `/admin/logs`:
De admin console houdt logs bij, te bekijken via `/admin/logs`:
- **Activiteiten log** (`admin/storage/logs/admin.log`) — admin acties zoals pagina's aanmaken, bewerken, verwijderen, plugin in/uitschakelen, configuratie wijzigen.
- **Requests log** (`admin/storage/logs/requests.log`) — elke pageview op de website, met IP, pagina, domein, taal, user-agent en referrer.
- **Dynamisch log** — gestructureerde logregels via `LogManager`, met gebeurtenistype, niveau, IP en bericht.
#### Dynamische logging configureren
Via `/admin/config`**Logging** kun je instellen hoe en wat er geregistreerd wordt:
- **Opslag**: `SQLite` (standaard) of `Syslog`.
- **Syslog server**: als er een host is opgegeven, worden logregels via UDP naar die server gestuurd. Laat leeg om SQLite te gebruiken.
- **Facility**: de categorie van de logbron in syslog. `local0``local7` zijn bedoeld voor eigen applicaties; `daemon`, `user` en `auth` zijn standaard systeemcategorieën.
- **Syslog ident**: de naam die in het logbericht verschijnt (bijv. `codepress`).
- **Gebeurtenissen**: kies welke types geregistreerd worden — `admin`, `requests`, `errors`, `security`, `content`, `system`.
Als er geen syslog-server is opgegeven, wordt altijd SQLite gebruikt (met een bestands-fallback als SQLite niet beschikbaar is).
Het dashboard toont de laatste 20 entries van elk log. Klik "Bekijk alle →" voor de volledige lijst, waar je ook kunt downloaden of wissen.
@@ -524,6 +583,8 @@ Deze handleiding is ook ingebouwd in het admin paneel via `/admin/guide`, met on
### Templates
Sjablonen zijn Twig-bestanden die per thema in `themes/<naam>/` staan. `ThemeManager` rendert ze en compileert `css/theme.scss` runtime naar `public/themes/<naam>/theme.css`.
#### Template Variabelen
**Site Info** - `site_title`, `author_name`, `author_website`, `author_git`
@@ -532,36 +593,39 @@ Deze handleiding is ook ingebouwd in het admin paneel via `/admin/guide`, met on
**Navigation** - `menu`, `breadcrumb`, `homepage`
**Theme (uit theme.json)** - `header_color`, `header_font_color`, `header_height`, `navigation_color`, `navigation_font_color`, `nav_height`, `sidebar_background`, `sidebar_border`, `background_image_css`, `background_image_opacity`
**Theme** - `theme_title`, `theme_css_url`, `theme_js_url`, `theme_config` (config uit theme.json)
**Language** - `current_lang`, `current_lang_upper`, `t_*` (vertaalde strings)
#### Layout Opties
Gebruik YAML frontmatter om layout te selecteren:
Gebruik YAML frontmatter om de sjabloon te selecteren. De layout-sleutel verwijst naar een template in het actieve thema:
```yaml
---
title: Mijn Pagina
layout: sidebar-content
layout: left_sidebar
plugins: HTMLBlock
---
```
#### Beschikbare Layouts
- `sidebar-content` - Sidebar links, content rechts (standaard)
- `content` - Alleen content (volle breedte)
- `sidebar` - Alleen sidebar
- `content-sidebar` - Content links, sidebar rechts
- `content-sidebar-reverse` - Content rechts, sidebar links
De beschikbare layouts worden bepaald door de `template`-sectie van het actieve thema (`themes/<naam>/theme.json`). Het standaard thema bevat:
- `full_content` - Alleen content (volle breedte)
- `left_sidebar` - Sidebar links, content rechts
- `right_sidebar` - Content links, sidebar rechts
- `custom1` - Custom layout
Vraag een pagina een onbekende layout aan, dan wordt de `default_template` uit `config` van het thema gebruikt.
#### Meta Data
```yaml
---
title: Pagina Titel
layout: content-sidebar
layout: left_sidebar
description: Pagina beschrijving
author: Auteur Naam
date: 2025-11-26
-3
View File
@@ -1,3 +0,0 @@
# test
Dit is een test plugin
-3
View File
@@ -1,3 +0,0 @@
{
"enabled": true
}
-35
View File
@@ -1,35 +0,0 @@
<?php
class test
{
private ?CMSAPI $api = null;
private array $config;
public function __construct()
{
$this->config = [
'title' => 'Dit is een test plugin',
'viewable' => true,
];
}
public function setAPI(CMSAPI $api): void
{
$this->api = $api;
}
public function getSidebarContent(): string
{
return 'Hallo';
}
public function getConfig(): array
{
return $this->config;
}
public function setConfig(array $config): void
{
$this->config = array_merge($this->config, $config);
}
}
+230 -150
View File
@@ -26,6 +26,13 @@ require_once __DIR__ . '/../cms/core/class/BotGuard.php';
require_once __DIR__ . '/../cms/core/class/Cache.php';
require_once __DIR__ . '/../cms/core/class/GeoIP.php';
require_once __DIR__ . '/../cms/core/class/Analytics.php';
require_once __DIR__ . '/../cms/core/class/LogManager.php';
// Initialize dynamic logging from site config
$siteConfigForLogging = file_exists($appConfig['config_json'])
? (json_decode(file_get_contents($appConfig['config_json']), true) ?? [])
: [];
LogManager::init($siteConfigForLogging['logging'] ?? []);
$auth = new AdminAuth($appConfig);
@@ -108,6 +115,14 @@ switch ($route) {
handleTheme($auth, $appConfig);
break;
case 'theme-new':
handleThemeNew($auth, $appConfig);
break;
case 'theme-delete':
handleThemeDelete($auth, $appConfig);
break;
case 'plugins':
handlePlugins($auth, $appConfig);
break;
@@ -328,6 +343,11 @@ function adminLog(array $config, string $level, string $message): void
$timestamp = date('Y-m-d H:i:s');
$ip = RequestLogger::getClientIp();
@file_put_contents($logFile, "[{$timestamp}] [{$level}] [{$ip}] {$message}\n", FILE_APPEND);
// Also record through the dynamic log manager (admin event)
if (class_exists('LogManager')) {
LogManager::log(LogManager::EVENT_ADMIN, $level, $message, ['ip' => $ip]);
}
}
function handleContentEdit(AdminAuth $auth, array $config): void
@@ -410,6 +430,18 @@ function handleContentEdit(AdminAuth $auth, array $config): void
$currentLayout = 'sidebar-content';
}
// Load available layouts from the active theme's theme.json
$themeLayouts = [];
$configJson = $config['config_json'];
$configData = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
if (!is_array($configData)) $configData = [];
$activeThemeName = $configData['active_theme'] ?? 'default';
$activeThemeFile = $config['codepress_root'] . '/themes/' . $activeThemeName . '/theme.json';
if (file_exists($activeThemeFile)) {
$themeJson = json_decode(file_get_contents($activeThemeFile), true) ?? [];
$themeLayouts = $themeJson['template'] ?? [];
}
$fileName = basename($filePath);
$route = 'content-edit';
@@ -522,6 +554,18 @@ function handleContentNew(AdminAuth $auth, array $config): void
}
}
// Load available layouts from the active theme's .twig files
$themeLayouts = [];
$configJson = $config['config_json'];
$configData = file_exists($configJson) ? json_decode(file_get_contents($configJson), true) : [];
if (!is_array($configData)) $configData = [];
$activeThemeName = $configData['active_theme'] ?? 'default';
$activeThemeFile = $config['codepress_root'] . '/themes/' . $activeThemeName . '/theme.json';
if (file_exists($activeThemeFile)) {
$themeJson = json_decode(file_get_contents($activeThemeFile), true) ?? [];
$themeLayouts = $themeJson['template'] ?? [];
}
$route = 'content-new';
require __DIR__ . '/../admin/templates/layout.php';
}
@@ -748,7 +792,7 @@ function handleConfig(AdminAuth $auth, array $config): void
$configData['seo']['description'] = trim($_POST['seo_description'] ?? '');
$configData['seo']['keywords'] = trim($_POST['seo_keywords'] ?? '');
$configData['author']['name'] = trim($_POST['author_name'] ?? '');
$configData['author']['website'] = trim($_POST['author_website'] ?? '');
$configData['author']['website'] = stripUrlScheme(trim($_POST['author_website'] ?? ''));
$configData['show_version'] = !empty($_POST['show_version']);
$configData['features']['auto_link_pages'] = !empty($_POST['feature_auto_link']);
$configData['features']['search_enabled'] = !empty($_POST['feature_search']);
@@ -767,6 +811,20 @@ function handleConfig(AdminAuth $auth, array $config): void
};
$configData['analytics']['excluded_ips'] = $parseLines($_POST['excluded_ips'] ?? '');
// Logging settings
$configData['logging']['enabled'] = !empty($_POST['logging_enabled']);
$configData['logging']['driver'] = ($_POST['logging_driver'] ?? 'sqlite') === 'syslog' ? 'syslog' : 'sqlite';
$configData['logging']['syslog_host'] = trim($_POST['syslog_host'] ?? '');
$configData['logging']['syslog_port'] = max(1, min(65535, (int)($_POST['syslog_port'] ?? 514)));
$configData['logging']['syslog_facility'] = preg_replace('/[^a-z0-9]/', '', $_POST['syslog_facility'] ?? 'local0');
$configData['logging']['syslog_ident'] = trim($_POST['syslog_ident'] ?? 'codepress');
$selectedEvents = $_POST['logging_events'] ?? [];
$allEvents = ['admin', 'requests', 'errors', 'security', 'content', 'system'];
$configData['logging']['events'] = [];
foreach ($allEvents as $ev) {
$configData['logging']['events'][$ev] = in_array($ev, $selectedEvents, true);
}
file_put_contents($configJson, json_encode($configData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', $user['username'] . ' wijzigde site configuratie');
$message = 'Configuratie opgeslagen.';
@@ -993,115 +1051,19 @@ function handleTheme(AdminAuth $auth, array $config): void
$csrf = $auth->getCsrfToken();
$configJson = $config['config_json'];
$themesDir = $config['codepress_root'] . '/themes';
$publicThemes = $config['codepress_root'] . '/public/themes';
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$action = $_POST['action'] ?? '';
if ($action === 'save') {
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? '');
$themeFile = $themesDir . '/' . $themeName . '/theme.json';
if (file_exists($themeFile)) {
$themeData = json_decode(file_get_contents($themeFile), true) ?? [];
foreach (['header_color', 'header_font_color', 'navigation_color', 'navigation_font_color', 'sidebar_background', 'sidebar_border'] as $field) {
$value = trim($_POST[$field] ?? '');
if (preg_match('/^#[0-9a-fA-F]{6}$/', $value)) {
$themeData[$field] = $value;
}
}
$themeData['name'] = trim($_POST['theme_name'] ?? $themeData['name'] ?? $themeName);
$themeData['header_height'] = preg_match('/^\d+$/', $_POST['header_height'] ?? '') ? trim($_POST['header_height']) : ($themeData['header_height'] ?? '56');
$themeData['nav_height'] = preg_match('/^\d+$/', $_POST['nav_height'] ?? '') ? trim($_POST['nav_height']) : ($themeData['nav_height'] ?? '42');
$themeData['background_image_opacity'] = preg_match('/^\d+$/', $_POST['background_image_opacity'] ?? '') ? max(0, min(100, intval($_POST['background_image_opacity']))) : ($themeData['background_image_opacity'] ?? '100');
// Handle background image upload
if (!empty($_FILES['bg_image']['name']) && $_FILES['bg_image']['error'] === UPLOAD_ERR_OK) {
$ext = strtolower(pathinfo($_FILES['bg_image']['name'], PATHINFO_EXTENSION));
if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'])) {
$imgName = $themeName . '_bg.' . $ext;
if (!is_dir($publicThemes)) mkdir($publicThemes, 0755, true);
move_uploaded_file($_FILES['bg_image']['tmp_name'], $publicThemes . '/' . $imgName);
$themeData['background_image'] = $imgName;
}
}
// Handle background image URL
$bgUrl = trim($_POST['background_image_url'] ?? '');
if (!empty($bgUrl)) {
$themeData['background_image'] = $bgUrl;
}
// Handle background image removal
if (!empty($_POST['bg_image_remove'])) {
if ($themeData['background_image'] ?? '') {
$oldFile = $publicThemes . '/' . $themeData['background_image'];
if (file_exists($oldFile)) unlink($oldFile);
}
$themeData['background_image'] = '';
}
file_put_contents($themeFile, json_encode($themeData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$message = 'Thema opgeslagen.';
$messageType = 'success';
}
} elseif ($action === 'activate') {
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? '');
if (file_exists($themesDir . '/' . $themeName . '/theme.json')) {
$cfg = json_decode(file_get_contents($configJson), true) ?? [];
$cfg['active_theme'] = $themeName;
file_put_contents($configJson, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$message = 'Thema geactiveerd.';
$messageType = 'success';
}
} elseif ($action === 'create') {
$newName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['new_name'] ?? '');
if (empty($newName)) {
$message = 'Geef een naam voor het nieuwe thema.';
$messageType = 'danger';
} elseif (file_exists($themesDir . '/' . $newName . '/theme.json')) {
$message = 'Thema bestaat al.';
$messageType = 'danger';
} else {
$defaults = [
'name' => $newName,
'header_color' => '#0a369d',
'header_font_color' => '#ffffff',
'header_height' => '56',
'navigation_color' => '#2754b4',
'navigation_font_color' => '#ffffff',
'nav_height' => '42',
'sidebar_background' => '#f8f9fa',
'sidebar_border' => '#dee2e6',
'background_image' => '',
'background_image_opacity' => '100',
];
$newThemeDir = $themesDir . '/' . $newName;
mkdir($newThemeDir, 0755, true);
file_put_contents($newThemeDir . '/theme.json', json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$message = 'Thema aangemaakt.';
$messageType = 'success';
}
} elseif ($action === 'delete') {
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? '');
$themeDir = $themesDir . '/' . $themeName;
if (is_dir($themeDir) && $themeName !== 'default') {
$themeFile = $themeDir . '/theme.json';
if (file_exists($themeFile)) unlink($themeFile);
// Remove theme directory if empty
$remaining = array_diff(scandir($themeDir), ['.', '..']);
if (empty($remaining)) rmdir($themeDir);
$cfg = json_decode(file_get_contents($configJson), true) ?? [];
if (($cfg['active_theme'] ?? '') === $themeName) {
$cfg['active_theme'] = 'default';
file_put_contents($configJson, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
$message = 'Thema verwijderd.';
$messageType = 'success';
}
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_POST['theme'] ?? '');
if ($themeName !== '' && file_exists($themesDir . '/' . $themeName . '/theme.json')) {
$cfg = json_decode(file_get_contents($configJson), true) ?? [];
$cfg['active_theme'] = $themeName;
file_put_contents($configJson, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
adminLog($config, 'info', $user['username'] . ' activeerde thema ' . $themeName);
$message = 'Thema geactiveerd.';
$messageType = 'success';
}
}
}
@@ -1123,18 +1085,135 @@ function handleTheme(AdminAuth $auth, array $config): void
}
}
// Load theme being edited
$editThemeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['edit'] ?? '');
$editTheme = null;
if ($editThemeName && isset($themes[$editThemeName])) {
$editTheme = $themes[$editThemeName];
$editTheme['name'] = $editThemeName;
}
$route = 'theme';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleThemeNew(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$themesDir = $config['codepress_root'] . '/themes';
$message = '';
$messageType = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
$message = 'Ongeldige CSRF token.';
$messageType = 'danger';
} else {
$newName = trim($_POST['theme_name'] ?? '');
if (empty($newName)) {
$message = 'Thema naam is verplicht.';
$messageType = 'danger';
} elseif (!preg_match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', $newName)) {
$message = 'Ongeldige thema naam. Gebruik alleen letters, cijfers, streepjes en underscores. Begin met een letter.';
$messageType = 'danger';
} elseif (file_exists($themesDir . '/' . $newName . '/theme.json')) {
$message = 'Thema met deze naam bestaat al.';
$messageType = 'danger';
} else {
$newThemeDir = $themesDir . '/' . $newName;
mkdir($newThemeDir, 0755, true);
$defaults = [
'title' => $newName,
'config' => [
'default_template' => 'full_content',
],
'template' => [
'full_content' => 'full_content.twig',
'left_sidebar' => 'left_sidebar.twig',
'right_sidebar' => 'right_sidebar.twig',
'custom1' => 'custom1.twig',
],
];
file_put_contents($newThemeDir . '/theme.json', json_encode($defaults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// Scaffold a full theme folder from the default theme
$defaultThemeDir = $themesDir . '/default';
if (is_dir($defaultThemeDir)) {
$copyDirs = ['partials', 'css', 'js'];
foreach ($copyDirs as $sub) {
$src = $defaultThemeDir . '/' . $sub;
if (is_dir($src)) {
$dst = $newThemeDir . '/' . $sub;
mkdir($dst, 0755, true);
foreach (scandir($src) as $f) {
if ($f[0] === '.') continue;
if (is_file($src . '/' . $f)) {
copy($src . '/' . $f, $dst . '/' . $f);
}
}
}
}
foreach (['base.twig', 'full_content.twig', 'left_sidebar.twig', 'right_sidebar.twig', 'custom1.twig'] as $tpl) {
if (is_file($defaultThemeDir . '/' . $tpl)) {
copy($defaultThemeDir . '/' . $tpl, $newThemeDir . '/' . $tpl);
}
}
// Copy the default theme's preview image if present
if (is_file($defaultThemeDir . '/theme.png')) {
copy($defaultThemeDir . '/theme.png', $newThemeDir . '/theme.png');
}
}
adminLog($config, 'info', $user['username'] . ' maakte thema ' . $newName . ' aan');
header('Location: /admin/theme');
exit;
}
}
}
$route = 'theme-new';
require __DIR__ . '/../admin/templates/layout.php';
}
function handleThemeDelete(AdminAuth $auth, array $config): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: /admin/theme');
exit;
}
$configJson = $config['config_json'];
$themesDir = $config['codepress_root'] . '/themes';
$themeName = preg_replace('/[^a-zA-Z0-9_-]/', '', $_GET['theme'] ?? '');
$themeDir = $themesDir . '/' . $themeName;
if ($themeName === '' || $themeName === 'default' || !is_dir($themeDir)) {
header('Location: /admin/theme');
exit;
}
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
// Recursively remove the entire theme directory
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($themeDir, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $file) {
if ($file->isDir()) {
@rmdir($file->getRealPath());
} else {
@unlink($file->getRealPath());
}
}
@rmdir($themeDir);
$cfg = json_decode(file_get_contents($configJson), true) ?? [];
if (($cfg['active_theme'] ?? '') === $themeName) {
$cfg['active_theme'] = 'default';
file_put_contents($configJson, json_encode($cfg, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
adminLog($config, 'info', ($_SESSION['admin_user'] ?? 'unknown') . ' verwijderde thema ' . $themeName);
}
header('Location: /admin/theme');
exit;
}
function handlePlugins(AdminAuth $auth, array $config): void
{
$user = $auth->getCurrentUser();
@@ -1707,55 +1786,39 @@ function handleLogs(AdminAuth $auth, array $config): void
$user = $auth->getCurrentUser();
$csrf = $auth->getCsrfToken();
$activeTab = $_GET['tab'] ?? 'admin';
if (!in_array($activeTab, ['admin', 'requests'])) $activeTab = 'admin';
$adminLogFile = $config['log_file'];
$requestLogFile = $config['request_log'];
// Filters
$filterEvent = $_GET['event'] ?? '';
$filterLevel = $_GET['level'] ?? '';
$filterSearch = trim($_GET['search'] ?? '');
$limit = max(10, min(1000, (int)($_GET['limit'] ?? 200)));
// Clear
if (isset($_GET['clear'])) {
$target = $activeTab === 'admin' ? $adminLogFile : $requestLogFile;
@file_put_contents($target, '');
$message = $activeTab === 'admin' ? 'Activiteiten log gewist.' : 'Requestlog gewist.';
header('Location: /admin/logs?tab=' . $activeTab);
LogManager::clear();
$message = 'Log gewist.';
header('Location: /admin/logs');
exit;
}
// Download
if (isset($_GET['download'])) {
$target = $activeTab === 'admin' ? $adminLogFile : $requestLogFile;
$filename = $activeTab === 'admin' ? 'admin.log' : 'requests.log';
if (file_exists($target)) {
header('Content-Type: text/plain');
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($target));
readfile($target);
$entries = LogManager::getLogs(1000, $filterEvent ?: null, $filterLevel ?: null, $filterSearch ?: null);
$out = '';
foreach ($entries as $e) {
$out .= '[' . $e['time'] . '] [' . $e['event'] . '] [' . $e['level'] . '] [' . $e['ip'] . '] ' . $e['message'] . "\n";
}
header('Content-Type: text/plain');
header('Content-Disposition: attachment; filename="codepress.log"');
echo $out;
exit;
}
// Read admin log
$adminLogs = [];
if (file_exists($adminLogFile)) {
$lines = file($adminLogFile);
$lines = array_slice($lines, -200);
foreach ($lines as $line) {
if (preg_match('/^\[([^\]]+)\] \[([^\]]+)\] \[([^\]]+)\] (.+)$/', trim($line), $m)) {
$adminLogs[] = [
'time' => $m[1],
'level' => strtolower($m[2]),
'ip' => $m[3],
'message' => $m[4],
];
}
}
$adminLogs = array_reverse($adminLogs);
}
// Read log entries with filters
$logEntries = LogManager::getLogs($limit, $filterEvent ?: null, $filterLevel ?: null, $filterSearch ?: null);
// Read request log
$requestLogger = new RequestLogger($requestLogFile);
$requestLogs = $requestLogger->getLogs(200);
// Available event types for the filter dropdown
$eventTypes = ['admin', 'requests', 'errors', 'security', 'content', 'system'];
$levelTypes = ['info', 'warning', 'error', 'debug'];
$route = 'logs';
require __DIR__ . '/../admin/templates/layout.php';
@@ -1826,6 +1889,22 @@ function handleUpdate(AdminAuth $auth, array $config): void
// --- Frontmatter helpers ---
/**
* Remove the URL scheme (http://, https://) from a website value so it is
* stored as a bare hostname. The engine adds the scheme back when rendering.
*
* @param string $url Raw URL value
* @return string Hostname without scheme
*/
function stripUrlScheme(string $url): string
{
$url = trim($url);
if (preg_match('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', $url)) {
$url = preg_replace('#^[a-zA-Z][a-zA-Z0-9+.\-]*://#', '', $url);
}
return rtrim($url, '/');
}
/**
* Keep a timestamped copy of a content file before it is overwritten.
*
@@ -1840,6 +1919,7 @@ function backupContentFile(string $filePath, int $keep = 5): void
$contentRoot = realpath(dirname(__DIR__) . '/content');
$realFile = realpath($filePath);
if (!$contentRoot || !$realFile || strpos($realFile, $contentRoot) !== 0) {
return;
}
+14 -2
View File
@@ -4,6 +4,9 @@ require_once __DIR__ . '/../cms/core/index.php';
$config = include __DIR__ . '/../cms/core/config.php';
// Initialize dynamic logging
LogManager::init($config['logging'] ?? []);
// Security headers
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: SAMEORIGIN');
@@ -114,8 +117,8 @@ $secSettings = $config['security'] ?? [
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$analyticsSettings = $config['analytics'] ?? [];
$excludedIps = $analyticsSettings['excluded_ips'] ?? [];
$isAllowedIp = in_array($clientIp, $secSettings['allowed_ips'] ?? [], true)
|| in_array($clientIp, $excludedIps, true);
$isAllowedIp = RequestLogger::ipMatchesList($clientIp, $secSettings['allowed_ips'] ?? [])
|| RequestLogger::ipMatchesList($clientIp, $excludedIps);
$requestStatus = 'ok';
if (!$isAllowedIp) {
@@ -184,6 +187,15 @@ if (!str_starts_with($path, '/-media/') && !str_starts_with($path, '/-assets/'))
$geoCountry
);
// Also record through the dynamic log manager (requests event)
LogManager::log(LogManager::EVENT_REQUESTS, 'info', 'Request: ' . $currentPage, [
'ip' => $storedIp,
'page' => $currentPage,
'status' => $requestStatus,
'country' => $geoCountry,
'user' => $loggedInUser,
]);
// Record aggregated statistics
if (!empty($analyticsSettings['enabled']) && !in_array($clientIp, $excludedIps, true)) {
$analytics = new Analytics($analyticsSettings);
Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

+136
View File
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="{{ current_lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }} - {{ site_title }}</title>
<!-- Skip to content link for accessibility -->
<a href="#main-content" class="skip-link sr-only sr-only-focusable">Skip to main content</a>
<!-- CMS Meta Tags -->
<meta name="generator" content="{{ site_title }} CMS">
<meta name="application-name" content="{{ site_title }}">
<meta name="author" content="{{ author_name }}">
<meta name="creator" content="{{ author_name }}">
<meta name="publisher" content="{{ author_name }}">
<!-- SEO Meta Tags -->
<meta name="description" content="{{ seo_description }}">
<meta name="keywords" content="{{ seo_keywords }}">
{% if block_ai_bots %}
<meta name="robots" content="noai, noimageai">
<meta name="tdm-reservation" content="1">
{% endif %}
{% if block_search_engines %}
<meta name="robots" content="noindex, nofollow">
{% endif %}
<!-- Author Links -->
<link rel="author" href="{{ author_website }}">
<link rel="me" href="{{ author_git }}">
<!-- Favicon and PWA -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="{{ theme_config.header_color|default('#0a369d') }}">
<!-- Styles -->
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/css/bootstrap-icons.css" rel="stylesheet">
<link href="/assets/css/style.css" rel="stylesheet">
<link href="/assets/css/mobile.css" rel="stylesheet">
{% if theme_css_url %}
<link href="{{ theme_css_url }}" rel="stylesheet">
{% endif %}
<!-- Accessibility styles -->
<style>
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 6px;
outline: 3px solid #0056b3;
outline-offset: 2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
</style>
<!-- Dynamic theme styles -->
<style>
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
#site-header > * {
position: relative;
z-index: 1;
}
</style>
</head>
<body>
{% include 'partials/header.twig' %}
<nav role="navigation" aria-label="Main navigation" id="site-navigation">
{% include 'partials/navigation.twig' %}
</nav>
<nav id="site-breadcrumb" class="breadcrumb-section bg-light border-bottom" aria-label="Breadcrumb navigation">
<div class="container-fluid">
<div class="row">
<div class="col-12 py-2">
<h2 class="sr-only">Breadcrumb Navigation</h2>
{{ breadcrumb|raw }}
</div>
</div>
</div>
</nav>
<main role="main" id="main-content" class="main-content" style="padding: 0;">
{% block content %}{% endblock %}
</main>
<footer role="contentinfo" id="site-footer">
{% include 'partials/footer.twig' %}
</footer>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
{% if theme_js_url %}
<script src="{{ theme_js_url }}"></script>
{% endif %}
</body>
</html>
+367
View File
@@ -0,0 +1,367 @@
// CodePress default theme styles
// Compiled at runtime by ThemeManager (scssphp) into public/themes/default/theme.css
// Theme color variables (defined per theme; used by the CSS custom properties below)
$header-bg: #0a369d;
$header-font: #ffffff;
$header-height: 56px;
$nav-bg: #2754b4;
$nav-font: #ffffff;
$nav-height: 42px;
$sidebar-bg: #f8f9fa;
$sidebar-border: #dee2e6;
// Optional header background image and overlay opacity
$header-bg-image: none;
$header-bg-opacity: 1;
:root {
--header-bg: #{$header-bg};
--header-font: #{$header-font};
--header-height: #{$header-height};
--nav-bg: #{$nav-bg};
--nav-font: #{$nav-font};
--nav-height: #{$nav-height};
--sidebar-bg: #{$sidebar-bg};
--sidebar-border: #{$sidebar-border};
}
// Header background (optional background image via $header-bg-image)
#site-header {
background-image: $header-bg-image;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
}
@if $header-bg-image != 'none' {
#site-header::before {
content: '';
position: absolute;
inset: 0;
background-color: var(--header-bg);
opacity: $header-bg-opacity;
pointer-events: none;
z-index: 0;
}
}
// Header styles
.navbar {
background-color: var(--header-bg) !important;
min-height: var(--header-height);
.navbar-brand,
.navbar-text,
.form-control,
.btn {
color: var(--header-font) !important;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0.7) !important;
}
.btn-outline-light {
border-color: var(--header-font) !important;
}
}
// Language dropdown styling
.dropdown-menu {
background-color: var(--header-bg) !important;
border: 1px solid var(--header-font) !important;
}
.dropdown-item {
color: var(--header-font) !important;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
color: var(--header-font) !important;
}
}
// Hide Bootstrap dropdown arrow and use custom icon
.dropdown-toggle::after {
display: none !important;
}
.btn-outline-light {
color: var(--header-font) !important;
border-color: var(--header-font) !important;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
color: var(--header-font) !important;
}
&:focus,
&:active,
.show > &.dropdown-toggle {
background-color: rgba(255, 255, 255, 0.1) !important;
color: var(--header-font) !important;
border-color: var(--header-font) !important;
box-shadow: none !important;
}
}
.bi-chevron-down {
font-size: 0.75em;
margin-left: 0.25rem;
}
// Remove Bootstrap default breadcrumb separators
.breadcrumb-item + .breadcrumb-item::before {
content: "" !important;
padding: 0 !important;
}
// Custom breadcrumb styling
.breadcrumb {
--bs-breadcrumb-divider: "";
.breadcrumb-item {
color: var(--nav-font) !important;
a {
color: var(--nav-font) !important;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
// Sidebar toggle button in breadcrumb
.sidebar-toggle-item {
display: flex;
align-items: center;
margin-right: 0.5rem;
}
.sidebar-toggle-btn {
padding: 0;
line-height: 1;
font-size: 1.1rem;
color: var(--header-bg) !important;
border: none !important;
background: transparent !important;
box-shadow: none !important;
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
// Sidebar hide/show transition
.sidebar-column {
transition: all 0.3s ease;
}
.sidebar-hidden {
display: none !important;
}
// Navigation section background
.navigation-section {
background-color: var(--nav-bg) !important;
color: var(--nav-font) !important;
min-height: var(--nav-height);
}
// Enhanced accessibility styles
.focus-visible:focus,
.btn:focus,
.form-control:focus,
.nav-link:focus {
outline: 3px solid #0056b3 !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 1px #ffffff, 0 0 0 4px #0056b3 !important;
}
// High contrast mode support
@media (prefers-contrast: high) {
:root {
--text-color: #000000;
--bg-color: #ffffff;
--border-color: #000000;
--focus-color: #000000;
}
.btn-primary {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
}
.btn-outline-light {
color: #000000 !important;
border-color: #000000 !important;
}
.text-muted {
color: #000000 !important;
}
.navbar {
background-color: #ffffff !important;
border-bottom: 1px solid #000000 !important;
}
}
// Reduced motion support
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// Remove nav-tabs background so it inherits from parent
.nav-tabs {
background-color: transparent !important;
border: none !important;
.nav-link {
background-color: transparent !important;
border: none !important;
color: var(--nav-font) !important;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
&.active {
background-color: rgba(255, 255, 255, 0.2) !important;
border-bottom: 2px solid var(--nav-font) !important;
}
}
}
// Sidebar styling
.sidebar-column {
background-color: var(--sidebar-bg) !important;
border-right: 1px solid var(--sidebar-border) !important;
position: sticky;
top: 0;
min-height: calc(100vh - var(--header-height) - var(--nav-height) - 42px);
}
.sidebar {
padding: 1.5rem;
height: 100%;
overflow-y: auto;
}
.content-column {
background-color: #ffffff;
}
.content-wrapper {
padding: 2rem;
padding-bottom: 80px !important;
}
// Ensure full height layout
.main-content {
flex: 1;
}
// Mobile responsive
@media (max-width: 767.98px) {
.sidebar-column {
border-right: none !important;
border-top: 1px solid var(--sidebar-border) !important;
min-height: auto;
margin-top: 1rem;
}
.content-column {
background-color: #ffffff;
}
.content-wrapper {
min-height: auto;
padding-bottom: 2rem !important;
}
}
// Tablet and mobile: sidebar below content
@media (max-width: 991.98px) {
.sidebar-column {
order: 2 !important;
}
.content-column {
order: 1 !important;
}
}
// Code block styling
pre {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
border: 1px solid #dee2e6;
margin-bottom: 1rem;
code {
background: none;
padding: 0;
color: #333;
font-size: 0.85rem;
line-height: 1.5;
}
}
code {
background: #e8e8e8;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.9em;
color: #d63384;
}
// Footer icon hover effects
.footer-icon {
color: #6c757d;
text-decoration: none;
transition: all 0.2s ease-in-out;
display: inline-block;
padding: 2px;
&:hover {
color: #0d6efd;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&.guide:hover {
color: #198754;
}
&.cms:hover {
color: #dc3545;
}
&.git:hover {
color: #6f42c1;
}
&.website:hover {
color: #fd7e14;
}
}
+18
View File
@@ -0,0 +1,18 @@
{% extends 'base.twig' %}
{% block content %}
<div class="container-fluid">
<div class="row g-0">
<aside role="complementary" aria-label="Sidebar content" id="site-sidebar" class="col-12 sidebar-column">
<div class="sidebar">
{{ sidebar_content|raw }}
</div>
</aside>
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends 'base.twig' %}
{% block content %}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
// CodePress default theme JavaScript
// Loaded after the global app.js on every page.
(function () {
'use strict';
// Sidebar toggle (used by the breadcrumb toggle button)
function toggleSidebar() {
var sidebar = document.getElementById('site-sidebar');
if (!sidebar) return;
sidebar.classList.toggle('sidebar-hidden');
var btn = document.querySelector('.sidebar-toggle-btn');
if (btn) {
var hidden = sidebar.classList.contains('sidebar-hidden');
btn.setAttribute('aria-expanded', hidden ? 'false' : 'true');
}
}
// Expose for inline onclick handlers
window.toggleSidebar = toggleSidebar;
})();
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.twig' %}
{% block content %}
{% if sidebar_content %}
<div class="row g-0">
<aside role="complementary" aria-label="Sidebar content" id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2 order-md-1">
<div class="sidebar h-100">
{{ sidebar_content|raw }}
</div>
</aside>
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1 order-md-2">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
{% else %}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
{% endif %}
{% endblock %}
@@ -5,38 +5,38 @@
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
<div class="file-info mb-2 mb-md-0">
<small class="text-muted">
<i class="bi bi-file-text footer-icon" title="{{t_file_details}}: {{page_title}}"></i>
<span class="page-title d-none d-lg-inline" title="{{page_title}}">{{page_title}}</span>
{{#file_info_block}}
<i class="bi bi-file-text footer-icon" title="{{ t_file_details }}: {{ page_title }}"></i>
<span class="page-title d-none d-lg-inline" title="{{ page_title }}">{{ page_title }}</span>
{% if file_info_block %}
<span class="ms-2">
{{#show_created}}
<i class="bi bi-calendar-plus footer-icon" title="{{t_created}}: {{created}}"></i>
<span class="file-created me-1" title="{{t_created}}: {{created}}">{{created}}</span>
{{/show_created}}
<i class="bi bi-calendar-check footer-icon" title="{{t_modified}}: {{modified}}"></i>
<span class="file-modified" title="{{t_modified}}: {{modified}}">{{modified}}</span>
{% if show_created %}
<i class="bi bi-calendar-plus footer-icon" title="{{ t_created }}: {{ created }}"></i>
<span class="file-created me-1" title="{{ t_created }}: {{ created }}">{{ created }}</span>
{% endif %}
<i class="bi bi-calendar-check footer-icon" title="{{ t_modified }}: {{ modified }}"></i>
<span class="file-modified" title="{{ t_modified }}: {{ modified }}">{{ modified }}</span>
</span>
{{/file_info_block}}
{% endif %}
</small>
</div>
<div class="site-info">
<small class="text-muted">
<a href="/{{current_lang}}/guide" class="footer-icon guide" title="{{t_guide}}">
<a href="/{{ current_lang }}/guide" class="footer-icon guide" title="{{ t_guide }}">
<i class="bi bi-book"></i>
</a>
<span class="ms-1">|</span>
{{#cms_version}}
<span class="ms-1 cms-version text-muted">{{cms_version}}</span>
{{/cms_version}}
<a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener noreferrer" class="footer-icon cms ms-1" title="{{t_powered_by}} CodePress CMS">
{% if cms_version %}
<span class="ms-1 cms-version text-muted">{{ cms_version }}</span>
{% endif %}
<a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener noreferrer" class="footer-icon cms ms-1" title="{{ t_powered_by }} CodePress CMS">
<i class="bi bi-cpu"></i>
</a>
<span class="ms-1">|</span>
<a href="{{author_website}}" target="_blank" rel="noopener noreferrer" class="footer-icon website" title="{{t_author_website}}">
<a href="{{ author_website }}" target="_blank" rel="noopener noreferrer" class="footer-icon website" title="{{ t_author_website }}">
<i class="bi bi-globe"></i>
</a>
<span class="ms-1">|</span>
<a href="{{author_git}}" target="_blank" rel="noopener noreferrer" class="footer-icon git" title="{{t_author_git}}">
<a href="{{ author_git }}" target="_blank" rel="noopener noreferrer" class="footer-icon git" title="{{ t_author_git }}">
<i class="bi bi-git"></i>
</a>
</small>
@@ -1,37 +1,37 @@
<header id="site-header" class="navbar navbar-expand-lg navbar-dark" style="background-color: transparent;">
<div class="container-fluid">
<a class="navbar-brand" href="/{{current_lang}}">
<a class="navbar-brand" href="/{{ current_lang }}">
<img src="/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2">
{{site_title}}
{{ site_title }}
</a>
<!-- Desktop search and language -->
<div class="d-none d-lg-flex ms-auto align-items-center">
<form class="d-flex me-3" method="GET" action="" role="search" aria-label="Site search">
<div class="form-group">
<label for="desktop-search-input" class="sr-only">{{t_search_placeholder}}</label>
<input class="form-control me-2 search-input" type="search" id="desktop-search-input" name="search" placeholder="{{t_search_placeholder}}" value="{{search_query}}" aria-describedby="search-help">
<label for="desktop-search-input" class="sr-only">{{ t_search_placeholder }}</label>
<input class="form-control me-2 search-input" type="search" id="desktop-search-input" name="search" placeholder="{{ t_search_placeholder }}" value="{{ search_query }}" aria-describedby="search-help">
<div id="search-help" class="sr-only">Enter keywords to search through the documentation</div>
</div>
<button class="btn btn-outline-light" type="submit" aria-label="{{t_search_button}}">
<button class="btn btn-outline-light" type="submit" aria-label="{{ t_search_button }}">
<i class="bi bi-search" aria-hidden="true"></i>
<span class="sr-only">{{t_search_button}}</span>
<span class="sr-only">{{ t_search_button }}</span>
</button>
</form>
<!-- Language switcher -->
<div class="dropdown">
<button class="btn btn-outline-light" type="button" data-bs-toggle="dropdown" aria-haspopup="menu" aria-expanded="false" aria-label="Select language - currently {{current_lang_upper}}">
{{current_lang_upper}} <i class="bi bi-chevron-down" aria-hidden="true"></i>
<button class="btn btn-outline-light" type="button" data-bs-toggle="dropdown" aria-haspopup="menu" aria-expanded="false" aria-label="Select language - currently {{ current_lang_upper }}">
{{ current_lang_upper }} <i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" role="menu">
{{#available_langs}}
{% for lang in available_langs %}
<li role="none">
<a class="dropdown-item {{#is_current}}active{{/is_current}}" href="{{url}}" role="menuitem" {{#is_current}}aria-current="true"{{/is_current}} lang="{{code}}">
{{native_name}}
<a class="dropdown-item {{ lang.is_current ? 'active' : '' }}" href="{{ lang.url }}" role="menuitem" {% if lang.is_current %}aria-current="true"{% endif %} lang="{{ lang.code }}">
{{ lang.native_name }}
</a>
</li>
{{/available_langs}}
{% endfor %}
</ul>
</div>
</div>
@@ -41,17 +41,17 @@
<button class="btn btn-outline-light" type="button" data-bs-toggle="collapse" data-bs-target="#mobileSearch" aria-controls="mobileSearch" aria-expanded="false" aria-label="Toggle search">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-outline-light" type="button" data-bs-toggle="dropdown" aria-haspopup="menu" aria-expanded="false" aria-label="Select language - currently {{current_lang_upper}}">
{{current_lang_upper}} <i class="bi bi-chevron-down" aria-hidden="true"></i>
<button class="btn btn-outline-light" type="button" data-bs-toggle="dropdown" aria-haspopup="menu" aria-expanded="false" aria-label="Select language - currently {{ current_lang_upper }}">
{{ current_lang_upper }} <i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" role="menu">
{{#available_langs}}
<li role="none">
<a class="dropdown-item {{#is_current}}active{{/is_current}}" href="{{url}}" role="menuitem" {{#is_current}}aria-current="true"{{/is_current}} lang="{{code}}">
{{native_name}}
</a>
</li>
{{/available_langs}}
{% for lang in available_langs %}
<li role="none">
<a class="dropdown-item {{ lang.is_current ? 'active' : '' }}" href="{{ lang.url }}" role="menuitem" {% if lang.is_current %}aria-current="true"{% endif %} lang="{{ lang.code }}">
{{ lang.native_name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
@@ -61,13 +61,13 @@
<div class="container-fluid px-0">
<form class="d-flex px-3 pb-3" method="GET" action="" role="search" aria-label="Site search">
<div class="form-group w-100">
<label for="mobile-search-input" class="sr-only">{{t_search_placeholder}}</label>
<input class="form-control me-2 search-input" type="search" id="mobile-search-input" name="search" placeholder="{{t_search_placeholder}}" value="{{search_query}}" aria-describedby="mobile-search-help">
<label for="mobile-search-input" class="sr-only">{{ t_search_placeholder }}</label>
<input class="form-control me-2 search-input" type="search" id="mobile-search-input" name="search" placeholder="{{ t_search_placeholder }}" value="{{ search_query }}" aria-describedby="mobile-search-help">
<div id="mobile-search-help" class="sr-only">Enter keywords to search through the documentation</div>
</div>
<button class="btn btn-outline-light" type="submit" aria-label="{{t_search_button}}">
<button class="btn btn-outline-light" type="submit" aria-label="{{ t_search_button }}">
<i class="bi bi-search" aria-hidden="true"></i>
<span class="sr-only">{{t_search_button}}</span>
<span class="sr-only">{{ t_search_button }}</span>
</button>
</form>
</div>
@@ -5,11 +5,11 @@
<div class="col">
<ul class="nav nav-tabs flex-wrap" role="menubar">
<li class="nav-item" role="none">
<a class="nav-link {{home_active_class}}" href="/{{current_lang}}" role="menuitem" aria-current="{{#is_homepage}}page{{/is_homepage}}">
<i class="bi bi-house" aria-hidden="true"></i> {{homepage_title}}
<a class="nav-link {{ home_active_class }}" href="/{{ current_lang }}" role="menuitem" {% if is_homepage %}aria-current="page"{% endif %}>
<i class="bi bi-house" aria-hidden="true"></i> {{ homepage_title }}
</a>
</li>
{{{menu}}}
{{ menu|raw }}
</ul>
</div>
</div>
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.twig' %}
{% block content %}
{% if sidebar_content %}
<div class="row g-0">
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
<aside role="complementary" aria-label="Sidebar content" id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2">
<div class="sidebar h-100">
{{ sidebar_content|raw }}
</div>
</aside>
</div>
{% else %}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
{% endif %}
{% endblock %}
+10 -11
View File
@@ -1,13 +1,12 @@
{
"name": "Standaard",
"header_color": "#0a369d",
"header_font_color": "#ffffff",
"header_height": "56",
"navigation_color": "#2754b4",
"navigation_font_color": "#ffffff",
"nav_height": "42",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6",
"background_image": "",
"background_image_opacity": "100"
"title": "default",
"config": {
"default_template": "full_content"
},
"template": {
"full_content": "full_content.twig",
"left_sidebar": "left_sidebar.twig",
"right_sidebar": "right_sidebar.twig",
"custom1": "custom1.twig"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+136
View File
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html lang="{{ current_lang }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }} - {{ site_title }}</title>
<!-- Skip to content link for accessibility -->
<a href="#main-content" class="skip-link sr-only sr-only-focusable">Skip to main content</a>
<!-- CMS Meta Tags -->
<meta name="generator" content="{{ site_title }} CMS">
<meta name="application-name" content="{{ site_title }}">
<meta name="author" content="{{ author_name }}">
<meta name="creator" content="{{ author_name }}">
<meta name="publisher" content="{{ author_name }}">
<!-- SEO Meta Tags -->
<meta name="description" content="{{ seo_description }}">
<meta name="keywords" content="{{ seo_keywords }}">
{% if block_ai_bots %}
<meta name="robots" content="noai, noimageai">
<meta name="tdm-reservation" content="1">
{% endif %}
{% if block_search_engines %}
<meta name="robots" content="noindex, nofollow">
{% endif %}
<!-- Author Links -->
<link rel="author" href="{{ author_website }}">
<link rel="me" href="{{ author_git }}">
<!-- Favicon and PWA -->
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="{{ theme_config.header_color|default('#613583') }}">
<!-- Styles -->
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
<link href="/assets/css/bootstrap-icons.css" rel="stylesheet">
<link href="/assets/css/style.css" rel="stylesheet">
<link href="/assets/css/mobile.css" rel="stylesheet">
{% if theme_css_url %}
<link href="{{ theme_css_url }}" rel="stylesheet">
{% endif %}
<!-- Accessibility styles -->
<style>
.skip-link {
position: absolute;
top: -40px;
left: 6px;
background: #000;
color: #fff;
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 6px;
outline: 3px solid #0056b3;
outline-offset: 2px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
padding: inherit;
margin: inherit;
overflow: visible;
clip: auto;
white-space: normal;
}
</style>
<!-- Dynamic theme styles -->
<style>
html, body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
}
#site-header > * {
position: relative;
z-index: 1;
}
</style>
</head>
<body>
{% include 'partials/header.twig' %}
<nav role="navigation" aria-label="Main navigation" id="site-navigation">
{% include 'partials/navigation.twig' %}
</nav>
<nav id="site-breadcrumb" class="breadcrumb-section bg-light border-bottom" aria-label="Breadcrumb navigation">
<div class="container-fluid">
<div class="row">
<div class="col-12 py-2">
<h2 class="sr-only">Breadcrumb Navigation</h2>
{{ breadcrumb|raw }}
</div>
</div>
</div>
</nav>
<main role="main" id="main-content" class="main-content" style="padding: 0;">
{% block content %}{% endblock %}
</main>
<footer role="contentinfo" id="site-footer">
{% include 'partials/footer.twig' %}
</footer>
<script src="/assets/js/bootstrap.bundle.min.js"></script>
<script src="/assets/js/app.js"></script>
{% if theme_js_url %}
<script src="{{ theme_js_url }}"></script>
{% endif %}
</body>
</html>
+331
View File
@@ -0,0 +1,331 @@
// CodePress demo theme styles
// A distinct look to demonstrate that themes are fully swappable.
// Theme color variables (defined per theme; used by the CSS custom properties below)
$header-bg: #613583;
$header-font: #ffffff;
$header-height: 120px;
$nav-bg: #813d9c;
$nav-font: #ffffff;
$nav-height: 50px;
$sidebar-bg: #f3e8f7;
$sidebar-border: #d8b4e0;
// Optional header background image and overlay opacity
$header-bg-image: none;
$header-bg-opacity: 1;
:root {
--header-bg: #{$header-bg};
--header-font: #{$header-font};
--header-height: #{$header-height};
--nav-bg: #{$nav-bg};
--nav-font: #{$nav-font};
--nav-height: #{$nav-height};
--sidebar-bg: #{$sidebar-bg};
--sidebar-border: #{$sidebar-border};
}
// Header background (optional background image via $header-bg-image)
#site-header {
background-image: $header-bg-image;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
position: relative;
}
@if $header-bg-image != 'none' {
#site-header::before {
content: '';
position: absolute;
inset: 0;
background-color: var(--header-bg);
opacity: $header-bg-opacity;
pointer-events: none;
z-index: 0;
}
}
// Rounded, softer header
.navbar {
background-color: var(--header-bg) !important;
min-height: var(--header-height);
border-bottom: 4px solid var(--nav-bg) !important;
.navbar-brand,
.navbar-text,
.form-control,
.btn {
color: var(--header-font) !important;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0.7) !important;
}
.btn-outline-light {
border-color: var(--header-font) !important;
}
}
// Language dropdown styling
.dropdown-menu {
background-color: var(--header-bg) !important;
border: 1px solid var(--header-font) !important;
}
.dropdown-item {
color: var(--header-font) !important;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
color: var(--header-font) !important;
}
}
.dropdown-toggle::after {
display: none !important;
}
.btn-outline-light {
color: var(--header-font) !important;
border-color: var(--header-font) !important;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
color: var(--header-font) !important;
}
&:focus,
&:active,
.show > &.dropdown-toggle {
background-color: rgba(255, 255, 255, 0.1) !important;
color: var(--header-font) !important;
border-color: var(--header-font) !important;
box-shadow: none !important;
}
}
.bi-chevron-down {
font-size: 0.75em;
margin-left: 0.25rem;
}
// Breadcrumb
.breadcrumb-item + .breadcrumb-item::before {
content: "" !important;
padding: 0 !important;
}
.breadcrumb {
--bs-breadcrumb-divider: "";
.breadcrumb-item {
color: var(--nav-font) !important;
a {
color: var(--nav-font) !important;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
.sidebar-toggle-item {
display: flex;
align-items: center;
margin-right: 0.5rem;
}
.sidebar-toggle-btn {
padding: 0;
line-height: 1;
font-size: 1.1rem;
color: var(--header-bg) !important;
border: none !important;
background: transparent !important;
box-shadow: none !important;
cursor: pointer;
&:hover {
opacity: 0.7;
}
}
.sidebar-column {
transition: all 0.3s ease;
}
.sidebar-hidden {
display: none !important;
}
// Navigation section background
.navigation-section {
background-color: var(--nav-bg) !important;
color: var(--nav-font) !important;
min-height: var(--nav-height);
}
// Accessibility
.focus-visible:focus,
.btn:focus,
.form-control:focus,
.nav-link:focus {
outline: 3px solid #0056b3 !important;
outline-offset: 2px !important;
box-shadow: 0 0 0 1px #ffffff, 0 0 0 4px #0056b3 !important;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
.nav-tabs {
background-color: transparent !important;
border: none !important;
.nav-link {
background-color: transparent !important;
border: none !important;
color: var(--nav-font) !important;
&:hover {
background-color: rgba(255, 255, 255, 0.1) !important;
}
&.active {
background-color: rgba(255, 255, 255, 0.2) !important;
border-bottom: 2px solid var(--nav-font) !important;
}
}
}
// Sidebar styling - rounded cards look
.sidebar-column {
background-color: var(--sidebar-bg) !important;
border-right: 1px solid var(--sidebar-border) !important;
position: sticky;
top: 0;
min-height: calc(100vh - var(--header-height) - var(--nav-height) - 42px);
}
.sidebar {
padding: 1.5rem;
height: 100%;
overflow-y: auto;
.card {
border-radius: 1rem;
border: 1px solid var(--sidebar-border);
}
}
.content-column {
background-color: #ffffff;
}
.content-wrapper {
padding: 2rem;
padding-bottom: 80px !important;
}
.main-content {
flex: 1;
}
// Mobile responsive
@media (max-width: 767.98px) {
.sidebar-column {
border-right: none !important;
border-top: 1px solid var(--sidebar-border) !important;
min-height: auto;
margin-top: 1rem;
}
.content-wrapper {
min-height: auto;
padding-bottom: 2rem !important;
}
}
@media (max-width: 991.98px) {
.sidebar-column {
order: 2 !important;
}
.content-column {
order: 1 !important;
}
}
// Code block styling
pre {
background: #f8f9fa;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
border: 1px solid #dee2e6;
margin-bottom: 1rem;
code {
background: none;
padding: 0;
color: #333;
font-size: 0.85rem;
line-height: 1.5;
}
}
code {
background: #e8e8e8;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.9em;
color: #d63384;
}
// Footer icon hover effects
.footer-icon {
color: #6c757d;
text-decoration: none;
transition: all 0.2s ease-in-out;
display: inline-block;
padding: 2px;
&:hover {
color: #0d6efd;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&.guide:hover {
color: #198754;
}
&.cms:hover {
color: #dc3545;
}
&.git:hover {
color: #6f42c1;
}
&.website:hover {
color: #fd7e14;
}
}
+18
View File
@@ -0,0 +1,18 @@
{% extends 'base.twig' %}
{% block content %}
<div class="container-fluid">
<div class="row g-0">
<aside role="complementary" aria-label="Sidebar content" id="site-sidebar" class="col-12 sidebar-column">
<div class="sidebar">
{{ sidebar_content|raw }}
</div>
</aside>
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
</div>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends 'base.twig' %}
{% block content %}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
{% endblock %}
+21
View File
@@ -0,0 +1,21 @@
// CodePress default theme JavaScript
// Loaded after the global app.js on every page.
(function () {
'use strict';
// Sidebar toggle (used by the breadcrumb toggle button)
function toggleSidebar() {
var sidebar = document.getElementById('site-sidebar');
if (!sidebar) return;
sidebar.classList.toggle('sidebar-hidden');
var btn = document.querySelector('.sidebar-toggle-btn');
if (btn) {
var hidden = sidebar.classList.contains('sidebar-hidden');
btn.setAttribute('aria-expanded', hidden ? 'false' : 'true');
}
}
// Expose for inline onclick handlers
window.toggleSidebar = toggleSidebar;
})();
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.twig' %}
{% block content %}
{% if sidebar_content %}
<div class="row g-0">
<aside role="complementary" aria-label="Sidebar content" id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2 order-md-1">
<div class="sidebar h-100">
{{ sidebar_content|raw }}
</div>
</aside>
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1 order-md-2">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
{% else %}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
{% endif %}
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
<footer class="bg-light border-top py-1">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
<div class="file-info mb-2 mb-md-0">
<small class="text-muted">
<i class="bi bi-file-text footer-icon" title="{{ t_file_details }}: {{ page_title }}"></i>
<span class="page-title d-none d-lg-inline" title="{{ page_title }}">{{ page_title }}</span>
{% if file_info_block %}
<span class="ms-2">
{% if show_created %}
<i class="bi bi-calendar-plus footer-icon" title="{{ t_created }}: {{ created }}"></i>
<span class="file-created me-1" title="{{ t_created }}: {{ created }}">{{ created }}</span>
{% endif %}
<i class="bi bi-calendar-check footer-icon" title="{{ t_modified }}: {{ modified }}"></i>
<span class="file-modified" title="{{ t_modified }}: {{ modified }}">{{ modified }}</span>
</span>
{% endif %}
</small>
</div>
<div class="site-info">
<small class="text-muted">
<a href="/{{ current_lang }}/guide" class="footer-icon guide" title="{{ t_guide }}">
<i class="bi bi-book"></i>
</a>
<span class="ms-1">|</span>
{% if cms_version %}
<span class="ms-1 cms-version text-muted">{{ cms_version }}</span>
{% endif %}
<a href="https://git.noorlander.info/E.Noorlander/CodePress.git" target="_blank" rel="noopener noreferrer" class="footer-icon cms ms-1" title="{{ t_powered_by }} CodePress CMS">
<i class="bi bi-cpu"></i>
</a>
<span class="ms-1">|</span>
<a href="{{ author_website }}" target="_blank" rel="noopener noreferrer" class="footer-icon website" title="{{ t_author_website }}">
<i class="bi bi-globe"></i>
</a>
<span class="ms-1">|</span>
<a href="{{ author_git }}" target="_blank" rel="noopener noreferrer" class="footer-icon git" title="{{ t_author_git }}">
<i class="bi bi-git"></i>
</a>
</small>
</div>
</div>
</div>
</div>
</div>
</footer>
+75
View File
@@ -0,0 +1,75 @@
<header id="site-header" class="navbar navbar-expand-lg navbar-dark" style="background-color: transparent;">
<div class="container-fluid">
<a class="navbar-brand" href="/{{ current_lang }}">
<img src="/assets/icon.svg" alt="CodePress Logo" width="32" height="32" class="me-2">
{{ site_title }}
</a>
<!-- Desktop search and language -->
<div class="d-none d-lg-flex ms-auto align-items-center">
<form class="d-flex me-3" method="GET" action="" role="search" aria-label="Site search">
<div class="form-group">
<label for="desktop-search-input" class="sr-only">{{ t_search_placeholder }}</label>
<input class="form-control me-2 search-input" type="search" id="desktop-search-input" name="search" placeholder="{{ t_search_placeholder }}" value="{{ search_query }}" aria-describedby="search-help">
<div id="search-help" class="sr-only">Enter keywords to search through the documentation</div>
</div>
<button class="btn btn-outline-light" type="submit" aria-label="{{ t_search_button }}">
<i class="bi bi-search" aria-hidden="true"></i>
<span class="sr-only">{{ t_search_button }}</span>
</button>
</form>
<!-- Language switcher -->
<div class="dropdown">
<button class="btn btn-outline-light" type="button" data-bs-toggle="dropdown" aria-haspopup="menu" aria-expanded="false" aria-label="Select language - currently {{ current_lang_upper }}">
{{ current_lang_upper }} <i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" role="menu">
{% for lang in available_langs %}
<li role="none">
<a class="dropdown-item {{ lang.is_current ? 'active' : '' }}" href="{{ lang.url }}" role="menuitem" {% if lang.is_current %}aria-current="true"{% endif %} lang="{{ lang.code }}">
{{ lang.native_name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Mobile search and language toggle -->
<div class="d-lg-none">
<button class="btn btn-outline-light" type="button" data-bs-toggle="collapse" data-bs-target="#mobileSearch" aria-controls="mobileSearch" aria-expanded="false" aria-label="Toggle search">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-outline-light" type="button" data-bs-toggle="dropdown" aria-haspopup="menu" aria-expanded="false" aria-label="Select language - currently {{ current_lang_upper }}">
{{ current_lang_upper }} <i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" role="menu">
{% for lang in available_langs %}
<li role="none">
<a class="dropdown-item {{ lang.is_current ? 'active' : '' }}" href="{{ lang.url }}" role="menuitem" {% if lang.is_current %}aria-current="true"{% endif %} lang="{{ lang.code }}">
{{ lang.native_name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
<!-- Mobile search bar -->
<div class="collapse navbar-collapse d-lg-none" id="mobileSearch">
<div class="container-fluid px-0">
<form class="d-flex px-3 pb-3" method="GET" action="" role="search" aria-label="Site search">
<div class="form-group w-100">
<label for="mobile-search-input" class="sr-only">{{ t_search_placeholder }}</label>
<input class="form-control me-2 search-input" type="search" id="mobile-search-input" name="search" placeholder="{{ t_search_placeholder }}" value="{{ search_query }}" aria-describedby="mobile-search-help">
<div id="mobile-search-help" class="sr-only">Enter keywords to search through the documentation</div>
</div>
<button class="btn btn-outline-light" type="submit" aria-label="{{ t_search_button }}">
<i class="bi bi-search" aria-hidden="true"></i>
<span class="sr-only">{{ t_search_button }}</span>
</button>
</form>
</div>
</div>
</header>
+17
View File
@@ -0,0 +1,17 @@
<nav class="navigation-section" role="navigation" aria-label="Main navigation">
<h2 class="sr-only">Site Navigation</h2>
<div class="container-fluid">
<div class="row align-items-center">
<div class="col">
<ul class="nav nav-tabs flex-wrap" role="menubar">
<li class="nav-item" role="none">
<a class="nav-link {{ home_active_class }}" href="/{{ current_lang }}" role="menuitem" {% if is_homepage %}aria-current="page"{% endif %}>
<i class="bi bi-house" aria-hidden="true"></i> {{ homepage_title }}
</a>
</li>
{{ menu|raw }}
</ul>
</div>
</div>
</div>
</nav>
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.twig' %}
{% block content %}
{% if sidebar_content %}
<div class="row g-0">
<section id="site-content" class="col-lg-9 col-md-8 content-column order-1">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
<aside role="complementary" aria-label="Sidebar content" id="site-sidebar" class="col-lg-3 col-md-4 sidebar-column order-2">
<div class="sidebar h-100">
{{ sidebar_content|raw }}
</div>
</aside>
</div>
{% else %}
<div class="container">
<section id="site-content" class="col-12">
<div class="content-wrapper p-4">
{{ content|raw }}
</div>
</section>
</div>
{% endif %}
{% endblock %}
+12
View File
@@ -0,0 +1,12 @@
{
"title": "demo",
"config": {
"default_template": "left_sidebar"
},
"template": {
"full_content": "full_content.twig",
"left_sidebar": "left_sidebar.twig",
"right_sidebar": "right_sidebar.twig",
"custom1": "custom1.twig"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Generate theme.png preview images for each theme using ImageMagick.
# Reads the theme's SCSS color variables and renders a simple page mockup.
set -euo pipefail
cd "$(dirname "$0")/../themes"
gen_preview() {
local dir="$1"
local scss="$dir/css/theme.scss"
if [[ ! -f "$scss" ]]; then
echo "Skip $dir (no theme.scss)"
return
fi
local header_bg header_font nav_bg nav_font sidebar_bg sidebar_border
header_bg=$(grep -oE '^\$header-bg:\s*[^;]+;' "$scss" | grep -oE '#[0-9a-fA-F]{6}' || echo "#0a369d")
header_font=$(grep -oE '^\$header-font:\s*[^;]+;' "$scss" | grep -oE '#[0-9a-fA-F]{6}' || echo "#ffffff")
nav_bg=$(grep -oE '^\$nav-bg:\s*[^;]+;' "$scss" | grep -oE '#[0-9a-fA-F]{6}' || echo "#2754b4")
nav_font=$(grep -oE '^\$nav-font:\s*[^;]+;' "$scss" | grep -oE '#[0-9a-fA-F]{6}' || echo "#ffffff")
sidebar_bg=$(grep -oE '^\$sidebar-bg:\s*[^;]+;' "$scss" | grep -oE '#[0-9a-fA-F]{6}' || echo "#f8f9fa")
sidebar_border=$(grep -oE '^\$sidebar-border:\s*[^;]+;' "$scss" | grep -oE '#[0-9a-fA-F]{6}' || echo "#dee2e6")
local size=640x400
local body_bg="#ffffff"
# Header bar
convert -size "$size" xc:"$body_bg" \
-fill "$header_bg" -draw "rectangle 0,0 639,70" \
-fill "$nav_bg" -draw "rectangle 0,70 639,95" \
-fill "$sidebar_bg" -draw "rectangle 0,95 180,399" \
-fill "$header_font" -font DejaVu-Sans-Bold -pointsize 20 -annotate +15+22 "$(basename "$dir")" \
-fill "$header_font" -font DejaVu-Sans -pointsize 13 -annotate +15+45 "CodePress" \
-fill "$nav_font" -font DejaVu-Sans -pointsize 12 -annotate +15+85 "Home | Over | Blog" \
-fill "#666666" -font DejaVu-Sans -pointsize 14 -annotate +200+130 "Welkom bij $(basename "$dir")" \
-fill "#999999" -font DejaVu-Sans -pointsize 11 -annotate +200+155 "Dit is een voorbeeld van het thema." \
-fill "#cccccc" -draw "rectangle 200,400 639,399" \
"$dir/theme.png"
echo "Generated $dir/theme.png"
}
for d in default demo test; do
if [[ -d "$d" ]]; then
gen_preview "$d"
fi
done
-13
View File
@@ -1,13 +0,0 @@
{
"name": "test",
"header_color": "#613583",
"header_font_color": "#ffffff",
"navigation_color": "#813d9c",
"navigation_font_color": "#ffffff",
"sidebar_background": "#f8f9fa",
"sidebar_border": "#dee2e6",
"header_height": "120",
"nav_height": "50",
"background_image": "test_bg.jpg",
"background_image_opacity": 10
}
+6
View File
@@ -7,5 +7,11 @@ $baseDir = dirname($vendorDir);
return array(
'6e3fae29631ef280660b3cdad06f25a8' => $vendorDir . '/symfony/deprecation-contracts/function.php',
'320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php',
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php',
'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php',
'89efb1254ef2d1c5d80096acd12c4098' => $vendorDir . '/twig/twig/src/Resources/core.php',
'ffecb95d45175fd40f75be8a23b34f90' => $vendorDir . '/twig/twig/src/Resources/debug.php',
'c7baa00073ee9c61edf148c51917cfb4' => $vendorDir . '/twig/twig/src/Resources/escaper.php',
'f844ccf1d25df8663951193c3fc307c8' => $vendorDir . '/twig/twig/src/Resources/string_loader.php',
);
+13
View File
@@ -6,14 +6,27 @@ $vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Twig\\' => array($vendorDir . '/twig/twig/src'),
'Symfony\\Polyfill\\Php80\\' => array($vendorDir . '/symfony/polyfill-php80'),
'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),
'Symfony\\Polyfill\\Ctype\\' => array($vendorDir . '/symfony/polyfill-ctype'),
'Symfony\\Component\\Filesystem\\' => array($vendorDir . '/symfony/filesystem'),
'SourceSpan\\' => array($vendorDir . '/scssphp/source-span/src'),
'ScssPhp\\ScssPhp\\' => array($vendorDir . '/scssphp/scssphp/src'),
'Psr\\Log\\' => array($vendorDir . '/psr/log/src'),
'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
'Psr\\EventDispatcher\\' => array($vendorDir . '/psr/event-dispatcher/src'),
'PhpMqtt\\Client\\' => array($vendorDir . '/php-mqtt/client/src'),
'Nette\\' => array($vendorDir . '/nette/schema/src', $vendorDir . '/nette/utils/src'),
'MyCLabs\\Enum\\' => array($vendorDir . '/myclabs/php-enum/src'),
'Mustache\\' => array($vendorDir . '/mustache/mustache/src'),
'MaxMind\\WebService\\' => array($vendorDir . '/maxmind/web-service-common/src/WebService'),
'MaxMind\\Exception\\' => array($vendorDir . '/maxmind/web-service-common/src/Exception'),
'MaxMind\\Db\\' => array($vendorDir . '/maxmind-db/reader/src/MaxMind/Db'),
'League\\Uri\\' => array($vendorDir . '/league/uri', $vendorDir . '/league/uri-interfaces'),
'League\\Config\\' => array($vendorDir . '/league/config/src'),
'League\\CommonMark\\' => array($vendorDir . '/league/commonmark/src'),
'GeoIp2\\' => array($vendorDir . '/geoip2/geoip2/src'),
'Dflydev\\DotAccessData\\' => array($vendorDir . '/dflydev/dot-access-data/src'),
'Composer\\CaBundle\\' => array($vendorDir . '/composer/ca-bundle/src'),
);
+82
View File
@@ -8,17 +8,33 @@ class ComposerStaticInit071586d19f5409de22b3235d85d8476c
{
public static $files = array (
'6e3fae29631ef280660b3cdad06f25a8' => __DIR__ . '/..' . '/symfony/deprecation-contracts/function.php',
'320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php',
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php',
'a4a119a56e50fbb293281d9a48007e0e' => __DIR__ . '/..' . '/symfony/polyfill-php80/bootstrap.php',
'89efb1254ef2d1c5d80096acd12c4098' => __DIR__ . '/..' . '/twig/twig/src/Resources/core.php',
'ffecb95d45175fd40f75be8a23b34f90' => __DIR__ . '/..' . '/twig/twig/src/Resources/debug.php',
'c7baa00073ee9c61edf148c51917cfb4' => __DIR__ . '/..' . '/twig/twig/src/Resources/escaper.php',
'f844ccf1d25df8663951193c3fc307c8' => __DIR__ . '/..' . '/twig/twig/src/Resources/string_loader.php',
);
public static $prefixLengthsPsr4 = array (
'T' =>
array (
'Twig\\' => 5,
),
'S' =>
array (
'Symfony\\Polyfill\\Php80\\' => 23,
'Symfony\\Polyfill\\Mbstring\\' => 26,
'Symfony\\Polyfill\\Ctype\\' => 23,
'Symfony\\Component\\Filesystem\\' => 29,
'SourceSpan\\' => 11,
'ScssPhp\\ScssPhp\\' => 16,
),
'P' =>
array (
'Psr\\Log\\' => 8,
'Psr\\Http\\Message\\' => 17,
'Psr\\EventDispatcher\\' => 20,
'PhpMqtt\\Client\\' => 15,
),
@@ -30,27 +46,68 @@ class ComposerStaticInit071586d19f5409de22b3235d85d8476c
array (
'MyCLabs\\Enum\\' => 13,
'Mustache\\' => 9,
'MaxMind\\WebService\\' => 19,
'MaxMind\\Exception\\' => 18,
'MaxMind\\Db\\' => 11,
),
'L' =>
array (
'League\\Uri\\' => 11,
'League\\Config\\' => 14,
'League\\CommonMark\\' => 18,
),
'G' =>
array (
'GeoIp2\\' => 7,
),
'D' =>
array (
'Dflydev\\DotAccessData\\' => 22,
),
'C' =>
array (
'Composer\\CaBundle\\' => 18,
),
);
public static $prefixDirsPsr4 = array (
'Twig\\' =>
array (
0 => __DIR__ . '/..' . '/twig/twig/src',
),
'Symfony\\Polyfill\\Php80\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-php80',
),
'Symfony\\Polyfill\\Mbstring\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-mbstring',
),
'Symfony\\Polyfill\\Ctype\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/polyfill-ctype',
),
'Symfony\\Component\\Filesystem\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/filesystem',
),
'SourceSpan\\' =>
array (
0 => __DIR__ . '/..' . '/scssphp/source-span/src',
),
'ScssPhp\\ScssPhp\\' =>
array (
0 => __DIR__ . '/..' . '/scssphp/scssphp/src',
),
'Psr\\Log\\' =>
array (
0 => __DIR__ . '/..' . '/psr/log/src',
),
'Psr\\Http\\Message\\' =>
array (
0 => __DIR__ . '/..' . '/psr/http-factory/src',
1 => __DIR__ . '/..' . '/psr/http-message/src',
),
'Psr\\EventDispatcher\\' =>
array (
0 => __DIR__ . '/..' . '/psr/event-dispatcher/src',
@@ -72,6 +129,23 @@ class ComposerStaticInit071586d19f5409de22b3235d85d8476c
array (
0 => __DIR__ . '/..' . '/mustache/mustache/src',
),
'MaxMind\\WebService\\' =>
array (
0 => __DIR__ . '/..' . '/maxmind/web-service-common/src/WebService',
),
'MaxMind\\Exception\\' =>
array (
0 => __DIR__ . '/..' . '/maxmind/web-service-common/src/Exception',
),
'MaxMind\\Db\\' =>
array (
0 => __DIR__ . '/..' . '/maxmind-db/reader/src/MaxMind/Db',
),
'League\\Uri\\' =>
array (
0 => __DIR__ . '/..' . '/league/uri',
1 => __DIR__ . '/..' . '/league/uri-interfaces',
),
'League\\Config\\' =>
array (
0 => __DIR__ . '/..' . '/league/config/src',
@@ -80,10 +154,18 @@ class ComposerStaticInit071586d19f5409de22b3235d85d8476c
array (
0 => __DIR__ . '/..' . '/league/commonmark/src',
),
'GeoIp2\\' =>
array (
0 => __DIR__ . '/..' . '/geoip2/geoip2/src',
),
'Dflydev\\DotAccessData\\' =>
array (
0 => __DIR__ . '/..' . '/dflydev/dot-access-data/src',
),
'Composer\\CaBundle\\' =>
array (
0 => __DIR__ . '/..' . '/composer/ca-bundle/src',
),
);
public static $classMap = array (
+19
View File
@@ -0,0 +1,19 @@
Copyright (C) 2016 Composer
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+85
View File
@@ -0,0 +1,85 @@
composer/ca-bundle
==================
Small utility library that lets you find a path to the system CA bundle,
and includes a fallback to the Mozilla CA bundle.
Originally written as part of [composer/composer](https://github.com/composer/composer),
now extracted and made available as a stand-alone library.
Installation
------------
Install the latest version with:
```bash
$ composer require composer/ca-bundle
```
Requirements
------------
* PHP 5.3.2 is required but using the latest version of PHP is highly recommended.
Basic usage
-----------
### `Composer\CaBundle\CaBundle`
- `CaBundle::getSystemCaRootBundlePath()`: Returns the system CA bundle path, or a path to the bundled one as fallback
- `CaBundle::getBundledCaBundlePath()`: Returns the path to the bundled CA file
- `CaBundle::validateCaFile($filename)`: Validates a CA file using openssl_x509_parse only if it is safe to use
- `CaBundle::isOpensslParseSafe()`: Test if it is safe to use the PHP function openssl_x509_parse()
- `CaBundle::reset()`: Resets the static caches
#### To use with curl
```php
$curl = curl_init("https://example.org/");
$caPathOrFile = \Composer\CaBundle\CaBundle::getSystemCaRootBundlePath();
if (is_dir($caPathOrFile)) {
curl_setopt($curl, CURLOPT_CAPATH, $caPathOrFile);
} else {
curl_setopt($curl, CURLOPT_CAINFO, $caPathOrFile);
}
$result = curl_exec($curl);
```
#### To use with php streams
```php
$opts = array(
'http' => array(
'method' => "GET"
)
);
$caPathOrFile = \Composer\CaBundle\CaBundle::getSystemCaRootBundlePath();
if (is_dir($caPathOrFile)) {
$opts['ssl']['capath'] = $caPathOrFile;
} else {
$opts['ssl']['cafile'] = $caPathOrFile;
}
$context = stream_context_create($opts);
$result = file_get_contents('https://example.com', false, $context);
```
#### To use with Guzzle
```php
$client = new \GuzzleHttp\Client([
\GuzzleHttp\RequestOptions::VERIFY => \Composer\CaBundle\CaBundle::getSystemCaRootBundlePath()
]);
```
License
-------
composer/ca-bundle is licensed under the MIT License, see the LICENSE file for details.
+54
View File
@@ -0,0 +1,54 @@
{
"name": "composer/ca-bundle",
"description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.",
"type": "library",
"license": "MIT",
"keywords": [
"cabundle",
"cacert",
"certificate",
"ssl",
"tls"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"support": {
"irc": "irc://irc.freenode.org/composer",
"issues": "https://github.com/composer/ca-bundle/issues"
},
"require": {
"ext-openssl": "*",
"ext-pcre": "*",
"php": "^7.2 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^8 || ^9",
"phpstan/phpstan": "^1.10",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"symfony/process": "^4.0 || ^5.0 || ^6.0 || ^7.0"
},
"autoload": {
"psr-4": {
"Composer\\CaBundle\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Composer\\CaBundle\\": "tests"
}
},
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"scripts": {
"test": "@php phpunit",
"phpstan": "@php phpstan analyse"
}
}
File diff suppressed because it is too large Load Diff
+322
View File
@@ -0,0 +1,322 @@
<?php
/*
* This file is part of composer/ca-bundle.
*
* (c) Composer <https://github.com/composer>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Composer\CaBundle;
use Psr\Log\LoggerInterface;
use Symfony\Component\Process\PhpProcess;
/**
* @author Chris Smith <chris@cs278.org>
* @author Jordi Boggiano <j.boggiano@seld.be>
*/
class CaBundle
{
/** @var string|null */
private static $caPath;
/** @var array<string, bool> */
private static $caFileValidity = array();
/**
* Returns the system CA bundle path, or a path to the bundled one
*
* This method was adapted from Sslurp.
* https://github.com/EvanDotPro/Sslurp
*
* (c) Evan Coury <me@evancoury.com>
*
* For the full copyright and license information, please see below:
*
* Copyright (c) 2013, Evan Coury
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* * Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* @param LoggerInterface $logger optional logger for information about which CA files were loaded
* @return string path to a CA bundle file or directory
*/
public static function getSystemCaRootBundlePath(?LoggerInterface $logger = null)
{
if (self::$caPath !== null) {
return self::$caPath;
}
$caBundlePaths = array();
// If SSL_CERT_FILE env variable points to a valid certificate/bundle, use that.
// This mimics how OpenSSL uses the SSL_CERT_FILE env variable.
$caBundlePaths[] = self::getEnvVariable('SSL_CERT_FILE');
// If SSL_CERT_DIR env variable points to a valid certificate/bundle, use that.
// This mimics how OpenSSL uses the SSL_CERT_FILE env variable.
$caBundlePaths[] = self::getEnvVariable('SSL_CERT_DIR');
$caBundlePaths[] = ini_get('openssl.cafile');
$caBundlePaths[] = ini_get('openssl.capath');
$otherLocations = array(
'/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', // Fedora, RHEL, CentOS (ca-certificates package) - NEW
'/etc/pki/tls/certs/ca-bundle.crt', // Fedora, RHEL, CentOS (ca-certificates package) - Deprecated
'/etc/ssl/certs/ca-certificates.crt', // Debian, Ubuntu, Gentoo, Arch Linux (ca-certificates package)
'/etc/ssl/ca-bundle.pem', // SUSE, openSUSE (ca-certificates package)
'/usr/ssl/certs/ca-bundle.crt', // Cygwin
'/opt/local/share/curl/curl-ca-bundle.crt', // OS X macports, curl-ca-bundle package
'/usr/local/share/curl/curl-ca-bundle.crt', // Default cURL CA bunde path (without --with-ca-bundle option)
'/usr/share/ssl/certs/ca-bundle.crt', // Really old RedHat?
'/etc/ssl/cert.pem', // OpenBSD
'/usr/local/etc/openssl/cert.pem', // OS X homebrew, openssl package
'/usr/local/etc/openssl@1.1/cert.pem', // OS X homebrew, openssl@1.1 package
'/opt/homebrew/etc/openssl@3/cert.pem', // macOS silicon homebrew, openssl@3 package
'/opt/homebrew/etc/openssl@1.1/cert.pem', // macOS silicon homebrew, openssl@1.1 package
'/etc/pki/tls/certs',
'/etc/ssl/certs', // FreeBSD
);
$caBundlePaths = array_merge($caBundlePaths, $otherLocations);
foreach ($caBundlePaths as $caBundle) {
if ($caBundle && self::caFileUsable($caBundle, $logger)) {
return self::$caPath = $caBundle;
}
if ($caBundle && self::caDirUsable($caBundle, $logger)) {
return self::$caPath = $caBundle;
}
}
return self::$caPath = static::getBundledCaBundlePath(); // Bundled CA file, last resort
}
/**
* Returns the path to the bundled CA file
*
* In case you don't want to trust the user or the system, you can use this directly
*
* @return string path to a CA bundle file
*/
public static function getBundledCaBundlePath()
{
$caBundleFile = __DIR__.'/../res/cacert.pem';
// cURL does not understand 'phar://' paths
// see https://github.com/composer/ca-bundle/issues/10
if (0 === strpos($caBundleFile, 'phar://')) {
$tempCaBundleFile = tempnam(sys_get_temp_dir(), 'openssl-ca-bundle-');
if (false === $tempCaBundleFile) {
throw new \RuntimeException('Could not create a temporary file to store the bundled CA file');
}
file_put_contents(
$tempCaBundleFile,
file_get_contents($caBundleFile)
);
register_shutdown_function(function() use ($tempCaBundleFile) {
@unlink($tempCaBundleFile);
});
$caBundleFile = $tempCaBundleFile;
}
return $caBundleFile;
}
/**
* Validates a CA file using opensl_x509_parse only if it is safe to use
*
* @param string $filename
* @param LoggerInterface $logger optional logger for information about which CA files were loaded
*
* @return bool
*/
public static function validateCaFile($filename, ?LoggerInterface $logger = null)
{
static $warned = false;
if (isset(self::$caFileValidity[$filename])) {
return self::$caFileValidity[$filename];
}
$contents = file_get_contents($filename);
if (is_string($contents) && strlen($contents) > 0) {
$contents = preg_replace("/^(\\-+(?:BEGIN|END))\\s+TRUSTED\\s+(CERTIFICATE\\-+)\$/m", '$1 $2', $contents);
if (null === $contents) {
// regex extraction failed
$isValid = false;
} else {
$isValid = (bool) openssl_x509_parse($contents);
}
} else {
$isValid = false;
}
if ($logger) {
$logger->debug('Checked CA file '.realpath($filename).': '.($isValid ? 'valid' : 'invalid'));
}
return self::$caFileValidity[$filename] = $isValid;
}
/**
* Test if it is safe to use the PHP function openssl_x509_parse().
*
* This checks if OpenSSL extensions is vulnerable to remote code execution
* via the exploit documented as CVE-2013-6420.
*
* @return bool
*/
public static function isOpensslParseSafe()
{
return true;
}
/**
* Resets the static caches
* @return void
*/
public static function reset()
{
self::$caFileValidity = array();
self::$caPath = null;
}
/**
* @param string $name
* @return string|false
*/
private static function getEnvVariable($name)
{
if (isset($_SERVER[$name])) {
return (string) $_SERVER[$name];
}
if (PHP_SAPI === 'cli' && ($value = getenv($name)) !== false && $value !== null) {
return (string) $value;
}
return false;
}
/**
* @param string|false $certFile
* @param LoggerInterface|null $logger
* @return bool
*/
private static function caFileUsable($certFile, ?LoggerInterface $logger = null)
{
return $certFile
&& self::isFile($certFile, $logger)
&& self::isReadable($certFile, $logger)
&& self::validateCaFile($certFile, $logger);
}
/**
* @param string|false $certDir
* @param LoggerInterface|null $logger
* @return bool
*/
private static function caDirUsable($certDir, ?LoggerInterface $logger = null)
{
return $certDir
&& self::isDir($certDir, $logger)
&& self::isReadable($certDir, $logger)
&& self::glob($certDir . '/*', $logger);
}
/**
* @param string $certFile
* @param LoggerInterface|null $logger
* @return bool
*/
private static function isFile($certFile, ?LoggerInterface $logger = null)
{
$isFile = @is_file($certFile);
if (!$isFile && $logger) {
$logger->debug(sprintf('Checked CA file %s does not exist or it is not a file.', $certFile));
}
return $isFile;
}
/**
* @param string $certDir
* @param LoggerInterface|null $logger
* @return bool
*/
private static function isDir($certDir, ?LoggerInterface $logger = null)
{
$isDir = @is_dir($certDir);
if (!$isDir && $logger) {
$logger->debug(sprintf('Checked directory %s does not exist or it is not a directory.', $certDir));
}
return $isDir;
}
/**
* @param string $certFileOrDir
* @param LoggerInterface|null $logger
* @return bool
*/
private static function isReadable($certFileOrDir, ?LoggerInterface $logger = null)
{
$isReadable = @is_readable($certFileOrDir);
if (!$isReadable && $logger) {
$logger->debug(sprintf('Checked file or directory %s is not readable.', $certFileOrDir));
}
return $isReadable;
}
/**
* @param string $pattern
* @param LoggerInterface|null $logger
* @return bool
*/
private static function glob($pattern, ?LoggerInterface $logger = null)
{
$certs = glob($pattern);
if ($certs === false) {
if ($logger) {
$logger->debug(sprintf("An error occurred while trying to find certificates for pattern: %s", $pattern));
}
return false;
}
if (count($certs) === 0) {
if ($logger) {
$logger->debug(sprintf("No CA files found for pattern: %s", $pattern));
}
return false;
}
return true;
}
}
+1027
View File
File diff suppressed because it is too large Load Diff
+128 -2
View File
@@ -3,7 +3,7 @@
'name' => '__root__',
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'f685c2490ab18e80afa64695135087eeeec57804',
'reference' => 'd453b8073f07afc24f2a03f94a982977263aa107',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
@@ -13,12 +13,21 @@
'__root__' => array(
'pretty_version' => 'dev-main',
'version' => 'dev-main',
'reference' => 'f685c2490ab18e80afa64695135087eeeec57804',
'reference' => 'd453b8073f07afc24f2a03f94a982977263aa107',
'type' => 'library',
'install_path' => __DIR__ . '/../../',
'aliases' => array(),
'dev_requirement' => false,
),
'composer/ca-bundle' => array(
'pretty_version' => '1.5.13',
'version' => '1.5.13.0',
'reference' => 'c008272789979f709f7fcb32c2ecf1d2db5e84e5',
'type' => 'library',
'install_path' => __DIR__ . '/./ca-bundle',
'aliases' => array(),
'dev_requirement' => false,
),
'dflydev/dot-access-data' => array(
'pretty_version' => 'v3.0.3',
'version' => '3.0.3.0',
@@ -28,6 +37,15 @@
'aliases' => array(),
'dev_requirement' => false,
),
'geoip2/geoip2' => array(
'pretty_version' => 'v2.13.0',
'version' => '2.13.0.0',
'reference' => '6a41d8fbd6b90052bc34dff3b4252d0f88067b23',
'type' => 'library',
'install_path' => __DIR__ . '/../geoip2/geoip2',
'aliases' => array(),
'dev_requirement' => false,
),
'league/commonmark' => array(
'pretty_version' => '2.8.0',
'version' => '2.8.0.0',
@@ -46,6 +64,42 @@
'aliases' => array(),
'dev_requirement' => false,
),
'league/uri' => array(
'pretty_version' => '7.8.1',
'version' => '7.8.1.0',
'reference' => '08cf38e3924d4f56238125547b5720496fac8fd4',
'type' => 'library',
'install_path' => __DIR__ . '/../league/uri',
'aliases' => array(),
'dev_requirement' => false,
),
'league/uri-interfaces' => array(
'pretty_version' => '7.8.1',
'version' => '7.8.1.0',
'reference' => '85d5c77c5d6d3af6c54db4a78246364908f3c928',
'type' => 'library',
'install_path' => __DIR__ . '/../league/uri-interfaces',
'aliases' => array(),
'dev_requirement' => false,
),
'maxmind-db/reader' => array(
'pretty_version' => 'v1.13.1',
'version' => '1.13.1.0',
'reference' => '2194f58d0f024ce923e685cdf92af3daf9951908',
'type' => 'library',
'install_path' => __DIR__ . '/../maxmind-db/reader',
'aliases' => array(),
'dev_requirement' => false,
),
'maxmind/web-service-common' => array(
'pretty_version' => 'v0.11.1',
'version' => '0.11.1.0',
'reference' => 'c309236b5a5555b96cf560089ec3cead12d845d2',
'type' => 'library',
'install_path' => __DIR__ . '/../maxmind/web-service-common',
'aliases' => array(),
'dev_requirement' => false,
),
'mustache/mustache' => array(
'pretty_version' => 'v3.0.0',
'version' => '3.0.0.0',
@@ -100,6 +154,24 @@
'aliases' => array(),
'dev_requirement' => false,
),
'psr/http-factory' => array(
'pretty_version' => '1.1.0',
'version' => '1.1.0.0',
'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-factory',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/http-message' => array(
'pretty_version' => '2.0',
'version' => '2.0.0.0',
'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
'type' => 'library',
'install_path' => __DIR__ . '/../psr/http-message',
'aliases' => array(),
'dev_requirement' => false,
),
'psr/log' => array(
'pretty_version' => '3.0.2',
'version' => '3.0.2.0',
@@ -109,6 +181,24 @@
'aliases' => array(),
'dev_requirement' => false,
),
'scssphp/scssphp' => array(
'pretty_version' => 'v2.1.0',
'version' => '2.1.0.0',
'reference' => 'd8450c2baf5fb07d00374999d0ea51276974d1b6',
'type' => 'library',
'install_path' => __DIR__ . '/../scssphp/scssphp',
'aliases' => array(),
'dev_requirement' => false,
),
'scssphp/source-span' => array(
'pretty_version' => 'v1.1.0',
'version' => '1.1.0.0',
'reference' => '37d653206daf11da1ee60b333984101bc4c27ba2',
'type' => 'library',
'install_path' => __DIR__ . '/../scssphp/source-span',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/deprecation-contracts' => array(
'pretty_version' => 'v3.6.0',
'version' => '3.6.0.0',
@@ -118,6 +208,33 @@
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/filesystem' => array(
'pretty_version' => 'v8.1.2',
'version' => '8.1.2.0',
'reference' => '17856b7a222664a26a5ea1cb06ee0721c2438217',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/filesystem',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/polyfill-ctype' => array(
'pretty_version' => 'v1.37.0',
'version' => '1.37.0.0',
'reference' => '141046a8f9477948ff284fa65be2095baafb94f2',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-ctype',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/polyfill-mbstring' => array(
'pretty_version' => 'v1.31.0',
'version' => '1.31.0.0',
'reference' => '85181ba99b2345b0ef10ce42ecac37612d9fd341',
'type' => 'library',
'install_path' => __DIR__ . '/../symfony/polyfill-mbstring',
'aliases' => array(),
'dev_requirement' => false,
),
'symfony/polyfill-php80' => array(
'pretty_version' => 'v1.33.0',
'version' => '1.33.0.0',
@@ -127,5 +244,14 @@
'aliases' => array(),
'dev_requirement' => false,
),
'twig/twig' => array(
'pretty_version' => 'v3.28.0',
'version' => '3.28.0.0',
'reference' => '597c12ed286fb9d1701a36684ce6e0cbe28ebc8b',
'type' => 'library',
'install_path' => __DIR__ . '/../twig/twig',
'aliases' => array(),
'dev_requirement' => false,
),
),
);
+2 -2
View File
@@ -4,8 +4,8 @@
$issues = array();
if (!(PHP_VERSION_ID >= 80100)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.';
if (!(PHP_VERSION_ID >= 80401)) {
$issues[] = 'Your Composer dependencies require a PHP version ">= 8.4.1". You are running ' . PHP_VERSION . '.';
}
if ($issues) {
+323
View File
@@ -0,0 +1,323 @@
CHANGELOG
=========
2.13.0 (2022-08-05)
-------------------
* The model class names are no longer constructed by concatenating strings.
This change was made to improve support for tools like PHP-Scoper.
Reported by Andrew Mead. GitHub #194.
* Box 4.0.1 is now used to generate the `geoip2.phar` file.
2.12.2 (2021-11-30)
-------------------
* The `geoip2.phar` now works when included from another directory.
Reported by Eduardo Ruiz. GitHub #179.
2.12.1 (2021-11-23)
-------------------
* The `geoip2.phar` included in 2.12.0 would only work in CLI applications.
This was due to a change in Box 3.x. The Phar should now work in all
applications. This release only affects users of the Phar file.
2.12.0 (2021-11-18)
-------------------
* Support for mobile country code (MCC) and mobile network codes (MNC) was
added for the GeoIP2 ISP and Enterprise databases as well as the GeoIP2
City and Insights web services. `$mobileCountryCode` and
`$mobileNetworkCode` properties were added to `GeoIp2\Model\Isp`
for the GeoIP2 ISP database and `GeoIp2\Record\Traits` for the Enterprise
database and the GeoIP2 City and Insights web services. We expect this data
to be available by late January, 2022.
* `geoip2.phar` is now generated with Box 3.x.
2.11.0 (2020-10-01)
-------------------
* IMPORTANT: PHP 7.2 or greater is now required.
* Added the `isResidentialProxy` property to `GeoIp2\Model\AnonymousIP` and
`GeoIp2\Record\Traits`.
* Additional type hints have been added.
2.10.0 (2019-12-12)
-------------------
* PHP 5.6 or greater is now required.
* The `network` property was added to `GeoIp2\Record\Traits`,
`GeoIp2\Model\AnonymousIp`, `GeoIp2\Model\Asn`,
`GeoIp2\Model\ConnectionType`, `Geoip2\Model\Domain`,
and `GeoIp2\Model\Isp`. This is a string in CIDR format representing the
largest network where all of the properties besides `ipAddress` have the
same value.
* Updated documentation of anonymizer properties - `isAnonymousVpn`
and `isHostingProvider` - to be more descriptive.
* The `userCount` property was added to `GeoIp2\Record\Traits`. This is an
integer which indicates the estimated number of users sharing the
IP/network during the past 24 hours. This output is available from GeoIP2
Precision Insights.
* The `staticIpScore` property was added to `GeoIp2\Record\Traits`. This is
a float which indicates how static or dynamic an IP address is. This
output is available from GeoIP2 Precision Insights.
2.9.0 (2018-04-10)
------------------
* Refer to account IDs using the terminology "account" rather than "user".
2.8.0 (2018-01-18)
------------------
* The `isInEuropeanUnion` property was added to `GeoIp2\Record\Country`
and `GeoIp2\Record\RepresentedCountry`. This property is `true` if the
country is a member state of the European Union.
2.7.0 (2017-10-27)
------------------
* The following new anonymizer properties were added to `GeoIp2\Record\Traits`
for use with GeoIP2 Precision Insights: `isAnonymous`, `isAnonymousVpn`,
`isHostingProvider`, `isPublicProxy`, and `isTorExitNode`.
2.6.0 (2017-07-10)
-----------------
* Code clean-up and tidying.
* Set minimum required PHP version to 5.4 in `composer.json`. Previously,
5.3 would work but was not tested. Now 5.4 is hard minimum version.
2.5.0 (2017-05-08)
------------------
* Support for PHP 5.3 was dropped.
* Added support for GeoLite2 ASN database.
2.4.5 (2017-01-31)
------------------
* Additional error checking on the data returned from `MaxMind\Db\Reader`
was added to help detect corrupt databases. GitHub #83.
2.4.4 (2016-10-11)
------------------
* `isset()` on `mostSpecificSubdivision` attribute now returns the
correct value. Reported by Juan Francisco Giordana. GitHub #81.
2.4.3 (2016-10-11)
------------------
* `isset()` on `name` attribute now returns the correct value. Reported by
Juan Francisco Giordana. GitHub #79.
2.4.2 (2016-08-17)
------------------
* Updated documentation to clarify what the accuracy radius refers to.
* Upgraded `maxmind/web-service-common` to 0.3.0. This version uses
`composer/ca-bundle` rather than our own CA bundle. GitHub #75.
* Improved PHP documentation generation.
2.4.1 (2016-06-10)
------------------
* Corrected type annotations in documentation. GitHub #66.
* Updated documentation to reflect that the accuracy radius is now included
in City.
* Upgraded web service client, which supports setting a proxy. GitHub #59.
2.4.0 (2016-04-15)
------------------
* Added support for the GeoIP2 Enterprise database.
2.3.3 (2015-09-24)
------------------
* Corrected case on `JsonSerializable` interface. Reported by Axel Etcheverry.
GitHub #56.
2.3.2 (2015-09-23)
------------------
* `JsonSerializable` compatibility interface was moved to `GeoIp2\Compat`
rather than the global namespace to prevent autoloading issues. Reported by
Tomas Buteler. GitHub #54.
* Missing documentation for the `$postal` property was added to the
`GeoIp2\Model\City` class. Fix by Roy Sindre Norangshol. GitHub #51.
* In the Phar distribution, source files for this module no longer have their
documentation stripped, allowing IDE introspection to work properly.
Reported by Dominic Black. GitHub #52.
2.3.1 (2015-06-30)
------------------
* Updated `maxmind/web-service-common` to version with fixes for PHP 5.3 and
5.4.
2.3.0 (2015-06-29)
------------------
* Support for demographics fields `averageIncome` and `populationDensity` in
the `Location` record, returned by the Insights endpoint.
* The `isAnonymousProxy` and `isSatelliteProvider` properties on
`GeoIP2\Record\Traits` have been deprecated. Please use our [GeoIP2
Anonymous IP database](https://www.maxmind.com/en/geoip2-anonymous-ip-database)
to determine whether an IP address is used by an anonymizing service.
2.2.0-beta1 (2015-06-09)
------------------------
* Typo fix in documentation.
2.2.0-alpha2 (2015-06-01)
-------------------------
* `maxmind-ws/web-service-common` was renamed to `maxmind/web-service-common`.
2.2.0-alpha1 (2015-05-22)
-------------------------
* The library no longer uses Guzzle and instead uses curl directly.
* Support for `timeout` and `connectTimout` were added to the `$options` array
passed to the `GeoIp2\WebService\Client` constructor. Pull request by Will
Bradley. GitHub #36.
2.1.1 (2014-12-03)
------------------
* The 2.1.0 Phar builds included a shebang line, causing issues when loading
it as a library. This has been corrected. GitHub #33.
2.1.0 (2014-10-29)
------------------
* Update ApiGen dependency to version that isn't broken on case sensitive
file systems.
* Added support for the GeoIP2 Anonymous IP database. The
`GeoIP2\Database\Reader` class now has an `anonymousIp` method which returns
a `GeoIP2\Model\AnonymousIp` object.
* Boolean attributes like those in the `GeoIP2\Record\Traits` class now return
`false` instead of `null` when they were not true.
2.0.0 (2014-09-22)
------------------
* First production release.
0.9.0 (2014-09-15)
------------------
* IMPORTANT: The deprecated `omni()` and `cityIspOrg()` methods have been
removed from `GeoIp2\WebService\Client`.
0.8.1 (2014-09-12)
------------------
* The check added to the `GeoIP2\Database\Reader` lookup methods in 0.8.0 did
not work with the GeoIP2 City Database Subset by Continent with World
Countries. This has been fixed. Fixes GitHub issue #23.
0.8.0 (2014-09-10)
------------------
* The `GeoIp2\Database\Reader` lookup methods (e.g., `city()`, `isp()`) now
throw a `BadMethodCallException` if they are used with a database that
does not match the method. In particular, doing a `city()` lookup on a
GeoIP2 Country database will result in an exception, and vice versa.
* A `metadata()` method has been added to the `GeoIP2\Database\Reader` class.
This returns a `MaxMind\Db\Reader\Metadata` class with information about the
database.
* The name attribute was missing from the RepresentedCountry class.
0.7.0 (2014-07-22)
------------------
* The web service client API has been updated for the v2.1 release of the web
service. In particular, the `cityIspOrg` and `omni` methods on
`GeoIp2\WebService\Client` should be considered deprecated. The `city`
method now provides all of the data formerly provided by `cityIspOrg`, and
the `omni` method has been replaced by the `insights` method.
* Support was added for GeoIP2 Connection Type, Domain and ISP databases.
0.6.3 (2014-05-12)
------------------
* With the previous Phar builds, some users received `phar error: invalid url
or non-existent phar` errors. The correct alias is now used for the Phar,
and this should no longer be an issue.
0.6.2 (2014-05-08)
------------------
* The Phar build was broken with Guzzle 3.9.0+. This has been fixed.
0.6.1 (2014-05-01)
------------------
* This API now officially supports HHVM.
* The `maxmind-db/reader` dependency was updated to a version that does not
require BC Math.
* The Composer compatibility autoload rules are now targeted more narrowly.
* A `box.json` file is included to build a Phar package.
0.6.0 (2014-02-19)
------------------
* This API is now licensed under the Apache License, Version 2.0.
* Model and record classes now implement `JsonSerializable`.
* `isset` now works with model and record classes.
0.5.0 (2013-10-21)
------------------
* Renamed $languages constructor parameters to $locales for both the Client
and Reader classes.
* Documentation and code clean-up (Ben Morel).
* Added the interface `GeoIp2\ProviderInterface`, which is implemented by both
`\GeoIp2\Database\Reader` and `\GeoIp2\WebService\Client`.
0.4.0 (2013-07-16)
------------------
* This is the first release with the GeoIP2 database reader. Please see the
`README.md` file and the `\GeoIp2\Database\Reader` class.
* The general exception classes were replaced with specific exception classes
representing particular types of errors, such as an authentication error.
0.3.0 (2013-07-12)
------------------
* In namespaces and class names, "GeoIP2" was renamed to "GeoIp2" to improve
consistency.
0.2.1 (2013-06-10)
------------------
* First official beta release.
* Documentation updates and corrections.
0.2.0 (2013-05-29)
------------------
* `GenericException` was renamed to `GeoIP2Exception`.
* We now support more languages. The new languages are de, es, fr, and pt-BR.
* The REST API now returns a record with data about your account. There is
a new `GeoIP\Records\MaxMind` class for this data.
* The `continentCode` attribute on `Continent` was renamed to `code`.
* Documentation updates.
0.1.1 (2013-05-14)
------------------
* Updated Guzzle version requirement.
* Fixed Composer example in README.md.
0.1.0 (2013-05-13)
------------------
* Initial release.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+442
View File
@@ -0,0 +1,442 @@
# GeoIP2 PHP API #
## Description ##
This package provides an API for the GeoIP2 and GeoLite2
[web services](https://dev.maxmind.com/geoip/docs/web-services?lang=en) and
[databases](https://dev.maxmind.com/geoip/docs/databases?lang=en).
## Install via Composer ##
We recommend installing this package with [Composer](https://getcomposer.org/).
### Download Composer ###
To download Composer, run in the root directory of your project:
```bash
curl -sS https://getcomposer.org/installer | php
```
You should now have the file `composer.phar` in your project directory.
### Install Dependencies ###
Run in your project root:
```sh
php composer.phar require geoip2/geoip2:~2.0
```
You should now have the files `composer.json` and `composer.lock` as well as
the directory `vendor` in your project directory. If you use a version control
system, `composer.json` should be added to it.
### Require Autoloader ###
After installing the dependencies, you need to require the Composer autoloader
from your code:
```php
require 'vendor/autoload.php';
```
## Install via Phar ##
Although we strongly recommend using Composer, we also provide a
[phar archive](https://php.net/manual/en/book.phar.php) containing most of the
dependencies for GeoIP2. Our latest phar archive is available on
[our releases page](https://github.com/maxmind/GeoIP2-php/releases).
### Install Dependencies ###
In order to use the phar archive, you must have the PHP
[Phar extension](https://php.net/manual/en/book.phar.php) installed and
enabled.
If you will be making web service requests, you must have the PHP
[cURL extension](https://php.net/manual/en/book.curl.php)
installed to use this archive. For Debian based distributions, this can
typically be found in the the `php-curl` package. For other operating
systems, please consult the relevant documentation. After installing the
extension you may need to restart your web server.
If you are missing this extension, you will see errors like the following:
```
PHP Fatal error: Uncaught Error: Call to undefined function MaxMind\WebService\curl_version()
```
### Require Package ###
To use the archive, just require it from your script:
```php
require 'geoip2.phar';
```
## Optional C Extension ##
The [MaxMind DB API](https://github.com/maxmind/MaxMind-DB-Reader-php)
includes an optional C extension that you may install to dramatically increase
the performance of lookups in GeoIP2 or GeoLite2 databases. To install, please
follow the instructions included with that API.
The extension has no effect on web-service lookups.
## IP Geolocation Usage ##
IP geolocation is inherently imprecise. Locations are often near the center of
the population. Any location provided by a GeoIP2 database or web service
should not be used to identify a particular address or household.
## Database Reader ##
### Usage ###
To use this API, you must create a new `\GeoIp2\Database\Reader` object with
the path to the database file as the first argument to the constructor. You
may then call the method corresponding to the database you are using.
If the lookup succeeds, the method call will return a model class for the
record in the database. This model in turn contains multiple container
classes for the different parts of the data such as the city in which the
IP address is located.
If the record is not found, a `\GeoIp2\Exception\AddressNotFoundException`
is thrown. If the database is invalid or corrupt, a
`\MaxMind\Db\InvalidDatabaseException` will be thrown.
See the API documentation for more details.
### City Example ###
```php
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-City.mmdb');
// Replace "city" with the appropriate method for your database, e.g.,
// "country".
$record = $reader->city('128.101.101.101');
print($record->country->isoCode . "\n"); // 'US'
print($record->country->name . "\n"); // 'United States'
print($record->country->names['zh-CN'] . "\n"); // '美国'
print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota'
print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN'
print($record->city->name . "\n"); // 'Minneapolis'
print($record->postal->code . "\n"); // '55455'
print($record->location->latitude . "\n"); // 44.9733
print($record->location->longitude . "\n"); // -93.2323
print($record->traits->network . "\n"); // '128.101.101.101/32'
```
### Anonymous IP Example ###
```php
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Anonymous-IP.mmdb');
$record = $reader->anonymousIp('128.101.101.101');
if ($record->isAnonymous) { print "anon\n"; }
print($record->ipAddress . "\n"); // '128.101.101.101'
print($record->network . "\n"); // '128.101.101.101/32'
```
### Connection-Type Example ###
```php
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Connection-Type.mmdb');
$record = $reader->connectionType('128.101.101.101');
print($record->connectionType . "\n"); // 'Corporate'
print($record->ipAddress . "\n"); // '128.101.101.101'
print($record->network . "\n"); // '128.101.101.101/32'
```
### Domain Example ###
```php
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Domain.mmdb');
$record = $reader->domain('128.101.101.101');
print($record->domain . "\n"); // 'umn.edu'
print($record->ipAddress . "\n"); // '128.101.101.101'
print($record->network . "\n"); // '128.101.101.101/32'
```
### Enterprise Example ###
```php
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Enterprise.mmdb');
// Use the ->enterprise method to do a lookup in the Enterprise database
$record = $reader->enterprise('128.101.101.101');
print($record->country->confidence . "\n"); // 99
print($record->country->isoCode . "\n"); // 'US'
print($record->country->name . "\n"); // 'United States'
print($record->country->names['zh-CN'] . "\n"); // '美国'
print($record->mostSpecificSubdivision->confidence . "\n"); // 77
print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota'
print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN'
print($record->city->confidence . "\n"); // 60
print($record->city->name . "\n"); // 'Minneapolis'
print($record->postal->code . "\n"); // '55455'
print($record->location->accuracyRadius . "\n"); // 50
print($record->location->latitude . "\n"); // 44.9733
print($record->location->longitude . "\n"); // -93.2323
print($record->traits->network . "\n"); // '128.101.101.101/32'
```
### ISP Example ###
```php
<?php
require_once 'vendor/autoload.php';
use GeoIp2\Database\Reader;
// This creates the Reader object, which should be reused across
// lookups.
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-ISP.mmdb');
$record = $reader->isp('128.101.101.101');
print($record->autonomousSystemNumber . "\n"); // 217
print($record->autonomousSystemOrganization . "\n"); // 'University of Minnesota'
print($record->isp . "\n"); // 'University of Minnesota'
print($record->organization . "\n"); // 'University of Minnesota'
print($record->ipAddress . "\n"); // '128.101.101.101'
print($record->network . "\n"); // '128.101.101.101/32'
```
## Database Updates ##
You can keep your databases up to date with our
[GeoIP Update program](https://github.com/maxmind/geoipupdate/releases).
[Learn more about GeoIP Update on our developer
portal.](https://dev.maxmind.com/geoip/updating-databases?lang=en)
There is also a third-party tool for updating databases using PHP and
Composer. MaxMind does not offer support for this tool or maintain it.
[Learn more about the Geoip2 Update tool for PHP and Composer on its
GitHub page.](https://github.com/tronovav/geoip2-update)
## Web Service Client ##
### Usage ###
To use this API, you must create a new `\GeoIp2\WebService\Client`
object with your `$accountId` and `$licenseKey`:
```php
$client = new Client(42, 'abcdef123456');
```
You may also call the constructor with additional arguments. The third argument
specifies the language preferences when using the `->name` method on the model
classes that this client creates. The fourth argument is additional options
such as `host` and `timeout`.
For instance, to call the GeoLite2 web service instead of the GeoIP2 web
service:
```php
$client = new Client(42, 'abcdef123456', ['en'], ['host' => 'geolite.info']);
```
After creating the client, you may now call the method corresponding to a
specific endpoint with the IP address to look up, e.g.:
```php
$record = $client->city('128.101.101.101');
```
If the request succeeds, the method call will return a model class for the
endpoint you called. This model in turn contains multiple record classes, each
of which represents part of the data returned by the web service.
If there is an error, a structured exception is thrown.
See the API documentation for more details.
### Example ###
```php
<?php
require_once 'vendor/autoload.php';
use GeoIp2\WebService\Client;
// This creates a Client object that can be reused across requests.
// Replace "42" with your account ID and "license_key" with your license
// key. Set the "host" to "geolite.info" in the fourth argument options
// array to use the GeoLite2 web service instead of the GeoIP2 web
// service.
$client = new Client(42, 'abcdef123456');
// Replace "city" with the method corresponding to the web service that
// you are using, e.g., "country", "insights".
$record = $client->city('128.101.101.101');
print($record->country->isoCode . "\n"); // 'US'
print($record->country->name . "\n"); // 'United States'
print($record->country->names['zh-CN'] . "\n"); // '美国'
print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota'
print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN'
print($record->city->name . "\n"); // 'Minneapolis'
print($record->postal->code . "\n"); // '55455'
print($record->location->latitude . "\n"); // 44.9733
print($record->location->longitude . "\n"); // -93.2323
print($record->traits->network . "\n"); // '128.101.101.101/32'
```
## Values to use for Database or Array Keys ##
**We strongly discourage you from using a value from any `names` property as
a key in a database or array.**
These names may change between releases. Instead we recommend using one of the
following:
* `GeoIp2\Record\City` - `$city->geonameId`
* `GeoIp2\Record\Continent` - `$continent->code` or `$continent->geonameId`
* `GeoIp2\Record\Country` and `GeoIp2\Record\RepresentedCountry` -
`$country->isoCode` or `$country->geonameId`
* `GeoIp2\Record\Subdivision` - `$subdivision->isoCode` or `$subdivision->geonameId`
### What data is returned? ###
While many of the end points return the same basic records, the attributes
which can be populated vary between end points. In addition, while an end
point may offer a particular piece of data, MaxMind does not always have every
piece of data for any given IP address.
Because of these factors, it is possible for any end point to return a record
where some or all of the attributes are unpopulated.
See the
[GeoIP2 web service docs](https://dev.maxmind.com/geoip/docs/web-services?lang=en)
for details on what data each end point may return.
The only piece of data which is always returned is the `ipAddress`
attribute in the `GeoIp2\Record\Traits` record.
## Integration with GeoNames ##
[GeoNames](https://www.geonames.org/) offers web services and downloadable
databases with data on geographical features around the world, including
populated places. They offer both free and paid premium data. Each
feature is unique identified by a `geonameId`, which is an integer.
Many of the records returned by the GeoIP2 web services and databases
include a `geonameId` property. This is the ID of a geographical feature
(city, region, country, etc.) in the GeoNames database.
Some of the data that MaxMind provides is also sourced from GeoNames. We
source things like place names, ISO codes, and other similar data from
the GeoNames premium data set.
## Reporting data problems ##
If the problem you find is that an IP address is incorrectly mapped,
please
[submit your correction to MaxMind](https://www.maxmind.com/en/correction).
If you find some other sort of mistake, like an incorrect spelling,
please check the [GeoNames site](https://www.geonames.org/) first. Once
you've searched for a place and found it on the GeoNames map view, there
are a number of links you can use to correct data ("move", "edit",
"alternate names", etc.). Once the correction is part of the GeoNames
data set, it will be automatically incorporated into future MaxMind
releases.
If you are a paying MaxMind customer and you're not sure where to submit
a correction, please
[contact MaxMind support](https://www.maxmind.com/en/support) for help.
## Other Support ##
Please report all issues with this code using the
[GitHub issue tracker](https://github.com/maxmind/GeoIP2-php/issues).
If you are having an issue with a MaxMind service that is not specific
to the client API, please see
[our support page](https://www.maxmind.com/en/support).
## Requirements ##
This library requires PHP 7.2 or greater.
This library also relies on the [MaxMind DB Reader](https://github.com/maxmind/MaxMind-DB-Reader-php).
## Contributing ##
Patches and pull requests are encouraged. All code should follow the PSR-2
style guidelines. Please include unit tests whenever possible. You may obtain
the test data for the maxmind-db folder by running `git submodule update
--init --recursive` or adding `--recursive` to your initial clone, or from
https://github.com/maxmind/MaxMind-DB
## Versioning ##
The GeoIP2 PHP API uses [Semantic Versioning](https://semver.org/).
## Copyright and License ##
This software is Copyright (c) 2013-2020 by MaxMind, Inc.
This is free software, licensed under the Apache License, Version 2.0.
+32
View File
@@ -0,0 +1,32 @@
{
"name": "geoip2/geoip2",
"description": "MaxMind GeoIP2 PHP API",
"keywords": ["geoip", "geoip2", "geolocation", "ip", "maxmind"],
"homepage": "https://github.com/maxmind/GeoIP2-php",
"type": "library",
"license": "Apache-2.0",
"authors": [
{
"name": "Gregory J. Oschwald",
"email": "goschwald@maxmind.com",
"homepage": "https://www.maxmind.com/"
}
],
"require": {
"maxmind-db/reader": "~1.8",
"maxmind/web-service-common": "~0.8",
"php": ">=7.2",
"ext-json": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.*",
"phpunit/phpunit": "^8.0 || ^9.0",
"squizlabs/php_codesniffer": "3.*",
"phpstan/phpstan": "*"
},
"autoload": {
"psr-4": {
"GeoIp2\\": "src"
}
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
require __DIR__ . '/../vendor/autoload.php';
use GeoIp2\Database\Reader;
srand(0);
$reader = new Reader('GeoIP2-City.mmdb');
$count = 500000;
$startTime = microtime(true);
for ($i = 0; $i < $count; ++$i) {
$ip = long2ip(rand(0, 2 ** 32 - 1));
try {
$t = $reader->city($ip);
} catch (\GeoIp2\Exception\AddressNotFoundException $e) {
}
if ($i % 10000 === 0) {
echo $i . ' ' . $ip . "\n";
}
}
$endTime = microtime(true);
$duration = $endTime - $startTime;
echo 'Requests per second: ' . $count / $duration . "\n";
+299
View File
@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Database;
use GeoIp2\Exception\AddressNotFoundException;
use GeoIp2\Model\AbstractModel;
use GeoIp2\Model\AnonymousIp;
use GeoIp2\Model\Asn;
use GeoIp2\Model\City;
use GeoIp2\Model\ConnectionType;
use GeoIp2\Model\Country;
use GeoIp2\Model\Domain;
use GeoIp2\Model\Enterprise;
use GeoIp2\Model\Isp;
use GeoIp2\ProviderInterface;
use MaxMind\Db\Reader as DbReader;
use MaxMind\Db\Reader\InvalidDatabaseException;
/**
* Instances of this class provide a reader for the GeoIP2 database format.
* IP addresses can be looked up using the database specific methods.
*
* ## Usage ##
*
* The basic API for this class is the same for every database. First, you
* create a reader object, specifying a file name. You then call the method
* corresponding to the specific database, passing it the IP address you want
* to look up.
*
* If the request succeeds, the method call will return a model class for
* the method you called. This model in turn contains multiple record classes,
* each of which represents part of the data returned by the database. If
* the database does not contain the requested information, the attributes
* on the record class will have a `null` value.
*
* If the address is not in the database, an
* {@link \GeoIp2\Exception\AddressNotFoundException} exception will be
* thrown. If an invalid IP address is passed to one of the methods, a
* SPL {@link \InvalidArgumentException} will be thrown. If the database is
* corrupt or invalid, a {@link \MaxMind\Db\Reader\InvalidDatabaseException}
* will be thrown.
*/
class Reader implements ProviderInterface
{
/**
* @var DbReader
*/
private $dbReader;
/**
* @var string
*/
private $dbType;
/**
* @var array<string>
*/
private $locales;
/**
* Constructor.
*
* @param string $filename the path to the GeoIP2 database file
* @param array $locales list of locale codes to use in name property
* from most preferred to least preferred
*
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function __construct(
string $filename,
array $locales = ['en']
) {
$this->dbReader = new DbReader($filename);
$this->dbType = $this->dbReader->metadata()->databaseType;
$this->locales = $locales;
}
/**
* This method returns a GeoIP2 City model.
*
* @param string $ipAddress an IPv4 or IPv6 address as a string
*
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
* not in the database
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function city(string $ipAddress): City
{
// @phpstan-ignore-next-line
return $this->modelFor(City::class, 'City', $ipAddress);
}
/**
* This method returns a GeoIP2 Country model.
*
* @param string $ipAddress an IPv4 or IPv6 address as a string
*
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
* not in the database
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function country(string $ipAddress): Country
{
// @phpstan-ignore-next-line
return $this->modelFor(Country::class, 'Country', $ipAddress);
}
/**
* This method returns a GeoIP2 Anonymous IP model.
*
* @param string $ipAddress an IPv4 or IPv6 address as a string
*
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
* not in the database
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function anonymousIp(string $ipAddress): AnonymousIp
{
// @phpstan-ignore-next-line
return $this->flatModelFor(
AnonymousIp::class,
'GeoIP2-Anonymous-IP',
$ipAddress
);
}
/**
* This method returns a GeoLite2 ASN model.
*
* @param string $ipAddress an IPv4 or IPv6 address as a string
*
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
* not in the database
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function asn(string $ipAddress): Asn
{
// @phpstan-ignore-next-line
return $this->flatModelFor(
Asn::class,
'GeoLite2-ASN',
$ipAddress
);
}
/**
* This method returns a GeoIP2 Connection Type model.
*
* @param string $ipAddress an IPv4 or IPv6 address as a string
*
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
* not in the database
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function connectionType(string $ipAddress): ConnectionType
{
// @phpstan-ignore-next-line
return $this->flatModelFor(
ConnectionType::class,
'GeoIP2-Connection-Type',
$ipAddress
);
}
/**
* This method returns a GeoIP2 Domain model.
*
* @param string $ipAddress an IPv4 or IPv6 address as a string
*
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
* not in the database
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function domain(string $ipAddress): Domain
{
// @phpstan-ignore-next-line
return $this->flatModelFor(
Domain::class,
'GeoIP2-Domain',
$ipAddress
);
}
/**
* This method returns a GeoIP2 Enterprise model.
*
* @param string $ipAddress an IPv4 or IPv6 address as a string
*
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
* not in the database
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function enterprise(string $ipAddress): Enterprise
{
// @phpstan-ignore-next-line
return $this->modelFor(Enterprise::class, 'Enterprise', $ipAddress);
}
/**
* This method returns a GeoIP2 ISP model.
*
* @param string $ipAddress an IPv4 or IPv6 address as a string
*
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
* not in the database
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
* is corrupt or invalid
*/
public function isp(string $ipAddress): Isp
{
// @phpstan-ignore-next-line
return $this->flatModelFor(
Isp::class,
'GeoIP2-ISP',
$ipAddress
);
}
private function modelFor(string $class, string $type, string $ipAddress): AbstractModel
{
[$record, $prefixLen] = $this->getRecord($class, $type, $ipAddress);
$record['traits']['ip_address'] = $ipAddress;
$record['traits']['prefix_len'] = $prefixLen;
return new $class($record, $this->locales);
}
private function flatModelFor(string $class, string $type, string $ipAddress): AbstractModel
{
[$record, $prefixLen] = $this->getRecord($class, $type, $ipAddress);
$record['ip_address'] = $ipAddress;
$record['prefix_len'] = $prefixLen;
return new $class($record);
}
private function getRecord(string $class, string $type, string $ipAddress): array
{
if (strpos($this->dbType, $type) === false) {
$method = lcfirst((new \ReflectionClass($class))->getShortName());
throw new \BadMethodCallException(
"The $method method cannot be used to open a {$this->dbType} database"
);
}
[$record, $prefixLen] = $this->dbReader->getWithPrefixLen($ipAddress);
if ($record === null) {
throw new AddressNotFoundException(
"The address $ipAddress is not in the database."
);
}
if (!\is_array($record)) {
// This can happen on corrupt databases. Generally,
// MaxMind\Db\Reader will throw a
// MaxMind\Db\Reader\InvalidDatabaseException, but occasionally
// the lookup may result in a record that looks valid but is not
// an array. This mostly happens when the user is ignoring all
// exceptions and the more frequent InvalidDatabaseException
// exceptions go unnoticed.
throw new InvalidDatabaseException(
"Expected an array when looking up $ipAddress but received: "
. \gettype($record)
);
}
return [$record, $prefixLen];
}
/**
* @throws \InvalidArgumentException if arguments are passed to the method
* @throws \BadMethodCallException if the database has been closed
*
* @return \MaxMind\Db\Reader\Metadata object for the database
*/
public function metadata(): DbReader\Metadata
{
return $this->dbReader->metadata();
}
/**
* Closes the GeoIP2 database and returns the resources to the system.
*/
public function close(): void
{
$this->dbReader->close();
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class AddressNotFoundException extends GeoIp2Exception
{
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class AuthenticationException extends GeoIp2Exception
{
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class GeoIp2Exception extends \Exception
{
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents an HTTP transport error.
*/
class HttpException extends GeoIp2Exception
{
/**
* The URI queried.
*
* @var string
*/
public $uri;
public function __construct(
string $message,
int $httpStatus,
string $uri,
\Exception $previous = null
) {
$this->uri = $uri;
parent::__construct($message, $httpStatus, $previous);
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents an error returned by MaxMind's GeoIP2
* web service.
*/
class InvalidRequestException extends HttpException
{
/**
* The code returned by the MaxMind web service.
*
* @var string
*/
public $error;
public function __construct(
string $message,
string $error,
int $httpStatus,
string $uri,
\Exception $previous = null
) {
$this->error = $error;
parent::__construct($message, $httpStatus, $uri, $previous);
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class OutOfQueriesException extends GeoIp2Exception
{
}
+68
View File
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* @ignore
*/
abstract class AbstractModel implements \JsonSerializable
{
/**
* @var array<string, mixed>
*/
protected $raw;
/**
* @ignore
*/
public function __construct(array $raw)
{
$this->raw = $raw;
}
/**
* @ignore
*
* @return mixed
*/
protected function get(string $field)
{
if (isset($this->raw[$field])) {
return $this->raw[$field];
}
if (preg_match('/^is_/', $field)) {
return false;
}
return null;
}
/**
* @ignore
*
* @return mixed
*/
public function __get(string $attr)
{
if ($attr !== 'instance' && property_exists($this, $attr)) {
return $this->{$attr};
}
throw new \RuntimeException("Unknown attribute: $attr");
}
/**
* @ignore
*/
public function __isset(string $attr): bool
{
return $attr !== 'instance' && isset($this->{$attr});
}
public function jsonSerialize(): array
{
return $this->raw;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoIP2 Anonymous IP model.
*
* @property-read bool $isAnonymous This is true if the IP address belongs to
* any sort of anonymous network.
* @property-read bool $isAnonymousVpn This is true if the IP address is
* registered to an anonymous VPN provider. If a VPN provider does not
* register subnets under names associated with them, we will likely only
* flag their IP ranges using the isHostingProvider property.
* @property-read bool $isHostingProvider This is true if the IP address belongs
* to a hosting or VPN provider (see description of isAnonymousVpn property).
* @property-read bool $isPublicProxy This is true if the IP address belongs to
* a public proxy.
* @property-read bool $isResidentialProxy This is true if the IP address is
* on a suspected anonymizing network and belongs to a residential ISP.
* @property-read bool $isTorExitNode This is true if the IP address is a Tor
* exit node.
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class AnonymousIp extends AbstractModel
{
/**
* @var bool
*/
protected $isAnonymous;
/**
* @var bool
*/
protected $isAnonymousVpn;
/**
* @var bool
*/
protected $isHostingProvider;
/**
* @var bool
*/
protected $isPublicProxy;
/**
* @var bool
*/
protected $isResidentialProxy;
/**
* @var bool
*/
protected $isTorExitNode;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->isAnonymous = $this->get('is_anonymous');
$this->isAnonymousVpn = $this->get('is_anonymous_vpn');
$this->isHostingProvider = $this->get('is_hosting_provider');
$this->isPublicProxy = $this->get('is_public_proxy');
$this->isResidentialProxy = $this->get('is_residential_proxy');
$this->isTorExitNode = $this->get('is_tor_exit_node');
$ipAddress = $this->get('ip_address');
$this->ipAddress = $ipAddress;
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoLite2 ASN model.
*
* @property-read int|null $autonomousSystemNumber The autonomous system number
* associated with the IP address.
* @property-read string|null $autonomousSystemOrganization The organization
* associated with the registered autonomous system number for the IP
* address.
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class Asn extends AbstractModel
{
/**
* @var int|null
*/
protected $autonomousSystemNumber;
/**
* @var string|null
*/
protected $autonomousSystemOrganization;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->autonomousSystemNumber = $this->get('autonomous_system_number');
$this->autonomousSystemOrganization =
$this->get('autonomous_system_organization');
$ipAddress = $this->get('ip_address');
$this->ipAddress = $ipAddress;
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* Model class for the data returned by City Plus web service and City
* database.
*
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more
* details.
*
* @property-read \GeoIp2\Record\City $city City data for the requested IP
* address.
* @property-read \GeoIp2\Record\Location $location Location data for the
* requested IP address.
* @property-read \GeoIp2\Record\Postal $postal Postal data for the
* requested IP address.
* @property-read array $subdivisions An array \GeoIp2\Record\Subdivision
* objects representing the country subdivisions for the requested IP
* address. The number and type of subdivisions varies by country, but a
* subdivision is typically a state, province, county, etc. Subdivisions
* are ordered from most general (largest) to most specific (smallest).
* If the response did not contain any subdivisions, this method returns
* an empty array.
* @property-read \GeoIp2\Record\Subdivision $mostSpecificSubdivision An object
* representing the most specific subdivision returned. If the response
* did not contain any subdivisions, this method returns an empty
* \GeoIp2\Record\Subdivision object.
*/
class City extends Country
{
/**
* @ignore
*
* @var \GeoIp2\Record\City
*/
protected $city;
/**
* @ignore
*
* @var \GeoIp2\Record\Location
*/
protected $location;
/**
* @ignore
*
* @var \GeoIp2\Record\Postal
*/
protected $postal;
/**
* @ignore
*
* @var array<\GeoIp2\Record\Subdivision>
*/
protected $subdivisions = [];
/**
* @ignore
*/
public function __construct(array $raw, array $locales = ['en'])
{
parent::__construct($raw, $locales);
$this->city = new \GeoIp2\Record\City($this->get('city'), $locales);
$this->location = new \GeoIp2\Record\Location($this->get('location'));
$this->postal = new \GeoIp2\Record\Postal($this->get('postal'));
$this->createSubdivisions($raw, $locales);
}
private function createSubdivisions(array $raw, array $locales): void
{
if (!isset($raw['subdivisions'])) {
return;
}
foreach ($raw['subdivisions'] as $sub) {
$this->subdivisions[] =
new \GeoIp2\Record\Subdivision($sub, $locales)
;
}
}
/**
* @ignore
*
* @return mixed
*/
public function __get(string $attr)
{
if ($attr === 'mostSpecificSubdivision') {
return $this->{$attr}();
}
return parent::__get($attr);
}
/**
* @ignore
*/
public function __isset(string $attr): bool
{
if ($attr === 'mostSpecificSubdivision') {
// We always return a mostSpecificSubdivision, even if it is the
// empty subdivision
return true;
}
return parent::__isset($attr);
}
private function mostSpecificSubdivision(): \GeoIp2\Record\Subdivision
{
return empty($this->subdivisions) ?
new \GeoIp2\Record\Subdivision([], $this->locales) :
end($this->subdivisions);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoIP2 Connection-Type model.
*
* @property-read string|null $connectionType The connection type may take the
* following values: "Dialup", "Cable/DSL", "Corporate", "Cellular".
* Additional values may be added in the future.
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class ConnectionType extends AbstractModel
{
/**
* @var string|null
*/
protected $connectionType;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->connectionType = $this->get('connection_type');
$ipAddress = $this->get('ip_address');
$this->ipAddress = $ipAddress;
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* Model class for the data returned by GeoIP2 Country web service and database.
*
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more details.
*
* @property-read \GeoIp2\Record\Continent $continent Continent data for the
* requested IP address.
* @property-read \GeoIp2\Record\Country $country Country data for the requested
* IP address. This object represents the country where MaxMind believes the
* end user is located.
* @property-read \GeoIp2\Record\MaxMind $maxmind Data related to your MaxMind
* account.
* @property-read \GeoIp2\Record\Country $registeredCountry Registered country
* data for the requested IP address. This record represents the country
* where the ISP has registered a given IP block and may differ from the
* user's country.
* @property-read \GeoIp2\Record\RepresentedCountry $representedCountry
* Represented country data for the requested IP address. The represented
* country is used for things like military bases. It is only present when
* the represented country differs from the country.
* @property-read \GeoIp2\Record\Traits $traits Data for the traits of the
* requested IP address.
* @property-read array $raw The raw data from the web service.
*/
class Country extends AbstractModel
{
/**
* @var \GeoIp2\Record\Continent
*/
protected $continent;
/**
* @var \GeoIp2\Record\Country
*/
protected $country;
/**
* @var array<string>
*/
protected $locales;
/**
* @var \GeoIp2\Record\MaxMind
*/
protected $maxmind;
/**
* @var \GeoIp2\Record\Country
*/
protected $registeredCountry;
/**
* @var \GeoIp2\Record\RepresentedCountry
*/
protected $representedCountry;
/**
* @var \GeoIp2\Record\Traits
*/
protected $traits;
/**
* @ignore
*/
public function __construct(array $raw, array $locales = ['en'])
{
parent::__construct($raw);
$this->continent = new \GeoIp2\Record\Continent(
$this->get('continent'),
$locales
);
$this->country = new \GeoIp2\Record\Country(
$this->get('country'),
$locales
);
$this->maxmind = new \GeoIp2\Record\MaxMind($this->get('maxmind'));
$this->registeredCountry = new \GeoIp2\Record\Country(
$this->get('registered_country'),
$locales
);
$this->representedCountry = new \GeoIp2\Record\RepresentedCountry(
$this->get('represented_country'),
$locales
);
$this->traits = new \GeoIp2\Record\Traits($this->get('traits'));
$this->locales = $locales;
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoIP2 Domain model.
*
* @property-read string|null $domain The second level domain associated with the
* IP address. This will be something like "example.com" or
* "example.co.uk", not "foo.example.com".
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class Domain extends AbstractModel
{
/**
* @var string|null
*/
protected $domain;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->domain = $this->get('domain');
$ipAddress = $this->get('ip_address');
$this->ipAddress = $ipAddress;
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* Model class for the data returned by GeoIP2 Enterprise database lookups.
*
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more
* details.
*/
class Enterprise extends City
{
}
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
/**
* Model class for the data returned by GeoIP2 Insights web service.
*
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for
* more details.
*/
class Insights extends City
{
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Model;
use GeoIp2\Util;
/**
* This class provides the GeoIP2 ISP model.
*
* @property-read int|null $autonomousSystemNumber The autonomous system number
* associated with the IP address.
* @property-read string|null $autonomousSystemOrganization The organization
* associated with the registered autonomous system number for the IP
* address.
* @property-read string|null $isp The name of the ISP associated with the IP
* address.
* @property-read string|null $mobileCountryCode The [mobile country code
* (MCC)](https://en.wikipedia.org/wiki/Mobile_country_code) associated with
* the IP address and ISP.
* @property-read string|null $mobileNetworkCode The [mobile network code
* (MNC)](https://en.wikipedia.org/wiki/Mobile_country_code) associated with
* the IP address and ISP.
* @property-read string|null $organization The name of the organization associated
* with the IP address.
* @property-read string $ipAddress The IP address that the data in the model is
* for.
* @property-read string $network The network in CIDR notation associated with
* the record. In particular, this is the largest network where all of the
* fields besides $ipAddress have the same value.
*/
class Isp extends AbstractModel
{
/**
* @var int|null
*/
protected $autonomousSystemNumber;
/**
* @var string|null
*/
protected $autonomousSystemOrganization;
/**
* @var string|null
*/
protected $isp;
/**
* @var string|null
*/
protected $mobileCountryCode;
/**
* @var string|null
*/
protected $mobileNetworkCode;
/**
* @var string|null
*/
protected $organization;
/**
* @var string
*/
protected $ipAddress;
/**
* @var string
*/
protected $network;
/**
* @ignore
*/
public function __construct(array $raw)
{
parent::__construct($raw);
$this->autonomousSystemNumber = $this->get('autonomous_system_number');
$this->autonomousSystemOrganization =
$this->get('autonomous_system_organization');
$this->isp = $this->get('isp');
$this->mobileCountryCode = $this->get('mobile_country_code');
$this->mobileNetworkCode = $this->get('mobile_network_code');
$this->organization = $this->get('organization');
$ipAddress = $this->get('ip_address');
$this->ipAddress = $ipAddress;
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace GeoIp2;
interface ProviderInterface
{
/**
* @param string $ipAddress an IPv4 or IPv6 address to lookup
*
* @return \GeoIp2\Model\Country a Country model for the requested IP address
*/
public function country(string $ipAddress): Model\Country;
/**
* @param string $ipAddress an IPv4 or IPv6 address to lookup
*
* @return \GeoIp2\Model\City a City model for the requested IP address
*/
public function city(string $ipAddress): Model\City;
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
abstract class AbstractPlaceRecord extends AbstractRecord
{
/**
* @var array<string>
*/
private $locales;
/**
* @ignore
*/
public function __construct(?array $record, array $locales = ['en'])
{
$this->locales = $locales;
parent::__construct($record);
}
/**
* @ignore
*
* @return mixed
*/
public function __get(string $attr)
{
if ($attr === 'name') {
return $this->name();
}
return parent::__get($attr);
}
/**
* @ignore
*/
public function __isset(string $attr): bool
{
if ($attr === 'name') {
return $this->firstSetNameLocale() !== null;
}
return parent::__isset($attr);
}
private function name(): ?string
{
$locale = $this->firstSetNameLocale();
// @phpstan-ignore-next-line
return $locale === null ? null : $this->names[$locale];
}
private function firstSetNameLocale(): ?string
{
foreach ($this->locales as $locale) {
if (isset($this->names[$locale])) {
return $locale;
}
}
return null;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
abstract class AbstractRecord implements \JsonSerializable
{
/**
* @var array<string, mixed>
*/
private $record;
/**
* @ignore
*/
public function __construct(?array $record)
{
$this->record = isset($record) ? $record : [];
}
/**
* @ignore
*
* @return mixed
*/
public function __get(string $attr)
{
// XXX - kind of ugly but greatly reduces boilerplate code
$key = $this->attributeToKey($attr);
if ($this->__isset($attr)) {
return $this->record[$key];
}
if ($this->validAttribute($attr)) {
if (preg_match('/^is_/', $key)) {
return false;
}
return null;
}
throw new \RuntimeException("Unknown attribute: $attr");
}
public function __isset(string $attr): bool
{
return $this->validAttribute($attr)
&& isset($this->record[$this->attributeToKey($attr)]);
}
private function attributeToKey(string $attr): string
{
return strtolower(preg_replace('/([A-Z])/', '_\1', $attr));
}
private function validAttribute(string $attr): bool
{
// @phpstan-ignore-next-line
return \in_array($attr, $this->validAttributes, true);
}
public function jsonSerialize(): ?array
{
return $this->record;
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* City-level data associated with an IP address.
*
* This record is returned by all location services and databases besides
* Country.
*
* @property-read int|null $confidence A value from 0-100 indicating MaxMind's
* confidence that the city is correct. This attribute is only available
* from the Insights service and the GeoIP2 Enterprise database.
* @property-read int|null $geonameId The GeoName ID for the city. This attribute
* is returned by all location services and databases.
* @property-read string|null $name The name of the city based on the locales list
* passed to the constructor. This attribute is returned by all location
* services and databases.
* @property-read array|null $names An array map where the keys are locale codes
* and the values are names. This attribute is returned by all location
* services and databases.
*/
class City extends AbstractPlaceRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = ['confidence', 'geonameId', 'names'];
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data for the continent record associated with an IP address.
*
* This record is returned by all location services and databases.
*
* @property-read string|null $code A two character continent code like "NA" (North
* America) or "OC" (Oceania). This attribute is returned by all location
* services and databases.
* @property-read int|null $geonameId The GeoName ID for the continent. This
* attribute is returned by all location services and databases.
* @property-read string|null $name Returns the name of the continent based on the
* locales list passed to the constructor. This attribute is returned by all location
* services and databases.
* @property-read array|null $names An array map where the keys are locale codes
* and the values are names. This attribute is returned by all location
* services and databases.
*/
class Continent extends AbstractPlaceRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = [
'code',
'geonameId',
'names',
];
}
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Record;
/**
* Contains data for the country record associated with an IP address.
*
* This record is returned by all location services and databases.
*
* @property-read int|null $confidence A value from 0-100 indicating MaxMind's
* confidence that the country is correct. This attribute is only available
* from the Insights service and the GeoIP2 Enterprise database.
* @property-read int|null $geonameId The GeoName ID for the country. This
* attribute is returned by all location services and databases.
* @property-read bool $isInEuropeanUnion This is true if the country is a
* member state of the European Union. This attribute is returned by all
* location services and databases.
* @property-read string|null $isoCode The two-character ISO 3166-1 alpha code
* for the country. See https://en.wikipedia.org/wiki/ISO_3166-1. This
* attribute is returned by all location services and databases.
* @property-read string|null $name The name of the country based on the locales
* list passed to the constructor. This attribute is returned by all location
* services and databases.
* @property-read array|null $names An array map where the keys are locale codes
* and the values are names. This attribute is returned by all location
* services and databases.
*/
class Country extends AbstractPlaceRecord
{
/**
* @ignore
*
* @var array<string>
*/
protected $validAttributes = [
'confidence',
'geonameId',
'isInEuropeanUnion',
'isoCode',
'names',
];
}

Some files were not shown because too many files have changed in this diff Show More