8 Commits
Author SHA1 Message Date
Johnnybegood90 3d0b4796b2 Update version 2026-03-14 03:13:28 +01:00
Johnnybegood90 de1f5a336c Version update. 2026-03-14 03:13:11 +01:00
Johnnybegood90 4f0e1f55ba Merge remote-tracking branch 'origin/beta'
Harden Docker packaging and bundle frontend assets locally

- replace fragile Docker file bind mount with persistent /data storage
- add container entrypoint to link config.json from persistent storage
- tighten Docker image permissions and add a basic healthcheck
- add .dockerignore to keep git metadata and runtime files out of the image
- bundle hls.js locally instead of loading it from a CDN
- download and serve UI/theme fonts locally instead of Google Fonts
- update service worker cache entries for local assets
- refresh README to reflect local bundled assets and cleaner deployment docs
2026-03-14 03:11:11 +01:00
Johnnybegood90 8ba8c3013e Harden Docker packaging and bundle frontend assets locally
- replace fragile Docker file bind mount with persistent /data storage
- add container entrypoint to link config.json from persistent storage
- tighten Docker image permissions and add a basic healthcheck
- add .dockerignore to keep git metadata and runtime files out of the image
- bundle hls.js locally instead of loading it from a CDN
- download and serve UI/theme fonts locally instead of Google Fonts
- update service worker cache entries for local assets
- refresh README to reflect local bundled assets and cleaner deployment docs
2026-03-14 03:10:21 +01:00
Johnnybegood90 9609f096ef Updating Docker Compose & README.md 2026-03-14 02:58:05 +01:00
Johnnybegood90 8c81c05bca Updating Docker Compose & README.md 2026-03-14 02:57:13 +01:00
Johnnybegood90 ff998d379d security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:44:48 +01:00
Johnnybegood90 62732ab2c6 security: harden proxy.php with config whitelist, fix XSS via EPG innerHTML 2026-03-14 02:43:11 +01:00
29 changed files with 304 additions and 88 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
.gitignore
.DS_Store
*.swp
data
assets/preview.png
assets/.DS_Store
src/.DS_Store
.version.json.swp
+1
View File
@@ -1,2 +1,3 @@
config.json config.json
data/
.DS_Store .DS_Store
+12 -2
View File
@@ -4,8 +4,18 @@ RUN docker-php-ext-install curl && \
a2enmod rewrite a2enmod rewrite
COPY . /var/www/html/ COPY . /var/www/html/
COPY docker/docker-entrypoint.sh /usr/local/bin/gridtv-entrypoint.sh
RUN chown -R www-data:www-data /var/www/html && \ RUN mkdir -p /data && \
chmod -R 775 /var/www/html chown -R www-data:www-data /var/www/html /data && \
find /var/www/html -type d -exec chmod 755 {} \; && \
find /var/www/html -type f -exec chmod 644 {} \; && \
chmod 755 /usr/local/bin/gridtv-entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD php -r "exit(@file_get_contents('http://127.0.0.1/') === false ? 1 : 0);"
ENTRYPOINT ["gridtv-entrypoint.sh"]
CMD ["apache2-foreground"]
EXPOSE 80 EXPOSE 80
+72 -11
View File
@@ -45,7 +45,7 @@ This demo runs with sample XMLTV feeds to showcase the interface.
- ⚙️ **Re-editable setup** — protected by an admin key, no SSH required to update config - ⚙️ **Re-editable setup** — protected by an admin key, no SSH required to update config
- 🔔 **Update notifications** — a badge appears in the topbar when a new release is available on GitHub - 🔔 **Update notifications** — a badge appears in the topbar when a new release is available on GitHub
- 🔄 **Auto-reload** EPG every 30 minutes - 🔄 **Auto-reload** EPG every 30 minutes
- 0️⃣ **Zero JS dependencies** — vanilla PHP, hls.js loaded from CDN - 0️⃣ **Zero build tooling** — vanilla PHP/JS/CSS, with bundled local assets
--- ---
@@ -70,15 +70,19 @@ docker compose up -d
Then open `http://localhost:8080` and follow the setup wizard. Then open `http://localhost:8080` and follow the setup wizard.
Docker stores the generated configuration in `./data/config.json` on the host.
To run on a custom port: To run on a custom port:
```bash ```bash
PORT=9000 docker compose up -d PORT=9000 docker compose up -d
``` ```
To back up or migrate your Docker setup, keep the `data/` directory.
--- ---
### Option B — Apache / Nginx ### Option B — Native PHP host
#### 1. Clone the repo #### 1. Clone the repo
@@ -94,7 +98,25 @@ chmod 775 /var/www/gridtv
chown -R www-data:www-data /var/www/gridtv chown -R www-data:www-data /var/www/gridtv
``` ```
#### 3a. Configure Nginx #### 3. Install php-curl
The built-in player uses `proxy.php` to relay HTTP streams over HTTPS. This requires the **php-curl** extension:
```bash
# Debian/Ubuntu — adjust version to match your PHP
apt install php8.4-curl
systemctl reload apache2 # or: systemctl reload nginx
```
#### 4. Reverse proxy examples
<details>
<summary>Show reverse proxy / vhost examples</summary>
These examples are intentionally minimal. Replace `guide.your-domain.com` with your domain and adjust the PHP socket or upstream target to match your host.
<details>
<summary>Nginx + PHP-FPM</summary>
```nginx ```nginx
server { server {
@@ -115,13 +137,16 @@ server {
} }
``` ```
> 💡 Adjust the PHP version (`php8.2-fpm`) to match the one installed on your server.
```bash ```bash
nginx -t && systemctl reload nginx nginx -t && systemctl reload nginx
``` ```
#### 3b. Configure Apache > Adjust `php8.2-fpm.sock` to match the PHP-FPM version installed on your server.
</details>
<details>
<summary>Apache vhost</summary>
```apache ```apache
<VirtualHost *:80> <VirtualHost *:80>
@@ -140,16 +165,52 @@ a2enmod php8.4 rewrite
systemctl reload apache2 systemctl reload apache2
``` ```
#### 4. Install php-curl </details>
The built-in player uses `proxy.php` to relay HTTP streams over HTTPS. This requires the **php-curl** extension: <details>
<summary>Caddy</summary>
```caddy
guide.your-domain.com {
root * /var/www/gridtv
php_fastcgi unix//run/php/php8.2-fpm.sock
file_server
}
```
```bash ```bash
# Debian/Ubuntu — adjust version to match your PHP systemctl reload caddy
apt install php8.4-curl
systemctl reload apache2 # or: systemctl reload nginx
``` ```
> If you use `xcaddy` or a distro package, keep the same site block and only adapt the PHP-FPM socket path.
</details>
<details>
<summary>Traefik (Docker labels)</summary>
Use this if GridTV runs in Docker and Traefik is your front proxy:
```yaml
services:
gridtv:
build: .
volumes:
- ./data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.gridtv.rule=Host(`guide.your-domain.com`)"
- "traefik.http.routers.gridtv.entrypoints=websecure"
- "traefik.http.routers.gridtv.tls=true"
- "traefik.http.services.gridtv.loadbalancer.server.port=80"
```
You still need a running Traefik instance with `websecure` configured and DNS pointing to it.
</details>
</details>
#### 5. First launch — Setup #### 5. First launch — Setup
Open your browser at `http://guide.your-domain.com`. Open your browser at `http://guide.your-domain.com`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+87
View File
@@ -0,0 +1,87 @@
@font-face {
font-family: 'Barlow Condensed';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/assets/fonts/barlow-condensed-300.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow Condensed';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/barlow-condensed-400.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow Condensed';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/assets/fonts/barlow-condensed-600.ttf') format('truetype');
}
@font-face {
font-family: 'Barlow Condensed';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/barlow-condensed-700.ttf') format('truetype');
}
@font-face {
font-family: 'Share Tech Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/share-tech-mono-400.ttf') format('truetype');
}
@font-face {
font-family: 'Rajdhani';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/rajdhani-400.ttf') format('truetype');
}
@font-face {
font-family: 'Rajdhani';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/assets/fonts/rajdhani-600.ttf') format('truetype');
}
@font-face {
font-family: 'Rajdhani';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/assets/fonts/rajdhani-700.ttf') format('truetype');
}
@font-face {
font-family: 'IM Fell English';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/im-fell-english-400.ttf') format('truetype');
}
@font-face {
font-family: 'IM Fell English';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/im-fell-english-400-italic.ttf') format('truetype');
}
@font-face {
font-family: 'Special Elite';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/assets/fonts/special-elite-400.ttf') format('truetype');
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,5 +4,5 @@ services:
ports: ports:
- "${PORT:-8080}:80" - "${PORT:-8080}:80"
volumes: volumes:
- ./config.json:/var/www/html/config.json - ./data:/data
restart: unless-stopped restart: unless-stopped
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
mkdir -p /data
chown -R www-data:www-data /data
chmod 775 /data
rm -f /var/www/html/config.json
ln -s /data/config.json /var/www/html/config.json
exec docker-php-entrypoint "$@"
+101 -58
View File
@@ -1,80 +1,129 @@
<?php <?php
/** /**
* GridTV — proxy.php * GridTV — proxy.php
* Proxy HTTP->HTTPS restreint aux hotes autorises dans config.json *
* Proxy HTTP->HTTPS restreint aux hotes explicitement configures dans config.json.
* Les IP privees et LAN sont autorisees si l'administrateur les a configurees.
* Les redirections sont suivies manuellement avec revalidation de l'hote a chaque saut.
*/ */
// ── Charger la whitelist depuis config.json ──────────────────────────────────── // ── Whitelist : hotes autorises extraits de config.json ───────────────────────
$config_path = __DIR__ . '/config.json'; $config_path = __DIR__ . '/config.json';
$allowed_hosts = []; $allowed_hosts = [];
if (file_exists($config_path)) { if (file_exists($config_path)) {
$config = json_decode(file_get_contents($config_path), true); $config = json_decode(file_get_contents($config_path), true) ?? [];
foreach ($config['epg_sources'] ?? [] as $src) { foreach ($config['epg_sources'] ?? [] as $src) {
foreach (['epg_url', 'm3u_url'] as $key) { foreach (['epg_url', 'm3u_url'] as $key) {
if (!empty($src[$key])) { if (!empty($src[$key])) {
$host = parse_url($src[$key], PHP_URL_HOST); $host = strtolower(parse_url($src[$key], PHP_URL_HOST) ?? '');
if ($host) $allowed_hosts[] = strtolower($host); if ($host !== '') $allowed_hosts[] = $host;
} }
} }
} }
} }
// ── Valider l'URL demandee ───────────────────────────────────────────────────── // ── Fonctions ─────────────────────────────────────────────────────────────────
function is_allowed_url(string $url, array $allowed_hosts): bool {
if (!preg_match('#^https?://#i', $url)) return false;
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
return $host !== '' && in_array($host, $allowed_hosts, true);
}
function resolve_url(string $base, string $location): string {
if (preg_match('#^https?://#i', $location)) return $location;
$parts = parse_url($base);
$origin = $parts['scheme'] . '://' . $parts['host'];
if (!empty($parts['port'])) $origin .= ':' . $parts['port'];
if ($location[0] === '/') return $origin . $location;
return $origin . rtrim(dirname($parts['path'] ?? '/'), '/') . '/' . $location;
}
/**
* Fetch avec redirections manuelles — chaque Location: est revalidee contre la whitelist.
* $stream = true : stream chunk par chunk (segments video)
* $stream = false : retourne le body complet (playlists m3u8)
*/
function fetch_with_checked_redirects(string $url, array $allowed_hosts, bool $stream = false): array {
$max_redirects = 5;
for ($i = 0; $i <= $max_redirects; $i++) {
if (!is_allowed_url($url, $allowed_hosts)) {
http_response_code(403); die('Host not allowed after redirect');
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_HEADER => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'],
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$header_size = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($response === false) {
http_response_code(502); die('Upstream error');
}
$raw_headers = substr($response, 0, $header_size);
$body = substr($response, $header_size);
// Redirection
if ($code >= 300 && $code < 400) {
if (!preg_match('/^Location:\s*(.+)$/mi', $raw_headers, $m)) {
http_response_code(502); die('Invalid redirect');
}
$url = resolve_url($url, trim($m[1]));
continue;
}
return [$code, $raw_headers, $body, $url];
}
http_response_code(508); die('Too many redirects');
}
// ── Valider l'URL initiale ─────────────────────────────────────────────────────
$url = $_GET['url'] ?? ''; $url = $_GET['url'] ?? '';
if (empty($url) || !preg_match('#^https?://#i', $url)) { if (!is_allowed_url($url, $allowed_hosts)) {
http_response_code(400); die('Invalid URL'); http_response_code(empty($allowed_hosts) ? 503 : 403);
die(empty($allowed_hosts) ? 'No sources configured' : 'Host not allowed');
} }
$parsed = parse_url($url); // ── Determiner le type de ressource ───────────────────────────────────────────
$host = strtolower($parsed['host'] ?? ''); $path = parse_url($url, PHP_URL_PATH) ?? '';
// Bloquer si hote absent de la whitelist
if (empty($allowed_hosts) || !in_array($host, $allowed_hosts, true)) {
http_response_code(403); die('Host not allowed');
}
// Bloquer les IPs privees, loopback, metadata cloud
function is_private_host(string $host): bool {
// Loopback / localhost
if ($host === 'localhost' || $host === '::1') return true;
// Metadata AWS/GCP/Azure
if ($host === '169.254.169.254' || $host === 'metadata.google.internal') return true;
// Resoudre et verifier si IP privee
$ip = filter_var($host, FILTER_VALIDATE_IP) ? $host : gethostbyname($host);
if (!filter_var($ip, FILTER_VALIDATE_IP)) return true; // echec resolution
return !filter_var($ip, FILTER_VALIDATE_IP, [
'flags' => FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
]);
}
// Note : on autorise les IPs privees si elles sont explicitement dans config.json
// (cas Tunarr/Jellyfin sur le reseau local) — on bloque seulement les hotes
// qui ne sont PAS dans la whitelist, ce qui couvre deja le SSRF.
// ── Proxy ─────────────────────────────────────────────────────────────────────
$base = preg_replace('#[^/]*(\?.*)?$#', '', $url);
$origin = $parsed['scheme'] . '://' . $parsed['host'];
$port = $parsed['port'] ?? null;
if ($port) $origin .= ':' . $port;
$path = $parsed['path'] ?? '';
$is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path); $is_segment = preg_match('#\.(ts|aac|mp4|m4s|fmp4)(\?|$)#i', $path);
header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Origin: *');
header('Cache-Control: no-cache'); header('Cache-Control: no-cache');
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0'; // ── Segment binaire — stream chunk par chunk ───────────────────────────────────
if ($is_segment) { if ($is_segment) {
header('Content-Type: video/MP2T'); header('Content-Type: video/MP2T');
header('X-Content-Type-Options: nosniff'); header('X-Content-Type-Options: nosniff');
if (ob_get_level()) ob_end_clean(); if (ob_get_level()) ob_end_clean();
$ch = curl_init($url); // Pour les segments, on suit les redirections en streaming direct
// apres avoir valide l'URL finale via fetch_with_checked_redirects en mode non-stream
[$code, , , $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
if ($code >= 400) { http_response_code($code); die(); }
// Maintenant streamer l'URL finale
$ua = $_SERVER['HTTP_USER_AGENT'] ?? 'Mozilla/5.0';
$ch = curl_init($final_url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 30, CURLOPT_TIMEOUT => 30,
CURLOPT_USERAGENT => $ua, CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'], CURLOPT_HTTPHEADER => ['Accept: */*'],
@@ -93,25 +142,19 @@ if ($is_segment) {
if (!$ok || $code >= 400) http_response_code($code ?: 502); if (!$ok || $code >= 400) http_response_code($code ?: 502);
curl_close($ch); curl_close($ch);
// ── Playlist m3u8 — fetch + réécriture URLs ────────────────────────────────────
} else { } else {
$ch = curl_init($url); [$code, , $body, $final_url] = fetch_with_checked_redirects($url, $allowed_hosts, false);
curl_setopt_array($ch, [
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_USERAGENT => $ua,
CURLOPT_HTTPHEADER => ['Accept: */*'],
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($body === false || $code >= 400) { if ($code >= 400) { http_response_code($code); die("Upstream error $code"); }
http_response_code($code ?: 502); die("Upstream error $code");
}
header('Content-Type: application/vnd.apple.mpegurl'); header('Content-Type: application/vnd.apple.mpegurl');
$final_parts = parse_url($final_url);
$origin = $final_parts['scheme'] . '://' . $final_parts['host'];
if (!empty($final_parts['port'])) $origin .= ':' . $final_parts['port'];
$base = preg_replace('#[^/]*(\?.*)?$#', '', $final_url);
$proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http') $proxy_base = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST'] . '://' . $_SERVER['HTTP_HOST']
. strtok($_SERVER['REQUEST_URI'], '?') . strtok($_SERVER['REQUEST_URI'], '?')
+1 -1
View File
@@ -109,7 +109,7 @@ $submitted_key = trim($_POST['admin_key_input'] ?? $_POST['admin_key_hidden']
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GridTV — Setup</title> <title>GridTV — Setup</title>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="/assets/fonts/fonts.css">
<style> <style>
:root { :root {
--bg:#0a0b0d;--surface:#111318;--surface2:#181b22; --bg:#0a0b0d;--surface:#111318;--surface2:#181b22;
+1 -1
View File
@@ -1,4 +1,4 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js"></script> <script src="/assets/vendor/hls.min.js"></script>
<script> <script>
<?php <?php
$js_dir = __DIR__ . '/../js/'; $js_dir = __DIR__ . '/../js/';
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="GridTV"> <meta name="apple-mobile-web-app-title" content="GridTV">
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap" rel="stylesheet"> <link rel="stylesheet" href="/assets/fonts/fonts.css">
<?php <?php
$css_dir = __DIR__ . '/../css/'; $css_dir = __DIR__ . '/../css/';
$css_files = ['base', 'topbar', 'grid', 'mobile', 'player', 'modals', 'search', 'program', 'favorites', 'updater']; $css_files = ['base', 'topbar', 'grid', 'mobile', 'player', 'modals', 'search', 'program', 'favorites', 'updater'];
+3 -3
View File
@@ -1,14 +1,14 @@
const CACHE_NAME = 'gridtv-v1'; const CACHE_NAME = 'gridtv-v2';
// Ressources statiques à mettre en cache immédiatement // Ressources statiques à mettre en cache immédiatement
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/', '/',
'/index.php', '/index.php',
'/manifest.json', '/manifest.json',
'/assets/fonts/fonts.css',
'/assets/icon-192.png', '/assets/icon-192.png',
'/assets/icon-512.png', '/assets/icon-512.png',
'https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap', '/assets/vendor/hls.min.js'
'https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js'
]; ];
// Install : mise en cache des assets statiques // Install : mise en cache des assets statiques
-2
View File
@@ -3,8 +3,6 @@
* Néons rose/cyan sur noir absolu. Blade Runner. * Néons rose/cyan sur noir absolu. Blade Runner.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;600;700&display=swap');
:root { :root {
--bg: #000008; --bg: #000008;
--surface: #080010; --surface: #080010;
-2
View File
@@ -3,8 +3,6 @@
* Salle de régie sombre, la base. * Salle de régie sombre, la base.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Barlow+Condensed:wght@300;400;600;700&display=swap');
:root { :root {
--bg: #0a0b0d; --bg: #0a0b0d;
--surface: #111318; --surface: #111318;
-2
View File
@@ -3,8 +3,6 @@
* Papier jauni, encre rouge et bleue, machine à écrire. * Papier jauni, encre rouge et bleue, machine à écrire.
*/ */
@import url('https://fonts.googleapis.com/css2?family=Special+Elite&display=swap');
:root { :root {
--bg: #f2e8c8; --bg: #f2e8c8;
--surface: #ede0b0; --surface: #ede0b0;
-2
View File
@@ -3,8 +3,6 @@
* Brun chaud, cuivre, laiton. Très 1880. * Brun chaud, cuivre, laiton. Très 1880.
*/ */
@import url('https://fonts.googleapis.com/css2?family=IM+Fell+English:ital@0;1&display=swap');
:root { :root {
--bg: #160e04; --bg: #160e04;
--surface: #201408; --surface: #201408;
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "1.4.2" "version": "1.4.3"
} }