ui: локальный Tabler dashboard + 150 проверенных TG-источников и probe 595 каналов
This commit is contained in:
@@ -220,6 +220,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.send_response(code)
|
self.send_response(code)
|
||||||
self.send_header("Content-Type", ctype)
|
self.send_header("Content-Type", ctype)
|
||||||
self.send_header("Content-Length", str(len(data)))
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
self.send_header("Cache-Control", "no-store")
|
self.send_header("Cache-Control", "no-store")
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(data)
|
self.wfile.write(data)
|
||||||
@@ -238,6 +239,24 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return self._send(404, "no ui", "text/plain")
|
return self._send(404, "no ui", "text/plain")
|
||||||
return self._send(200, f.read_bytes(), "text/html; charset=utf-8")
|
return self._send(200, f.read_bytes(), "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
if path.startswith("/assets/"):
|
||||||
|
# Only vendored public assets; never expose data, sessions or arbitrary files.
|
||||||
|
root = (WEB / "vendor").resolve()
|
||||||
|
types = {".css": "text/css; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml", ".png": "image/png",
|
||||||
|
".woff": "font/woff", ".woff2": "font/woff2"}
|
||||||
|
try:
|
||||||
|
asset = (WEB / urllib.parse.unquote(path[len("/assets/"):])).resolve()
|
||||||
|
asset.relative_to(root)
|
||||||
|
ctype = types.get(asset.suffix.lower())
|
||||||
|
if not ctype or not asset.is_file():
|
||||||
|
return self._send(404, "not found", "text/plain")
|
||||||
|
data = asset.read_bytes()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return self._send(404, "not found", "text/plain")
|
||||||
|
return self._send(200, data, ctype)
|
||||||
|
|
||||||
if path == "/api/panels":
|
if path == "/api/panels":
|
||||||
import json as _json
|
import json as _json
|
||||||
fp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "panels.json")
|
fp = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "panels.json")
|
||||||
|
|||||||
@@ -46,4 +46,306 @@ TG_ABUSE = [
|
|||||||
"adapter": "tg_web", "tags": ["cve", "abuse"], "limit": 25},
|
"adapter": "tg_web", "tags": ["cve", "abuse"], "limit": 25},
|
||||||
{"name": "tg_hunt_news", "category": "abuse", "kind": "html", "url": "https://t.me/s/hunt_news",
|
{"name": "tg_hunt_news", "category": "abuse", "kind": "html", "url": "https://t.me/s/hunt_news",
|
||||||
"adapter": "tg_web", "tags": ["security", "news"], "limit": 25},
|
"adapter": "tg_web", "tags": ["security", "news"], "limit": 25},
|
||||||
|
|
||||||
|
# ---- автопул из 595 каналов (27.10.26): проверены живыми, items>=10 ----
|
||||||
|
{"name": "tgp_freegptmaster", "category": "abuse", "kind": "html", "url": "https://t.me/s/freegptmaster",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aibotplus_channel", "category": "abuse", "kind": "html", "url": "https://t.me/s/aibotplus_channel",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_timenewsai", "category": "abuse", "kind": "html", "url": "https://t.me/s/timenewsai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_midjourney_free_design", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_free_design",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_midjourney_free_images", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_free_images",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_midjourney_free_anime", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_free_anime",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_localllama_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/localllama_ru",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_midjourney_free_logo", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_free_logo",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_omggpt", "category": "abuse", "kind": "html", "url": "https://t.me/s/omggpt",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_nanoprompthub", "category": "abuse", "kind": "html", "url": "https://t.me/s/nanoprompthub",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_for_bussiness", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_for_bussiness",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_in_devops", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_in_devops",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_llm_ml_tech", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_llm_ml_tech",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_n_hardware", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_n_hardware",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_code_stars", "category": "abuse", "kind": "html", "url": "https://t.me/s/code_stars",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_news_cn", "category": "abuse", "kind": "html", "url": "https://t.me/s/AI_News_CN",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_sgcode", "category": "abuse", "kind": "html", "url": "https://t.me/s/sgcode",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_coursevania", "category": "abuse", "kind": "html", "url": "https://t.me/s/Coursevania",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_coursecouponclub", "category": "abuse", "kind": "html", "url": "https://t.me/s/coursecouponclub",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_udemyfree4_u", "category": "abuse", "kind": "html", "url": "https://t.me/s/Udemyfree4_u",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aimafiaclub", "category": "abuse", "kind": "html", "url": "https://t.me/s/AImafiaClub",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_simplificaconia", "category": "abuse", "kind": "html", "url": "https://t.me/s/simplificaconia",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_artificial_intelligence_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/Artificial_Intelligence_AI",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ucretsiz_kurspaylasim", "category": "abuse", "kind": "html", "url": "https://t.me/s/ucretsiz_kurspaylasim",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ahmetbalatcom", "category": "abuse", "kind": "html", "url": "https://t.me/s/ahmetbalatcom",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_sepaychannel", "category": "abuse", "kind": "html", "url": "https://t.me/s/sepaychannel",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_inet_vn", "category": "abuse", "kind": "html", "url": "https://t.me/s/inet_vn",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_j2team_community_news", "category": "abuse", "kind": "html", "url": "https://t.me/s/j2team_community_news",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_start_in_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/start_in_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_habr_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/habr_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_berloga", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_berloga",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_kanal", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_kanal",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_na_russkom", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_na_russkom",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_novosti", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_novosti",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_nox", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_nox",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_sapiens", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_sapiens",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_sashaa_blog", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_sashaa_blog",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aidailywire", "category": "abuse", "kind": "html", "url": "https://t.me/s/aidailywire",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aidialogs", "category": "abuse", "kind": "html", "url": "https://t.me/s/aidialogs",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aidivision", "category": "abuse", "kind": "html", "url": "https://t.me/s/aidivision",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aiefn", "category": "abuse", "kind": "html", "url": "https://t.me/s/aiefn",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aigrams_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/aigrams_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aihohohoho", "category": "abuse", "kind": "html", "url": "https://t.me/s/aihohohoho",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aii_tools", "category": "abuse", "kind": "html", "url": "https://t.me/s/aii_tools",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aimasef", "category": "abuse", "kind": "html", "url": "https://t.me/s/aimasef",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_airdro_007", "category": "abuse", "kind": "html", "url": "https://t.me/s/airdro_007",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aistudio4u", "category": "abuse", "kind": "html", "url": "https://t.me/s/aistudio4u",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aitexhnology", "category": "abuse", "kind": "html", "url": "https://t.me/s/aitexhnology",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aitoolscollection", "category": "abuse", "kind": "html", "url": "https://t.me/s/aitoolscollection",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aitoolsday", "category": "abuse", "kind": "html", "url": "https://t.me/s/aitoolsday",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_bolshiedannye", "category": "abuse", "kind": "html", "url": "https://t.me/s/bolshiedannye",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_bot_dev_py_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/bot_dev_py_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_chatgptpromptnano", "category": "abuse", "kind": "html", "url": "https://t.me/s/chatgptpromptnano",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_claude_popusk", "category": "abuse", "kind": "html", "url": "https://t.me/s/claude_popusk",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_claudenews", "category": "abuse", "kind": "html", "url": "https://t.me/s/claudenews",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_clorecoin_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/clorecoin_ru",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_codex_api_online", "category": "abuse", "kind": "html", "url": "https://t.me/s/codex_api_online",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_codex_neiro", "category": "abuse", "kind": "html", "url": "https://t.me/s/codex_neiro",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_gri_nada_gpt", "category": "abuse", "kind": "html", "url": "https://t.me/s/gri_nada_gpt",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ideipitona", "category": "abuse", "kind": "html", "url": "https://t.me/s/ideipitona",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ii_papka", "category": "abuse", "kind": "html", "url": "https://t.me/s/ii_papka",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_intelektii1", "category": "abuse", "kind": "html", "url": "https://t.me/s/intelektii1",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_itjobs_nocode", "category": "abuse", "kind": "html", "url": "https://t.me/s/itjobs_nocode",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_kuznicon", "category": "abuse", "kind": "html", "url": "https://t.me/s/kuznicon",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_midjourney_a1", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjourney_a1",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_nejrokotiki", "category": "abuse", "kind": "html", "url": "https://t.me/s/nejrokotiki",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_nerdhubchannel", "category": "abuse", "kind": "html", "url": "https://t.me/s/nerdhubchannel",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_neurodromo", "category": "abuse", "kind": "html", "url": "https://t.me/s/neurodromo",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_outlinevpnv2rayng", "category": "abuse", "kind": "html", "url": "https://t.me/s/outlinevpnv2rayng",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_photo_ai_free", "category": "abuse", "kind": "html", "url": "https://t.me/s/photo_ai_free",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_pro100_python", "category": "abuse", "kind": "html", "url": "https://t.me/s/pro100_python",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_promptyo", "category": "abuse", "kind": "html", "url": "https://t.me/s/promptyo",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_realaihub", "category": "abuse", "kind": "html", "url": "https://t.me/s/realaihub",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_robocounsel", "category": "abuse", "kind": "html", "url": "https://t.me/s/robocounsel",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_smm_top_vacancy", "category": "abuse", "kind": "html", "url": "https://t.me/s/smm_top_vacancy",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_sobolevalizfoto", "category": "abuse", "kind": "html", "url": "https://t.me/s/sobolevalizfoto",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_startup_fairy", "category": "abuse", "kind": "html", "url": "https://t.me/s/startup_fairy",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_teamdemonmain", "category": "abuse", "kind": "html", "url": "https://t.me/s/teamdemonmain",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_udalenrabota59", "category": "abuse", "kind": "html", "url": "https://t.me/s/udalenrabota59",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_umnik_prompts", "category": "abuse", "kind": "html", "url": "https://t.me/s/umnik_prompts",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_vibe_coding_jobs", "category": "abuse", "kind": "html", "url": "https://t.me/s/vibe_coding_jobs",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_vibecodingvibes", "category": "abuse", "kind": "html", "url": "https://t.me/s/vibecodingvibes",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_beautiful_wallpaper2025", "category": "abuse", "kind": "html", "url": "https://t.me/s/beautiful_wallpaper2025",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_cydiaguide", "category": "abuse", "kind": "html", "url": "https://t.me/s/cydiaguide",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_pixoradigitalnew", "category": "abuse", "kind": "html", "url": "https://t.me/s/pixoradigitalnew",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_kokoro_online", "category": "abuse", "kind": "html", "url": "https://t.me/s/kokoro_online",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_connect_hub", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_connect_hub",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_cvedetector", "category": "abuse", "kind": "html", "url": "https://t.me/s/CVEDetector",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_exee", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_exee",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_web3_aggregator", "category": "abuse", "kind": "html", "url": "https://t.me/s/WEB3_AGGREGATOR",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_clodex_api", "category": "abuse", "kind": "html", "url": "https://t.me/s/clodex_api",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_curiousscan", "category": "abuse", "kind": "html", "url": "https://t.me/s/curiousscan",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_jcnode", "category": "abuse", "kind": "html", "url": "https://t.me/s/jcnode",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ragnarokzone", "category": "abuse", "kind": "html", "url": "https://t.me/s/ragnarokzone",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_valencylab", "category": "abuse", "kind": "html", "url": "https://t.me/s/valencylab",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_githubradar", "category": "abuse", "kind": "html", "url": "https://t.me/s/GitHubRadar",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_matrixx", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_matrixx",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_unicodeunicode", "category": "abuse", "kind": "html", "url": "https://t.me/s/unicodeunicode",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_kyloai", "category": "abuse", "kind": "html", "url": "https://t.me/s/kyloai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_neuraldvig", "category": "abuse", "kind": "html", "url": "https://t.me/s/neuraldvig",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_hubcryptocis", "category": "abuse", "kind": "html", "url": "https://t.me/s/hubcryptocis",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_by_miracles", "category": "abuse", "kind": "html", "url": "https://t.me/s/by_miracles",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_satu_digital", "category": "abuse", "kind": "html", "url": "https://t.me/s/satu_digital",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_tunvnprehubnoti", "category": "abuse", "kind": "html", "url": "https://t.me/s/tunvnprehubnoti",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_productvc", "category": "abuse", "kind": "html", "url": "https://t.me/s/productVC",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_toolsdealshub", "category": "abuse", "kind": "html", "url": "https://t.me/s/toolsdealshub",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_nullscode", "category": "abuse", "kind": "html", "url": "https://t.me/s/nullscode",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_digital_ponos", "category": "abuse", "kind": "html", "url": "https://t.me/s/digital_ponos",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_lachugashop", "category": "abuse", "kind": "html", "url": "https://t.me/s/LachugaShop",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ramikrispinds", "category": "abuse", "kind": "html", "url": "https://t.me/s/ramikrispinds",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_howto_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/Howto_AI",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_neuralprofit", "category": "abuse", "kind": "html", "url": "https://t.me/s/NeuralProfit",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_baza_dannykh_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/baza_dannykh_ru",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_bloombusiness", "category": "abuse", "kind": "html", "url": "https://t.me/s/bloombusiness",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_business_ru", "category": "abuse", "kind": "html", "url": "https://t.me/s/business_ru",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_for_dev", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_for_dev",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_business_news_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/business_news_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_omnilogin", "category": "abuse", "kind": "html", "url": "https://t.me/s/omnilogin",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_gleb_pro_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/gleb_pro_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_paragonzone", "category": "abuse", "kind": "html", "url": "https://t.me/s/paragonzone",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_caocongbptg", "category": "abuse", "kind": "html", "url": "https://t.me/s/caocongbptg",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_xanllu", "category": "abuse", "kind": "html", "url": "https://t.me/s/xanllu",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ai_barkov", "category": "abuse", "kind": "html", "url": "https://t.me/s/ai_barkov",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aibusines2", "category": "abuse", "kind": "html", "url": "https://t.me/s/aibusines2",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aidevtoolszoomcamp", "category": "abuse", "kind": "html", "url": "https://t.me/s/aidevtoolszoomcamp",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aikonten", "category": "abuse", "kind": "html", "url": "https://t.me/s/aikonten",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_aipythonmoney", "category": "abuse", "kind": "html", "url": "https://t.me/s/aipythonmoney",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_annlatushkina_kanal", "category": "abuse", "kind": "html", "url": "https://t.me/s/annlatushkina_kanal",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_buhaistrikalos", "category": "abuse", "kind": "html", "url": "https://t.me/s/buhaistrikalos",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_claude_mania", "category": "abuse", "kind": "html", "url": "https://t.me/s/claude_mania",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_edvardgrishin27", "category": "abuse", "kind": "html", "url": "https://t.me/s/edvardgrishin27",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ekryukova", "category": "abuse", "kind": "html", "url": "https://t.me/s/ekryukova",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_firstaihub", "category": "abuse", "kind": "html", "url": "https://t.me/s/firstaihub",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_gigachatm", "category": "abuse", "kind": "html", "url": "https://t.me/s/gigachatm",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_gpt_chatu", "category": "abuse", "kind": "html", "url": "https://t.me/s/gpt_chatu",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_hiaimedia", "category": "abuse", "kind": "html", "url": "https://t.me/s/hiaimedia",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_istobzorov", "category": "abuse", "kind": "html", "url": "https://t.me/s/istobzorov",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_midjornium", "category": "abuse", "kind": "html", "url": "https://t.me/s/midjornium",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_n2d2ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/n2d2ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_neirosetr", "category": "abuse", "kind": "html", "url": "https://t.me/s/neirosetr",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_promptgpt_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/promptgpt_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_promtychatgpt", "category": "abuse", "kind": "html", "url": "https://t.me/s/promtychatgpt",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_sky_net_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/sky_net_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_stable_diffusion5", "category": "abuse", "kind": "html", "url": "https://t.me/s/stable_diffusion5",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_the_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/the_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_ultravibecoder", "category": "abuse", "kind": "html", "url": "https://t.me/s/ultravibecoder",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_unterberg_ai", "category": "abuse", "kind": "html", "url": "https://t.me/s/unterberg_ai",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_kodjournal", "category": "abuse", "kind": "html", "url": "https://t.me/s/kodjournal",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_productvibe", "category": "abuse", "kind": "html", "url": "https://t.me/s/productvibe",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
|
{"name": "tgp_nineaws", "category": "abuse", "kind": "html", "url": "https://t.me/s/nineaws",
|
||||||
|
"adapter": "tg_web", "tags": ["abuse", "tg"], "limit": 25},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -454,10 +454,10 @@ def test_http_exports_match_feed_filters_and_item_find(monkeypatch):
|
|||||||
server.server_close()
|
server.server_close()
|
||||||
thread.join(timeout=5)
|
thread.join(timeout=5)
|
||||||
|
|
||||||
def test_web_has_free_panel_block():
|
def test_web_has_local_dashboard_and_free_panel_block():
|
||||||
"""Вкладка абузов обязана показывать free-панели new-api."""
|
"""The ready-made UI stays local and retains the free-panel discovery feature."""
|
||||||
html = open(os.path.join(ROOT, "web", "index.html"), encoding="utf-8").read()
|
html = (ROOT / "web" / "index.html").read_text(encoding="utf-8")
|
||||||
assert "loadPanels" in html
|
assert "loadPanels" in html
|
||||||
assert "/api/panels" in html
|
assert "/api/panels" in html
|
||||||
assert "pn-grid" in html
|
assert "/assets/vendor/tabler/tabler.min.css" in html
|
||||||
assert ".pn-grid{" in html
|
assert "/assets/vendor/tabler/tabler.min.js" in html
|
||||||
|
|||||||
77
tests/test_static_assets.py
Normal file
77
tests/test_static_assets.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Local UI dependencies are served without exposing project data."""
|
||||||
|
import http.client
|
||||||
|
import threading
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from omni import serve
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def static_server(tmp_path, monkeypatch):
|
||||||
|
web = tmp_path / "web"
|
||||||
|
vendor = web / "vendor" / "tabler"
|
||||||
|
vendor.mkdir(parents=True)
|
||||||
|
(vendor / "tabler.min.css").write_text("body{color:#123}", encoding="utf-8")
|
||||||
|
(vendor / "tabler.min.js").write_text("window.tabler={};", encoding="utf-8")
|
||||||
|
(web / "index.html").write_text("<html>panel</html>", encoding="utf-8")
|
||||||
|
(web / "private.js").write_text("private", encoding="utf-8")
|
||||||
|
(vendor / "secret.json").write_text('{"secret":"test-only"}', encoding="utf-8")
|
||||||
|
(tmp_path / "outside.js").write_text("outside", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(serve, "WEB", web)
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), serve.Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
def get(path):
|
||||||
|
connection = http.client.HTTPConnection("127.0.0.1", server.server_port, timeout=5)
|
||||||
|
try:
|
||||||
|
connection.request("GET", path)
|
||||||
|
response = connection.getresponse()
|
||||||
|
return response.status, dict(response.getheaders()), response.read()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield get
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,ctype,expected", [
|
||||||
|
("tabler.min.css", "text/css; charset=utf-8", b"body{color:#123}"),
|
||||||
|
("tabler.min.js", "text/javascript; charset=utf-8", b"window.tabler={};"),
|
||||||
|
])
|
||||||
|
def test_local_template_assets(static_server, name, ctype, expected):
|
||||||
|
status, headers, body = static_server("/assets/vendor/tabler/" + name + "?v=1.7.1")
|
||||||
|
assert status == 200 and body == expected
|
||||||
|
assert headers["Content-Type"] == ctype
|
||||||
|
assert headers["X-Content-Type-Options"] == "nosniff"
|
||||||
|
assert int(headers["Content-Length"]) == len(expected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", [
|
||||||
|
"/assets/vendor/tabler/missing.js",
|
||||||
|
"/assets/vendor/tabler/",
|
||||||
|
"/assets/vendor/tabler/secret.json",
|
||||||
|
"/assets/private.js",
|
||||||
|
"/assets/index.html",
|
||||||
|
"/assets/vendor/../../private.js",
|
||||||
|
"/assets/vendor/%2e%2e/%2e%2e/outside.js",
|
||||||
|
"/assets/%2foutside.js",
|
||||||
|
"/assets/vendor/..%5c..%5cprivate.js",
|
||||||
|
"/assets/vendor/%00.js",
|
||||||
|
])
|
||||||
|
def test_assets_reject_private_files_and_traversal(static_server, path):
|
||||||
|
status, _, body = static_server(path)
|
||||||
|
assert status == 404
|
||||||
|
assert body == b"not found"
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_still_served(static_server):
|
||||||
|
status, headers, body = static_server("/")
|
||||||
|
assert status == 200 and body == b"<html>panel</html>"
|
||||||
|
assert headers["Content-Type"].startswith("text/html")
|
||||||
122
tools/tg_pool_probe.py
Normal file
122
tools/tg_pool_probe.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Probe public Telegram previews without writing posts to the store.
|
||||||
|
|
||||||
|
Run from the repository root: python tools/tg_pool_probe.py
|
||||||
|
Each of up to 12 coordinator threads runs one isolated adapter subprocess.
|
||||||
|
The 20-second subprocess deadline includes imports, network fallbacks and parsing;
|
||||||
|
Future.result(timeout=...) alone would leave hung network threads running.
|
||||||
|
Results preserve pool order and username spelling; ok means items > 0.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
WORKERS = 12
|
||||||
|
TIMEOUT = 20
|
||||||
|
LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
def load_channels(path: Path) -> list[str]:
|
||||||
|
channels = json.loads(path.read_text(encoding="utf-8"))["channels"]
|
||||||
|
if not isinstance(channels, list):
|
||||||
|
raise ValueError("channels must be a list")
|
||||||
|
names = []
|
||||||
|
for entry in channels:
|
||||||
|
username = entry.get("username") if isinstance(entry, dict) else entry
|
||||||
|
if not isinstance(username, str) or not re.fullmatch(r"[A-Za-z0-9_]+", username):
|
||||||
|
raise ValueError(f"Invalid username in channels: {username!r}")
|
||||||
|
names.append(username)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def adapter_count(username: str) -> int:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from omni import feeds
|
||||||
|
|
||||||
|
src = SimpleNamespace(name=f"ab_tg_auto_{username}", category="abuse",
|
||||||
|
kind="site", url=f"https://t.me/s/{username}",
|
||||||
|
adapter="tg_web", tags=("abuse", "abuse", "tg"),
|
||||||
|
limit=LIMIT, detail=False, extra={})
|
||||||
|
# Call the actual adapter and validation directly so unexpected exceptions
|
||||||
|
# reach the subprocess exit status instead of fetch() swallowing them.
|
||||||
|
return len(feeds._valid_items(feeds.ADAPTERS["tg_web"](src, LIMIT) or []))
|
||||||
|
|
||||||
|
|
||||||
|
def probe(username: str, timeout: float = TIMEOUT) -> dict:
|
||||||
|
count = 0
|
||||||
|
try:
|
||||||
|
env = dict(os.environ, PYTHONPATH="", PYTHONIOENCODING="utf-8",
|
||||||
|
PYTHONUTF8="1", PYTHONDONTWRITEBYTECODE="1")
|
||||||
|
result = subprocess.run([sys.executable, str(Path(__file__).resolve()),
|
||||||
|
"--probe", username],
|
||||||
|
cwd=str(ROOT), env=env, capture_output=True,
|
||||||
|
encoding="utf-8", timeout=timeout, check=False)
|
||||||
|
if result.returncode == 0:
|
||||||
|
value = json.loads(result.stdout)
|
||||||
|
if type(value) is int and 0 <= value <= LIMIT:
|
||||||
|
count = value
|
||||||
|
else:
|
||||||
|
print(f"probe error @{username}: exit={result.returncode} "
|
||||||
|
f"{result.stderr[-300:].strip()}", file=sys.stderr, flush=True)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print(f"probe timeout @{username}: {timeout:g}s", file=sys.stderr, flush=True)
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
print(f"probe error @{username}: {type(exc).__name__}: {exc}",
|
||||||
|
file=sys.stderr, flush=True)
|
||||||
|
return {"username": username, "items": count, "ok": count > 0}
|
||||||
|
|
||||||
|
|
||||||
|
def save_results(path: Path, rows: list[dict | None]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps([r for r in rows if r is not None],
|
||||||
|
ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--input", type=Path, default=ROOT / "data/radar_sources.json")
|
||||||
|
parser.add_argument("--output", type=Path, default=ROOT / "data/tg_pool_probe.json")
|
||||||
|
parser.add_argument("--workers", type=int, choices=range(1, WORKERS + 1), default=WORKERS)
|
||||||
|
parser.add_argument("--timeout", type=float, default=TIMEOUT)
|
||||||
|
parser.add_argument("--probe", help=argparse.SUPPRESS)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not 0 < args.timeout <= TIMEOUT:
|
||||||
|
parser.error("timeout must be greater than zero and at most 20 seconds")
|
||||||
|
if args.probe is not None:
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9_]+", args.probe):
|
||||||
|
parser.error("invalid username")
|
||||||
|
print(json.dumps(adapter_count(args.probe)))
|
||||||
|
return 0
|
||||||
|
if args.input.resolve() == args.output.resolve():
|
||||||
|
parser.error("output must not overwrite the input pool")
|
||||||
|
names = load_channels(args.input)
|
||||||
|
rows = [None] * len(names)
|
||||||
|
print(f"channels={len(names)} workers={args.workers} timeout={args.timeout:g}s limit={LIMIT}", flush=True)
|
||||||
|
with ThreadPoolExecutor(max_workers=args.workers) as executor:
|
||||||
|
pending = {executor.submit(probe, name, args.timeout): i
|
||||||
|
for i, name in enumerate(names)}
|
||||||
|
for done, future in enumerate(as_completed(pending), 1):
|
||||||
|
rows[pending[future]] = future.result()
|
||||||
|
if done % 25 == 0 or done == len(names):
|
||||||
|
save_results(args.output, rows)
|
||||||
|
positive = sum(r is not None and r["ok"] for r in rows)
|
||||||
|
print(f"checked={done}/{len(names)} positive={positive}", flush=True)
|
||||||
|
save_results(args.output, rows)
|
||||||
|
print(json.dumps({"checked": len(rows),
|
||||||
|
"positive": sum(r["ok"] for r in rows),
|
||||||
|
"zero": sum(r["items"] == 0 for r in rows),
|
||||||
|
"items_ge_10": sum(r["items"] >= 10 for r in rows),
|
||||||
|
"output": str(args.output)}, ensure_ascii=False), flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,95 +1,237 @@
|
|||||||
"""Смоук панели в реальном Chromium: скриншоты + ошибки консоли.
|
"""Read-only Chromium acceptance checks for the black OMNI panel.
|
||||||
|
|
||||||
python tools/ui_smoke.py [--url http://127.0.0.1:8078/] [--shots data/shots]
|
python tools/ui_smoke.py [--url http://127.0.0.1:8078/] [--shots data/shots/black_shadcn]
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from urllib.parse import parse_qs, quote, urljoin, urlsplit
|
||||||
|
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
|
||||||
async def run(url, out):
|
async def run(url, out):
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
errors, report = [], {}
|
errors, assets, external = [], {}, set()
|
||||||
async with async_playwright() as p:
|
report = {}
|
||||||
b = await p.chromium.launch()
|
origin = urlsplit(url).netloc
|
||||||
pg = await b.new_page(viewport={"width": 1500, "height": 950})
|
async with async_playwright() as playwright:
|
||||||
pg.on("console", lambda m: errors.append([m.type, m.text]) if m.type in ("error",) else None)
|
browser = await playwright.chromium.launch()
|
||||||
pg.on("pageerror", lambda e: errors.append(["pageerror", str(e)]))
|
page = await browser.new_page(viewport={"width": 1440, "height": 960})
|
||||||
await pg.goto(url, wait_until="domcontentloaded", timeout=30000)
|
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||||
await pg.wait_for_selector(".kpi", timeout=20000)
|
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||||
await pg.wait_for_timeout(1200)
|
|
||||||
report["title"] = await pg.title()
|
|
||||||
report["nav"] = await pg.locator(".nav-i").count()
|
|
||||||
report["kpi"] = (await pg.locator(".kpi").all_inner_texts())[:4]
|
|
||||||
await pg.screenshot(path=str(out / "v2_overview.png"), full_page=False)
|
|
||||||
|
|
||||||
await pg.click("[data-nav=feed]")
|
def response_seen(response):
|
||||||
await pg.wait_for_selector("#list .item", timeout=20000)
|
parts = urlsplit(response.url)
|
||||||
report["feed_rows"] = await pg.locator(".item").count()
|
if parts.path.startswith("/assets/"):
|
||||||
await pg.screenshot(path=str(out / "v2_feed.png"))
|
assets[parts.path] = response.status
|
||||||
await pg.locator(".item").first.click()
|
if parts.scheme in ("http", "https") and parts.netloc != origin:
|
||||||
await pg.wait_for_timeout(1200)
|
external.add(parts.netloc)
|
||||||
report["drawer"] = (await pg.locator("#d-title").inner_text())[:100]
|
|
||||||
await pg.screenshot(path=str(out / "v2_drawer.png"))
|
|
||||||
await pg.keyboard.press("Escape")
|
|
||||||
|
|
||||||
await pg.click("[data-nav=sources]")
|
page.on("response", response_seen)
|
||||||
await pg.wait_for_timeout(1500)
|
|
||||||
report["src_rows"] = await pg.locator("#src-tbl tbody tr").count()
|
|
||||||
await pg.screenshot(path=str(out / "v2_sources.png"))
|
|
||||||
|
|
||||||
await pg.click("[data-nav=abuse]")
|
async def settled():
|
||||||
await pg.wait_for_timeout(1500)
|
await page.wait_for_load_state("networkidle", timeout=25000)
|
||||||
report["abuse_rows"] = await pg.locator(".item").count()
|
|
||||||
await pg.screenshot(path=str(out / "v2_abuse.png"))
|
|
||||||
|
|
||||||
await pg.click("[data-nav=runs]")
|
async def navigate(view):
|
||||||
await pg.wait_for_timeout(900)
|
nav = page.locator(f'[data-nav="{view}"]').first
|
||||||
report["run_rows"] = await pg.locator("#r-tbl tbody tr").count()
|
if not await nav.is_visible():
|
||||||
await pg.screenshot(path=str(out / "v2_runs.png"))
|
summary = page.locator("#system-nav > summary")
|
||||||
|
if await summary.count() and await page.locator("#system-nav").get_attribute("open") is None:
|
||||||
|
await summary.click()
|
||||||
|
await nav.click()
|
||||||
|
await page.wait_for_function("v => location.hash.startsWith('#/' + v)", arg=view)
|
||||||
|
await settled()
|
||||||
|
|
||||||
await pg.click("[data-nav=tg]")
|
async def assert_no_overflow(label):
|
||||||
await pg.wait_for_timeout(2000)
|
size = await page.evaluate("({scroll:document.documentElement.scrollWidth, width:innerWidth})")
|
||||||
report["tg"] = (await pg.locator(".card").first.inner_text())[:160].replace("\n", " | ")
|
assert size["scroll"] <= size["width"] + 1, f"Horizontal overflow at {label}: {size}"
|
||||||
await pg.screenshot(path=str(out / "v2_tg.png"))
|
|
||||||
|
|
||||||
await pg.click("[data-nav=keys]")
|
async def assert_dark_theme(label):
|
||||||
await pg.wait_for_timeout(800)
|
colors = await page.evaluate("""() => {
|
||||||
report["key_rows"] = await pg.locator("#k-tbl tbody tr").count()
|
const canvas = document.createElement('canvas');
|
||||||
await pg.screenshot(path=str(out / "v2_keys.png"))
|
canvas.width = canvas.height = 1;
|
||||||
|
const ctx = canvas.getContext('2d', {willReadFrequently: true});
|
||||||
|
const selectors = ['body', '.navbar', '.card', '.list-group-item',
|
||||||
|
'.form-control', '.form-select', '.table', '.table thead',
|
||||||
|
'.offcanvas.show', '.modal.show .modal-content', '.dropdown-menu.show'];
|
||||||
|
return selectors.flatMap(selector => [...document.querySelectorAll(selector)]
|
||||||
|
.filter(el => el.getClientRects().length && getComputedStyle(el).visibility !== 'hidden')
|
||||||
|
.slice(0, 12).map(el => {
|
||||||
|
const bg = getComputedStyle(el).backgroundColor;
|
||||||
|
ctx.clearRect(0, 0, 1, 1);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, 1, 1);
|
||||||
|
return {selector, bg, rgba: [...ctx.getImageData(0, 0, 1, 1).data]};
|
||||||
|
}));
|
||||||
|
}""")
|
||||||
|
assert await page.evaluate("matchMedia('(prefers-color-scheme: dark)').matches || getComputedStyle(document.documentElement).colorScheme.includes('dark')")
|
||||||
|
assert any(c["selector"] == "body" and c["rgba"][3] == 255 for c in colors), colors
|
||||||
|
light = [c for c in colors if c["rgba"][3] > 127 and max(c["rgba"][:3]) > 64]
|
||||||
|
assert not light, f"Light surfaces in {label}: {light}"
|
||||||
|
report.setdefault("dark_routes", []).append(label)
|
||||||
|
|
||||||
await pg.click("[data-nav=digest]")
|
try:
|
||||||
await pg.wait_for_timeout(500)
|
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||||
await pg.click("[data-act=digest]")
|
await page.wait_for_selector("#list .item", timeout=25000)
|
||||||
await pg.wait_for_timeout(6000)
|
await settled()
|
||||||
report["digest_chars"] = len(await pg.locator("#d-out").inner_text())
|
assert await page.locator('[data-nav="feed"]').count() == 1
|
||||||
await pg.screenshot(path=str(out / "v2_digest.png"))
|
report["default_feed"] = await page.locator("#list .item").count()
|
||||||
|
report["navigation_routes"] = await page.locator("[data-nav]").count()
|
||||||
|
assert report["navigation_routes"] == 9
|
||||||
|
await assert_no_overflow("desktop")
|
||||||
|
await assert_dark_theme("feed")
|
||||||
|
await page.screenshot(path=str(out / "black_feed.png"))
|
||||||
|
|
||||||
await pg.click("[data-nav=watch]")
|
# A real search must update both results and the total, not just the input.
|
||||||
await pg.wait_for_timeout(600)
|
missing = "zz_omni_smoke_no_match_7f91"
|
||||||
await pg.screenshot(path=str(out / "v2_watch.png"))
|
async with page.expect_response(lambda r: "/api/items?" in r.url and missing in r.url) as pending:
|
||||||
|
await page.locator("#q").fill(missing)
|
||||||
|
await page.locator("#q").press("Enter")
|
||||||
|
searched = await (await pending.value).json()
|
||||||
|
await settled()
|
||||||
|
assert searched["total"] == 0
|
||||||
|
assert await page.locator("#list .item").count() == 0
|
||||||
|
report["search_empty_state"] = True
|
||||||
|
await page.locator("#q").fill("")
|
||||||
|
await page.locator("#q").press("Enter")
|
||||||
|
await page.wait_for_selector("#list .item", timeout=20000)
|
||||||
|
await settled()
|
||||||
|
|
||||||
# мобильный вид
|
# Reading panel: real detail response, keyboard focus containment and Escape.
|
||||||
await pg.set_viewport_size({"width": 420, "height": 900})
|
row = page.locator("#list .item").first
|
||||||
await pg.click("[data-nav=feed]")
|
opener = row.locator("[data-id]").first if await row.locator("[data-id]").count() else row
|
||||||
await pg.wait_for_timeout(1200)
|
async with page.expect_response(re.compile(r"/api/item/\d+")) as detail_pending:
|
||||||
await pg.screenshot(path=str(out / "v2_mobile.png"))
|
await opener.click()
|
||||||
await b.close()
|
detail = await (await detail_pending.value).json()
|
||||||
report["console"] = errors[:12]
|
assert detail.get("ok") and detail.get("item", {}).get("title")
|
||||||
print(json.dumps(report, ensure_ascii=False, indent=1))
|
await page.wait_for_selector("#drawer.show", timeout=15000)
|
||||||
return 0 if not errors else 1
|
await settled()
|
||||||
|
assert (await page.locator("#d-title").inner_text()).strip()
|
||||||
|
await assert_dark_theme("reader")
|
||||||
|
await page.screenshot(path=str(out / "black_reader.png"))
|
||||||
|
focusables = page.locator('#drawer a[href], #drawer button:not([disabled]), #drawer input:not([disabled])')
|
||||||
|
if await focusables.count():
|
||||||
|
await focusables.last.focus()
|
||||||
|
await page.keyboard.press("Tab")
|
||||||
|
assert await page.evaluate("document.querySelector('#drawer').contains(document.activeElement)")
|
||||||
|
await focusables.first.focus()
|
||||||
|
await page.keyboard.press("Shift+Tab")
|
||||||
|
assert await page.evaluate("document.querySelector('#drawer').contains(document.activeElement)")
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.wait_for_function("!document.querySelector('#drawer').classList.contains('show')")
|
||||||
|
await page.locator("#drawer").wait_for(state="hidden")
|
||||||
|
assert await opener.evaluate("el => document.activeElement === el"), "Reader did not restore focus"
|
||||||
|
await page.locator('[data-act="help"]').first.click()
|
||||||
|
await page.wait_for_selector("#modal.show")
|
||||||
|
await settled()
|
||||||
|
await assert_dark_theme("help modal")
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await page.locator("#modal").wait_for(state="hidden")
|
||||||
|
report["reader_keyboard"] = True
|
||||||
|
|
||||||
|
# Source filtering is a real API query, not a dead text field.
|
||||||
|
await navigate("sources")
|
||||||
|
await page.wait_for_selector("#src-tbl")
|
||||||
|
async with page.expect_response(lambda r: "/api/sources?" in r.url and missing in r.url) as src_pending:
|
||||||
|
await page.locator("#s-q").fill(missing)
|
||||||
|
source_search = await (await src_pending.value).json()
|
||||||
|
await settled()
|
||||||
|
assert not source_search["items"]
|
||||||
|
report["source_search"] = True
|
||||||
|
await page.locator("#s-q").fill("")
|
||||||
|
await settled()
|
||||||
|
await assert_dark_theme("sources")
|
||||||
|
await page.screenshot(path=str(out / "black_sources.png"))
|
||||||
|
|
||||||
|
await navigate("abuse")
|
||||||
|
report["find_rows"] = await page.locator("#ab-list .item").count()
|
||||||
|
await assert_dark_theme("abuse")
|
||||||
|
await page.screenshot(path=str(out / "black_finds.png"))
|
||||||
|
async with page.expect_response(lambda r: urlsplit(r.url).path == "/api/panels") as panels_pending:
|
||||||
|
await page.locator('[data-ab-tab="panels"]').click()
|
||||||
|
panels = await (await panels_pending.value).json()
|
||||||
|
await settled()
|
||||||
|
assert isinstance(panels.get("panels"), list), panels.keys()
|
||||||
|
assert await page.locator("#pn-box").is_visible()
|
||||||
|
await assert_dark_theme("api panels")
|
||||||
|
report["api_panels"] = len(panels["panels"])
|
||||||
|
for view, selector in (("runs", "#r-tbl"), ("tg", ".card"), ("keys", "#k-tbl"), ("watch", ".card"), ("overview", ".card")):
|
||||||
|
await navigate(view)
|
||||||
|
await page.wait_for_selector(selector)
|
||||||
|
await assert_dark_theme(view)
|
||||||
|
await page.screenshot(path=str(out / f"black_{view}.png"))
|
||||||
|
report[view] = "rendered"
|
||||||
|
await navigate("digest")
|
||||||
|
async with page.expect_response(lambda r: "/api/digest?" in r.url) as digest_pending:
|
||||||
|
await page.locator('[data-act="digest"]').click()
|
||||||
|
digest = await (await digest_pending.value).json()
|
||||||
|
await settled()
|
||||||
|
assert digest.get("ok") and len(digest.get("md", "")) > 80
|
||||||
|
assert len((await page.locator("#d-out").inner_text()).strip()) > 80
|
||||||
|
report["digest"] = "rendered"
|
||||||
|
await assert_dark_theme("digest")
|
||||||
|
await page.screenshot(path=str(out / "black_digest.png"))
|
||||||
|
|
||||||
|
# Deep-link source filters and both exported formats must stay in sync.
|
||||||
|
sample = await (await page.request.get(url.rstrip("/") + "/api/items?limit=1")).json()
|
||||||
|
source = sample["items"][0]["source"]
|
||||||
|
deep_link = url.rstrip("/") + "/#/feed?sort=fresh&days=3650&src=" + quote(source, safe="")
|
||||||
|
await page.goto(deep_link, wait_until="domcontentloaded")
|
||||||
|
await page.wait_for_selector("#list .item")
|
||||||
|
await settled()
|
||||||
|
for extension in ("json", "csv"):
|
||||||
|
link = page.locator(f'a[href*="/api/export.{extension}"]').first
|
||||||
|
href = await link.get_attribute("href")
|
||||||
|
assert href is not None
|
||||||
|
query = parse_qs(urlsplit(href).query)
|
||||||
|
assert query.get("src") == [source] and query.get("days") == ["3650"]
|
||||||
|
exported = await page.request.get(urljoin(url, href))
|
||||||
|
assert exported.status == 200
|
||||||
|
if extension == "json":
|
||||||
|
data = await exported.json()
|
||||||
|
assert data["count"] and all(item["source"] == source for item in data["items"])
|
||||||
|
report["filtered_exports"] = True
|
||||||
|
|
||||||
|
# Mobile navigation has labels, a real toggle, and no page overflow.
|
||||||
|
await page.set_viewport_size({"width": 390, "height": 844})
|
||||||
|
await page.locator("#sidebar-toggle").click()
|
||||||
|
await page.locator("#sidebar-menu").wait_for(state="visible")
|
||||||
|
assert await page.locator('[data-nav="sources"]').is_visible()
|
||||||
|
await assert_no_overflow("mobile navigation 390")
|
||||||
|
await assert_dark_theme("mobile navigation")
|
||||||
|
await page.screenshot(path=str(out / "black_mobile_navigation.png"))
|
||||||
|
await page.locator('[data-nav="feed"]').click()
|
||||||
|
await settled()
|
||||||
|
await assert_no_overflow("mobile 390")
|
||||||
|
await page.screenshot(path=str(out / "black_mobile.png"))
|
||||||
|
await page.set_viewport_size({"width": 768, "height": 1024})
|
||||||
|
await assert_no_overflow("tablet 768")
|
||||||
|
report["responsive"] = [390, 768, 1440]
|
||||||
|
|
||||||
|
assert assets.get("/assets/vendor/tabler/tabler.min.css") == 200
|
||||||
|
assert assets.get("/assets/vendor/tabler/tabler.min.js") == 200
|
||||||
|
assert not external, f"External asset/API dependencies: {sorted(external)}"
|
||||||
|
assert not errors, errors
|
||||||
|
finally:
|
||||||
|
report["assets"] = assets
|
||||||
|
report["external_hosts"] = sorted(external)
|
||||||
|
report["console_errors"] = errors
|
||||||
|
(out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
await browser.close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
ap.add_argument("--url", default="http://127.0.0.1:8078/")
|
parser.add_argument("--url", default="http://127.0.0.1:8078/")
|
||||||
ap.add_argument("--shots", default="data/shots")
|
parser.add_argument("--shots", default="data/shots/black_shadcn")
|
||||||
a = ap.parse_args()
|
args = parser.parse_args()
|
||||||
sys.exit(asyncio.run(run(a.url, pathlib.Path(a.shots))))
|
sys.exit(asyncio.run(run(args.url, pathlib.Path(args.shots))))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
1528
web/index.html
1528
web/index.html
File diff suppressed because it is too large
Load Diff
93
web/vendor/inter/LICENSE
vendored
Normal file
93
web/vendor/inter/LICENSE
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
BIN
web/vendor/inter/files/inter-cyrillic-ext-wght-normal.woff2
vendored
Normal file
BIN
web/vendor/inter/files/inter-cyrillic-ext-wght-normal.woff2
vendored
Normal file
Binary file not shown.
BIN
web/vendor/inter/files/inter-cyrillic-wght-normal.woff2
vendored
Normal file
BIN
web/vendor/inter/files/inter-cyrillic-wght-normal.woff2
vendored
Normal file
Binary file not shown.
BIN
web/vendor/inter/files/inter-latin-ext-wght-normal.woff2
vendored
Normal file
BIN
web/vendor/inter/files/inter-latin-ext-wght-normal.woff2
vendored
Normal file
Binary file not shown.
BIN
web/vendor/inter/files/inter-latin-wght-normal.woff2
vendored
Normal file
BIN
web/vendor/inter/files/inter-latin-wght-normal.woff2
vendored
Normal file
Binary file not shown.
17
web/vendor/inter/provenance.json
vendored
Normal file
17
web/vendor/inter/provenance.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@fontsource-variable/inter",
|
||||||
|
"version": "5.3.0",
|
||||||
|
"repository": "https://github.com/fontsource/font-files",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"tarball": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"retrieved": "2026-10-27",
|
||||||
|
"modifications": "None to vendor font binaries; OMNI self-hosts Latin/Cyrillic variable WOFF2 subsets.",
|
||||||
|
"files": {
|
||||||
|
"files/inter-latin-wght-normal.woff2": "3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62",
|
||||||
|
"files/inter-latin-ext-wght-normal.woff2": "34b9c504cab7a73e37b746343a449132e56cf7b5481af2cb81dc74dcff25c956",
|
||||||
|
"files/inter-cyrillic-wght-normal.woff2": "71d5ee93cc1e9f1d520a3a8b66456de18c7879d8df09d57fcd2eaff75fef0075",
|
||||||
|
"files/inter-cyrillic-ext-wght-normal.woff2": "ca157063339ac4ad418f214f3abfed119b0798ab4d377386ce5c9e5a7a435ebd",
|
||||||
|
"LICENSE": "3b0a5fca3d17942cde889069889dedbbbd075e9b599968c82a95f4d944e9b345"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
web/vendor/tabler/LICENSE
vendored
Normal file
21
web/vendor/tabler/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2018-2026 The Tabler Authors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
17
web/vendor/tabler/provenance.json
vendored
Normal file
17
web/vendor/tabler/provenance.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@tabler/core",
|
||||||
|
"version": "1.7.1",
|
||||||
|
"repository": "https://github.com/tabler/tabler",
|
||||||
|
"commit": "07f60c07da13d8ff957a370434b7ef5c86f5ab94",
|
||||||
|
"release": "https://github.com/tabler/tabler/releases/tag/%40tabler/core%401.7.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"tarball": "https://registry.npmjs.org/@tabler/core/-/core-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-sC42sr/uslr7kIZwyBPESWJKRm1E4hYAMMZyLDoyG0ZlEOlglRW0L5c1gf8x6i+QApT+AtHm10fDP1j2WgQJpg==",
|
||||||
|
"retrieved": "2026-10-27",
|
||||||
|
"modifications": "None to vendored CSS/JS. Application adapts upstream vertical layout and email inbox patterns.",
|
||||||
|
"files": {
|
||||||
|
"LICENSE": "4f88a82d13be3c5c63a12c5631eae914aa4381b6dc17641bf1ab85f3f8f6c8a5",
|
||||||
|
"tabler.min.css": "dcf84d7b4319c29ab33450bfe2a7bbf607458d994ef71023f7509e912cafdbd5",
|
||||||
|
"tabler.min.js": "2a3b9ecae44d821b21ba4521da33fca1bdc692454745536418c4e078e8ba6abf"
|
||||||
|
}
|
||||||
|
}
|
||||||
8
web/vendor/tabler/tabler.min.css
vendored
Normal file
8
web/vendor/tabler/tabler.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
8
web/vendor/tabler/tabler.min.js
vendored
Normal file
8
web/vendor/tabler/tabler.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user