Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3c7e3241 | ||
|
|
fdbb4c6a26 | ||
|
|
007815dec1 | ||
|
|
7080d30510 | ||
|
|
fd22c7552c | ||
|
|
75525b4d57 | ||
|
|
b35aaced54 | ||
|
|
3a47b84a1b | ||
|
|
4844cdb7b6 | ||
|
|
6c22740fbd | ||
|
|
a0cfa765bd | ||
|
|
62b8f056fa | ||
|
|
0edf6e35d7 | ||
|
|
db11fabe1a | ||
|
|
0f5d1b5221 | ||
|
|
9935bc7f1f | ||
|
|
42ffd887d2 | ||
|
|
96b73e4751 | ||
|
|
0aa2477f35 | ||
|
|
c17bf8ca83 | ||
|
|
ea5156aa6c | ||
|
|
e97985b624 | ||
|
|
3e2eb74c76 | ||
|
|
8c02395d0d | ||
|
|
60bece505c | ||
|
|
c56f88911c | ||
|
|
b28f7886e1 | ||
|
|
335eaa0545 | ||
|
|
4b2720f8b7 | ||
|
|
aedd47ba36 |
No files matched your search
@@ -11,6 +11,12 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 'Docker Tags'
|
||||
id: tags
|
||||
uses: cssnr/docker-tags-action@v2
|
||||
with:
|
||||
images: 'g.lair.moe/${{ vars.DOCKER_USERNAME }}/lair.moe'
|
||||
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -27,5 +33,6 @@ jobs:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: g.lair.moe/${{ vars.DOCKER_USERNAME }}/lair.moe:latest
|
||||
labels: ${{ steps.tags.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+2
-2
@@ -5,5 +5,5 @@ __pycache__/
|
||||
*$py.class
|
||||
.python-version
|
||||
|
||||
static/style/*.css
|
||||
static/style/*.css.map
|
||||
*.css
|
||||
*.css.map
|
||||
+36
-12
@@ -1,23 +1,47 @@
|
||||
FROM node:18-alpine as sass
|
||||
FROM node:18-alpine AS sass-builder
|
||||
|
||||
RUN NODE_OPTIONS=--dns-result-order=ipv4first npm install -g sass
|
||||
RUN NODE_OPTIONS=--dns-result-order=ipv4first npm install -g sass@latest --omit=dev --no-fund --no-audit
|
||||
WORKDIR /build
|
||||
COPY ./static/style ./style
|
||||
RUN sass ./style:./style \
|
||||
--no-source-map \
|
||||
--style=compressed
|
||||
COPY ./blueprints ./blueprints
|
||||
|
||||
RUN sass ./blueprints:./blueprints \
|
||||
--no-source-map \
|
||||
--style=compressed \
|
||||
--quiet
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -y \
|
||||
libmagic1 \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
COPY --from=sass /build/style/ ./static/style/
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
ENV FLASK_ENV=production
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
COPY . .
|
||||
|
||||
CMD ["gunicorn", "app:app", "-b", "0.0.0.0:80", "--workers", "4"]
|
||||
COPY --from=sass-builder /build/blueprints/ ./blueprints/
|
||||
|
||||
RUN useradd -m -u 1001 appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV FLASK_ENV=production \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
CMD ["gunicorn", "app:app", \
|
||||
"-b", "0.0.0.0:80", \
|
||||
"--workers", "4", \
|
||||
"--worker-class", "sync", \
|
||||
"--worker-tmp-dir", "/dev/shm", \
|
||||
"--access-logfile", "-", \
|
||||
"--error-logfile", "-", \
|
||||
"--log-level", "info"]
|
||||
@@ -1,79 +1,36 @@
|
||||
from os import system as console
|
||||
from configparser import ConfigParser
|
||||
from htmlmin import minify
|
||||
from flask import (
|
||||
Flask,
|
||||
g,
|
||||
request,
|
||||
render_template,
|
||||
from modules import locale
|
||||
from modules import domains
|
||||
|
||||
from blueprints.root import bp as root_bp
|
||||
from blueprints.risdeveau import bp as rdv_bp
|
||||
|
||||
import blueprints.root.modules.style
|
||||
import blueprints.risdeveau.modules.style
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
static_folder=None,
|
||||
subdomain_matching=True,
|
||||
template_folder="blueprints"
|
||||
)
|
||||
|
||||
app.jinja_env.add_extension('pypugjs.ext.jinja.PyPugJSExtension')
|
||||
|
||||
translations_cache = {}
|
||||
app.before_request(locale.before_request)
|
||||
app.context_processor(locale.inject_translations)
|
||||
app.context_processor(domain.inject_get_domain)
|
||||
|
||||
def load_translations(lang):
|
||||
if lang not in translations_cache:
|
||||
translations_cache[lang] = {}
|
||||
|
||||
try:
|
||||
config = ConfigParser()
|
||||
config.read(f'locale/{lang}.ini')
|
||||
for section in config.sections():
|
||||
translations_cache[lang][section] = dict(config.items(section))
|
||||
except:
|
||||
pass
|
||||
|
||||
return translations_cache[lang]
|
||||
|
||||
|
||||
def get_locale():
|
||||
return request.accept_languages.best_match(('en', 'ru', 'de', 'fr', 'ja'), 'en')
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
g.locale = get_locale()
|
||||
g.translations = load_translations(g.locale)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_translations():
|
||||
def translate(text, **kwargs):
|
||||
if ":" in text:
|
||||
section, key = text.split(":", 1)
|
||||
else:
|
||||
section, key = "common", text
|
||||
|
||||
template = g.translations \
|
||||
.get(section, {}) \
|
||||
.get(key, f"${section}: {key}$")
|
||||
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except:
|
||||
return template
|
||||
|
||||
return {'_': translate}
|
||||
app.register_blueprint(root_bp)
|
||||
app.register_blueprint(rdv_bp)
|
||||
|
||||
|
||||
if app.debug:
|
||||
console("sass static/style/main.scss static/style/main.css")
|
||||
console("sass static/style/risdeveau.scss static/style/risdeveau.css")
|
||||
blueprints.root.modules.style.compile_styles()
|
||||
blueprints.risdeveau.modules.style.compile_styles()
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return minify(render_template('index.html'), remove_empty_space=True)
|
||||
|
||||
@app.route("/host")
|
||||
def host():
|
||||
return minify(render_template('host.html'), remove_empty_space=True)
|
||||
|
||||
@app.route("/us")
|
||||
def us():
|
||||
return minify(render_template('us.html'), remove_empty_space=True)
|
||||
|
||||
@app.route("/risdeveau")
|
||||
def risdeveau():
|
||||
return minify(render_template('personal/risdeveau.html'), remove_empty_space=True)
|
||||
app.config['SERVER_NAME'] = "localhost:5000"
|
||||
else:
|
||||
app.config['SERVER_NAME'] = "lair.moe"
|
||||
@@ -0,0 +1,106 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
abort,
|
||||
make_response,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
)
|
||||
import magic
|
||||
from htmlmin import minify
|
||||
from musicbrainzngs import get_image_front
|
||||
|
||||
from .modules.api.lb import data as lb_data, refresh_cache as lb_refresh
|
||||
from .modules.api.steam import data as steam_data, refresh_cache as steam_refresh
|
||||
|
||||
def tmsmp(sec: int) -> str:
|
||||
if sec == 0:
|
||||
return 0
|
||||
elif sec < 60:
|
||||
return f"{sec} s"
|
||||
elif sec < 60*60:
|
||||
minutes = round(sec / 60, 1)
|
||||
return f"{minutes:.0f} m" if minutes.is_integer() else f"{minutes:.1f} m"
|
||||
elif sec < 60*60*24:
|
||||
hours = round(sec / 3600, 1)
|
||||
return f"{hours:.0f} h" if hours.is_integer() else f"{hours:.1f} h"
|
||||
else:
|
||||
days = round(sec / 86400, 1)
|
||||
return f"{days:.0f} d" if days.is_integer() else f"{days:.1f} d"
|
||||
|
||||
def utmsmp(unix: int) -> str:
|
||||
return datetime \
|
||||
.utcfromtimestamp(unix) \
|
||||
.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
def rtmsmp(unix: int) -> str:
|
||||
return tmsmp(int(time() - unix))
|
||||
|
||||
bp = Blueprint(
|
||||
"risdeveau",
|
||||
__name__,
|
||||
subdomain="risdeveau",
|
||||
template_folder="..",
|
||||
static_folder=None
|
||||
)
|
||||
|
||||
def render_tmpl(filename: str, **kwargs) -> str:
|
||||
template_path = os.path.join("risdeveau/templates", filename)
|
||||
return minify(
|
||||
render_template(template_path, **kwargs),
|
||||
remove_empty_space=True
|
||||
)
|
||||
|
||||
@bp.route("/static/<path:filename>")
|
||||
def static(filename: str):
|
||||
static_folders = ("static", "../root/static")
|
||||
for dir in static_folders:
|
||||
if os.path.exists(path := os.path.join("blueprints/risdeveau", dir, filename)):
|
||||
return send_file(path)
|
||||
return abort(404)
|
||||
|
||||
@bp.route("/asset/mb/<mbid>")
|
||||
def mb_cover(mbid):
|
||||
r = make_response(image := get_image_front(mbid, "250"))
|
||||
r.headers['Content-Type'] = magic.from_buffer(image[:2048], mime=True)
|
||||
r.headers['Cache-Control'] = 'public, max-age=86400'
|
||||
r.headers['Expires'] = (datetime.now() + timedelta(days=1)) \
|
||||
.strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||
return r
|
||||
|
||||
args = {
|
||||
"lb": lb_data,
|
||||
"steam": steam_data,
|
||||
"tmsmp": tmsmp,
|
||||
"utmsmp": utmsmp,
|
||||
"rtmsmp": rtmsmp
|
||||
}
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
lb_refresh()
|
||||
steam_refresh()
|
||||
return render_tmpl('index.pug', **args)
|
||||
|
||||
@bp.route("/m/<module>")
|
||||
def module(module):
|
||||
if module == "listenbrainz":
|
||||
lb_refresh()
|
||||
elif module == "steam":
|
||||
steam_refresh()
|
||||
if none_match := request.headers.get('if-none-match'):
|
||||
match module:
|
||||
case "listenbrainz":
|
||||
if none_match == lb_data['etag']:
|
||||
return '', 304
|
||||
|
||||
case "steam":
|
||||
if none_match == steam_data['etag']:
|
||||
return '', 304
|
||||
|
||||
return render_tmpl(f'{module}.pug', **args)
|
||||
@@ -0,0 +1,193 @@
|
||||
import atexit
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from hashlib import md5
|
||||
from json import dumps
|
||||
from time import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
import requests
|
||||
from flask import Flask, jsonify
|
||||
|
||||
from .scheduler_guard import should_start_scheduler
|
||||
from .shared_cache import atomic_write_json, cache_file, load_json_if_newer
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cache:
|
||||
data = {}
|
||||
last_updated = time()
|
||||
status = None
|
||||
|
||||
|
||||
data = {
|
||||
"caches": {
|
||||
"now": Cache(),
|
||||
"listens": Cache()
|
||||
},
|
||||
"last_updated": time(),
|
||||
"etag": ""
|
||||
}
|
||||
|
||||
_IS_WRITER = should_start_scheduler()
|
||||
_CACHE_PATH = cache_file("listenbrainz")
|
||||
_CACHE_MTIME = 0.0
|
||||
|
||||
|
||||
def refresh_cache() -> None:
|
||||
"""Refresh in-memory cache from the shared JSON file (for non-writer workers)."""
|
||||
global _CACHE_MTIME
|
||||
if _IS_WRITER:
|
||||
return
|
||||
|
||||
payload, mtime = load_json_if_newer(_CACHE_PATH, _CACHE_MTIME)
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
try:
|
||||
data["etag"] = payload.get("etag", data.get("etag", ""))
|
||||
data["last_updated"] = payload.get("last_updated", data.get("last_updated", time()))
|
||||
caches = payload.get("caches", {})
|
||||
for key, cache in data.get("caches", {}).items():
|
||||
if isinstance(caches, dict) and key in caches and isinstance(caches[key], dict):
|
||||
c = caches[key]
|
||||
cache.data = c.get("data", cache.data)
|
||||
cache.last_updated = c.get("last_updated", cache.last_updated)
|
||||
cache.status = c.get("status", cache.status)
|
||||
finally:
|
||||
_CACHE_MTIME = mtime
|
||||
|
||||
|
||||
def _persist_cache() -> None:
|
||||
"""Persist current cache state to the shared JSON file (writer worker only)."""
|
||||
if not _IS_WRITER:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"etag": data.get("etag", ""),
|
||||
"last_updated": data.get("last_updated", time()),
|
||||
"caches": {
|
||||
k: {
|
||||
"data": v.data,
|
||||
"last_updated": v.last_updated,
|
||||
"status": v.status,
|
||||
}
|
||||
for k, v in data.get("caches", {}).items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(_CACHE_PATH, payload)
|
||||
|
||||
|
||||
def yt_cover(youtube_url):
|
||||
parsed_url = urlparse(youtube_url)
|
||||
|
||||
if parsed_url.netloc in ("youtube.com", "music.youtube.com"):
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
video_id = query_params.get('v', [None])[0]
|
||||
|
||||
elif parsed_url.netloc == 'youtu.be':
|
||||
video_id = parsed_url.path[1:]
|
||||
|
||||
if not video_id:
|
||||
return
|
||||
|
||||
return f"https://img.youtube.com/vi/{video_id}/2.jpg"
|
||||
|
||||
|
||||
def parse_listens(json: dict) -> dict:
|
||||
cover_replacing = {
|
||||
"1e699948-c7c8-4bb2-9f8b-62e14b882a5d": "ca464c1d-5848-45bb-b92d-b1e4b00f9d65",
|
||||
"0d516a93-061e-4a27-9cf7-f36e3a96f888": "5cc0c0c7-22f9-4a4b-a24c-f1a6732f813b",
|
||||
"92ea5cc8-80e0-4da0-a10b-1bc2f8e8781e": "e8f3e14a-4794-4bab-b403-d562cdad4c2f",
|
||||
}
|
||||
|
||||
new_json = {
|
||||
"count": json["count"],
|
||||
"listens": []
|
||||
}
|
||||
|
||||
for track in json["listens"]:
|
||||
listened_at = track.get("listened_at", 0)
|
||||
track = track["track_metadata"]
|
||||
|
||||
new_track = {
|
||||
"artist_name": track["artist_name"],
|
||||
"track_name": track["track_name"],
|
||||
"listened_at": listened_at
|
||||
}
|
||||
|
||||
if mb := track.get("mbid_mapping"):
|
||||
new_track["id"] = \
|
||||
cover_replacing.get(mb["release_mbid"],
|
||||
mb.get("caa_release_mbid",
|
||||
mb["release_mbid"]
|
||||
))
|
||||
new_track["artist_name"] = mb["artists"][0]["artist_credit_name"]
|
||||
new_track["track_name"] = mb["recording_name"]
|
||||
elif info := track.get("additional_info"):
|
||||
if info \
|
||||
.get("music_service_name", "") \
|
||||
.lower() in ("youtube", "youtube music"):
|
||||
if cover := yt_cover(track["additional_info"]["origin_url"]):
|
||||
new_track["cover_url"] = cover
|
||||
|
||||
if "cover_url" not in new_track.keys() and "id" in new_track.keys():
|
||||
new_track["cover_url"] = "/asset/mb/" + new_track["id"]
|
||||
|
||||
new_json["listens"].append(new_track)
|
||||
|
||||
return new_json
|
||||
|
||||
|
||||
def api_request(url: str, cache: Cache):
|
||||
changed = False
|
||||
prev_status = cache.status
|
||||
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
if response.status_code == 200:
|
||||
json = parse_listens(response.json().get("payload"))
|
||||
cache.status = 'success'
|
||||
|
||||
if cache.data != json:
|
||||
cache.data = json
|
||||
cache.last_updated = time()
|
||||
data['last_updated'] = time()
|
||||
data['etag'] = md5(''.join(
|
||||
( dumps(data['caches'][x].data) for x in data['caches'] )
|
||||
).encode()).hexdigest()
|
||||
changed = True
|
||||
else:
|
||||
cache.status = f'error: {response.status_code}'
|
||||
except Exception as e:
|
||||
cache.status = f'error: {str(e)}'
|
||||
|
||||
if prev_status != cache.status:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
_persist_cache()
|
||||
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
if _IS_WRITER:
|
||||
scheduler.add_job(
|
||||
func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/listens?count=5", data['caches']['listens']),
|
||||
trigger=IntervalTrigger(minutes=1),
|
||||
id='risdeveau.listenbrainz.listens',
|
||||
replace_existing=True
|
||||
)
|
||||
scheduler.add_job(
|
||||
func=lambda: api_request("https://api.listenbrainz.org/1/user/risdeveau/playing-now", data['caches']['now']),
|
||||
trigger=IntervalTrigger(seconds=15),
|
||||
id='risdeveau.listenbrainz.playing-now',
|
||||
replace_existing=True
|
||||
)
|
||||
_persist_cache()
|
||||
scheduler.start()
|
||||
atexit.register(lambda: scheduler.shutdown())
|
||||
else:
|
||||
refresh_cache()
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import fcntl
|
||||
from typing import Optional
|
||||
|
||||
_LOCK_FD: Optional[int] = None # keep fd open for process lifetime
|
||||
|
||||
|
||||
def should_start_scheduler() -> bool:
|
||||
"""Return True in exactly one process (the scheduler owner).
|
||||
|
||||
Under gunicorn --workers N, code is imported in N processes. If APScheduler
|
||||
starts at import time, you get N schedulers => N× API calls.
|
||||
|
||||
We prevent that by taking a non-blocking exclusive lock on a lockfile.
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
|
||||
def cache_dir() -> str:
|
||||
"""Directory where the scheduler owner writes shared cache JSON files."""
|
||||
d = os.environ.get("LAIR_CACHE_DIR", "/tmp/lair-cache")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def cache_file(name: str) -> str:
|
||||
return os.path.join(cache_dir(), f"{name}.json")
|
||||
|
||||
|
||||
def atomic_write_json(path: str, payload: dict) -> None:
|
||||
"""Write JSON atomically so readers never observe partial content."""
|
||||
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
|
||||
|
||||
|
||||
def load_json_if_newer(path: str, last_mtime: float) -> Tuple[Optional[dict], float]:
|
||||
"""Load JSON only if the file exists and has mtime newer than last_mtime."""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except FileNotFoundError:
|
||||
return None, last_mtime
|
||||
except Exception:
|
||||
return None, last_mtime
|
||||
|
||||
mtime = float(st.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
|
||||
@@ -0,0 +1,173 @@
|
||||
import atexit
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from hashlib import md5
|
||||
from json import dumps
|
||||
from os import environ
|
||||
from time import time
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
import requests
|
||||
from flask import Flask
|
||||
|
||||
from .scheduler_guard import should_start_scheduler
|
||||
from .shared_cache import atomic_write_json, cache_file, load_json_if_newer
|
||||
|
||||
|
||||
TOKEN = environ.get("STEAM_TOKEN")
|
||||
MY_ID = 76561198826355942
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cache:
|
||||
data = {}
|
||||
last_updated = time()
|
||||
status = None
|
||||
|
||||
|
||||
data = {
|
||||
"caches": {
|
||||
"recent": Cache(),
|
||||
"owned": Cache()
|
||||
},
|
||||
"last_updated": time(),
|
||||
"etag": ""
|
||||
}
|
||||
|
||||
_IS_WRITER = should_start_scheduler()
|
||||
_CACHE_PATH = cache_file("steam")
|
||||
_CACHE_MTIME = 0.0
|
||||
|
||||
|
||||
def refresh_cache() -> None:
|
||||
"""Refresh in-memory cache from the shared JSON file (for non-writer workers)."""
|
||||
global _CACHE_MTIME
|
||||
if _IS_WRITER:
|
||||
return
|
||||
|
||||
payload, mtime = load_json_if_newer(_CACHE_PATH, _CACHE_MTIME)
|
||||
if payload is None:
|
||||
return
|
||||
|
||||
try:
|
||||
data["etag"] = payload.get("etag", data.get("etag", ""))
|
||||
data["last_updated"] = payload.get("last_updated", data.get("last_updated", time()))
|
||||
caches = payload.get("caches", {})
|
||||
for key, cache in data.get("caches", {}).items():
|
||||
if isinstance(caches, dict) and key in caches and isinstance(caches[key], dict):
|
||||
c = caches[key]
|
||||
cache.data = c.get("data", cache.data)
|
||||
cache.last_updated = c.get("last_updated", cache.last_updated)
|
||||
cache.status = c.get("status", cache.status)
|
||||
finally:
|
||||
_CACHE_MTIME = mtime
|
||||
|
||||
|
||||
def _persist_cache() -> None:
|
||||
"""Persist current cache state to the shared JSON file (writer worker only)."""
|
||||
if not _IS_WRITER:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"etag": data.get("etag", ""),
|
||||
"last_updated": data.get("last_updated", time()),
|
||||
"caches": {
|
||||
k: {
|
||||
"data": v.data,
|
||||
"last_updated": v.last_updated,
|
||||
"status": v.status,
|
||||
}
|
||||
for k, v in data.get("caches", {}).items()
|
||||
},
|
||||
}
|
||||
atomic_write_json(_CACHE_PATH, payload)
|
||||
|
||||
|
||||
def modify_game_list(json: dict) -> dict:
|
||||
if 'games' in json.keys():
|
||||
apps = (3301060, 404790, 1281930, 1920960, 1325960, 431960)
|
||||
new_games = []
|
||||
for i, g in enumerate(json['games']):
|
||||
if g['appid'] not in apps:
|
||||
json['games'][i]['h_cover'] = f"https://shared.fastly.steamstatic.com/store_item_assets//steam/apps/{g['appid']}/header.jpg"
|
||||
json['games'][i]['v_cover'] = f"https://shared.fastly.steamstatic.com/store_item_assets//steam/apps/{g['appid']}/library_600x900.jpg"
|
||||
new_games.append(json['games'][i])
|
||||
json['games'] = new_games
|
||||
return json
|
||||
|
||||
|
||||
def steam_request(interface: str, method: str, v: int = 1, **kwargs) -> requests.Response:
|
||||
return requests.get(
|
||||
f"https://api.steampowered.com/{interface}/{method}/v{v:04}/",
|
||||
params=dict({"key": TOKEN}, **kwargs),
|
||||
timeout=10
|
||||
)
|
||||
|
||||
|
||||
def api_request(cache, *args, **kwargs):
|
||||
changed = False
|
||||
prev_status = cache.status
|
||||
|
||||
try:
|
||||
response = steam_request(*args, **kwargs)
|
||||
if response.status_code == 200:
|
||||
json = modify_game_list(response.json().get("response"))
|
||||
cache.status = 'success'
|
||||
|
||||
if cache.data != json:
|
||||
cache.data = json
|
||||
cache.last_updated = time()
|
||||
data['last_updated'] = time()
|
||||
data['etag'] = md5(''.join(
|
||||
( dumps(data['caches'][x].data) for x in data['caches'] )
|
||||
).encode()).hexdigest()
|
||||
changed = True
|
||||
else:
|
||||
cache.status = f'error: {response.status_code}'
|
||||
except Exception as e:
|
||||
cache.status = f'error: {str(e)}'
|
||||
|
||||
if prev_status != cache.status:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
_persist_cache()
|
||||
|
||||
|
||||
if TOKEN:
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
if _IS_WRITER:
|
||||
scheduler.add_job(
|
||||
func=lambda: api_request(data['caches']['recent'], "IPlayerService", "GetRecentlyPlayedGames", steamid=MY_ID),
|
||||
trigger=IntervalTrigger(minutes=15),
|
||||
id='risdeveau.steam.recent',
|
||||
replace_existing=True
|
||||
)
|
||||
scheduler.add_job(
|
||||
func=lambda: api_request(data['caches']['owned'], "IPlayerService", "GetOwnedGames", steamid=MY_ID, include_appinfo=1, include_played_free_games=1),
|
||||
trigger=IntervalTrigger(minutes=60),
|
||||
id='risdeveau.steam.owned',
|
||||
replace_existing=True
|
||||
)
|
||||
|
||||
_persist_cache()
|
||||
scheduler.start()
|
||||
|
||||
api_request(data['caches']['recent'], "IPlayerService", "GetRecentlyPlayedGames", steamid=MY_ID)
|
||||
api_request(data['caches']['owned'], "IPlayerService", "GetOwnedGames", steamid=MY_ID, include_appinfo=1, include_played_free_games=1)
|
||||
|
||||
atexit.register(lambda: scheduler.shutdown())
|
||||
else:
|
||||
refresh_cache()
|
||||
else:
|
||||
msg = "STEAM_TOKEN is not defined"
|
||||
print(msg)
|
||||
for c in data["caches"].values():
|
||||
c.status = msg
|
||||
if _IS_WRITER:
|
||||
_persist_cache()
|
||||
else:
|
||||
refresh_cache()
|
||||
@@ -0,0 +1,9 @@
|
||||
from os import system as console
|
||||
from os.path import join
|
||||
|
||||
def compile_styles():
|
||||
dir = "blueprints/risdeveau/static/style"
|
||||
files = ("risdeveau",)
|
||||
|
||||
for file in files:
|
||||
console(f"sass {join(dir, file+'.scss')} {join(dir, file+'.css')}")
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 594 B |
Binary file not shown.
|
After Width: | Height: | Size: 876 B |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 271 KiB |
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,68 @@
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('rtime', (unixTimestamp) => ({
|
||||
targetDate: new Date(unixTimestamp * 1000),
|
||||
timeString: '',
|
||||
timer: null,
|
||||
interval: 1000,
|
||||
|
||||
colorClasses: {
|
||||
green: 't-green',
|
||||
yellow: 't-yellow',
|
||||
orange: 't-orange',
|
||||
red: 't-red'
|
||||
},
|
||||
|
||||
currentColor: 'green',
|
||||
|
||||
get textColorClass() {
|
||||
return this.colorClasses[this.currentColor];
|
||||
},
|
||||
|
||||
init() {
|
||||
this.updateTime();
|
||||
this.timer = setInterval(() => this.updateTime(), this.interval);
|
||||
|
||||
this.$el.addEventListener('alpine:removing', () => {
|
||||
if (this.interval) clearInterval(this.interval);
|
||||
});
|
||||
},
|
||||
|
||||
updateTime() {
|
||||
const now = new Date();
|
||||
const diffInSeconds = Math.floor((now - this.targetDate) / 1000);
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
const diffInHours = Math.floor(diffInSeconds / 3600);
|
||||
const diffInDays = Math.floor(diffInSeconds / 86400);
|
||||
|
||||
let newInterval = this.interval;
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
this.timeString = 'a moment ago';
|
||||
} else if (diffInMinutes < 60) {
|
||||
this.timeString = `${diffInMinutes} m ago`;
|
||||
newInterval = 10000;
|
||||
} else if (diffInHours < 24) {
|
||||
this.timeString = `${diffInHours} h ago`;
|
||||
newInterval = 60000;
|
||||
} else {
|
||||
this.timeString = `${diffInDays} d ago`;
|
||||
clearInterval(this.timer);
|
||||
}
|
||||
|
||||
if (diffInMinutes <= 15) {
|
||||
this.currentColor = 'green';
|
||||
} else if (diffInHours < 1) {
|
||||
this.currentColor = 'yellow';
|
||||
} else if (diffInDays < 1) {
|
||||
this.currentColor = 'orange';
|
||||
} else {
|
||||
this.currentColor = 'red';
|
||||
}
|
||||
|
||||
if (this.interval != newInterval) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = setInterval(() => this.updateTime(), newInterval);
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
@use "sass:color";
|
||||
@use "../../../root/static/style/catppuccin" as theme;
|
||||
|
||||
h3 {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.qr {
|
||||
img { width: 100% }
|
||||
p { text-align: center; }
|
||||
|
||||
&.blocks {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
width: calc(100vw - 1rem);
|
||||
max-width: 45rem;
|
||||
scroll: {
|
||||
behavior: smooth;
|
||||
snap-type: x mandatory;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
|
||||
&:hover .block.qr:not(:hover) {
|
||||
filter: blur(5px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.block {
|
||||
flex: 0 0 calc(100vw - 2rem);
|
||||
scroll-snap-align: start;
|
||||
max-width: 13.666rem;
|
||||
}
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
|
||||
&.active {
|
||||
box-shadow: theme.$green 0 0 5px 0;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
object-fit: cover;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.steam {
|
||||
.block {
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
height: 7rem;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: .5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table, tbody {
|
||||
vertical-align: baseline;
|
||||
border-collapse: collapse;
|
||||
|
||||
tr {
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
background-color: color.change(theme.$surface1, $alpha:75%);
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: unset;
|
||||
}
|
||||
|
||||
th + td, td + td {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pie:hover {
|
||||
animation: rotateY-animation 2s linear infinite;
|
||||
transform-style: preserve-3d;
|
||||
|
||||
@keyframes rotateY-animation {
|
||||
to {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
from {
|
||||
transform: rotateY(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
div
|
||||
.88-31
|
||||
a.disabled(href="https://chest.lair.moe")
|
||||
img(src="/static/img/88x31/gf.png")
|
||||
|
||||
a(href="//lair.moe")
|
||||
img(src="/static/img/88x31/lair.gif")
|
||||
|
||||
a#pie(href="https://preview.about.akarpov.ru")
|
||||
img(src="/static/img/88x31/withpie.gif")
|
||||
|
||||
.88-31
|
||||
a(href="https://g.lair.moe/Sweetbread/nixos-config")
|
||||
img(src="/static/img/88x31/nixos.webp")
|
||||
|
||||
img(src="/static/img/88x31/teto.webp")
|
||||
@@ -0,0 +1,40 @@
|
||||
.block
|
||||
h3 Development
|
||||
.blocks.badges
|
||||
a.block(href="//g.lair.moe/Sweetbread")
|
||||
img.icon(src="/static/img/service/gitea.webp")
|
||||
| Gitea
|
||||
|
||||
a.block(href="https://github.com/VerySweetBread")
|
||||
img.icon(src="https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png")
|
||||
| GitHub
|
||||
|
||||
a.block(href="https://git.kolibrios.org/Sweetbread")
|
||||
img.icon(src="https://git.kolibrios.org/assets/img/logo.svg")
|
||||
| KolibriOS Git
|
||||
|
||||
h3 Contacts
|
||||
.blocks.badges
|
||||
a.block(href="https://matrix.to/#/@risdeveau:lair.moe")
|
||||
img.icon(src="https://matrix.org/assets/favicon.ico")
|
||||
| Matrix
|
||||
|
||||
a.block(href="//b.lair.moe/@risdeveau")
|
||||
img.icon(src="/static/img/service/sharkey.webp")
|
||||
| Fediverse
|
||||
|
||||
a.block(href="https://discord.com/users/459823895256498186")
|
||||
img.icon(src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d80db9971f10a9757c99_Symbol.svg")
|
||||
| Discord
|
||||
|
||||
a.block(href="mailto:risdeveau@lair.moe") Mail
|
||||
|
||||
h3 Game accounts
|
||||
.blocks.badges
|
||||
a.block(href="https://steamcommunity.com/id/risdeveau")
|
||||
img.icon(src="https://store.steampowered.com/favicon.ico")
|
||||
| Steam
|
||||
|
||||
a.block(href="https://gamebanana.com/members/3899828")
|
||||
img.icon(src="https://images.gamebanana.com/static/img/favicon/favicon.ico")
|
||||
| GameBanana
|
||||
@@ -0,0 +1,14 @@
|
||||
div
|
||||
h3 Wallets
|
||||
.blocks.qr
|
||||
.block.qr
|
||||
p POL, BNB
|
||||
img(src="/static/img/wallets/evm.webp")
|
||||
|
||||
.block.qr
|
||||
p TON
|
||||
img(src="/static/img/wallets/ton.webp")
|
||||
|
||||
.block.qr
|
||||
p XMR
|
||||
img(src="/static/img/wallets/xmr.webp")
|
||||
@@ -0,0 +1,51 @@
|
||||
doctype html
|
||||
|
||||
html(lang="en")
|
||||
head
|
||||
title Sweet Bread
|
||||
|
||||
each f in ('tw', 'main', 'risdeveau')
|
||||
link(rel="stylesheet", href="/static/style/#{f}.css")
|
||||
|
||||
link(rel="icon" type="image/webp" href="/static/img/us/risdeveau.webp")
|
||||
|
||||
script(src="/static/script/rtime.js")
|
||||
if request.headers.get('DNT') != "1":
|
||||
script(
|
||||
src="https://track.lair.moe/api/script.js"
|
||||
data-site-id="1"
|
||||
defer
|
||||
)
|
||||
script(
|
||||
src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"
|
||||
integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"
|
||||
crossorigin="anonymous"
|
||||
)
|
||||
script(defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js")
|
||||
|
||||
meta(name="viewport" content="width=device-width, initial-scale=1.0")
|
||||
meta(
|
||||
name="htmx-config"
|
||||
content='{
|
||||
"responseHandling":[
|
||||
{"code":"204", "swap": false},
|
||||
{"code":"304", "swap": false},
|
||||
{"code":"[23]..", "swap": true},
|
||||
{"code":"422", "swap": true},
|
||||
{"code":"[45]..", "swap": false, "error":true},
|
||||
{"code":"...", "swap": true}
|
||||
]
|
||||
}'
|
||||
)
|
||||
|
||||
body
|
||||
header
|
||||
a(href=url_for('root.index')) Lair
|
||||
|
||||
main
|
||||
include risdeveau/templates/info.pug
|
||||
include risdeveau/templates/contacts.pug
|
||||
include risdeveau/templates/listenbrainz.pug
|
||||
include risdeveau/templates/steam.pug
|
||||
include risdeveau/templates/donate.pug
|
||||
include risdeveau/templates/88x31.pug
|
||||
@@ -0,0 +1,35 @@
|
||||
.block
|
||||
table
|
||||
tr
|
||||
th DoB
|
||||
td 2005-01-13
|
||||
tr
|
||||
th Languages
|
||||
td
|
||||
table
|
||||
tr
|
||||
td Russian
|
||||
td Native
|
||||
tr
|
||||
td English
|
||||
td B2
|
||||
tr
|
||||
td French
|
||||
td A1?
|
||||
tr
|
||||
td German
|
||||
td A2?
|
||||
tr
|
||||
td Japanese
|
||||
td Beginner
|
||||
|
||||
tr
|
||||
th Student
|
||||
td
|
||||
table
|
||||
tr
|
||||
td Programmer
|
||||
td 2.5/4yr.
|
||||
tr
|
||||
td Translator
|
||||
td 2.5/3yr.
|
||||
@@ -0,0 +1,32 @@
|
||||
mixin track_block(track, is_active=false)
|
||||
- set act_cls = "active" if is_active else ""
|
||||
.block.track(class=act_cls)
|
||||
if track.cover_url
|
||||
img(src=track.cover_url)
|
||||
div
|
||||
p
|
||||
b= track.artist_name
|
||||
p= track.track_name
|
||||
|
||||
if not is_active
|
||||
p(
|
||||
x-data="rtime(#{track.listened_at})",
|
||||
x-text="`Listened ${timeString}`",
|
||||
:class="textColorClass"
|
||||
)
|
||||
|
||||
.block(
|
||||
hx-get="/m/listenbrainz",
|
||||
hx-trigger="every 15s",
|
||||
hx-swap="outerHTML",
|
||||
hx-headers='{"If-None-Match": "#{lb.etag}"}'
|
||||
)
|
||||
h2
|
||||
a(href="https://listenbrainz.org/user/risdeveau/") Listenbrainz
|
||||
|
||||
if lb.caches.now.data and lb.caches.now.data.listens[0]
|
||||
+track_block(lb.caches.now.data.listens[0], true)
|
||||
|
||||
if lb.caches.listens.data and lb.caches.listens.data.listens
|
||||
each track in lb.caches.listens.data.listens
|
||||
+track_block(track)
|
||||
@@ -0,0 +1,52 @@
|
||||
mixin game_block(g, show_last_played=false)
|
||||
a.block(href="https://store.steampowered.com/app/#{g.appid}", target="_blank")
|
||||
picture
|
||||
source(media="(max-width: 45rem)", srcset=g.v_cover)
|
||||
img(src=g.h_cover)
|
||||
div
|
||||
strong= g.name
|
||||
if g.playtime_2weeks
|
||||
p Played last 2 weeks: #{tmsmp(g.playtime_2weeks * 60)}
|
||||
p
|
||||
- var lin = g.playtime_linux_forever > 0;
|
||||
- var win = g.playtime_windows_forever > 0;
|
||||
| Total played:
|
||||
if lin
|
||||
= tmsmp(g.playtime_linux_forever * 60)
|
||||
| (
|
||||
abbr(title="On Linux") L
|
||||
| )
|
||||
if lin and win
|
||||
= " + "
|
||||
if win
|
||||
= tmsmp(g.playtime_windows_forever * 60)
|
||||
| (
|
||||
abbr(title="On Windows") W
|
||||
| )
|
||||
if lin and win
|
||||
= " = "
|
||||
= tmsmp(g.playtime_forever * 60)
|
||||
if show_last_played and g.rtime_last_played != 0
|
||||
p Last played: #{utmsmp(g.rtime_last_played)}
|
||||
|
||||
.block.steam(
|
||||
hx-get="/m/steam",
|
||||
hx-trigger="every 1m",
|
||||
hx-swap="outerHTML",
|
||||
hx-headers='{ "If-None-Match": "#{steam.etag}" }'
|
||||
)
|
||||
h2
|
||||
a(href="https://steamcommunity.com/id/risdeveau") Steam
|
||||
|
||||
if steam.caches.recent.data.games
|
||||
h3 Recently played:
|
||||
each g in steam.caches.recent.data.games
|
||||
+game_block(g)
|
||||
p(x-data="rtime(#{steam.caches.recent.last_updated})", x-text="`Last updated: ${timeString}`")
|
||||
|
||||
if steam.caches.owned.data.games
|
||||
h3 Top played games:
|
||||
- var owned_games = steam.caches.owned.data.games | sort(attribute="playtime_forever", reverse=true)
|
||||
each g in owned_games[:5]
|
||||
+game_block(g, true)
|
||||
p(x-data="rtime(#{steam.caches.owned.last_updated})", x-text="`Last updated: ${timeString}`")
|
||||
@@ -0,0 +1,31 @@
|
||||
from htmlmin import minify
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
|
||||
from .modules.euroring import data as euroring_data, refresh_cache as euroring_refresh
|
||||
|
||||
bp = Blueprint(
|
||||
"root",
|
||||
__name__,
|
||||
template_folder="templates",
|
||||
static_folder="static"
|
||||
)
|
||||
|
||||
def render_tmpl(filename: str) -> str:
|
||||
euroring_refresh()
|
||||
return minify(
|
||||
render_template(filename, euroring=euroring_data),
|
||||
remove_empty_space=True
|
||||
)
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
def index():
|
||||
return render_tmpl('index.pug')
|
||||
|
||||
@bp.route("/host")
|
||||
def host():
|
||||
return render_tmpl('host.pug')
|
||||
|
||||
@bp.route("/us")
|
||||
def us():
|
||||
return render_tmpl('us.pug')
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import re
|
||||
from time import time
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
from blueprints.risdeveau.modules.api.scheduler_guard import should_start_scheduler
|
||||
from blueprints.risdeveau.modules.api.shared_cache import (
|
||||
atomic_write_json,
|
||||
cache_file,
|
||||
load_json_if_newer,
|
||||
)
|
||||
|
||||
|
||||
EURORING_SOURCE_URL = os.environ.get(
|
||||
"EURORING_SOURCE_URL",
|
||||
"https://euroring.neocities.org/scripts/onionring-variables.js",
|
||||
)
|
||||
EURORING_SITE_URL = os.environ.get("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() if data["enabled"] else False
|
||||
_CACHE_PATH = cache_file("webring")
|
||||
_CACHE_MTIME = 0.0
|
||||
|
||||
|
||||
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_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"
|
||||
index_url = _parse_js_string_value(text, "indexPage")
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,9 @@
|
||||
from os import system as console
|
||||
from os.path import join
|
||||
|
||||
def compile_styles():
|
||||
dir = "blueprints/root/static/style"
|
||||
files = ("main", "tw")
|
||||
|
||||
for file in files:
|
||||
console(f"sass {join(dir, file+'.scss')} {join(dir, file+'.css')}")
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,25 @@
|
||||
// Palette: Catppuccin Mocha
|
||||
// https://catppuccin.com/palette/
|
||||
$base: #1e1e2e;
|
||||
$text: #cdd6f4;
|
||||
|
||||
$mantle: #181825;
|
||||
$crust: #11111b;
|
||||
|
||||
$overlay0: #6c7086;
|
||||
$overlay1: #7f849c;
|
||||
$overlay2: #9399b2;
|
||||
|
||||
$surface0: #313244;
|
||||
$surface1: #45475a;
|
||||
$surface2: #585b70;
|
||||
|
||||
$subtext0: #a6adc8;
|
||||
$subtext1: #bac2de;
|
||||
|
||||
$red: #f38ba8;
|
||||
$yellow: #f9e2af;
|
||||
$green: #a6e3a1;
|
||||
$peach: #fab387;
|
||||
$blue: #89b4fa;
|
||||
$mauve: #8839ef;
|
||||
@@ -1,29 +1,5 @@
|
||||
@use "sass:color";
|
||||
|
||||
// Palette: Catppuccin Mocha
|
||||
// https://catppuccin.com/palette/
|
||||
$base: #1e1e2e;
|
||||
$text: #cdd6f4;
|
||||
|
||||
$mantle: #181825;
|
||||
$crust: #11111b;
|
||||
|
||||
$overlay0: #6c7086;
|
||||
$overlay1: #7f849c;
|
||||
$overlay2: #9399b2;
|
||||
|
||||
$surface0: #313244;
|
||||
$surface1: #45475a;
|
||||
$surface2: #585b70;
|
||||
|
||||
$subtext0: #a6adc8;
|
||||
$subtext1: #bac2de;
|
||||
|
||||
$red: #f38ba8;
|
||||
$green: #a6e3a1;
|
||||
$peach: #fab387;
|
||||
$blue: #89b4fa;
|
||||
$mauve: #8839ef;
|
||||
@use "catppuccin" as theme;
|
||||
|
||||
|
||||
html {
|
||||
@@ -33,9 +9,9 @@ html {
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: $base;
|
||||
background-color: theme.$base;
|
||||
font-family: Pixeloid, PixelMPlus;
|
||||
color: $text;
|
||||
color: theme.$text;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
@@ -55,7 +31,7 @@ h1 {
|
||||
a {
|
||||
color: unset;
|
||||
text: {
|
||||
decoration: underline {color: $blue};
|
||||
decoration: underline {color: theme.$blue};
|
||||
underline-offset: 1px;
|
||||
}
|
||||
transition: 0.3s ease;
|
||||
@@ -68,7 +44,7 @@ a {
|
||||
transition: none !important;
|
||||
display: inline-block;
|
||||
transform: scale(.98) !important;
|
||||
background-color: $mantle !important;
|
||||
background-color: theme.$mantle !important;
|
||||
}
|
||||
|
||||
&.block {
|
||||
@@ -76,7 +52,7 @@ a {
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02) translateY(-.25rem);
|
||||
background-color: $surface1;
|
||||
background-color: theme.$surface1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +68,7 @@ ul {
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background-color: $mantle;
|
||||
background-color: theme.$mantle;
|
||||
padding: .5rem;
|
||||
font-size: larger;
|
||||
|
||||
@@ -105,7 +81,7 @@ footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
background-color: $mantle;
|
||||
background-color: theme.$mantle;
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
column-gap: 4ch;
|
||||
@@ -113,38 +89,48 @@ footer {
|
||||
|
||||
.mono {
|
||||
font-family: Monocraft, monospace;
|
||||
background-color: $mantle;
|
||||
background-color: theme.$mantle;
|
||||
border-radius: 2px;
|
||||
padding: 0 .25rem;
|
||||
color: $subtext0;
|
||||
color: theme.$subtext0;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&:hover {
|
||||
transition: .3s ease;
|
||||
background-color: $crust;
|
||||
background-color: theme.$crust;
|
||||
}
|
||||
}
|
||||
|
||||
.block {
|
||||
display: block;
|
||||
background-color: $surface0;
|
||||
background-color: theme.$surface0;
|
||||
border-radius: .5rem;
|
||||
padding: .5rem;
|
||||
|
||||
h2 {
|
||||
margin: -.5rem -.5rem 1rem;
|
||||
padding: .5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.block {
|
||||
background-color: theme.$surface1;
|
||||
}
|
||||
|
||||
& + & {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
&.red {
|
||||
background-color: color.mix($surface0, $red, 60%);
|
||||
background-color: color.mix(theme.$surface0, theme.$red, 60%);
|
||||
}
|
||||
&.orange {
|
||||
background-color: color.mix($surface0, $peach, 60%);
|
||||
background-color: color.mix(theme.$surface0, theme.$peach, 60%);
|
||||
}
|
||||
&.green {
|
||||
background-color: color.mix($surface0, $green, 60%);
|
||||
&:hover { background-color: color.mix($surface1, $green, 60%); }
|
||||
&:active { background-color: color.mix($mantle, $green, 60%) !important; }
|
||||
background-color: color.mix(theme.$surface0, theme.$green, 60%);
|
||||
&:hover { background-color: color.mix(theme.$surface1, theme.$green, 60%); }
|
||||
&:active { background-color: color.mix(theme.$mantle, theme.$green, 60%) !important; }
|
||||
}
|
||||
|
||||
& .header {
|
||||
@@ -199,6 +185,8 @@ footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
& + & { margin-top: .25rem; }
|
||||
|
||||
a {
|
||||
padding: .5rem 1rem;
|
||||
margin: .1rem !important;
|
||||
@@ -206,6 +194,39 @@ footer {
|
||||
}
|
||||
}
|
||||
|
||||
.\38 8-31 {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
& + & {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
a, img {
|
||||
width: 88px;
|
||||
height: 31px;
|
||||
}
|
||||
|
||||
img {
|
||||
transition-timing-function: ease-out;
|
||||
transition-duration: .2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 50%;
|
||||
cursor: not-allowed;
|
||||
|
||||
a {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: .5rem;
|
||||
|
||||
@@ -214,15 +235,15 @@ footer {
|
||||
}
|
||||
|
||||
&-track {
|
||||
background-color: $base;
|
||||
background-color: theme.$base;
|
||||
}
|
||||
|
||||
&-thumb {
|
||||
background-color: $overlay0;
|
||||
background-color: theme.$overlay0;
|
||||
border-radius: .25rem;
|
||||
|
||||
&:hover {
|
||||
background-color: $overlay1;
|
||||
background-color: theme.$overlay1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@use "catppuccin" as theme;
|
||||
|
||||
.t {
|
||||
&-red { color: theme.$red; }
|
||||
&-orange { color: theme.$peach; }
|
||||
&-yellow { color: theme.$yellow; }
|
||||
&-green { color: theme.$green; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
doctype html
|
||||
html(lang=g.locale)
|
||||
head
|
||||
title Lair
|
||||
|
||||
link(rel="stylesheet" href="/static/style/main.css")
|
||||
link(rel="icon" type="image/webp" href="/static/img/lair.webp")
|
||||
|
||||
//- Yggdrasil links
|
||||
link(rel="ygg-banner" href="/static/img/88x31/lair.gif")
|
||||
|
||||
link(
|
||||
rel="alternate"
|
||||
data-ygg-type="clearnet"
|
||||
href="https://lair.moe"
|
||||
title="Clearnet address")
|
||||
link(
|
||||
rel="alternate"
|
||||
data-ygg-type="alfis"
|
||||
href="http://lair.ygg"
|
||||
title="Alfis domain")
|
||||
link(
|
||||
rel="alternate"
|
||||
data-ygg-type="ygg-ipv6"
|
||||
href="http://[201:96:5188::a690:7908:da7a]"
|
||||
title="Direct IPv6")
|
||||
|
||||
script(src="/static/script/copy-mono.js")
|
||||
|
||||
if request.headers.get('DNT') != "1":
|
||||
script(
|
||||
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="mock-email" content="admin@example.com")
|
||||
|
||||
meta(property="og:type" value="website")
|
||||
meta(property="og:url" value="https://lair.moe")
|
||||
meta(property="og:title" value="Lair.moe")
|
||||
meta(property="og:image" value="https://lair.moe/static/img/lair.webp")
|
||||
meta(property="og:description" value=_("description"))
|
||||
|
||||
//- Yggdrasil meta
|
||||
meta(name="ygg-category" content="service")
|
||||
meta(name="ygg-topic" content="federation")
|
||||
meta(name="ygg-topic" content="tools")
|
||||
|
||||
meta(name="ygg-language" content="ru")
|
||||
meta(name="ygg-language" content="en")
|
||||
meta(name="ygg-language" content="de")
|
||||
meta(name="ygg-language" content="fr")
|
||||
meta(name="ygg-language" content="jp")
|
||||
body
|
||||
include root/templates/header.pug
|
||||
|
||||
h1
|
||||
block title
|
||||
|
||||
main
|
||||
block content
|
||||
|
||||
include root/templates/footer.pug
|
||||
@@ -0,0 +1,9 @@
|
||||
footer
|
||||
div
|
||||
| lair.moe 🄯 2025 - 2026</div>
|
||||
div
|
||||
a(href="https://g.lair.moe/Sweetbread/lair.moe")= _('site source')
|
||||
div
|
||||
= _('contact us')
|
||||
= ": "
|
||||
a(href="mailto:admin@lair.moe") admin@lair.moe
|
||||
@@ -0,0 +1,17 @@
|
||||
header
|
||||
if request.path != url_for('.index')
|
||||
a(href=url_for('.index')) Lair
|
||||
else
|
||||
div
|
||||
|
||||
.header-links
|
||||
-
|
||||
var links = (
|
||||
('.us', _('about us')),
|
||||
('.host', _('about host')),
|
||||
)
|
||||
each l, t in links
|
||||
if url_for(l) == request.path
|
||||
strong= t
|
||||
else
|
||||
a(href=url_for(l))= t
|
||||
@@ -0,0 +1,20 @@
|
||||
extends root/templates/base.pug
|
||||
|
||||
block title
|
||||
= _('about host')
|
||||
|
||||
block content
|
||||
a.block(href="https://play2go.cloud/?ref_id=4baFoOIp5QE" target="_blank")
|
||||
strong= _("host:hoster")
|
||||
| : play2go
|
||||
p= _('host:hoster_descr')
|
||||
|
||||
.block
|
||||
strong= _("host:specifications")
|
||||
| :
|
||||
ul
|
||||
li CPU: Ryzen 9@3.4GHz (4 cores)
|
||||
li RAM: 8 GB
|
||||
li SSD: 150 GB
|
||||
li ETH: 500Mb/s
|
||||
li Loc: Deutschland, Frankfurt am Main
|
||||
@@ -0,0 +1,94 @@
|
||||
extends root/templates/base.pug
|
||||
|
||||
block title
|
||||
img.icon(src="/static/img/lair.webp")
|
||||
| Lair
|
||||
|
||||
block content
|
||||
a.block(href="https://b.lair.moe" target="_blank")
|
||||
.header
|
||||
img.icon(src="/static/img/service/sharkey.webp")
|
||||
strong Sharkey
|
||||
p= _('index.descr:sharkey')
|
||||
|
||||
a.block(href="https://g.lair.moe" target="_blank")
|
||||
.header
|
||||
img.icon(src="/static/img/service/gitea.webp")
|
||||
strong Gitea
|
||||
p= _('index.descr:gitea')
|
||||
|
||||
.block
|
||||
p
|
||||
a(href="https://m.lair.moe" target="_blank")
|
||||
strong Matrix
|
||||
| — {{ _('index.descr:matrix') }}
|
||||
p
|
||||
a(href="//c.lair.moe" target="_blank")
|
||||
strong Copyparty
|
||||
| — {{ _('index.descr:copyparty') }}
|
||||
p
|
||||
a(href="https://tools.lair.moe" target="_blank")
|
||||
strong IT-tools
|
||||
| — {{ _('index.descr:tools') }}
|
||||
p
|
||||
a(href="https://vert.lair.moe" target="_blank")
|
||||
strong Vert
|
||||
| — {{ _('index.descr:vert') }}
|
||||
|
||||
.block
|
||||
strong= _('index:altfronts')
|
||||
p
|
||||
a(href="https://s.lair.moe" target="_blank")
|
||||
strong 4get
|
||||
| — {{ _('index.descr:4get') }}
|
||||
p
|
||||
a(href="https://tl.lair.moe" target="_blank")
|
||||
strong TransLite
|
||||
| — {{ _('index.descr:tl') }}
|
||||
p
|
||||
a(href="https://lyr.lair.moe" target="_blank")
|
||||
strong Intellectual
|
||||
| — {{ _('index.descr:lyr') }}
|
||||
|
||||
.block
|
||||
div
|
||||
strong DNS
|
||||
| :
|
||||
ul
|
||||
li
|
||||
span.mono 64.188.64.176
|
||||
li
|
||||
| DoT:
|
||||
span.mono lair.moe:853
|
||||
li
|
||||
| DoH:
|
||||
span.mono dns.lair.moe
|
||||
|
||||
p
|
||||
strong Yggdrasil
|
||||
| :
|
||||
ul
|
||||
li
|
||||
= "IPv6: "
|
||||
span.mono 201:96:5188::a690:7908:da7a
|
||||
li
|
||||
= "DNS: "
|
||||
span.mono ygg.lair.moe
|
||||
li
|
||||
= "Alfis: "
|
||||
span.mono lair.ygg
|
||||
|
||||
.webring
|
||||
a.block(href="https://nixwebr.ing/prev/lair" rel="external prev") ←
|
||||
a.block(href="https://nixwebr.ing/" rel="external") Nix webring
|
||||
a.block(href="https://nixwebr.ing/next/lair" rel="external next") →
|
||||
|
||||
.webring
|
||||
a.block(href="https://ctp-webr.ing/lair/previous" rel="external prev") ←
|
||||
a.block(href="https://ctp-webr.ing/" rel="external") Catppuccin webring
|
||||
a.block(href="https://ctp-webr.ing/lair/next" rel="external next") →
|
||||
|
||||
.webring
|
||||
a.block(href=euroring.prev_url rel="external prev") ←
|
||||
a.block(href=euroring.index_url rel="external") Euroring
|
||||
a.block(href=euroring.next_url rel="external next") →
|
||||
@@ -0,0 +1,17 @@
|
||||
extends root/templates/base.pug
|
||||
|
||||
block title
|
||||
| О нас
|
||||
|
||||
block content
|
||||
a.block.green(href=url_for('risdeveau.index'))
|
||||
.header
|
||||
img.icon(src="/static/img/us/risdeveau.webp")
|
||||
| Sweetbread
|
||||
| Главный админ, занимается почти всеми сервисами. Создал этот сайт
|
||||
|
||||
.block.orange.disabled
|
||||
.header
|
||||
img.icon(src="/static/img/us/chest.webp")
|
||||
| Chest
|
||||
| Должна была помогать делать этот сайт
|
||||
@@ -8,7 +8,6 @@ about host = Über Server
|
||||
|
||||
[index]
|
||||
altfronts = Altfronts
|
||||
bottom_text = Außerdem bieten wir {glitchtip}, {baikal} und {freshrss} für Mitglieder unserer Gruppe an!
|
||||
|
||||
[index.descr]
|
||||
sharkey = Föderierter Microblogging-Dienst auf Basis des ActivityPub-Protokolls
|
||||
|
||||
+5
-1
@@ -5,10 +5,14 @@ contact us = Contact us
|
||||
about us = About us
|
||||
about host = About host
|
||||
|
||||
contacts = Contacts
|
||||
donate = Donate
|
||||
|
||||
description = Small personal site
|
||||
|
||||
|
||||
[index]
|
||||
altfronts = Altfronts
|
||||
bottom_text = We also have {glitchtip}, {baikal} and {freshrss} for members of our squad!
|
||||
|
||||
[index.descr]
|
||||
sharkey = ActivityPub-based federated microblogging service
|
||||
|
||||
@@ -8,7 +8,6 @@ about host = À propos de serveur
|
||||
|
||||
[index]
|
||||
altfronts = Altfronts
|
||||
bottom_text = On a aussi {glitchtip}, {baikal} et {freshrss} pour les membres du groupe !
|
||||
|
||||
[index.descr]
|
||||
sharkey = Service de microblogging fédéré avec ActivityPub
|
||||
|
||||
@@ -8,7 +8,6 @@ about host = サーバーについて
|
||||
|
||||
[index]
|
||||
altfronts = 代替フロントエンド
|
||||
bottom_text = メンバーには {glitchtip}、{baikal}、{freshrss} も使えるよ!
|
||||
|
||||
[index.descr]
|
||||
sharkey = ActivityPubを使った連合型マイクロブログ
|
||||
|
||||
+5
-1
@@ -5,10 +5,14 @@ contact us = Для связи
|
||||
about us = О нас
|
||||
about host = О хосте
|
||||
|
||||
contacts = Контакты
|
||||
donate = Донат
|
||||
|
||||
description = Небольшой личный сайт
|
||||
|
||||
|
||||
[index]
|
||||
altfronts = Альтфронты
|
||||
bottom_text = Ещё у нас есть {glitchtip}, {baikal} и {freshrss} для участников нашей группы!
|
||||
|
||||
[index.descr]
|
||||
sharkey = Федеративная микроблогинговая система поверх протокола ActivityPub
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from flask import g, request
|
||||
|
||||
def get_curr_mode() -> str:
|
||||
match request.headers['host']:
|
||||
case "lair.moe": return "clearnet"
|
||||
case "ygg.lair.moe": return "ygg-dns"
|
||||
case "lair.ygg": return "ygg-alfis"
|
||||
case "lair.ygg.at": return "ygg-alfis-at"
|
||||
else: return "ip"
|
||||
|
||||
def inject_get_domain():
|
||||
def get_domain(domain: str, mode=get_cur_mode(): str) -> str:
|
||||
match mode:
|
||||
case "clearnet": return f"{domain}.lair.moe"
|
||||
case "ygg-dns": return f"{domain}.ygg.lair.moe"
|
||||
case "ygg-alfis": return f"{domain}.lair.ygg"
|
||||
case "ygg-alfis-at": return f"{domain}.lair.ygg.at"
|
||||
else: return ""
|
||||
|
||||
return {':': get_domain}
|
||||
@@ -0,0 +1,47 @@
|
||||
from configparser import ConfigParser
|
||||
from flask import g, request
|
||||
|
||||
|
||||
translations_cache = {}
|
||||
|
||||
|
||||
def load_translations(lang):
|
||||
if lang not in translations_cache:
|
||||
translations_cache[lang] = {}
|
||||
|
||||
try:
|
||||
config = ConfigParser()
|
||||
config.read(f'locale/{lang}.ini')
|
||||
for section in config.sections():
|
||||
translations_cache[lang][section] = dict(config.items(section))
|
||||
except:
|
||||
pass
|
||||
|
||||
return translations_cache[lang]
|
||||
|
||||
def get_locale():
|
||||
return request.accept_languages.best_match(
|
||||
('en', 'ru', 'de', 'fr', 'ja'),
|
||||
) or 'en'
|
||||
|
||||
def before_request():
|
||||
g.locale = get_locale()
|
||||
g.translations = load_translations(g.locale)
|
||||
|
||||
def inject_translations():
|
||||
def translate(text, **kwargs):
|
||||
if ":" in text:
|
||||
section, key = text.split(":", 1)
|
||||
else:
|
||||
section, key = "common", text
|
||||
|
||||
template = g.translations \
|
||||
.get(section, {}) \
|
||||
.get(key, f"${section}: {key}$")
|
||||
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except:
|
||||
return template
|
||||
|
||||
return {'_': translate}
|
||||
@@ -1,3 +1,8 @@
|
||||
Flask==3.1.1
|
||||
gunicorn
|
||||
htmlmin2
|
||||
requests
|
||||
APScheduler
|
||||
musicbrainzngs
|
||||
python-magic
|
||||
git+https://github.com/VerySweetBread/pypugjs
|
||||
@@ -7,8 +7,9 @@ pkgs.mkShell {
|
||||
|
||||
buildInputs = with pypkgs; [
|
||||
python
|
||||
python-magic
|
||||
virtualenv
|
||||
pkgs.nodePackages.sass
|
||||
pkgs.dart-sass
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
@@ -1,33 +0,0 @@
|
||||
h3 {
|
||||
margin-block-end: 0;
|
||||
}
|
||||
|
||||
.qr {
|
||||
img { width: 100% }
|
||||
p { text-align: center; }
|
||||
|
||||
&.blocks {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
width: calc(100vw - 1rem);
|
||||
max-width: 45rem;
|
||||
scroll: {
|
||||
behavior: smooth;
|
||||
snap-type: x mandatory;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
|
||||
&:hover .block.qr:not(:hover) {
|
||||
filter: blur(5px);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&.block {
|
||||
flex: 0 0 calc(100vw - 2rem);
|
||||
scroll-snap-align: start;
|
||||
max-width: 13.666rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Lair</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/style/main.css">
|
||||
<link rel="icon" type="image/webp" href="/static/icon/lair.webp" />
|
||||
<script src="/static/script/copy-mono.js"> </script>
|
||||
<script
|
||||
src="https://track.lair.moe/api/script.js"
|
||||
data-site-id="1"
|
||||
defer
|
||||
></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="mock-email" content="admin@example.com">
|
||||
</head>
|
||||
<body>
|
||||
{% include 'header.tmpl' %}
|
||||
|
||||
<h1>{% block title %}{% endblock %}</h1>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% include 'footer.tmpl' %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +0,0 @@
|
||||
<footer>
|
||||
<div>lair.moe © 2025</div>
|
||||
<div><a href="https://g.lair.moe/Sweetbread/lair.moe">{{ _('site source') }}</a></div>
|
||||
<div>{{ _('contact us') }}: <a href="mailto:admin@lair.moe">admin@lair.moe</a></div>
|
||||
</footer>
|
||||
@@ -1,20 +0,0 @@
|
||||
<header>
|
||||
{%- if request.path != url_for('index') %}
|
||||
<a href="{{ url_for('index') }}">Lair</a>
|
||||
{%- else %}
|
||||
<div></div>
|
||||
{%- endif %}
|
||||
|
||||
<div class="header-links">
|
||||
{%- for (l, t) in (
|
||||
('us', _('about us')),
|
||||
('host', _('about host'))
|
||||
) %}
|
||||
{%- if url_for(l) == request.path %}
|
||||
<strong>{{ t }}</strong>
|
||||
{%- else %}
|
||||
<a href="{{ url_for(l) }}">{{ t }}</a>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
</div>
|
||||
</header>
|
||||
@@ -1,21 +0,0 @@
|
||||
{% extends 'base.tmpl' %}
|
||||
|
||||
{% block title %}{{ _('about host') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a href="https://play2go.cloud/?ref_id=4baFoOIp5QE" target="_blank" class="block">
|
||||
<strong>{{ _("host:hoster") }}</strong>: play2go
|
||||
<p>{{ _('host:hoster_descr') }}</p>
|
||||
</a>
|
||||
|
||||
<div class="block">
|
||||
<strong>{{ _("host:specifications") }}</strong>:
|
||||
<ul>
|
||||
<li>CPU: Ryzen 9@3.4GHz (4 cores)</li>
|
||||
<li>RAM: 8 GB</li>
|
||||
<li>SSD: 150 GB</li>
|
||||
<li>ETH: 500Mb/s</li>
|
||||
<li>Loc: Deutschland, Frankfurt am Main</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,68 +0,0 @@
|
||||
{% extends 'base.tmpl' %}
|
||||
|
||||
{% block title %}
|
||||
<img src="/static/icon/lair.webp" class="icon" />
|
||||
Lair
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a href="https://b.lair.moe" target="_blank" class="block">
|
||||
<div class="header">
|
||||
<img src="/static/icon/service/sharkey.webp" class="icon"/>
|
||||
<strong>Sharkey</strong>
|
||||
</div>
|
||||
<p>{{ _('index.descr:sharkey') }}</p>
|
||||
</a>
|
||||
<a href="https://g.lair.moe" target="_blank" class="block">
|
||||
<div class="header">
|
||||
<img src="/static/icon/service/gitea.webp" class="icon"/>
|
||||
<strong>Gitea</strong>
|
||||
</div>
|
||||
<p>{{ _('index.descr:gitea') }}</p>
|
||||
</a>
|
||||
|
||||
<div class="block">
|
||||
<p><a href="https://m.codrs.ru" target="_blank"><strong>Matrix</strong></a> — {{ _('index.descr:matrix') }}</p>
|
||||
<p><a href="https://c.lair.moe" target="_blank"><strong>Copyparty</strong></a> — {{ _('index.descr:copyparty') }}</p>
|
||||
<p><a href="https://tools.lair.moe" target="_blank"><strong>IT-tools</strong></a> — {{ _('index.descr:tools') }}</p>
|
||||
<p><a href="https://vert.lair.moe" target="_blank"><strong>Vert</strong></a> — {{ _('index.descr:vert') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<strong>{{ _('index:altfronts') }}</strong>
|
||||
<p><a href="https://s.lair.moe" target="_blank"><strong>4get</strong></a> — {{ _('index.descr:4get') }}</p>
|
||||
<p><a href="https://tl.lair.moe" target="_blank"><strong>TransLite</strong></a> — {{ _('index.descr:tl') }}</p>
|
||||
<p><a href="https://lyr.lair.moe" target="_blank"><strong>Intellectual</strong></a> — {{ _('index.descr:lyr') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div>
|
||||
<strong>DNS</strong>:
|
||||
<ul>
|
||||
<li><span class="mono">64.188.64.176</span></li>
|
||||
<li>DoT: <span class="mono">lair.moe:853</span></li>
|
||||
<li>DoH: <span class="mono">dns.lair.moe</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Yggdrasil</strong>:
|
||||
<span class="mono">200:ee1:bad2:1732:4b91:c3e3:2f08:29b3</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
{{
|
||||
_('index:bottom_text',
|
||||
glitchtip='<a href="https://bug.codrs.ru" target="_blank"><strong>GlitchTip</strong></a>',
|
||||
baikal='<a href="https://dav.lair.moe" target="_blank"><strong>Baikal</strong></a>',
|
||||
freshrss='<a href="https://rss.lair.moe" target="_blank"><strong>FreshRSS</strong></a>',
|
||||
) | safe
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="webring">
|
||||
<a class="block" href="https://webring.otomir23.me/lair/prev"><</a>
|
||||
<a class="block" href="https://webring.otomir23.me/">Otoring</a>
|
||||
<a class="block" href="https://webring.otomir23.me/lair/next">></a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,88 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Sweet Bread</title>
|
||||
|
||||
<link rel="stylesheet" href="/static/style/main.css">
|
||||
<link rel="stylesheet" href="/static/style/risdeveau.css">
|
||||
<link rel="icon" type="image/webp" href="/static/icon/us/risdeveau.webp" />
|
||||
<script
|
||||
src="https://track.lair.moe/api/script.js"
|
||||
data-site-id="1"
|
||||
defer
|
||||
></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a href="{{ url_for('index') }}">Lair</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h3>Development</h3>
|
||||
<div class="blocks badges">
|
||||
<a class="block" href="//g.lair.moe/Sweetbread">
|
||||
<img class="icon" src="/static/icon/service/gitea.webp" />
|
||||
Gitea
|
||||
</a>
|
||||
<a class="block" href="https://github.com/VerySweetBread">
|
||||
<img class="icon" src="https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png" />
|
||||
GitHub
|
||||
</a>
|
||||
<a class="block" href="https://git.kolibrios.org/Sweetbread">
|
||||
<img class="icon" src="https://git.kolibrios.org/assets/img/logo.svg" />
|
||||
KolibriOS Git
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3>Contacts</h3>
|
||||
<div class="blocks badges">
|
||||
<a class="block" href="https://matrix.to/#/@risdeveau:codrs.ru">
|
||||
<img class="icon" src="https://matrix.org/assets/favicon.ico" />
|
||||
Matrix
|
||||
</a>
|
||||
<a class="block" href="//b.lair.moe/@risdeveau">
|
||||
<img class="icon" src="/static/icon/service/sharkey.webp" />
|
||||
Fediverse
|
||||
</a>
|
||||
<a class="block" href="https://discord.com/users/459823895256498186">
|
||||
<img class="icon" src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d80db9971f10a9757c99_Symbol.svg" />
|
||||
Discord
|
||||
</a>
|
||||
<a class="block" href="mailto:risdeveau@lair.moe">
|
||||
Mail
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3>Game accounts</h3>
|
||||
<div class="blocks badges">
|
||||
<a class="block" href="https://steamcommunity.com/id/risdeveau">
|
||||
<img class="icon" src="https://store.steampowered.com/favicon.ico" />
|
||||
Steam
|
||||
</a>
|
||||
<a class="block" href="https://gamebanana.com/members/3899828">
|
||||
<img class="icon" src="https://images.gamebanana.com/static/img/favicon/favicon.ico" />
|
||||
GameBanana
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3>Wallets</h3>
|
||||
<div class="blocks qr">
|
||||
<div class="block qr">
|
||||
<p>POL, BNB</p>
|
||||
<img src="/static/img/risdeveau/wallets/evm.webp">
|
||||
</div>
|
||||
|
||||
<div class="block qr">
|
||||
<p>TON</p>
|
||||
<img src="/static/img/risdeveau/wallets/ton.webp">
|
||||
</div>
|
||||
|
||||
<div class="block qr">
|
||||
<p>XMR</p>
|
||||
<img src="/static/img/risdeveau/wallets/xmr.webp">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,29 +0,0 @@
|
||||
{% extends 'base.tmpl' %}
|
||||
|
||||
{% block title %}О нас{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<a href="{{ url_for('risdeveau') }}" class="block green">
|
||||
<div class="header">
|
||||
<img src="/static/icon/us/risdeveau.webp" class="icon"/>
|
||||
Sweetbread
|
||||
</div>
|
||||
Главный админ, занимается почти всеми сервисами. Создал этот сайт
|
||||
</a>
|
||||
|
||||
<div class="block red">
|
||||
<div class="header">
|
||||
<img src="/static/icon/us/zxcqirara.webp" class="icon"/>
|
||||
zxcqirara
|
||||
</div>
|
||||
Второй админ, занимается майном и VPN
|
||||
</div>
|
||||
|
||||
<div class="block orange">
|
||||
<div class="header">
|
||||
<img src="/static/icon/us/chest.webp" class="icon"/>
|
||||
Chest
|
||||
</div>
|
||||
Должна была помогать делать этот сайт
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user