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:
@@ -20,6 +20,9 @@ admin/storage/cache/
|
||||
admin/storage/geoip/
|
||||
admin/storage/stats.json
|
||||
|
||||
# Runtime-compiled theme assets
|
||||
public/themes/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)): ?>
|
||||
|
||||
@@ -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)): ?>
|
||||
|
||||
@@ -1,42 +1,72 @@
|
||||
<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 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 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 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 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: 500px; overflow-y: auto;">
|
||||
<?php if (empty($adminLogs)): ?>
|
||||
<p class="text-muted p-3 mb-0">Geen activiteiten geregistreerd.</p>
|
||||
<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 ($adminLogs as $log): ?>
|
||||
<?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>
|
||||
@@ -47,71 +77,3 @@
|
||||
<?php endif; ?>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
+61
-240
@@ -1,283 +1,104 @@
|
||||
<?php if ($editTheme): ?>
|
||||
|
||||
<!-- 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
|
||||
<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>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<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="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>
|
||||
|
||||
<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 class="d-flex align-items-start">
|
||||
<i class="bi bi-folder2-open text-primary me-2 mt-1"></i>
|
||||
<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>
|
||||
<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>
|
||||
<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="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 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>
|
||||
|
||||
<?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 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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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 · 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">
|
||||
<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-outline-success btn-sm">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success" title="Activeren">
|
||||
<i class="bi bi-check-circle"></i> Activeren
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php if ($themeName !== 'default'): ?>
|
||||
<form method="POST" action="/admin/theme">
|
||||
<form method="POST" action="/admin/theme-delete?theme=<?= urlencode($themeName) ?>" class="d-inline" onsubmit="return confirm('Weet je zeker dat je thema '<?= htmlspecialchars($themeData['title'] ?? $themeName) ?>' wilt verwijderen? Alle bestanden worden permanent verwijderd.')">
|
||||
<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 '<?= htmlspecialchars($themeData['name'] ?? $themeName) ?>' wilt verwijderen?')">
|
||||
<i class="bi bi-trash"></i> Verwijderen
|
||||
<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;
|
||||
<style>
|
||||
.theme-card {
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
});
|
||||
});
|
||||
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>
|
||||
.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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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'
|
||||
];
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="html-content">
|
||||
<article class="content-body" role="main">
|
||||
{{{content}}}
|
||||
</article>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="markdown-content">
|
||||
<article class="content-body" role="main">
|
||||
{{{content}}}
|
||||
</article>
|
||||
</div>
|
||||
@@ -1,5 +0,0 @@
|
||||
<div class="php-content">
|
||||
<article class="content-body" role="main">
|
||||
{{{content}}}
|
||||
</article>
|
||||
</div>
|
||||
+3
-1
@@ -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
File diff suppressed because it is too large
Load Diff
+25
-3
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# test
|
||||
|
||||
Dit is een test plugin
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"enabled": true
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+224
-144
@@ -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,116 +1051,20 @@ 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') {
|
||||
if ($auth->verifyCsrf($_POST['csrf_token'] ?? '')) {
|
||||
$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')) {
|
||||
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';
|
||||
}
|
||||
} 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 |
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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 %}
|
||||
@@ -7,16 +7,16 @@
|
||||
<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}}
|
||||
{% if file_info_block %}
|
||||
<span class="ms-2">
|
||||
{{#show_created}}
|
||||
{% 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>
|
||||
{{/show_created}}
|
||||
{% 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">
|
||||
@@ -25,9 +25,9 @@
|
||||
<i class="bi bi-book"></i>
|
||||
</a>
|
||||
<span class="ms-1">|</span>
|
||||
{{#cms_version}}
|
||||
{% if cms_version %}
|
||||
<span class="ms-1 cms-version text-muted">{{ cms_version }}</span>
|
||||
{{/cms_version}}
|
||||
{% 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>
|
||||
@@ -25,13 +25,13 @@
|
||||
{{ 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>
|
||||
@@ -45,13 +45,13 @@
|
||||
{{ 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>
|
||||
@@ -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}}">
|
||||
<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>
|
||||
@@ -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
@@ -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 |
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 |
Executable
+47
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+6
@@ -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',
|
||||
);
|
||||
|
||||
Vendored
+13
@@ -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'),
|
||||
);
|
||||
|
||||
Vendored
+82
@@ -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 (
|
||||
|
||||
Vendored
+19
@@ -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.
|
||||
Vendored
+85
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
+2910
File diff suppressed because it is too large
Load Diff
+322
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+1027
File diff suppressed because it is too large
Load Diff
Vendored
+128
-2
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Vendored
+2
-2
@@ -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) {
|
||||
|
||||
Vendored
+323
@@ -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.
|
||||
Vendored
+202
@@ -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.
|
||||
Vendored
+442
@@ -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.
|
||||
Vendored
+32
@@ -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
@@ -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
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Exception;
|
||||
|
||||
/**
|
||||
* This class represents a generic error.
|
||||
*/
|
||||
class GeoIp2Exception extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user