3 Commits

Author SHA1 Message Date
Sweetbread fd22c7552c Respect DNT header
Docker Build and Push / build-and-push (push) Successful in 24s
2026-04-09 04:38:52 +03:00
Sweetbread 75525b4d57 Add some webrings
Difficult euroring parsing was made by GPT too. Its realisation is
horror
2026-04-09 04:38:52 +03:00
Sweetbread b35aaced54 Add own 88x31
(and rename icon->img, btw...)
2026-04-09 04:38:52 +03:00
8 changed files with 312 additions and 28 deletions
+8 -8
View File
@@ -2,29 +2,29 @@
h3 Development h3 Development
.blocks.badges .blocks.badges
a.block(href="//g.lair.moe/Sweetbread") a.block(href="//g.lair.moe/Sweetbread")
img.img(src="/static/img/service/gitea.webp") img.icon(src="/static/img/service/gitea.webp")
| Gitea | Gitea
a.block(href="https://github.com/VerySweetBread") a.block(href="https://github.com/VerySweetBread")
img.img(src="https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png") img.icon(src="https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png")
| GitHub | GitHub
a.block(href="https://git.kolibrios.org/Sweetbread") a.block(href="https://git.kolibrios.org/Sweetbread")
img.img(src="https://git.kolibrios.org/assets/img/logo.svg") img.icon(src="https://git.kolibrios.org/assets/img/logo.svg")
| KolibriOS Git | KolibriOS Git
h3 Contacts h3 Contacts
.blocks.badges .blocks.badges
a.block(href="https://matrix.to/#/@risdeveau:lair.moe") a.block(href="https://matrix.to/#/@risdeveau:lair.moe")
img.img(src="https://matrix.org/assets/favimg.ico") img.icon(src="https://matrix.org/assets/favicon.ico")
| Matrix | Matrix
a.block(href="//b.lair.moe/@risdeveau") a.block(href="//b.lair.moe/@risdeveau")
img.img(src="/static/img/service/sharkey.webp") img.icon(src="/static/img/service/sharkey.webp")
| Fediverse | Fediverse
a.block(href="https://discord.com/users/459823895256498186") a.block(href="https://discord.com/users/459823895256498186")
img.img(src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d80db9971f10a9757c99_Symbol.svg") img.icon(src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d80db9971f10a9757c99_Symbol.svg")
| Discord | Discord
a.block(href="mailto:risdeveau@lair.moe") Mail a.block(href="mailto:risdeveau@lair.moe") Mail
@@ -32,9 +32,9 @@
h3 Game accounts h3 Game accounts
.blocks.badges .blocks.badges
a.block(href="https://steamcommunity.com/id/risdeveau") a.block(href="https://steamcommunity.com/id/risdeveau")
img.img(src="https://store.steampowered.com/favimg.ico") img.icon(src="https://store.steampowered.com/favicon.ico")
| Steam | Steam
a.block(href="https://gamebanana.com/members/3899828") a.block(href="https://gamebanana.com/members/3899828")
img.img(src="https://images.gamebanana.com/static/img/favimg/favimg.ico") img.icon(src="https://images.gamebanana.com/static/img/favicon/favicon.ico")
| GameBanana | GameBanana
+7 -6
View File
@@ -7,14 +7,15 @@ html(lang="en")
each f in ('tw', 'main', 'risdeveau') each f in ('tw', 'main', 'risdeveau')
link(rel="stylesheet", href="/static/style/#{f}.css") link(rel="stylesheet", href="/static/style/#{f}.css")
link(rel="img" type="image/webp" href="/static/img/us/risdeveau.webp") link(rel="icon" type="image/webp" href="/static/img/us/risdeveau.webp")
script(src="/static/script/rtime.js") script(src="/static/script/rtime.js")
script( if request.headers.get('DNT') != "1":
src="https://track.lair.moe/api/script.js" script(
data-site-id="1" src="https://track.lair.moe/api/script.js"
defer data-site-id="1"
) defer
)
script( script(
src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"
integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"
+4 -1
View File
@@ -1,6 +1,8 @@
from htmlmin import minify from htmlmin import minify
from flask import Blueprint, render_template, request, jsonify from flask import Blueprint, render_template, request, jsonify
from .modules.euroring import data as euroring_data, refresh_cache as euroring_refresh
bp = Blueprint( bp = Blueprint(
"root", "root",
__name__, __name__,
@@ -9,8 +11,9 @@ bp = Blueprint(
) )
def render_tmpl(filename: str) -> str: def render_tmpl(filename: str) -> str:
euroring_refresh()
return minify( return minify(
render_template(filename), render_template(filename, euroring=euroring_data),
remove_empty_space=True remove_empty_space=True
) )
+256
View File
@@ -0,0 +1,256 @@
from __future__ import annotations
import atexit
import json
import os
import re
import tempfile
from time import time
from urllib.parse import urlsplit
import fcntl
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
def _cache_dir() -> str:
path = os.environ.get("LAIR_CACHE_DIR", "/tmp/lair-cache")
os.makedirs(path, exist_ok=True)
return path
def _cache_file(name: str) -> str:
return os.path.join(_cache_dir(), f"{name}.json")
def _atomic_write_json(path: str, payload: dict) -> None:
parent = os.path.dirname(path) or "."
fd, tmp = tempfile.mkstemp(prefix=".tmp-", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, separators=(",", ":"))
os.replace(tmp, path)
finally:
try:
if os.path.exists(tmp):
os.unlink(tmp)
except Exception:
pass
_CACHE_MTIME = 0.0
_LOCK_FD: int | None = None
def _load_json_if_newer(path: str, last_mtime: float) -> tuple[dict | None, float]:
try:
stat = os.stat(path)
except FileNotFoundError:
return None, last_mtime
except Exception:
return None, last_mtime
mtime = float(stat.st_mtime)
if mtime <= float(last_mtime):
return None, last_mtime
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f), mtime
except Exception:
return None, last_mtime
def _should_start_scheduler() -> bool:
global _LOCK_FD
if _LOCK_FD is not None:
return True
lock_path = os.environ.get("LAIR_SCHED_LOCK", "/tmp/lair-scheduler.lock")
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_LOCK_FD = fd
return True
except BlockingIOError:
os.close(fd)
return False
except Exception:
try:
os.close(fd)
finally:
return False
EURORING_SOURCE_URL = "https://euroring.neocities.org/scripts/onionring-variables.js"
EURORING_SITE_URL = "https://lair.moe"
EURORING_TIMEOUT = float(os.environ.get("EURORING_TIMEOUT", "10"))
data = {
"enabled": bool(EURORING_SOURCE_URL and EURORING_SITE_URL),
"status": "disabled",
"source_url": EURORING_SOURCE_URL,
"site_url": EURORING_SITE_URL,
"ring_name": "",
"index_url": None,
"prev_url": None,
"next_url": None,
"count": 0,
"last_updated": 0,
}
_IS_WRITER = _should_start_scheduler()
_CACHE_PATH = _cache_file("webring")
def _site_key(url: str) -> tuple[str, int | None, str, str]:
parts = urlsplit(url.strip())
hostname = (parts.hostname or "").lower()
port = parts.port
path = re.sub(r"/+$", "", parts.path or "")
query = parts.query or ""
return hostname, port, path, query
def _parse_js_string_value(text: str, variable: str) -> str | None:
match = re.search(
rf"var\s+{re.escape(variable)}\s*=\s*(['\"])(.*?)\1\s*;",
text,
re.S,
)
if not match:
return None
return match.group(2).strip()
def _parse_bool_value(text: str, variable: str) -> bool | None:
match = re.search(rf"var\s+{re.escape(variable)}\s*=\s*(true|false)\s*;", text)
if not match:
return None
return match.group(1) == "true"
def _parse_sites(text: str) -> list[str]:
match = re.search(r"var\s+sites\s*=\s*\[(.*?)\]\s*;", text, re.S)
if not match:
raise ValueError("sites array not found")
sites_block = match.group(1)
sites: list[str] = []
for raw_line in sites_block.splitlines():
line = raw_line.strip()
if not line or line.startswith("//"):
continue
site_match = re.match(
r"(?P<quote>['\"])(?P<url>.*?)(?P=quote)\s*,?\s*(?://.*)?$",
line,
)
if site_match:
sites.append(site_match.group("url").strip())
if not sites:
raise ValueError("sites array is empty")
return sites
def _compute_ring_payload(text: str) -> dict:
sites = _parse_sites(text)
target_key = _site_key(EURORING_SITE_URL)
current_index = None
for i, site in enumerate(sites):
if _site_key(site) == target_key:
current_index = i
break
if current_index is None:
raise ValueError(f"site not found in ring: {EURORING_SITE_URL}")
ring_name = _parse_js_string_value(text, "ringName") or "Webring"
use_index = _parse_bool_value(text, "useIndex")
index_url = _parse_js_string_value(text, "indexPage") if use_index else None
return {
"enabled": True,
"status": "success",
"source_url": EURORING_SOURCE_URL,
"site_url": EURORING_SITE_URL,
"ring_name": ring_name,
"index_url": index_url,
"prev_url": sites[(current_index - 1) % len(sites)],
"next_url": sites[(current_index + 1) % len(sites)],
"count": len(sites),
"last_updated": time(),
}
def refresh_cache() -> None:
global _CACHE_MTIME
if _IS_WRITER:
return
payload, mtime = _load_json_if_newer(_CACHE_PATH, _CACHE_MTIME)
if payload is None:
return
data.update(payload)
_CACHE_MTIME = mtime
def _persist_cache() -> None:
if not _IS_WRITER:
return
_atomic_write_json(_CACHE_PATH, data)
def fetch_webring() -> None:
if not data["enabled"]:
return
previous_state = dict(data)
try:
response = requests.get(EURORING_SOURCE_URL, timeout=EURORING_TIMEOUT)
response.raise_for_status()
payload = _compute_ring_payload(response.text)
data.update(payload)
except Exception as exc:
data.update({
"enabled": True,
"status": f"error: {exc}",
"source_url": EURORING_SOURCE_URL,
"site_url": EURORING_SITE_URL,
"ring_name": data.get("ring_name") or "Webring",
"index_url": data.get("index_url"),
"prev_url": data.get("prev_url"),
"next_url": data.get("next_url"),
"count": data.get("count", 0),
"last_updated": data.get("last_updated", 0),
})
if data != previous_state:
_persist_cache()
if data["enabled"]:
scheduler = BackgroundScheduler()
if _IS_WRITER:
scheduler.add_job(
func=fetch_webring,
trigger=IntervalTrigger(days=1),
id="root.webring.refresh",
replace_existing=True,
)
_persist_cache()
scheduler.start()
fetch_webring()
atexit.register(lambda: scheduler.shutdown())
else:
refresh_cache()
+4 -2
View File
@@ -138,7 +138,7 @@ footer {
align-items: center; align-items: center;
font-size: x-large; font-size: x-large;
.img { .icon {
margin-right: .5ch; margin-right: .5ch;
} }
} }
@@ -174,7 +174,7 @@ footer {
} }
} }
.img { .icon {
width: 1.5em; width: 1.5em;
vertical-align: middle; vertical-align: middle;
border-radius: .2em; border-radius: .2em;
@@ -185,6 +185,8 @@ footer {
display: flex; display: flex;
justify-content: center; justify-content: center;
& + & { margin-top: .25rem; }
a { a {
padding: .5rem 1rem; padding: .5rem 1rem;
margin: .1rem !important; margin: .1rem !important;
+9 -6
View File
@@ -4,14 +4,17 @@ html(lang=g.locale)
title Lair title Lair
link(rel="stylesheet" href="/static/style/main.css") link(rel="stylesheet" href="/static/style/main.css")
link(rel="img" type="image/webp" href="/static/img/lair.webp") link(rel="icon" type="image/webp" href="/static/img/lair.webp")
script(src="/static/script/copy-mono.js") script(src="/static/script/copy-mono.js")
script(
src="https://track.lair.moe/api/script.js" if request.headers.get('DNT') != "1":
data-site-id="1" script(
defer src="https://track.lair.moe/api/script.js"
) data-site-id="1"
defer
)
meta(name="viewport" content="width=device-width, initial-scale=1.0") meta(name="viewport" content="width=device-width, initial-scale=1.0")
meta(name="mock-email" content="admin@example.com") meta(name="mock-email" content="admin@example.com")
+22 -3
View File
@@ -1,19 +1,19 @@
extends root/templates/base.pug extends root/templates/base.pug
block title block title
img.img(src="/static/img/lair.webp") img.icon(src="/static/img/lair.webp")
| Lair | Lair
block content block content
a.block(href="https://b.lair.moe" target="_blank") a.block(href="https://b.lair.moe" target="_blank")
.header .header
img.img(src="/static/img/service/sharkey.webp") img.icon(src="/static/img/service/sharkey.webp")
strong Sharkey strong Sharkey
p= _('index.descr:sharkey') p= _('index.descr:sharkey')
a.block(href="https://g.lair.moe" target="_blank") a.block(href="https://g.lair.moe" target="_blank")
.header .header
img.img(src="/static/img/service/gitea.webp") img.icon(src="/static/img/service/gitea.webp")
strong Gitea strong Gitea
p= _('index.descr:gitea') p= _('index.descr:gitea')
@@ -68,3 +68,22 @@ block content
strong Yggdrasil strong Yggdrasil
| : | :
span.mono 200:ee1:bad2:1732:4b91:c3e3:2f08:29b3 span.mono 200:ee1:bad2:1732:4b91:c3e3:2f08:29b3
.webring
a.block(href="https://nixwebr.ing/prev/lair" rel="external prev") &larr;
a.block(href="https://nixwebr.ing/" rel="external") Nix webring
a.block(href="https://nixwebr.ing/next/lair" rel="external next") &rarr;
.webring
a.block(href="https://ctp-webr.ing/lair/previous" rel="external prev") &larr;
a.block(href="https://ctp-webr.ing/" rel="external") Catppuccin webring
a.block(href="https://ctp-webr.ing/lair/next" rel="external next") &rarr;
if euroring.prev_url and euroring.next_url
.webring
a.block(href=euroring.prev_url rel="external prev") &larr;
if euroring.index_url
a.block(href=euroring.index_url rel="external") Euroring
else
span.block= euroring.ring_name
a.block(href=euroring.next_url rel="external next") &rarr;
+2 -2
View File
@@ -6,12 +6,12 @@ block title
block content block content
a.block.green(href=url_for('risdeveau.index')) a.block.green(href=url_for('risdeveau.index'))
.header .header
img.img(src="/static/img/us/risdeveau.webp") img.icon(src="/static/img/us/risdeveau.webp")
| Sweetbread | Sweetbread
| Главный админ, занимается почти всеми сервисами. Создал этот сайт | Главный админ, занимается почти всеми сервисами. Создал этот сайт
.block.orange.disabled .block.orange.disabled
.header .header
img.img(src="/static/img/us/chest.webp") img.icon(src="/static/img/us/chest.webp")
| Chest | Chest
| Должна была помогать делать этот сайт | Должна была помогать делать этот сайт