fix: resolve 3 security audit issues

#3: Stored XSS in stats dashboard - escape p[path] with html.escape()
#4: Caddy timeout race - increase read/write_timeout 30s -> 60s
#5: Missing CSP header - add Content-Security-Policy to Caddyfile
This commit is contained in:
akiba
2026-06-30 14:03:48 +00:00
parent 085cc7a140
commit 122c408fff
32 changed files with 9066 additions and 859 deletions

119
AGENTS.md
View File

@@ -1,15 +1,49 @@
# AGENTS — AO3 Mirror
# AGENTS — AO3 Mirror v5
This file is for AI agents (Hermes, Claude Code, Codex) working on the AO3 reverse proxy mirror.
## Project Overview
AO3 Mirror is a high-availability reverse proxy for archiveofourown.org, designed to restore access for Chinese users. It bypasses Cloudflare's bot protection using TLS fingerprint impersonation (curl_cffi), a tiered WebShare proxy pool (726 proxies, 72%+ CF bypass rate), cookie-aware session management, and user-browser CF challenge solving.
AO3 Mirror v5 is a high-performance reverse proxy for archiveofourown.org, redesigned from the ground up with sticky sessions, Service Worker client-side caching, and a full 5000-proxy P2C pool — matching the go3 architecture with modern Python tooling.
- **Domain**: agento3.miscs.dev (behind Cloudflare CDN)
- **Target**: archiveofourown.org
- **Stack**: Python 3.11, FastAPI, uvicorn + uvloop + httptools, curl_cffi, Caddy
- **QPS**: 720 (cache) | 270 (direct proxy)
- **Proxy Pool**: 5000 WebShare proxies, P2C selection, state machine (healthy/unstable/blocked/probing)
- **Client**: Service Worker for static cache + offline fallback
- **Architecture Reference**: https://tomorin.cv/AO3-CN/go3 (go3 v2 branch)
## Architecture v5
```
User Browser
│ ao3_sessid_proxy cookie = sticky proxy
├─ Service Worker (sw.js)
│ ├─ Cache API — static assets cached in browser
│ ├─ Navigation intercept — offline mirror picker
│ └─ Request passthrough — for non-static, non-nav
└─ HTTPS → Caddy → 2× uvicorn workers (8081-8082)
├─ Proxy Pool (5000, P2C selection)
│ ├─ State machine: healthy/unstable/blocked/probing
│ ├─ Concurrency limit: asyncio.Semaphore(400)
│ └─ Passive probing: 10s interval, 10 concurrency
├─ Sticky Session (ao3_sessid_proxy)
│ └─ User pinned to one proxy for session duration
├─ Service Worker Deploy (/sw.js, /sw-YYYYMMDD.js)
│ └─ HTML injection: auto-register SW on page load
├─ CF Challenge → User Browser
│ └─ All proxies blocked → rewrite challenge → browser solves
├─ Static Cache (server-side LRU + disk MD5)
└─ Local routes: /stats, /health, /metrics
```
## Build Commands
@@ -26,85 +60,60 @@ sudo systemctl restart ao3-daemon
# Restart Caddy only
sudo systemctl reload caddy
# Scan proxy pool (MUST run in foreground!)
cd /home/ubuntu/ao3-mirror && python3 scripts/scan_proxies_cffi.py
# Syntax check
# Syntax check all Python files
python3 -m py_compile proxy_pool.py ao3_fetcher.py app.py cache.py stats.py url_rewriter.py
# Deploy Service Worker update
# 1. Edit static/sw.js
# 2. Update SERVICE_WORKER_DATE in app.py
# 3. Restart workers
```
## Architecture
```
User → Cloudflare CDN (agento3.miscs.dev) → Caddy :443 (60s, round-robin)
→ 2× uvicorn workers (8081-8082) [cpu-pinned, uvloop]
→ Tiered Proxy Pool:
Fast (50): POST/login → 8s timeout, 1 retry
Main (676): GET/browse → 15s timeout, 2 retries
→ Cookie-aware sessions: per-proxy cf_clearance persistence
→ TLS impersonation: safari15_5, safari17_0, chrome123, chrome124
→ archiveofourown.org
CF Challenge Solving (v4):
1. All proxies hit CF challenge → generate challenge_token
2. Rewrite challenge page → forward to user's browser
3. User browser executes CF JS → challenge solved
4. cf_clearance captured → saved to proxy cookie jar
5. Original request retried → content delivered
```
### Core Files
### Core Files (v5)
| File | Purpose |
|------|---------|
| `app.py` | FastAPI backend v4 — proxy handler, stats, CF challenge solving |
| `proxy_pool.py` | Tiered async proxy pool — cookie jar, weighted selection, sampling |
| `ao3_fetcher.py` | Async fetcher — CF detection, smart retry, cookie injection |
| `app.py` | FastAPI backend v5 — proxy handler, sticky sessions, SW deploy, stats |
| `proxy_pool.py` | P2C proxy pool v5 — 5000 proxies, state machine, semaphore limiter |
| `ao3_fetcher.py` | Single-request fetcher v5 — no internal retry, CF detection |
| `cache.py` | Per-worker LRU cache (5000 entries, path-differentiated TTL) |
| `stats.py` | In-memory stats with batch SQLite flush every 60s |
| `url_rewriter.py` | URL/header rewriting (ao3 → mirror domain) |
| `scripts/daemon.py` | Passive worker supervision (systemd, 30s check, 3-strike restart) |
| `scripts/scan_proxies_cffi.py` | Proxy scanner with TLS fingerprint fallback |
| `start.sh` | Startup script — kills old workers, starts new, reloads Caddy |
| `Caddyfile` | Caddy config — TLS, round-robin, 60s timeouts |
| `static/sw.js` | Service Worker — client-side cache, offline fallback |
| `Caddyfile` | Caddy config — SW routes, round-robin, 60s timeouts |
| `scripts/daemon.py` | Passive worker supervision (systemd) |
| `start.sh` | Startup — kills old workers, starts new, reloads Caddy |
## Security Baseline
- No secrets in code — proxy credentials are in `/home/ubuntu/proxy.txt`
- Worker processes bound to 127.0.0.1 only (not exposed publicly)
- Cloudflare CDN terminates TLS and provides DDoS protection at edge
- No secrets in code — proxy credentials in `/home/ubuntu/proxy.txt`
- Workers bound to 127.0.0.1 only
- Cloudflare CDN terminates TLS at edge
- CORS headers restrict cross-origin access
- stats/health/metrics endpoints are read-only, no mutation
- All proxied content is user-facing; no admin endpoints exposed
- stats/health/metrics are read-only
## Engine Guidance
- Complex multi-file changes, architecture evolution → Hermes (here)
- Quick targeted fixes, single-file changes → Hermes
- Deploy, monitor, notify, schedule → Hermes
- Proxy scanning, proxy pool refresh → Hermes cron (30min)
- Proxy pool refresh → Hermes cron (30min)
- Not sure? Start with Hermes — everything runs via Hermes
## Monitoring
- Health endpoint: https://agento3.miscs.dev/health
- Stats dashboard: https://agento3.miscs.dev/stats
- Metrics (Prometheus): https://agento3.miscs.dev/metrics
- Worker logs: /home/ubuntu/ao3-mirror/worker-0.log, worker-1.log
- Daemon log: /home/ubuntu/ao3-mirror/daemon.log
- Systemd: `systemctl status ao3-daemon`, `journalctl -u ao3-daemon`
- Health: https://agento3.miscs.dev/health
- Stats: https://agento3.miscs.dev/stats
- Metrics: https://agento3.miscs.dev/metrics
- SW: https://agento3.miscs.dev/sw.js → /sw-YYYYMMDD.js
## Deployment
## CTO Goal (Persistent Focus)
- Single server (current host)
- Caddy manages Let's Encrypt TLS certs on agento3.miscs.dev
- CF CDN fronts the domain (Orange Cloud = on)
- Proxy pool: WebShare static residential proxies, refreshed every 30min via Hermes cron
- No CI/CD — manual deploy via start.sh
Manage akiba/agento3 as CTO. Triage issues hourly, implement top priority, get founder approval before merging. Never ship without YES.
## Commit Conventions
- One commit per meaningful change
- Python files only (no binaries, no .pyc, no logs, no .env)
- SOUL.md updated to reflect architectural changes
- Python files only (no binaries, .pyc, logs, .env)
- SOUL.md updated for architectural changes
- ao3-mirror skill updated when workflows change

112
Caddyfile
View File

@@ -1,5 +1,5 @@
# AO3 Mirror - Caddy 配置
# 前端负载均衡 + TLS 终止 + Cloudflare CDN 集成
# AO3 Mirror v5 - Caddy 配置
# 前端负载均衡 + TLS 终止 + Cloudflare CDN 集成 + Service Worker
agento3.miscs.dev {
# 全局头
@@ -9,6 +9,7 @@ agento3.miscs.dev {
X-Frame-Options "SAMEORIGIN"
X-XSS-Protection "1; mode=block"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'"
-Server
-X-Powered-By
}
@@ -26,6 +27,60 @@ agento3.miscs.dev {
# 压缩
encode gzip
# Service Worker routes — versioned + redirect
route /sw-* {
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
}
header {
Cache-Control "public, max-age=86400, immutable"
}
}
# SW redirect
route /sw.js {
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
}
header {
Cache-Control "no-store"
}
}
# Mirror domains JSON
route /mirror-domains.json {
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
}
header {
Cache-Control "public, max-age=3600"
}
}
# 后端负载均衡 (轮询) - 2 workers on 2-core machine
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
@@ -34,7 +89,7 @@ agento3.miscs.dev {
health_interval 10s
health_timeout 5s
# 超时配置
# 超时配置 — 代理可能需要更长时间
transport http {
read_timeout 30s
write_timeout 30s
@@ -55,6 +110,7 @@ stats.agento3.miscs.dev {
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'"
-Server
-X-Powered-By
}
@@ -72,41 +128,67 @@ stats.agento3.miscs.dev {
# 压缩
encode gzip
# 路由规则 - 只暴露 /stats /metrics
# 路由规则 - 只暴露 /stats, /metrics, /health, /sw
route {
# /stats 页面 - 代理到后端
# /stats 页面
reverse_proxy /stats* 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 30s
write_timeout 30s
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
# /metrics 指标 - 代理到后端
# /metrics 指标
reverse_proxy /metrics* 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 30s
write_timeout 30s
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
# Service Worker
reverse_proxy /sw* 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}
}
# Mirror domains
reverse_proxy /mirror-domains* 127.0.0.1:8081 127.0.0.1:8082 {
lb_policy round_robin
health_uri /health
health_interval 10s
health_timeout 5s
transport http {
read_timeout 60s
write_timeout 60s
dial_timeout 5s
}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
header_up X-Real-IP {remote_host}

View File

@@ -1,25 +1,22 @@
"""
异步 AO3 内容抓取器 v4Cookie 感知 + CF 挑战检测 + 智能重试
异步 AO3 内容抓取器 v5单次代理请求 + CF 挑战检测
v4 vs v3:
- CF 挑战检测:不再把 403/503 一律当失败
- Cookie 注入:请求时自动携带 proxy 级别的 cookiescf_clearance 等)
- Cookie 保存:成功请求后自动提取 Set-Cookie 并保存到 proxy cookie jar
- 智能重试:挑战时优先用带 cookie 的代理,无 cookie 则换代理
- 挑战页面透传:所有重试都遇到挑战时,返回挑战 HTML 让用户浏览器求解
v5 vs v4:
- 去掉内部重试循环(重试逻辑迁移到 app.py 的 P2C 选择中)
- 简化 API直接接受 ProxySession 对象
- 保留 CF 挑战检测 + 状态机反馈
"""
import asyncio
import logging
import time
from typing import Optional
from proxy_pool import get_proxy_pool, ProxySession
from proxy_pool import ProxySession
logger = logging.getLogger("ao3-fetcher")
# ─── CF Challenge Detection ───────────────────────────────────────────────
# Markers in response body that indicate a Cloudflare challenge page
CF_CHALLENGE_MARKERS = [
b'/cdn-cgi/challenge-platform',
b'cf-challenge-running',
@@ -28,29 +25,24 @@ CF_CHALLENGE_MARKERS = [
b'challenge-platform',
b'cf-turnstile',
b'cf_chl_',
# Sometimes CF just returns "Just a moment..." without JS markers
b'Checking your browser',
b'Just a moment...',
]
def is_cf_challenge(status: int, body: bytes, headers: dict) -> bool:
"""Detect if response is a Cloudflare challenge page (not a real error)."""
"""Detect if response is a Cloudflare challenge page."""
if status not in (403, 503, 429):
return False
# Quick check: CF always sets Server header on challenge pages
server = headers.get("Server", headers.get("server", ""))
if "cloudflare" not in server.lower():
# Check body for challenge markers
for marker in CF_CHALLENGE_MARKERS:
if marker in body:
return True
return False
# Server: cloudflare + non-200 status = likely challenge
for marker in CF_CHALLENGE_MARKERS:
if marker in body:
return True
# If Server is cloudflare and status is 403/503, it's almost certainly a challenge
if status in (403, 503):
return True
return False
@@ -69,31 +61,29 @@ CHROME_HEADERS = {
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"DNT": "1",
}
API_HEADERS = {
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.9",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
}
# ─── Timeout config ───────────────────────────────────────────────────────
FAST_REQUEST_TIMEOUT = 8 # login/register/signup
# Timeout config
FAST_REQUEST_TIMEOUT = 8
NORMAL_REQUEST_TIMEOUT = 15
CONNECT_TIMEOUT = 5
MAX_RETRIES = 2
FAST_MAX_RETRIES = 1
FAST_PATHS = {
"/users/login",
"/users/sign_up",
"/users/new",
"/invitation_requests",
"/token_dispenser.json",
"/user_sessions",
"/users/login", "/users/sign_up", "/users/new",
"/invitation_requests", "/token_dispenser.json", "/user_sessions",
}
@@ -106,200 +96,97 @@ def _is_fast_path(url: str) -> bool:
return False
def _now() -> float:
try:
return asyncio.get_running_loop().time()
except RuntimeError:
return time.time()
def _merge_cookies(proxy_cookies: str, user_cookies: str) -> str:
"""Merge proxy-level cookies (cf_clearance) with user cookies. User cookies take precedence."""
if not proxy_cookies and not user_cookies:
return ""
if not proxy_cookies:
return user_cookies
if not user_cookies:
return proxy_cookies
# User cookies take priority (they contain session auth)
# But put proxy cookies first so user cookies can override
return f"{proxy_cookies}; {user_cookies}"
# ─── Main fetch function ──────────────────────────────────────────────────
async def fetch_url(
url: str,
proxy: Optional[ProxySession] = None,
method: str = "GET",
headers: Optional[dict] = None,
body: Optional[bytes] = None,
cookies: Optional[dict] = None,
is_api: bool = False,
preferred_proxy: Optional[str] = None, # v4: proxy host:port for challenge affinity
) -> dict:
"""
Async fetch from AO3 through proxy pool with cookie-aware session reuse.
Single async fetch through a specific proxy. No internal retry loop.
Priority-based:
- Login/register paths → fast pool + 8s timeout + 1 retry
- Normal paths → cookie-preferring proxies + 15s timeout + 2 retries
Args:
url: Target AO3 URL
proxy: ProxySession to use (must already be selected by app.py)
method: HTTP method
headers: Request headers
body: Request body (POST/PUT/PATCH)
cookies: User cookies dict
is_api: Use API headers instead of Chrome headers
v4 improvements:
- CF challenge detection: don't treat challenge as proxy failure
- Cookie injection: auto-send saved cf_clearance per proxy
- Cookie saving: auto-extract Set-Cookie on success
- Challenge body returned for user-browser solving
Returns: {
"status": 200,
"headers": {...},
"body": b"...",
"cookies": {...},
"success": True/False,
"error": "...",
"elapsed": 0.5,
"proxy_host": "p.webshare.io:10296",
# v4:
"is_challenge": False,
"challenge_body": b"" | None, # CF challenge HTML for user-browser solving
"challenge_proxy": "" | None, # which proxy got the challenge
}
Returns:
dict with success/status/headers/body/cookies/elapsed/is_challenge etc.
"""
pool = get_proxy_pool()
base_headers = API_HEADERS.copy() if is_api else CHROME_HEADERS.copy()
if proxy is None:
return {
"status": 0, "headers": {}, "body": b"", "cookies": {},
"headers_raw": [],
"success": False, "error": "No proxy provided",
"elapsed": 0, "proxy_host": None,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}
host_port = proxy.host_port
start_time = time.time()
base_headers = API_HEADERS.copy() if is_api else CHROME_HEADERS.copy()
if headers:
for h in ["Cookie", "Referer", "Content-Type", "X-Requested-With", "Accept",
"Origin", "X-CSRF-Token", "Authorization"]:
for h in ["Cookie", "Content-Type", "X-Requested-With",
"Accept", "X-CSRF-Token", "Authorization"]:
if h in headers:
base_headers[h] = headers[h]
# Rewrite Origin and Referer: mirror domain → AO3 domain
# AO3 CSRF checks Origin against archiveofourown.org
if "Origin" in headers:
origin = headers["Origin"]
origin = origin.replace("agento3.miscs.dev", "archiveofourown.org")
base_headers["Origin"] = origin
if "Referer" in headers:
referer = headers["Referer"]
referer = referer.replace("agento3.miscs.dev", "archiveofourown.org")
base_headers["Referer"] = referer
# Filter out OUR cookies — proxy is transparent, only AO3 cookies should reach AO3
FORWARD_BLOCKED = {"ao3_sessid_proxy", "__cf_bm", "_cfuvid", "_cf_token"}
user_cookie_str = ""
if cookies:
user_cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
# Determine priority level
is_fast = _is_fast_path(url) or method in ("POST", "PUT", "PATCH")
request_timeout = FAST_REQUEST_TIMEOUT if is_fast else NORMAL_REQUEST_TIMEOUT
max_retries = FAST_MAX_RETRIES if is_fast else MAX_RETRIES
last_error = None
last_challenge_body = None
last_challenge_proxy = None
seen_proxies = set() # Don't retry with same proxy
# v4: if preferred_proxy is set (from challenge affinity), use it first
if preferred_proxy:
try:
target_proxy = None
for p in pool._proxies:
if p.host_port == preferred_proxy:
target_proxy = p
break
if target_proxy and target_proxy.is_available:
result = await _try_proxy(
target_proxy, url, method, body, base_headers, user_cookie_str,
request_timeout, is_fast
)
if result["success"]:
# preferred proxy worked — save cookies
target_proxy.save_cookies(result.get("headers", {}))
return result
if result.get("is_challenge"):
last_challenge_body = result.get("challenge_body")
last_challenge_proxy = target_proxy.host_port
seen_proxies.add(target_proxy.host_port)
except Exception:
pass
for attempt in range(max_retries):
# v4: prefer proxies with cookies, fallback to any available
if attempt == 0:
# First attempt: use proxy with cookies if available
proxy = pool.get_proxy_with_cookies()
if not proxy:
proxy = pool.get_fast_proxy() if is_fast else pool.get_proxy()
elif attempt == 1 and last_challenge_body:
# Second attempt after challenge: try another proxy with cookies
proxy = pool.get_proxy_with_cookies()
# Make sure it's not the same one
if proxy and proxy.host_port in seen_proxies:
proxy = pool.get_fast_proxy() if is_fast else pool.get_proxy()
if not proxy:
proxy = pool.get_fast_proxy() if is_fast else pool.get_proxy()
else:
proxy = pool.get_fast_proxy() if is_fast else pool.get_proxy()
if not proxy or proxy.host_port in seen_proxies:
# Find an unseen proxy
for _ in range(5):
p = pool.get_proxy()
if p and p.host_port not in seen_proxies:
proxy = p
break
if not proxy or proxy.host_port in seen_proxies:
continue
seen_proxies.add(proxy.host_port)
result = await _try_proxy(
proxy, url, method, body, base_headers, user_cookie_str,
request_timeout, is_fast
user_cookie_str = "; ".join(
f"{k}={v}" for k, v in cookies.items()
if k not in FORWARD_BLOCKED
)
if result["success"]:
# Save response cookies to proxy cookie jar for future requests
proxy.save_cookies(result.get("headers", {}))
return result
if result.get("is_challenge"):
last_challenge_body = result.get("challenge_body")
last_challenge_proxy = proxy.host_port
last_error = result.get("error", "CF_CHALLENGE")
# Don't break — try another proxy
continue
# Real failure (connection error, timeout)
last_error = result.get("error", "UNKNOWN")
# All retries exhausted
return {
"status": 0, "headers": {}, "body": b"", "cookies": {},
"success": False,
"error": f"All retries failed: {last_error}",
"elapsed": 0, "proxy_host": None,
"is_challenge": last_challenge_body is not None,
"challenge_body": last_challenge_body,
"challenge_proxy": last_challenge_proxy,
}
async def _try_proxy(
proxy: ProxySession,
url: str,
method: str,
body: Optional[bytes],
base_headers: dict,
user_cookie_str: str,
request_timeout: int,
is_fast: bool,
) -> dict:
"""Try a single request through one proxy. Returns result dict."""
host_port = f"{proxy.host}:{proxy.port}"
start_time = _now()
is_fast = _is_fast_path(url) or method in ("POST", "PUT", "PATCH")
request_timeout = FAST_REQUEST_TIMEOUT if is_fast else NORMAL_REQUEST_TIMEOUT
try:
session = await proxy.get_session()
# v4: Build per-request headers with proxy cookies
# IMPORTANT: use headers= parameter (never session.headers.update() — race condition!)
request_headers = base_headers.copy()
# Inject proxy-level cookies (cf_clearance, etc.)
# Inject proxy-level cookies (cf_clearance)
proxy_cookie_str = proxy.get_cookie_header()
cookie_str = _merge_cookies(proxy_cookie_str, user_cookie_str)
if cookie_str:
request_headers["Cookie"] = cookie_str
# Execute request
request_cookie_preview = cookie_str[:500] + "..." if len(cookie_str) > 500 else cookie_str
logger.info(f"[FETCH] {method} {url[:80]} full_cookie={request_cookie_preview}")
if method == "GET":
resp = await session.get(url, timeout=request_timeout, headers=request_headers)
elif method == "POST":
@@ -310,89 +197,79 @@ async def _try_proxy(
resp = await session.request(method, url, data=body, timeout=request_timeout,
headers=request_headers)
elapsed = _now() - start_time
elapsed = time.time() - start_time
status = resp.status_code
resp_body = resp.content
resp_headers = dict(resp.headers)
resp_headers = dict(resp.headers) # For Content-Type/Location lookups
resp_headers_raw = list(resp.headers.multi_items()) # Preserves ALL Set-Cookie values
# Debug: log response Set-Cookie count
set_cookie_count = sum(1 for k, v in resp_headers_raw if k.lower() == "set-cookie")
resp_status = resp.status_code
body_preview = resp_body[:200].decode('utf-8', errors='replace')
logger.info(f"[FETCH_RESP] {method} {url[:60]} status={resp_status} set_cookies={set_cookie_count} body={body_preview}")
resp_cookies = {}
if hasattr(resp, "cookies"):
for k, v in resp.cookies.items():
resp_cookies[k] = v
# v4: Check for CF challenge
# CF challenge detection
if is_cf_challenge(status, resp_body, resp_headers):
proxy.mark_challenged()
logger.warning(f"[CF_CHALLENGE] {host_port} -> {url[:60]}: {status} ({elapsed:.1f}s)")
logger.warning(f"[CF_CHALLENGE] {host_port} -> {url[:60]}: {status}")
return {
"status": status,
"headers": resp_headers,
"body": resp_body,
"status": status, "headers": resp_headers, "body": resp_body,
"headers_raw": resp_headers_raw,
"cookies": resp_cookies,
"success": False,
"error": f"CF_CHALLENGE_{status}",
"elapsed": elapsed,
"proxy_host": host_port,
"is_challenge": True,
"challenge_body": resp_body,
"success": False, "error": f"CF_CHALLENGE_{status}",
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": True, "challenge_body": resp_body,
"challenge_proxy": host_port,
}
# Success — any 2xx-4xx is proxied through
# Success
if 200 <= status < 500:
proxy.mark_success(elapsed)
logger.debug(f"{host_port} -> {url[:60]}: {status} ({elapsed:.2f}s)")
# v4: Save response cookies to proxy
sc_from_dict = resp_headers.get("Set-Cookie", resp_headers.get("set-cookie", "NONE"))
logger.info(f"[SAVE_COOKIES] proxy={host_port} set_cookie_dict={sc_from_dict[:120]}...")
proxy.save_cookies(resp_headers)
return {
"status": status,
"headers": resp_headers,
"body": resp_body,
"status": status, "headers": resp_headers, "body": resp_body,
"headers_raw": resp_headers_raw,
"cookies": resp_cookies,
"success": True,
"elapsed": elapsed,
"proxy_host": host_port,
"is_challenge": False,
"challenge_body": None,
"challenge_proxy": None,
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}
# True error status (5xx)
proxy.mark_failure()
logger.warning(f"Attempt: {host_port} -> {url[:60]}: {status}")
# Error status
logger.warning(f"{host_port} -> {url[:60]}: {status}")
return {
"status": status,
"headers": resp_headers,
"body": resp_body,
"status": status, "headers": resp_headers, "body": resp_body,
"headers_raw": resp_headers_raw,
"cookies": resp_cookies,
"success": False,
"error": f"HTTP_{status}",
"elapsed": elapsed,
"proxy_host": host_port,
"is_challenge": False,
"challenge_body": None,
"challenge_proxy": None,
"success": False, "error": f"HTTP_{status}",
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}
except asyncio.TimeoutError:
proxy.mark_failure()
elapsed = _now() - start_time
logger.warning(f"TIMEOUT: {host_port} -> {url[:60]} ({elapsed:.1f}s, timeout={request_timeout}s)")
elapsed = time.time() - start_time
logger.warning(f"TIMEOUT: {host_port} -> {url[:60]}")
return {
"status": 0, "headers": {}, "body": b"", "cookies": {},
"headers_raw": [],
"success": False, "error": "TIMEOUT",
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
}
except Exception as e:
proxy.mark_failure()
elapsed = _now() - start_time
elapsed = time.time() - start_time
err_str = str(e)[:120]
logger.debug(f"ERROR: {host_port} -> {e} ({elapsed:.1f}s)")
logger.debug(f"ERROR: {host_port} -> {err_str}")
return {
"status": 0, "headers": {}, "body": b"", "cookies": {},
"headers_raw": [],
"success": False, "error": err_str,
"elapsed": elapsed, "proxy_host": host_port,
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,

590
app.py
View File

@@ -1,13 +1,17 @@
"""
AO3 反代后端 v4Cookie 感知 + CF 挑战用户浏览器求解
AO3 反代后端 v5粘性会话 + Service Worker + P2C 代理池 + 并发限流
v4 vs v3:
- Challenge token 映射:用户浏览器求解 CF 挑战时,保证同一 proxy 亲和性
- 挑战页面透传:所有代理都遇到 CF 挑战时,把挑战页发给用户浏览器求解
- Cookie 流:成功响应自动保存 proxy cookie后续请求自动携带
- 统计面板展示 cookie-aware 代理数
v5 vs v4:
- 粘性会话: ao3_sessid_proxy cookie (对标 go3)
- Service Worker: /sw.js, /sw-YYYYMMDD.js for client-side caching
- HTML 注入: 自动注入 SW 注册脚本 + 镜像域名
- P2C 代理选择: Power of Two Choices (对标 go3)
- 并发限流: asyncio.Semaphore(400) (Webshare 上限)
- 全量 5000 代理: 不再按端口过滤
- 状态机: healthy/unstable/blocked/probing
"""
import hashlib
import html
import json
import logging
import os
@@ -23,9 +27,9 @@ import uvicorn
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from proxy_pool import get_proxy_pool
from ao3_fetcher import fetch_url, is_cf_challenge
from url_rewriter import rewrite_body, rewrite_response_headers, needs_rewrite, MIRROR_DOMAIN
from proxy_pool import get_proxy_pool, MAX_CONCURRENT_REQUESTS, OUTAGE_THRESHOLD
from ao3_fetcher import fetch_url, is_cf_challenge, FAST_REQUEST_TIMEOUT, NORMAL_REQUEST_TIMEOUT
from url_rewriter import rewrite_body, rewrite_response_headers, rewrite_response_headers_raw, needs_rewrite, MIRROR_DOMAIN
from cache import get_cache, get_ttl_for_path
from stats import get_stats_collector
@@ -39,13 +43,15 @@ logger = logging.getLogger("ao3-backend")
AO3_BASE = "https://archiveofourown.org"
MIRROR_HOST = MIRROR_DOMAIN
# Service Worker version date (change on SW updates)
SERVICE_WORKER_DATE = "20260623"
LOCAL_PATHS = {"/stats", "/health", "/metrics", "/favicon.ico", "/robots.txt"}
# ─── Challenge Token Map (v4) ──────────────────────────────────────────────
# Maps challenge_token → (method, ao3_url, headers, body, cookies, proxy_host, expires)
# ─── Challenge Token Map (v4 retained) ──────────────────────────────────────
_challenge_map: dict[str, tuple] = {}
_challenge_lock = threading.Lock()
CHALLENGE_TOKEN_TTL = 120 # 2 minutes for the user's browser to solve the challenge
CHALLENGE_TOKEN_TTL = 120
def _make_challenge_token() -> str:
@@ -55,7 +61,6 @@ def _make_challenge_token() -> str:
def _store_challenge(token: str, method: str, ao3_url: str, headers: dict,
body: bytes, cookies: dict, proxy_host: str):
with _challenge_lock:
# Clean expired tokens
now = time.time()
expired = [k for k, v in _challenge_map.items() if v[6] < now]
for k in expired:
@@ -71,16 +76,38 @@ def _get_challenge(token: str) -> tuple | None:
del _challenge_map[token]
return entry
if entry:
del _challenge_map[token] # expired
del _challenge_map[token]
return None
# ─── Sticky Session ─────────────────────────────────────────────────────────
STICKY_COOKIE_NAME = "ao3_sessid_proxy"
def get_sticky_proxy_idx(request: Request) -> tuple[int, bool]:
"""Get sticky proxy index from cookie. Returns (idx, had_sticky)."""
c = request.cookies.get(STICKY_COOKIE_NAME)
if c is not None:
try:
idx = int(c)
pool = get_proxy_pool()
if 0 <= idx < len(pool.proxies):
return idx, True
except (ValueError, TypeError):
pass
# No sticky cookie — random
import random
pool = get_proxy_pool()
return random.randint(0, max(len(pool.proxies) - 1, 0)), False
# ─── App ───────────────────────────────────────────────────────────────────
app = FastAPI(
title="AO3 Mirror",
description="AO3 reverse proxy mirror for Chinese users",
version="4.0.0",
version="5.0.0",
docs_url=None,
redoc_url=None,
)
@@ -106,31 +133,51 @@ def build_ao3_url(path: str, query: str = "") -> str:
# ─── Challenge page URL rewriting ──────────────────────────────────────────
def _rewrite_challenge_page(body: bytes, proxy_host: str, token: str) -> bytes:
"""Rewrite CF challenge page so all URLs go through mirror, and tag with challenge token."""
import re
# Basic AO3 domain rewrite
"""Rewrite CF challenge page — inject retry UI + auto cookie."""
# Rewrite domain
body = body.replace(b"archiveofourown.org", MIRROR_DOMAIN.encode())
# Rewrite CF challenge paths — our CF CDN would intercept /cdn-cgi/,
# so we remap to /__cf/ which our proxy handler forwards to AO3
body = body.replace(b"/cdn-cgi/", b"/__cf/")
body = body.replace(b"cdn-cgi/challenge", b"__cf/challenge")
# Rewrite /cdn-cgi/ challenge endpoints to go through mirror
# These are CF's internal challenge platform URLs
body = body.replace(
b"/cdn-cgi/challenge-platform",
f"/cdn-cgi/challenge-platform".encode()
# Inject cookie + retry overlay
retry_overlay = (
f'<script>document.cookie="_cf_token={token};path=/;max-age={CHALLENGE_TOKEN_TTL}";</script>'.encode()
+ b'<style>#cf-retry-overlay{position:fixed;top:0;left:0;right:0;background:#990000;color:#fff;text-align:center;padding:12px;z-index:99999;font-family:sans-serif}'
+ b'#cf-retry-overlay a{color:#fff;font-weight:bold;text-decoration:underline}</style>'
+ b'<div id="cf-retry-overlay">'
+ 'AO3 Mirror - CF challenge solving in background. '.encode()
+ '<a href="javascript:location.reload()">Click to retry</a> after a few seconds.'.encode()
+ b'</div>'
)
# Inject a marker meta tag so we can detect challenge pages coming back
marker = f'<meta name="cf-challenge-proxy" content="{proxy_host}">'.encode()
marker_cookie = (
f'<script>document.cookie="_cf_token={token};path=/;max-age={CHALLENGE_TOKEN_TTL}";</script>'
).encode()
body = body.replace(b"<head>", b"<head>" + marker + marker_cookie, 1)
if b"<head>" not in body:
body = b"<head>" + marker + marker_cookie + b"</head>" + body
body = body.replace(b"<body", retry_overlay + b"<body", 1)
if b"<body" not in body:
body = retry_overlay + body
return body
# ─── HTML injection (Service Worker) ───────────────────────────────────────
def inject_service_worker(html_body: bytes) -> bytes:
"""Inject SW registration script into HTML, before </head>."""
import re
sw_script = (
b'<script>'
b'if("serviceWorker"in navigator){'
b'navigator.serviceWorker.register("/sw.js",{scope:"/"}).catch(function(){})'
b'}'
b'</script>'
)
# Match </head> with optional leading whitespace
match = re.search(rb'\s*</head>', html_body)
if match:
pos = match.start()
return html_body[:pos] + sw_script + html_body[pos:]
# Fallback: insert at beginning if no head tag
return sw_script + html_body
# ─── Response header helpers ───────────────────────────────────────────────
def filter_response_headers(headers: dict) -> dict:
@@ -149,7 +196,7 @@ def filter_response_headers(headers: dict) -> dict:
if key.lower().startswith("cf-"):
continue
result[key] = value
result["X-Proxy"] = "AO3-Mirror/4.0"
result["X-Proxy"] = "AO3-Mirror/5.0"
result["X-Cache"] = "MISS"
result["Cache-Control"] = "public, max-age=60, s-maxage=60"
return result
@@ -169,15 +216,12 @@ def add_cors(response: Response):
async def health():
pool = get_proxy_pool()
stats = pool.get_stats()
resp = JSONResponse({
return JSONResponse({
"status": "ok",
"timestamp": time.time(),
"version": "4.0.0",
"version": "5.0.0",
"proxy_pool": stats,
})
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
resp.headers["Pragma"] = "no-cache"
return resp
@app.get("/robots.txt")
@@ -187,6 +231,43 @@ async def robots():
)
# ─── Service Worker Routes ─────────────────────────────────────────────────
@app.get("/sw.js")
async def service_worker_redirect():
"""Redirect to versioned SW file."""
return RedirectResponse(url=f"/sw-{SERVICE_WORKER_DATE}.js", status_code=302)
@app.get("/sw-{date}.js")
async def service_worker(date: str):
"""Serve versioned Service Worker."""
sw_path = os.path.join(os.path.dirname(__file__), "static", "sw.js")
try:
with open(sw_path, "rb") as f:
sw_content = f.read()
except FileNotFoundError:
return Response(status_code=404)
return Response(
content=sw_content,
media_type="application/javascript",
headers={
"Cache-Control": "public, max-age=86400, immutable",
"Service-Worker-Allowed": "/",
},
)
@app.get("/mirror-domains.json")
async def mirror_domains():
"""JSON endpoint for SW cross-domain awareness."""
return JSONResponse({
"domains": [MIRROR_DOMAIN],
"primary": MIRROR_DOMAIN,
})
# ─── Stats Dashboard ───────────────────────────────────────────────────────
@app.get("/stats", response_class=HTMLResponse)
@@ -213,7 +294,7 @@ async def stats_page():
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AO3 Mirror - 统计面板 v4</title>
<title>AO3 Mirror - 统计面板 v5</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
@@ -241,12 +322,13 @@ async def stats_page():
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.8em; }}
.badge-green {{ background: rgba(74, 222, 128, 0.15); color: #4ade80; }}
.badge-red {{ background: rgba(248, 113, 113, 0.15); color: #f87171; }}
.badge-yellow {{ background: rgba(251, 191, 36, 0.15); color: #fbbf24; }}
@media (max-width: 768px) {{ .chart-row {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="container">
<h1>🛡 AO3 Mirror 统计面板 v4</h1>
<h1>🛡 AO3 Mirror 统计面板 v5</h1>
<div class="grid">
<div class="card">
@@ -275,14 +357,14 @@ async def stats_page():
<div class="sub">P99: {stats['p99_elapsed_ms']} ms</div>
</div>
<div class="card">
<div class="label">代理池</div>
<div class="value green">{proxy_stats['alive']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
<div class="sub">可用: {proxy_stats['available']} | 受损: {proxy_stats['dead']} | Banned: {proxy_stats['banned']}</div>
<div class="label">代理池 ({proxy_stats['total']})</div>
<div class="value green">{proxy_stats['healthy']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
<div class="sub">不稳定: {proxy_stats['unstable']} | 封禁: {proxy_stats['blocked']} | 探测: {proxy_stats['probing']}</div>
</div>
<div class="card">
<div class="label">Cookie 代理</div>
<div class="value orange">{proxy_stats['with_cookies']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['alive']}</span></div>
<div class="sub">已持有 cf_clearance</div>
<div class="value orange">{proxy_stats['with_cookies']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
<div class="sub">inflight: {proxy_stats['total_inflight']}</div>
</div>
<div class="card">
<div class="label">本地缓存</div>
@@ -306,7 +388,7 @@ async def stats_page():
<h2>热门路径</h2>
<table>
<tr><th>路径</th><th>请求数</th><th>成功</th><th>平均延迟</th></tr>
{''.join(f'<tr><td>{p["path"]}</td><td>{p["requests"]:,}</td><td><span class="badge {"badge-green" if p["requests"]==0 or p["successful"]/max(p["requests"],1)>0.8 else "badge-red"}">{round(p["successful"]/max(p["requests"],1)*100)}%</span></td><td>{p["avg_elapsed"]*1000:.0f}ms</td></tr>' for p in stats['top_paths'][:15])}
{''.join(f'<tr><td>{html.escape(p["path"])}</td><td>{p["requests"]:,}</td><td><span class="badge {"badge-green" if p["requests"]==0 or p["successful"]/max(p["requests"],1)>0.8 else "badge-red"}">{round(p["successful"]/max(p["requests"],1)*100)}%</span></td><td>{p["avg_elapsed"]*1000:.0f}ms</td></tr>' for p in stats['top_paths'][:15])}
</table>
</div>
</div>
@@ -394,11 +476,13 @@ async def metrics():
f'ao3_mirror_avg_elapsed_ms {stats["avg_elapsed_ms"]}',
"# HELP ao3_mirror_proxy_pool Proxy pool status",
"# TYPE ao3_mirror_proxy_pool gauge",
f'ao3_mirror_proxy_alive {proxy_stats["alive"]}',
f'ao3_mirror_proxy_dead {proxy_stats["dead"]}',
f'ao3_mirror_proxy_banned {proxy_stats["banned"]}',
f'ao3_mirror_proxy_available {proxy_stats["available"]}',
f'ao3_mirror_proxy_total {proxy_stats["total"]}',
f'ao3_mirror_proxy_healthy {proxy_stats["healthy"]}',
f'ao3_mirror_proxy_unstable {proxy_stats["unstable"]}',
f'ao3_mirror_proxy_blocked {proxy_stats["blocked"]}',
f'ao3_mirror_proxy_probing {proxy_stats["probing"]}',
f'ao3_mirror_proxy_with_cookies {proxy_stats["with_cookies"]}',
f'ao3_mirror_proxy_inflight {proxy_stats["total_inflight"]}',
"# HELP ao3_mirror_cache_size Current cache size",
"# TYPE ao3_mirror_cache_size gauge",
f'ao3_mirror_cache_size {cache_stats["size"]}',
@@ -406,11 +490,11 @@ async def metrics():
return PlainTextResponse("\n".join(lines))
# ─── Proxy Core (v4) ──────────────────────────────────────────────────────
# ─── Proxy handler (v5) ──────────────────────────────────────────────────
@app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH"])
async def proxy_handler(request: Request, path: str):
"""Main proxy handler — with cookie-aware fetching and CF challenge solving."""
"""Main proxy handler — sticky sessions + P2C selection + semaphore-limited."""
start_time = time.time()
client_ip = get_client_ip(request)
@@ -418,88 +502,93 @@ async def proxy_handler(request: Request, path: str):
path = ""
full_path = f"/{path}" if path else "/"
if full_path in LOCAL_PATHS or full_path.startswith("/stats") or full_path.startswith("/health"):
if full_path in LOCAL_PATHS or full_path.startswith("/sw"):
return JSONResponse({"error": "Not found"}, status_code=404)
# Remap /__cf/ → /cdn-cgi/ (CF challenge paths bypass our CDN)
if full_path.startswith("/__cf/"):
path = "cdn-cgi" + path[4:]
full_path = "/" + path
if request.method == "OPTIONS":
resp = Response()
add_cors(resp)
return resp
# ── v4: Challenge token check ──────────────────────────────────────────
# If this request has a _cf_token cookie, it's part of a challenge resolution flow
pool = get_proxy_pool()
query_string = request.url.query
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
# ── v4: Challenge token check ────────────────────────────────────────
cf_token = request.cookies.get("_cf_token") or request.query_params.get("_cf_token")
preferred_proxy = None
if cf_token:
challenge_entry = _get_challenge(cf_token)
if challenge_entry:
orig_method, orig_url, orig_headers, orig_body, orig_cookies, proxy_host, _ = challenge_entry
preferred_proxy = proxy_host
logger.info(f"Challenge resolution: using proxy {proxy_host} for {orig_url[:60]}")
# Re-fetch the original request with the challenge proxy
logger.info(f"Challenge resolution: using proxy {proxy_host}")
challenge_proxy_obj = pool.get_by_addr(proxy_host)
result = await fetch_url(
url=orig_url, method=orig_method,
proxy=challenge_proxy_obj,
headers=dict(request.headers),
body=await request.body() if orig_method in ("POST", "PUT", "PATCH") else None,
cookies=orig_cookies,
is_api="/api/" in orig_url,
preferred_proxy=preferred_proxy,
)
if result["success"]:
# Challenge solved! Proxy now has cf_clearance. Return content.
elapsed = time.time() - start_time
rewritten_body = rewrite_body(result["body"],
result["headers"].get("Content-Type", ""))
result["headers"].get("Content-Type", result["headers"].get("content-type", "")))
content_type = result["headers"].get("Content-Type", result["headers"].get("content-type", ""))
if "text/html" in content_type:
rewritten_body = inject_service_worker(rewritten_body)
rewritten_headers = rewrite_response_headers(result["headers"])
final_headers = filter_response_headers(rewritten_headers)
final_headers.pop("Set-Cookie", None)
final_headers.pop("set-cookie", None)
final_headers["X-Cache"] = "MISS"
final_headers["X-CF-Status"] = "solved"
# Set cf_clearance as a cookie on the mirror domain too
# so subsequent requests benefit
resp = Response(content=rewritten_body, status_code=result["status"],
headers=final_headers)
# Forward any Set-Cookie from AO3 (includes cf_clearance)
for k, v in result["headers"].items():
if k.lower() == "set-cookie":
# Rewrite domain
v_rewritten = v.replace("domain=archiveofourown.org",
f"domain={MIRROR_DOMAIN}")
v_rewritten = v_rewritten.replace("domain=.archiveofourown.org",
f"domain=.{MIRROR_DOMAIN}")
resp.headers.add("Set-Cookie", v_rewritten)
# Clean up challenge token cookie
# Append ALL Set-Cookie from raw (preserves multi-value)
# Filter: strip AO3 CF cookies
SKIP_CF = {"__cf_bm", "_cfuvid"}
ao3_headers_raw = result.get("headers_raw", [])
if ao3_headers_raw:
rewritten_raw = rewrite_response_headers_raw(ao3_headers_raw)
for key, value in rewritten_raw:
if key.lower() == "set-cookie":
cookie_name = value.split("=")[0].split(";")[0].strip()
if cookie_name in SKIP_CF:
continue
resp.headers.append("Set-Cookie", value)
resp.delete_cookie("_cf_token", path="/")
collector = get_stats_collector()
collector.log_request(method=orig_method, path=full_path,
status=result["status"], elapsed=elapsed,
cached=False, proxy_host=preferred_proxy,
cached=False, proxy_host=proxy_host,
client_ip=client_ip)
add_cors(resp)
return resp
# ── Normal request flow ────────────────────────────────────────────────
query_string = request.url.query
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
# Check cache
# ── Static cache check (static assets only) ────────────────────
from cache import is_static_path
cache = get_cache()
cached = cache.get(ao3_url, dict(request.headers))
if cached:
body, resp_headers, status = cached
elapsed = time.time() - start_time
collector = get_stats_collector()
collector.log_request(method=request.method, path=full_path, status=status,
elapsed=elapsed, cached=True, client_ip=client_ip)
headers = filter_response_headers(resp_headers)
headers["X-Cache"] = "HIT"
return Response(content=body, status_code=status, headers=headers)
if request.method == "GET" and is_static_path(full_path):
cached = cache.get(ao3_url)
if cached:
body, resp_headers, status = cached
elapsed = time.time() - start_time
collector = get_stats_collector()
collector.log_request(method=request.method, path=full_path, status=status,
elapsed=elapsed, cached=True, client_ip=client_ip)
return Response(content=body, status_code=status, headers=resp_headers)
# Prepare request
# ── Prepare request ──────────────────────────────────────────────────
method = request.method
client_headers = dict(request.headers)
req_body = None
@@ -514,101 +603,167 @@ async def proxy_handler(request: Request, path: str):
k, v = pair.split("=", 1)
cookies[k.strip()] = v.strip()
# Forward to AO3
result = await fetch_url(
url=ao3_url, method=method,
headers=client_headers, body=req_body, cookies=cookies,
is_api="/api/" in full_path or full_path.startswith("/api/"),
preferred_proxy=preferred_proxy,
)
# ── v5: Sticky proxy + P2C selection with semaphore ──────────────────
sticky_idx, had_sticky = get_sticky_proxy_idx(request)
final_proxy_idx = sticky_idx
tried_idxs: set[int] = set()
import random
is_fast = method in ("POST", "PUT", "PATCH") or "/users/" in full_path
request_timeout = FAST_REQUEST_TIMEOUT if is_fast else NORMAL_REQUEST_TIMEOUT
# Try with sticky first, then P2C fallback
has_session = bool(cookies) # Stateful request: has user cookies
session_cookie_val = cookies.get("_otwarchive_session", "")[:80]
logger.info(f"Request {method} {full_path} | cookies={list(cookies.keys())} | session={session_cookie_val}... | has_session={has_session}")
async with pool.concurrency_limiter:
# First attempt: sticky proxy
result = None
sticky_proxy = pool.sticky_select(sticky_idx, tried_idxs)
if sticky_proxy:
sticky_proxy.inflight += 1
tried_idxs.add(sticky_proxy.idx)
try:
result = await fetch_url(
url=ao3_url, method=method,
proxy=sticky_proxy,
headers=client_headers, body=req_body, cookies=cookies,
is_api="/api/" in full_path,
)
if result["success"]:
final_proxy_idx = sticky_proxy.idx
sticky_proxy.mark_success(result.get("elapsed", 0))
sticky_proxy.inflight -= 1
elif result.get("is_challenge"):
sticky_proxy.mark_challenged()
sticky_proxy.inflight -= 1
else:
status = result.get("status", 0)
if status in (403, 525):
sticky_proxy.mark_blocked()
else:
sticky_proxy.mark_failure(is_network=True)
sticky_proxy.inflight -= 1
result = None # Force retry
except Exception:
if sticky_proxy:
sticky_proxy.mark_failure(is_network=True)
sticky_proxy.inflight -= 1
result = None
# Retry loop with P2C — but NEVER for stateful requests
# If the sticky proxy failed on a request with cookies, switching
# proxies breaks CSRF/session affinity. Forward CF challenge instead.
if (not result or not result["success"]) and not has_session:
max_retries = 5
for attempt in range(max_retries):
if attempt > 0:
await _sleep(0.1 * (2 ** min(attempt, 3)))
proxy = pool.p2c_select(exclude_idxs=tried_idxs)
if not proxy:
break
proxy.inflight += 1
tried_idxs.add(proxy.idx)
try:
result = await fetch_url(
url=ao3_url, method=method,
proxy=proxy,
headers=client_headers, body=req_body, cookies=cookies,
is_api="/api/" in full_path,
)
if result["success"]:
final_proxy_idx = proxy.idx
proxy.mark_success(result.get("elapsed", 0))
proxy.inflight -= 1
break
elif result.get("is_challenge"):
proxy.mark_challenged()
proxy.inflight -= 1
else:
status = result.get("status", 0)
if status in (403, 525):
proxy.mark_blocked()
elif status > 0:
proxy.mark_failure(is_network=False)
else:
proxy.mark_failure(is_network=True)
proxy.inflight -= 1
result = None
except Exception:
proxy.mark_failure(is_network=True)
proxy.inflight -= 1
result = None
elapsed = time.time() - start_time
# ── v4: CF Challenge handling ──────────────────────────────────────────
if result.get("is_challenge") and result.get("challenge_body"):
challenge_proxy = result.get("challenge_proxy", "unknown")
challenge_body = result["challenge_body"]
# ── All proxies exhausted — outage ───────────────────────────────────
if not result or not result["success"]:
if result and result.get("is_challenge") and result.get("challenge_body"):
challenge_proxy = result.get("challenge_proxy", "unknown")
token = _make_challenge_token()
_store_challenge(token, method, ao3_url, client_headers,
req_body or b"", cookies, challenge_proxy)
rewritten_challenge = _rewrite_challenge_page(
result["challenge_body"], challenge_proxy, token
)
logger.warning(f"CF Challenge via {challenge_proxy}, sending to browser")
collector = get_stats_collector()
collector.log_request(method=method, path=full_path, status=503,
elapsed=elapsed, cached=False,
proxy_host=challenge_proxy, client_ip=client_ip)
resp = HTMLResponse(
content=rewritten_challenge,
status_code=200, # 200 so browser renders and executes CF JS
headers={
"X-CF-Challenge": "true",
"X-CF-Challenge-Proxy": challenge_proxy,
},
)
resp.set_cookie(
key="_cf_token", value=token,
path="/", max_age=CHALLENGE_TOKEN_TTL,
httponly=False, samesite="lax",
)
resp.delete_cookie("cf_clearance", path="/")
add_cors(resp)
return resp
# Generate challenge token for proxy affinity
token = _make_challenge_token()
_store_challenge(token, method, ao3_url, client_headers,
req_body or b"", cookies, challenge_proxy)
# Check if we hit outage threshold
if pool.pool_unavailable_ratio() >= OUTAGE_THRESHOLD:
logger.error(f"OUTAGE: {pool.pool_unavailable_ratio():.1%} proxies unavailable")
return HTMLResponse(
content=_outage_page(), status_code=503
)
# Rewrite challenge page for user's browser
rewritten_challenge = _rewrite_challenge_page(challenge_body, challenge_proxy, token)
logger.warning(
f"CF Challenge detected via {challenge_proxy} for {ao3_url[:80]}. "
f"Sending challenge to user browser (token={token[:8]}...)"
)
collector = get_stats_collector()
collector.log_request(method=method, path=full_path, status=503,
elapsed=elapsed, cached=False,
proxy_host=challenge_proxy, client_ip=client_ip)
# Return challenge page with 503 status + token cookie
resp = HTMLResponse(
content=rewritten_challenge,
status_code=503,
headers={
"X-CF-Challenge": "true",
"X-CF-Challenge-Proxy": challenge_proxy,
"Retry-After": "5",
},
)
resp.set_cookie(
key="_cf_token", value=token,
path="/", max_age=CHALLENGE_TOKEN_TTL,
httponly=False, # JS needs to read it
samesite="lax",
)
resp.delete_cookie("cf_clearance", path="/") # Clear stale clearance
add_cors(resp)
return resp
# ── Standard failure ───────────────────────────────────────────────────
if not result["success"]:
logger.error(f"Failed to fetch {ao3_url[:80]}: {result.get('error', 'unknown')}")
logger.error(f"Failed: {ao3_url[:80]}: all retries exhausted")
collector = get_stats_collector()
collector.log_request(method=method, path=full_path, status=502,
elapsed=elapsed, cached=False, client_ip=client_ip)
return HTMLResponse(content=_transient_error_page(), status_code=502)
error_html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>AO3 Mirror - 暂时不可用</title>
<style>
body {{ font-family: sans-serif; text-align: center; padding: 50px; background: #0f0f1a; color: #e0e0e0; }}
h1 {{ color: #990000; }}
.card {{ background: #1a1a2e; border-radius: 10px; padding: 30px; max-width: 500px; margin: 30px auto; border: 1px solid #2a2a40; }}
.btn {{ display: inline-block; padding: 10px 24px; background: #990000; color: white; text-decoration: none; border-radius: 6px; margin-top: 15px; }}
</style></head>
<body>
<div class="card">
<h1>🔄 正在尝试连接 AO3</h1>
<p>镜像站正在尝试通过代理重新连接 AO3 服务器。</p>
<p style="color: #888; font-size: 0.9em;">请稍后刷新页面重试。</p>
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
</div>
</body>
</html>"""
return HTMLResponse(content=error_html, status_code=502)
# ── Success ────────────────────────────────────────────────────────────
# ── Success ──────────────────────────────────────────────────────────
ao3_status = result["status"]
ao3_headers = result.get("headers", {})
ao3_headers_raw = result.get("headers_raw", [])
raw_body = result.get("body", b"")
content_type = ao3_headers.get("Content-Type", "")
content_type = ao3_headers.get("Content-Type", ao3_headers.get("content-type", ""))
rewritten_body = rewrite_body(raw_body, content_type)
# Inject Service Worker into HTML responses
if "text/html" in content_type:
rewritten_body = inject_service_worker(rewritten_body)
rewritten_headers = rewrite_response_headers(ao3_headers)
final_headers = filter_response_headers(rewritten_headers)
# Remove Set-Cookie from dict — multi-value Set-Cookie from raw below
final_headers.pop("Set-Cookie", None)
final_headers.pop("set-cookie", None)
final_headers["X-Cache"] = "MISS"
# Cache successful GET responses
if method == "GET" and 200 <= ao3_status < 400:
ttl = get_ttl_for_path(full_path)
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status, ttl=ttl)
# Cache successful static GET responses (disk MD5 cache, like go3)
if method == "GET" and ao3_status == 200 and is_static_path(full_path):
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status)
# Handle redirects
if 300 <= ao3_status < 400 and "location" in rewritten_headers:
@@ -624,27 +779,94 @@ h1 {{ color: #990000; }}
client_ip=client_ip)
resp = Response(content=rewritten_body, status_code=ao3_status, headers=final_headers)
# Forward Set-Cookie from AO3 (user session cookies + cf_clearance)
for k, v in ao3_headers.items():
if k.lower() == "set-cookie":
v_rewritten = v.replace("domain=archiveofourown.org",
f"domain={MIRROR_DOMAIN}")
v_rewritten = v_rewritten.replace("domain=.archiveofourown.org",
f"domain=.{MIRROR_DOMAIN}")
resp.headers.add("Set-Cookie", v_rewritten)
# Append ALL Set-Cookie headers from raw (preserves multi-value — pitfall #18a)
# Filter: strip AO3 CF cookies — our own CF edge handles these
SKIP_COOKIES = {"__cf_bm", "_cfuvid"}
if ao3_headers_raw:
rewritten_raw = rewrite_response_headers_raw(ao3_headers_raw)
for key, value in rewritten_raw:
if key.lower() == "set-cookie":
cookie_name = value.split("=")[0].split(";")[0].strip()
if cookie_name in SKIP_COOKIES:
continue
logger.info(f"[SET_COOKIE_FWD] {value[:120]}...")
resp.headers.append("Set-Cookie", value)
# Set sticky session cookie
resp.set_cookie(
key=STICKY_COOKIE_NAME,
value=str(final_proxy_idx),
path="/",
httponly=True,
samesite="lax",
)
add_cors(resp)
return resp
# ─── Error pages ───────────────────────────────────────────────────────────
def _outage_page() -> str:
return """<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>AO3 Mirror - 暂时不可用</title>
<style>
body{font-family:sans-serif;text-align:center;padding:50px;background:#0f0f1a;color:#e0e0e0}
h1{color:#990000}
.card{background:#1a1a2e;border-radius:10px;padding:30px;max-width:500px;margin:30px auto;border:1px solid #2a2a40}
.btn{display:inline-block;padding:10px 24px;background:#990000;color:#fff;text-decoration:none;border-radius:6px;margin-top:15px}
</style></head>
<body>
<div class="card">
<h1>⚠️ AO3 暂时不可用</h1>
<p>所有代理目前都无法连接 AO3。可能 AO3 正在维护或大规模封锁。</p>
<p style="color:#888">请稍后重试。如果持续出现,请切换到数据流量。</p>
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
</div>
</body>
</html>"""
def _transient_error_page() -> str:
return """<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>AO3 Mirror - 连接失败</title>
<style>
body{font-family:sans-serif;text-align:center;padding:50px;background:#0f0f1a;color:#e0e0e0}
h1{color:#990000}
.card{background:#1a1a2e;border-radius:10px;padding:30px;max-width:500px;margin:30px auto;border:1px solid #2a2a40}
.btn{display:inline-block;padding:10px 24px;background:#990000;color:#fff;text-decoration:none;border-radius:6px;margin-top:15px}
</style></head>
<body>
<div class="card">
<h1>🔄 正在尝试连接 AO3</h1>
<p>镜像站正在通过代理重新连接 AO3 服务器。</p>
<p style="color:#888">请稍后刷新页面重试。</p>
<a class="btn" href="/" onclick="location.reload()">刷新页面</a>
</div>
</body>
</html>"""
# ─── Helper ────────────────────────────────────────────────────────────────
async def _sleep(seconds: float):
import asyncio
await asyncio.sleep(seconds)
# ─── Startup / Shutdown ────────────────────────────────────────────────────
@app.on_event("startup")
async def startup():
logger.info("AO3 Mirror backend v4 starting up...")
get_proxy_pool()
logger.info("AO3 Mirror backend v5 starting up...")
pool = get_proxy_pool()
get_cache()
get_stats_collector()
logger.info("AO3 Mirror backend v4 started (cookie-aware + CF challenge solving)")
logger.info(f"AO3 Mirror backend v5 started ({len(pool.proxies)} proxies, "
f"P2C selection, sticky sessions, SW deploy)")
@app.on_event("shutdown")
@@ -660,7 +882,7 @@ async def shutdown():
if __name__ == "__main__":
port = int(os.environ.get("PORT", "8080"))
workers = int(os.environ.get("WORKERS", "4"))
logger.info(f"Starting AO3 Mirror v4 on port {port} with {workers} workers")
logger.info(f"Starting AO3 Mirror v5 on port {port} with {workers} workers")
uvicorn.run(
"app:app",

225
cache.py
View File

@@ -1,123 +1,164 @@
"""
简单高效的缓存层
- 内存 LRU 缓存,每个 worker 独立
- 短 TTL 避免内容过时
- 针对不同路径设置不同 TTL
"""
静态资源磁盘缓存 v5 — 对标 go3
只缓存明确是静态资源的路径CSS/JS/图片/字体)。
动态 HTML / API / 登录流程 零缓存 — 直接透传。
缓存文件以 MD5(URL) 命名,存储在 cache/ 目录。
go3 参考: staticExtensions map + MD5 cache
"""
import hashlib
import logging
import os
import threading
import time
from collections import OrderedDict
from typing import Optional
logger = logging.getLogger("ao3-cache")
class LRUCache:
"""Thread-safe LRU cache with TTL support."""
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
def __init__(self, capacity: int = 2000, default_ttl: int = 30):
self.capacity = capacity
self.default_ttl = default_ttl
self._cache: OrderedDict[str, tuple[float, bytes, dict, int]] = OrderedDict()
# (expiry_time, body, headers, status)
self._lock = threading.RLock()
# Only cache these extensions — everything else passes through
STATIC_EXTENSIONS = {
".css", ".js", ".jpg", ".jpeg", ".png", ".gif", ".ico",
".woff", ".woff2", ".ttf", ".svg", ".webp",
}
def _make_key(self, url: str, headers: Optional[dict] = None) -> str:
"""Generate cache key from URL and key headers."""
# Use URL + relevant headers
accept = ""
if headers:
accept = headers.get("Accept-Encoding", "")
raw = f"{url}|{accept}"
return hashlib.md5(raw.encode()).hexdigest()
# Path prefixes that are always static
STATIC_PATH_PREFIXES = (
"/stylesheets/", "/javascripts/", "/images/", "/media/",
"/skins/", "/assets/", "/favicon.ico",
)
def get(self, url: str, headers: Optional[dict] = None) -> Optional[tuple[bytes, dict, int]]:
"""Get cached response. Returns (body, headers, status) or None."""
key = self._make_key(url, headers)
with self._lock:
if key not in self._cache:
return None
expiry, body, resp_headers, status = self._cache[key]
if time.time() > expiry:
del self._cache[key]
return None
# Move to end (most recently used)
self._cache.move_to_end(key)
return (body, resp_headers, status)
# Cache TTL: 7 days for static assets (like go3's static_cache_ttl_seconds)
STATIC_TTL = 604800 # 7 days
def set(self, url: str, body: bytes, headers: dict, status: int,
ttl: Optional[int] = None, request_headers: Optional[dict] = None):
"""Store response in cache."""
key = self._make_key(url, request_headers)
t = ttl if ttl is not None else self.default_ttl
expiry = time.time() + t
with self._lock:
self._cache[key] = (expiry, body, headers, status)
self._cache.move_to_end(key)
if len(self._cache) > self.capacity:
self._cache.popitem(last=False)
def is_static_path(path: str) -> bool:
"""Check if a URL path points to a static asset."""
path_lower = path.lower()
for ext in STATIC_EXTENSIONS:
if path_lower.endswith(ext):
return True
for prefix in STATIC_PATH_PREFIXES:
if path_lower.startswith(prefix):
return True
return False
def invalidate(self, url: str, headers: Optional[dict] = None):
"""Remove a specific URL from cache."""
key = self._make_key(url, headers)
with self._lock:
self._cache.pop(key, None)
def clear(self):
with self._lock:
self._cache.clear()
class StaticCache:
"""Disk-based static file cache. No LRU, no TTL on dynamic content."""
@property
def size(self) -> int:
with self._lock:
return len(self._cache)
def __init__(self):
os.makedirs(CACHE_DIR, exist_ok=True)
self._lock = threading.Lock()
self._hits = 0
self._misses = 0
def _cache_path(self, url: str) -> str:
h = hashlib.md5(url.encode()).hexdigest()
return os.path.join(CACHE_DIR, h)
def get(self, url: str) -> Optional[tuple[bytes, dict, int]]:
"""Get cached static file. Returns (body, headers, status) or None."""
if not is_static_path(url):
return None
path = self._cache_path(url)
try:
with self._lock:
if os.path.exists(path):
mtime = os.path.getmtime(path)
if time.time() - mtime < STATIC_TTL:
with open(path, "rb") as f:
body = f.read()
self._hits += 1
# Minimal headers for static content
headers = {
"Content-Type": _guess_content_type(url),
"Cache-Control": f"public, max-age={STATIC_TTL}, immutable",
}
return (body, headers, 200)
else:
os.remove(path)
except Exception:
pass
self._misses += 1
return None
def set(self, url: str, body: bytes, headers: dict, status: int):
"""Store static file on disk."""
if not is_static_path(url):
return
if status != 200:
return
path = self._cache_path(url)
try:
with self._lock:
with open(path, "wb") as f:
f.write(body)
except Exception as e:
logger.debug(f"Cache write failed: {e}")
def get_stats(self) -> dict:
with self._lock:
return {
"size": len(self._cache),
"capacity": self.capacity,
"usage_pct": round(len(self._cache) / self.capacity * 100, 1) if self.capacity else 0,
}
try:
files = os.listdir(CACHE_DIR)
total_size = sum(
os.path.getsize(os.path.join(CACHE_DIR, f))
for f in files
if os.path.isfile(os.path.join(CACHE_DIR, f))
)
except Exception:
files = []
total_size = 0
return {
"size": len(files),
"capacity": "unlimited",
"usage_pct": round(total_size / (1024 * 1024), 1),
"hits": self._hits,
"misses": self._misses,
}
# TTL 策略:不同路径不同缓存时间
PATH_TTL = {
"/": 30, # 首页 30s
"/works": 60, # 作品列表 60s
"/chapters": 120, # 章节内容 120s
"/series": 60, # 系列 60s
"/collections": 60,
"/tags": 60,
"/users": 30,
"/pseuds": 30,
"/bookmarks": 60,
"/skins": 300, # CSS 皮肤缓存 5 分钟
"/stylesheets": 300,
"/images": 600, # 图片缓存 10 分钟
"/media": 600,
"/javascripts": 300,
"/api": 15, # API 响应 15s
"/external_links": 30,
# Default: 30s
}
def _guess_content_type(url: str) -> str:
url_lower = url.lower()
if url_lower.endswith(".css"):
return "text/css; charset=utf-8"
if url_lower.endswith(".js"):
return "application/javascript; charset=utf-8"
if url_lower.endswith(".png"):
return "image/png"
if url_lower.endswith(".jpg") or url_lower.endswith(".jpeg"):
return "image/jpeg"
if url_lower.endswith(".gif"):
return "image/gif"
if url_lower.endswith(".svg"):
return "image/svg+xml"
if url_lower.endswith(".ico"):
return "image/x-icon"
if url_lower.endswith(".woff"):
return "font/woff"
if url_lower.endswith(".woff2"):
return "font/woff2"
if url_lower.endswith(".ttf"):
return "font/ttf"
if url_lower.endswith(".webp"):
return "image/webp"
return "application/octet-stream"
def get_ttl_for_path(path: str) -> int:
"""Determine cache TTL based on URL path."""
for prefix, ttl in PATH_TTL.items():
if path.startswith(prefix):
return ttl
return 30 # default
"""Only used for static path TTL. Returns 0 for dynamic paths."""
return STATIC_TTL if is_static_path(path) else 0
# 全局缓存实例
_cache: Optional[LRUCache] = None
# ─── Singleton ──────────────────────────────────────────────────────────────
_cache: Optional[StaticCache] = None
def get_cache() -> LRUCache:
def get_cache() -> StaticCache:
global _cache
if _cache is None:
_cache = LRUCache(capacity=5000, default_ttl=30)
_cache = StaticCache()
return _cache

View File

@@ -0,0 +1,8 @@
/*!
* JavaScript Cookie v2.2.1
* https://github.com/js-cookie/js-cookie
*
* Copyright 2006, 2015 Klaus Hartl & Fagner Brack
* Released under the MIT license
*/
!function(e){var n;if("function"==typeof define&&define.amd&&(define(e),n=!0),"object"==typeof exports&&(module.exports=e(),n=!0),!n){var t=window.Cookies,o=window.Cookies=e();o.noConflict=function(){return window.Cookies=t,o}}}(function(){function e(){for(var e=0,n={};e<arguments.length;e++){var t=arguments[e];for(var o in t)n[o]=t[o]}return n}function n(e){return e.replace(/(%[0-9A-Z]{2})+/g,decodeURIComponent)}return function t(o){function r(){}function i(n,t,i){if("undefined"!=typeof document){"number"==typeof(i=e({path:"/"},r.defaults,i)).expires&&(i.expires=new Date(1*new Date+864e5*i.expires)),i.expires=i.expires?i.expires.toUTCString():"";try{var c=JSON.stringify(t);/^[\{\[]/.test(c)&&(t=c)}catch(e){}t=o.write?o.write(t,n):encodeURIComponent(String(t)).replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g,decodeURIComponent),n=encodeURIComponent(String(n)).replace(/%(23|24|26|2B|5E|60|7C)/g,decodeURIComponent).replace(/[\(\)]/g,escape);var f="";for(var u in i)i[u]&&(f+="; "+u,!0!==i[u]&&(f+="="+i[u].split(";")[0]));return document.cookie=n+"="+t+f}}function c(e,t){if("undefined"!=typeof document){for(var r={},i=document.cookie?document.cookie.split("; "):[],c=0;c<i.length;c++){var f=i[c].split("="),u=f.slice(1).join("=");t||'"'!==u.charAt(0)||(u=u.slice(1,-1));try{var a=n(f[0]);if(u=(o.read||o)(u,a)||n(u),t)try{u=JSON.parse(u)}catch(e){}if(r[a]=u,e===a)break}catch(e){}}return e?r[e]:r}}return r.set=i,r.get=function(e){return c(e,!1)},r.getJSON=function(e){return c(e,!0)},r.remove=function(n,t){i(n,"",e(t,{expires:-1}))},r.defaults={},r.withConverter=t,r}(function(){})});

152
cache/0ff52d5b7b9f8180daa067cf312b4baf vendored Normal file
View File

@@ -0,0 +1,152 @@
/* MEDIA: only screen and (max-width: 62em), handheld ENDMEDIA */
/* 04 region dashboard */
#dashboard {
clear: both;
float: none;
margin: 1% 3.5%;
max-width: 100%;
padding: 0;
width: auto;
}
#dashboard, #dashboard.own {
border-bottom: 10px solid #900;
border-top: 10px solid #900;
padding: 0.5em 0;
border-radius: 0.25em;
}
#dashboard ul {
border: none;
display: inline;
padding: 0;
text-align: left;
}
#dashboard li {
display: inline;
}
#dashboard a, #dashboard span {
display: inline-block;
margin: 0.25em 0;
}
#dashboard .secondary {
background: #eee;
padding: 0.375em 0 0.625em;
box-shadow: inset 2px 2px 5px #bbb;
}
#dashboard .secondary a {
margin: 0.125em 0;
}
#dashboard .landmark {
clear: none;
float: left;
}
/* 05 region main */
#main, #main.dashboard {
float: none;
margin: auto;
padding-left: 3.5%;
padding-right: 3.5%;
width: auto;
}
/* 07 interactions */
form.single input[type="text"] {
width: 100%;
box-sizing: border-box;
}
form.single input[type="submit"] {
margin-top: 0.375em;
}
form.single span.submit {
display: block;
text-align: right;
}
form.single ul.autocomplete {
display: block;
}
form.single .autocomplete li.input {
margin-right: 0;
}
/* 08 actions */
.javascript .work.navigation .secondary {
width: 100%;
box-sizing: border-box;
}
/* If we use display without the .expanded qualifier, it will override .hidden's
display: none and secondary will always be open. */
.javascript .work.navigation .expanded + .secondary, .javascript .work.navigation .secondary li, .javascript .work.navigation .secondary p, .javascript .work.navigation .secondary a {
display: block;
}
.javascript .work.navigation .secondary p {
padding-block-start: 0.375em;
}
.javascript .work.navigation .secondary select {
max-width: 100%;
min-width: auto;
}
.javascript .work.navigation .secondary a {
height: 100%;
min-height: 1.286em;
padding-inline: 0.5em;
white-space: normal;
}
/* 16 zone system */
.logged-in .splash > .module {
width: 48.5%
}
.logged-in .splash > div:nth-of-type(odd) {
margin-left: 0;
margin-right: 1.5%;
}
.logged-in .splash > div:nth-of-type(even) {
margin-left: 1.5%;
margin-right: 0;
}
/* 18 zone searchbrowse */
form.filters {
width: auto;
min-width: 23%;
max-width: 24%;
}
.filters fieldset {
margin-right: 0;
}
form.filters dl {
margin-left: 0.25em;
margin-right: 0.25em;
}
/* 21 userstuff */
#workskin {
margin: auto 1.5%;
}

BIN
cache/17342e82684c267ba4f1e531738eb513 vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
cache/2f7d84701e69c2978451a4693dbf6b35 vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
function setupFilterToggles(){var e=$j(".filters").find("dt.filter-toggle");e.each(function(){var e=$j(this).next().attr("id");$j(this).wrapInner('<button type="button" class="expander" aria-expanded="false" aria-controls="'+e+'"></button>')}),$j("dt.tags button").on("click",function(){"false"==$j(this).attr("aria-expanded")?$j(this).attr("aria-expanded","true"):$j(this).attr("aria-expanded","false")})}function showFilters(){var e=$j(".filters").find("dd.expandable");e.each(function(e,t){var a=$j(t).find("input").filter('[value]:not([value=""])'),r=$j(t).attr("id"),n=$j("#toggle_"+r),i=$j('[aria-controls="'+r+'"]');a.each(function(e,a){$j(a).is(':checked, [type="text"]')&&($j(t).removeClass("hidden"),$j(n).removeClass("collapsed").addClass("expanded"),$j(i).attr("aria-expanded","true"))})})}function setupNarrowScreenFilters(){var e=$j("form.filters"),t=$j("#outer"),a=$j("#go_to_filters"),r=$j("#leave_filters");a.click(function(a){a.preventDefault(),e.removeClass("narrow-hidden"),t.addClass("filtering"),e.find(":focusable").first().focus(),e.trap()}),r.click(function(r){r.preventDefault(),t.removeClass("filtering"),e.addClass("narrow-hidden"),a.focus()})}$j(document).ready(function(){setupFilterToggles(),showFilters(),setupNarrowScreenFilters()});

76
cache/331a4a4d15181598c366618812c89376 vendored Normal file
View File

@@ -0,0 +1,76 @@
/* MEDIA: speech */
.landmark {
opacity: 1;
height: auto;
width: auto;
font-size: 100%;
line-height: 0;
color: black;
}
a.tag {
speak: no-punctuation;
}
em {
voice-stress: moderate;
}
strong, .warnings, .caution {
voice-stress: strong;
}
cite {
voice-rate: 95%;
}
acronym {
speak: spell-out;
}
abbr {
content: attr(title);
}
code, kbd, tt, samp {
voice-rate: 95%;
}
.userstuff {
speak-numeral: continuous;
}
.meta {
voice-rate: 110%;
}
.blurb {
voice-rate: 105%;
}
.userstuff h1, .userstuff h2.title {
voice-rate: 90%;
pause: 5ms 10ms;
}
.chapter {
pause: 10ms 10ms;
}
.userstuff dt {
voice-balance: leftwards;
}
.userstuff dd {
voice-balance: rightwards;
}
.navigation {
voice-stress: moderate;
speak: no-punctuation;
}
.splash .browse li a:before {
speak: none;
}

View File

@@ -0,0 +1 @@
!function(o){"use strict";var n="[data-toggle=dropdown]",t=function(n){var t=o(n).on("click.dropdown.data-api",this.toggle);o("html").on("click.dropdown.data-api",(function(){t.parent().removeClass("open")}))};function e(){o(n).each((function(){d(o(this)).removeClass("open")}))}function d(n){var t,e=n.attr("data-target");return e||(e=(e=n.attr("href"))&&/#/.test(e)&&e.replace(/.*(?=#[^\s]*$)/,"")),(t=e&&o(e))&&t.length||(t=n.parent()),t}t.prototype={constructor:t,toggle:function(n){var t,r,i=o(this);if(!i.is(".disabled, :disabled"))return r=(t=d(i)).hasClass("open"),e(),r?(t.children("ul").hide(),i.blur()):(t.toggleClass("open").children("ul").removeAttr("style"),i.focus()),i.focus(),!1},keydown:function(t){var e,r,i,a,s;if(/(38|40|27)/.test(t.keyCode)&&(e=o(this),t.preventDefault(),t.stopPropagation(),!e.is(".disabled, :disabled"))){if(!(a=(i=d(e)).hasClass("open"))||a&&27==t.keyCode)return 27==t.which&&i.find(n).focus(),e.click();(r=o("ul.menu li:not(.divider):visible a",i)).length&&(s=r.index(r.filter(":focus")),38==t.keyCode&&s>0&&s--,40==t.keyCode&&s<r.length-1&&s++,~s||(s=0),r.eq(s).focus())}}};var r=o.fn.dropdown;o.fn.dropdown=function(n){return this.each((function(){var e=o(this),d=e.data("dropdown");d||e.data("dropdown",d=new t(this)),"string"==typeof n&&d[n].call(e)}))},o.fn.dropdown.Constructor=t,o.fn.dropdown.noConflict=function(){return o.fn.dropdown=r,this},o(document).on("click.dropdown.data-api",e).on("click.dropdown.data-api",".dropdown form",(function(o){o.stopPropagation()})).on("click.dropdown-menu",(function(o){o.stopPropagation()})).on("click.dropdown.data-api",n,t.prototype.toggle).on("keydown.dropdown.data-api",n+", ul.menu",t.prototype.keydown).on("mouseenter",".dropdown",(function(n){var t=o(this);t.siblings(".open").length&&t.children("ul").hide()})).on("mouseleave",".dropdown",(function(n){o(this).children("ul").removeAttr("")}))}(window.jQuery);

View File

@@ -0,0 +1,8 @@
/*!
Copyright (c) 2011, 2012 Julien Wajsberg <felash@gmail.com>
All rights reserved.
Official repository: https://github.com/julienw/jquery-trap-input
License is there: https://github.com/julienw/jquery-trap-input/blob/master/LICENSE
This is version 1.2.0.
*/(function(e,t){function r(e){if(e.keyCode===9){var t=!!e.shiftKey;if(i(this,e.target,t)){e.preventDefault();e.stopPropagation()}}}function i(e,t,n){var r=a(e),i=t,s,o,u,f;do{s=r.index(i);o=s+1;u=s-1;f=r.length-1;switch(s){case-1:return false;case 0:u=f;break;case f:o=0;break}if(n){o=u}i=r.get(o);if(!i||i===t){return true}try{i.focus()}catch(l){return true}}while(r.length>1&&t===t.ownerDocument.activeElement);return true}function s(){return this.tabIndex>0}function o(){return!this.tabIndex}function u(e,t){return e.t-t.t||e.i-t.i}function a(t){var n=e(t);var r=[],i=0;h.enable&&h.enable();n.find("a[href], link[href], [draggable=true], [contenteditable=true], :input:enabled, [tabindex=0]").filter(":visible").filter(o).each(function(e,t){r.push({v:t,t:0,i:i++})});n.find("[tabindex]").filter(":visible").filter(s).each(function(e,t){r.push({v:t,t:t.tabIndex,i:i++})});h.disable&&h.disable();r=e.map(r.sort(u),function(e){return e.v});return e(r)}function f(){this.keydown(r);this.data(n,true);return this}function l(){this.unbind("keydown",r);this.removeData(n);return this}function c(){return!!this.data(n)}var n="trap.isTrapping";e.fn.extend({trap:f,untrap:l,isTrapping:c});var h={};if(e.find.find&&e.find.attr!==e.attr){(function(){function i(e){var r=e.getAttributeNode(n);return r&&r.specified?parseInt(r.value,10):t}function s(){r[n]=r.tabIndex=i}function o(){delete r[n];delete r.tabIndex}var n="tabindex";var r=e.expr.attrHandle;h={enable:s,disable:o}})()}})(jQuery);

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2007 Ariel Flesler - aflesler ○ gmail • com | https://github.com/flesler
* Licensed under MIT
* @author Ariel Flesler
* @version 2.1.2
*/
;(function(f){"use strict";"function"===typeof define&&define.amd?define(["jquery"],f):"undefined"!==typeof module&&module.exports?module.exports=f(require("jquery")):f(jQuery)})(function($){"use strict";function n(a){return!a.nodeName||-1!==$.inArray(a.nodeName.toLowerCase(),["iframe","#document","html","body"])}function h(a){return $.isFunction(a)||$.isPlainObject(a)?a:{top:a,left:a}}var p=$.scrollTo=function(a,d,b){return $(window).scrollTo(a,d,b)};p.defaults={axis:"xy",duration:0,limit:!0};$.fn.scrollTo=function(a,d,b){"object"=== typeof d&&(b=d,d=0);"function"===typeof b&&(b={onAfter:b});"max"===a&&(a=9E9);b=$.extend({},p.defaults,b);d=d||b.duration;var u=b.queue&&1<b.axis.length;u&&(d/=2);b.offset=h(b.offset);b.over=h(b.over);return this.each(function(){function k(a){var k=$.extend({},b,{queue:!0,duration:d,complete:a&&function(){a.call(q,e,b)}});r.animate(f,k)}if(null!==a){var l=n(this),q=l?this.contentWindow||window:this,r=$(q),e=a,f={},t;switch(typeof e){case "number":case "string":if(/^([+-]=?)?\d+(\.\d+)?(px|%)?$/.test(e)){e= h(e);break}e=l?$(e):$(e,q);case "object":if(e.length===0)return;if(e.is||e.style)t=(e=$(e)).offset()}var v=$.isFunction(b.offset)&&b.offset(q,e)||b.offset;$.each(b.axis.split(""),function(a,c){var d="x"===c?"Left":"Top",m=d.toLowerCase(),g="scroll"+d,h=r[g](),n=p.max(q,c);t?(f[g]=t[m]+(l?0:h-r.offset()[m]),b.margin&&(f[g]-=parseInt(e.css("margin"+d),10)||0,f[g]-=parseInt(e.css("border"+d+"Width"),10)||0),f[g]+=v[m]||0,b.over[m]&&(f[g]+=e["x"===c?"width":"height"]()*b.over[m])):(d=e[m],f[g]=d.slice&& "%"===d.slice(-1)?parseFloat(d)/100*n:d);b.limit&&/^\d+$/.test(f[g])&&(f[g]=0>=f[g]?0:Math.min(f[g],n));!a&&1<b.axis.length&&(h===f[g]?f={}:u&&(k(b.onAfterFirst),f={}))});k(b.onAfter)}})};p.max=function(a,d){var b="x"===d?"Width":"Height",h="scroll"+b;if(!n(a))return a[h]-$(a)[b.toLowerCase()]();var b="client"+b,k=a.ownerDocument||a.document,l=k.documentElement,k=k.body;return Math.max(l[h],k[h])-Math.min(l[b],k[b])};$.Tween.propHooks.scrollLeft=$.Tween.propHooks.scrollTop={get:function(a){return $(a.elem)[a.prop]()}, set:function(a){var d=this.get(a);if(a.options.interrupt&&a._last&&a._last!==d)return $(a.elem).stop();var b=Math.round(a.now);d!==b&&($(a.elem)[a.prop](b),a._last=this.get(a))}};return p});

641
cache/4853d7f210093728ff9ad1ca4d54f9e4 vendored Normal file
View File

@@ -0,0 +1,641 @@
// Place your application-specific JavaScript functions and classes here
// This file is automatically included by javascript_include_tag :defaults
//things to do when the page loads
$j(document).ready(function() {
setupToggled();
if ($j('form#work-form')) { hideFormFields(); }
hideHideMe();
showShowMe();
handlePopUps();
attachCharacterCounters();
setupAccordion();
setupDropdown();
updateCachedTokens();
// add clear to items on the splash page in older browsers
$j('.splash').children('div:nth-of-type(odd)').addClass('odd');
// make Share buttons on works and own bookmarks visible
$j('.actions').children('.share').removeClass('hidden');
// make Approve buttons on inbox items visible
$j('#inbox-form, .messages').find('.unreviewed').find('.review').find('a').removeClass('hidden');
prepareDeleteLinks();
thermometer();
$j('body').addClass('javascript');
});
///////////////////////////////////////////////////////////////////
// Autocomplete
///////////////////////////////////////////////////////////////////
function get_token_input_options(self) {
return {
searchingText: self.data('autocomplete-searching-text'),
hintText: self.data('autocomplete-hint-text'),
noResultsText: self.data('autocomplete-no-results-text'),
minChars: self.data('autocomplete-min-chars'),
queryParam: "term",
preventDuplicates: true,
tokenLimit: self.data('autocomplete-token-limit'),
liveParams: self.data('autocomplete-live-params'),
makeSortable: self.data('autocomplete-sortable')
};
}
// Look for autocomplete_options in application helper and throughout the views to
// see how to use this!
var input = $j('input.autocomplete');
if (input.livequery) {
jQuery(function($) {
$('input.autocomplete').livequery(function(){
var self = $(this);
var token_input_options = get_token_input_options(self);
var method;
try {
method = $.parseJSON(self.data('autocomplete-method'));
} catch (err) {
method = self.data('autocomplete-method');
}
self.tokenInput(method, token_input_options);
});
});
}
///////////////////////////////////////////////////////////////////
// expand, contract, shuffle
jQuery(function($){
$(".expand").each(function(){
// start by hiding the list in the page
list = $($(this).data("action-target"));
if (!list.data("force-expand") || list.children().size() > 25 || list.data("force-contract")) {
list.hide();
$(this).show();
} else {
// show the shuffle and contract button only
$(this).nextAll(".shuffle").show();
$(this).next(".contract").show();
}
// set up click event to expand the list
$(this).click(function(event){
list = $($(this).data("action-target"));
list.show();
// show the contract & shuffle buttons and hide us
$(this).next(".contract").show();
$(this).nextAll(".shuffle").show();
$(this).hide();
});
});
$(".contract").each(function(){
$(this).click(function(event){
// hide the list when clicked
list = $($(this).data("action-target"));
list.hide();
// show the expand and shuffle buttons and hide us
$(this).prev(".expand").show();
$(this).nextAll(".shuffle").hide();
$(this).hide();
});
});
$(".shuffle").each(function(){
// shuffle the list's children when clicked
$(this).click(function(event){
list = $($(this).data("action-target"));
list.children().shuffle();
});
});
$(".expand_all").each(function(){
target = "." + $(this).data("target-class");
$(this).click(function(event) {
$(this).closest(target).find(".expand").click();
});
});
$(".contract_all").each(function(){
target = "." + $(this).data("target-class");
$(this).click(function(event) {
$(this).closest(target).find(".contract").click();
});
});
});
// check all or none within the parent fieldset, optionally with a string to match on the id attribute of the checkboxes
// stored in the "data-checkbox-id-filter" attribute on the all/none links.
// allow for some flexibility by checking the next and previous fieldset if the checkboxes aren't in this one
jQuery(function($){
$('.check_all').each(function(){
$(this).click(function(event){
var filter = $(this).data('checkbox-id-filter');
var checkboxes;
if (filter) {
checkboxes = $(this).closest('fieldset').find('input[id*="' + filter + '"][type="checkbox"]');
} else {
checkboxes = $(this).closest("fieldset").find(':checkbox');
if (checkboxes.length == 0) {
checkboxes = $(this).closest("fieldset").next().find(':checkbox');
if (checkboxes.length == 0) {
checkboxes = $(this).closest("fieldset").prev().find(':checkbox');
}
}
}
checkboxes.prop('checked', true);
event.preventDefault();
});
});
$('.check_none').each(function(){
$(this).click(function(event){
var filter = $(this).data('checkbox-id-filter');
var checkboxes;
if (filter) {
checkboxes = $(this).closest('fieldset').find('input[id*="' + filter + '"][type="checkbox"]');
} else {
checkboxes = $(this).closest("fieldset").find(':checkbox');
if (checkboxes.length == 0) {
checkboxes = $(this).closest("fieldset").next().find(':checkbox');
if (checkboxes.length == 0) {
checkboxes = $(this).closest("fieldset").prev().find(':checkbox');
}
}
}
checkboxes.prop('checked', false);
event.preventDefault();
});
});
});
// Set up open and close toggles for a given object
// Typical setup (this will leave the toggled item open for users without javascript but hide the controls from them):
// <a class="foo_open hidden">Open Foo</a>
// <div id="foo" class="toggled">
// foo!
// <a class="foo_close hidden">Close</a>
// </div>
//
// Notes:
// - The open button CANNOT be inside the toggled div, the close button can be (but doesn't have to be)
// - You can have multiple open and close buttons for the same div since those are labeled with classes
// - You don't have to use div and a, those are just examples. Anything you put the toggled and _open/_close classes on will work.
// - If you want the toggled item not to be visible to users without JavaScript by default, add the class "hidden" to the toggled item as well.
// (and you can then add an alternative link for them using <noscript>)
// - Generally reserved for toggling complex elements like bookmark forms and challenge sign-ups; for simple elements like lists use setupAccordion.
function setupToggled(){
$j('.toggled').filter(function(){
return $j(this).closest('.userstuff').length === 0;
}).each(function(){
var node = $j(this);
var open_toggles = $j('.' + node.attr('id') + "_open");
var close_toggles = $j('.' + node.attr('id') + "_close");
if (node.hasClass('open')) {
close_toggles.each(function(){$j(this).show();});
open_toggles.each(function(){$j(this).hide();});
} else {
node.hide();
close_toggles.each(function(){$j(this).hide();});
open_toggles.each(function(){$j(this).show();});
}
open_toggles.each(function(){
$j(this).click(function(e){
if ($j(this).attr('href') == '#') {e.preventDefault();}
node.show();
open_toggles.each(function(){$j(this).hide();});
close_toggles.each(function(){$j(this).show();});
});
});
close_toggles.each(function(){
$j(this).click(function(e){
if ($j(this).attr('href') == '#') {e.preventDefault();}
node.hide();
close_toggles.each(function(){$j(this).hide();});
open_toggles.each(function(){$j(this).show();});
});
});
});
}
function hideHideMe() {
$j('.hideme').each(function() { $j(this).hide(); });
}
function showShowMe() {
$j('.showme').each(function() { $j(this).show(); });
}
function handlePopUps() {
$j("a[data_popup]").click(function(event, element) {
if (event.stopped) return;
window.open($j(element).attr('href'));
event.stop();
});
}
// used in nested form fields for deleting a nested resource
// see prompt form for example
function remove_section(link, class_of_section_to_remove) {
$j(link).siblings(":input[type=hidden]").val("1"); // relies on the "_destroy" field being the nearest hidden field
var section = $j(link).closest("." + class_of_section_to_remove);
section.find(".required input, .required textarea").each(function(index) {
var element = eval('validation_for_' + $j(this).attr('id'));
element.disable();
});
section.hide();
}
// used with nested form fields for dynamically stuffing in an extra partial
// see challenge signup form and prompt form for an example
function add_section(link, nested_model_name, content) {
// get the right new_id which should be in a div with class "last_id" at the bottom of
// the nearest section
var last_id = parseInt($j(link).parent().siblings('.last_id').last().html());
var new_id = last_id + 1;
var regexp = new RegExp("new_" + nested_model_name, "g");
content = content.replace(regexp, new_id);
// kludgy: show the hidden remove_section link (we don't want it showing for non-js users)
content = content.replace('class="hidden showme"', '');
$j(link).parent().before(content);
}
// An attempt to replace the various work form toggle methods with a more generic one
function toggleFormField(element_id) {
var ticky = $j('#' + element_id + '-show');
if (ticky.is(':checked')) {
$j('#' + element_id).removeClass('hidden');
}
else {
$j('#' + element_id).addClass('hidden');
if (element_id != 'chapters-options' && element_id != 'backdate-options') {
$j('#' + element_id).find(':input[type!="hidden"]').each(function(index, d) {
if ($j(d).attr('type') == "checkbox") {$j(d).attr('checked', false);}
else {$j(d).val('');}
});
}
}
// We want to check this whether the ticky is checked or not
if (element_id == 'chapters-options') {
var item = document.getElementById('work_wip_length');
if (item.value == 1 || item.value == '1') {item.value = '?';}
else {item.value = 1;}
}
}
// Hides expandable form field options if Javascript is enabled
function hideFormFields() {
if ($j('form#work-form') != null) {
var toHide = ['#co-authors-options', '#front-notes-options', '#end-notes-options', '#chapters-options',
'#parent-options', '#series-options', '#backdate-options', '#override_tags-options'];
$j.each(toHide, function(index, name) {
if ($j(name)) {
if (!($j(name + '-show').is(':checked'))) { $j(name).addClass('hidden'); }
}
});
$j('form#work-form').className = $j('form#work-form').className;
}
}
// Hides the extra checkbox fields in prompt form
function hideField(id) {
$j('#' + id).toggle();
}
function attachCharacterCounters() {
var countFn = function() {
var counter = (function(input) {
/* Character-counted inputs do not always have the same hierarchical relationship
to their associated counter elements in the DOM, and some cc-inputs have
duplicate ids. So search for the input's associated counter element first by id,
then by checking the input's siblings, then by checking its cousins. */
var cc = $j('.character_counter [id='+input.attr('id')+'_counter]');
if (cc.length === 1) { return cc; } // id search, use attribute selector rather
// than # to check for duplicate ids
cc = input.nextAll('.character_counter').first().find('.value'); // sibling search
if (cc.length) { return cc; }
var parent = input.parent(); // 2 level cousin search
for (var i = 0; i < 2; i++) {
cc = parent.nextAll('.character_counter').find('.value');
if (cc.length) { return cc; }
parent = parent.parent();
}
return $j(); // return empty jquery element if search found nothing
})($j(this)),
max = parseInt(counter.attr('data-maxlength'), 10),
val = $j(this).val().replace(/\r\n/g,'\n').replace(/\r|\n/g,'\r\n'),
remaining = max - val.length;
counter.html(remaining).attr("aria-valuenow", remaining);
};
$j(document).on('keyup keydown mouseup mousedown change', '.observe_textlength', countFn);
$j('.observe_textlength').each(countFn);
}
// add attributes that are only needed in the primary menus and when JavaScript is enabled
function setupDropdown(){
$j('#header').find('.dropdown').attr("aria-haspopup", true);
$j('#header').find('.dropdown, .dropdown .actions').children('a').attr({
'class': 'dropdown-toggle',
'data-toggle': 'dropdown',
'data-target': '#'
});
$j('.dropdown').find('.menu').addClass("dropdown-menu");
}
// Accordion-style collapsible widgets
// The pane element can be shown or hidden using the expander (link)
// Apply hidden to the pane element if it shouldn't be visible when JavaScript is disabled
// Typical set up:
// <li aria-haspopup="true">
// <a href="#">Expander</a>
// <div class="expandable">
// foo!
// </div>
// </li>
function setupAccordion() {
$j(".expandable").filter(function() {
return $j(this).closest(".userstuff").length === 0;
}).each(function() {
var pane = $j(this);
// hide the pane element if it's not hidden by default
if ( !pane.hasClass("hidden") ) {
pane.addClass("hidden");
};
// make the expander visible
// add the default collapsed state
// make it do the expanding and collapsing
pane.prev().removeClass("hidden").addClass("collapsed").click(function(e) {
var expander = $j(this);
if (expander.attr('href') == '#') {
e.preventDefault();
};
// change the classes upon clicking the expander
expander.toggleClass("collapsed").toggleClass("expanded").next().toggleClass("hidden");
});
});
}
// Remove the /confirm_delete portion of delete links so user who have JS enabled will
// be able to delete items via hyperlink (per rails/jquery-ujs) rather than a dedicated
// form page.
function prepareDeleteLinks() {
$j('a[href$="/confirm_delete"][data-confirm]').each(function(){
this.href = this.href.replace(/\/confirm_delete$/, "");
$j(this).attr("data-method", "delete");
});
// Removing non-default orphan_account pseuds from works
$j('a[href$="/confirm_remove_pseud"][data-confirm]').each(function() {
this.href = this.href.replace(/\/confirm_remove_pseud$/, "/remove_pseud");
$j(this).attr("data-method", "put");
});
// For purging assignments in gift exchanges. This is only on one page and easy to
// check, so don't worry about adding a fallback data-confirm message.
$j('a[href$="/confirm_purge"][data-confirm]').each(function() {
this.href = this.href.replace(/\/confirm_purge$/, "/purge");
$j(this).attr("data-method", "post");
});
}
/// Kudos
$j(document).ready(function() {
$j('input#kudo_submit').on("click", function(event) {
event.preventDefault();
$j.ajax({
type: 'POST',
url: '/kudos.js',
data: jQuery('#new_kudo').serialize(),
error: function(jqXHR, textStatus, errorThrown) {
var msg = 'Sorry, we were unable to save your kudos';
// When we hit the rate limit, the response from Rack::Attack is a plain text 429.
if (jqXHR.status == "429") {
msg = "Sorry, you can't leave more kudos right now. Please try again in a few minutes.";
} else {
var data = $j.parseJSON(jqXHR.responseText);
if (data.error_message) {
msg = data.error_message;
}
}
$j('#kudos_message').addClass('kudos_error').text(msg);
},
success: function(data) {
$j('#kudos_message').addClass('notice').text('Thank you for leaving kudos!');
}
});
});
// Scroll to the top of the comments section when loading additional pages via Ajax in comment pagination.
$j('#comments_placeholder').on('click.rails', '.pagination a[data-remote]', function(e){
$j.scrollTo('#comments_placeholder');
});
// Scroll to the top of the comments section when loading comments via AJAX
$j("#show_comments_link_top").on('click.rails', 'a[href*="show_comments"]', function(e){
$j.scrollTo('#comments');
});
});
// For simple forms that appear to toggle between creating and destroying records
// e.g. favorite tags, subscriptions
// <form> needs ajax-create-destroy class, data-create-value, data-destroy-value
// data-create-value: text of the button for creating, e.g. Favorite, Subscribe
// data-destroy-value: text of button for destroying, e.g. Unfavorite, Unsubscribe
// controller needs item_id and item_success_message for save success and
// item_success_message for destroy success
$j(document).ready(function() {
$j('form.ajax-create-destroy').on("click", function(event) {
event.preventDefault();
var form = $j(this);
var formAction = form.attr('action');
var formSubmit = form.find('[type="submit"]');
var createValue = form.data('create-value');
var destroyValue = form.data('destroy-value');
var flashContainer = $j('.flash');
$j.ajax({
type: 'POST',
url: formAction,
data: form.serialize(),
dataType: 'json',
success: function(data) {
flashContainer.removeClass('error').empty();
if (data.item_id) {
flashContainer.addClass('notice').html(data.item_success_message);
formSubmit.val(destroyValue);
form.append('<input name="_method" type="hidden" value="delete">');
form.attr('action', formAction + '/' + data.item_id);
} else {
flashContainer.addClass('notice').html(data.item_success_message);
formSubmit.val(createValue);
form.find('input[name="_method"]').remove();
form.attr('action', formAction.replace(/\/\d+/, ''));
}
},
error: function(xhr, textStatus, errorThrown) {
flashContainer.empty();
flashContainer.addClass('error notice');
try {
jQuery.parseJSON(xhr.responseText);
} catch (e) {
flashContainer.append("We're sorry! Something went wrong.");
return;
}
$j.each(jQuery.parseJSON(xhr.responseText).errors, function(index, error) {
flashContainer.append(error + " ");
});
}
});
});
});
// For simple forms that update or destroy records and remove them from a listing
// e.g. delete from history, mark as read, delete invitation request
// <form> needs ajax-remove class
// controller needs item_success_message
$j(document).ready(function() {
$j('form.ajax-remove').on("click", function(event) {
event.preventDefault();
var form = $j(this);
var formAction = form.attr('action');
// The record we're removing is probably in a list, but might be in a table
if (form.closest('li.group').length !== 0) {
formParent = form.closest('li.group');
} else { formParent = form.closest('tr'); };
// The admin div does not hold a flash container
var parentContainer = formParent.closest('div:not(.admin)');
var flashContainer = parentContainer.find('.flash');
$j.ajax({
type: 'POST',
url: formAction,
data: form.serialize(),
dataType: 'json',
success: function(data) {
flashContainer.removeClass('error').empty();
flashContainer.addClass('notice').html(data.item_success_message);
},
error: function(xhr, textStatus, errorThrown) {
flashContainer.empty();
flashContainer.addClass('error notice');
try {
jQuery.parseJSON(xhr.responseText);
} catch (e) {
flashContainer.append("We're sorry! Something went wrong.");
return;
}
$j.each(jQuery.parseJSON(xhr.responseText).errors, function(index, error) {
flashContainer.append(error + " ");
});
}
});
$j(document).ajaxSuccess(function() {
formParent.slideUp(function() {
$j(this).remove();
});
});
});
});
// FUNDRAISING THERMOMETER adapted from http://jsfiddle.net/GeekyJohn/vQ4Xn/
function thermometer() {
var banners = $j('.announcement').filter(function(){
return $j(this).closest('.userstuff').length === 0;
});
banners.has('.goal').each(function(){
var banner_content = $j(this).find('.userstuff');
banner_goal_text = banner_content.find('span.goal').html();
banner_progress_text = banner_content.find('span.progress').html();
if ($j(this).find('span.goal').hasClass('stretch')){
stretch = true
} else { stretch = false }
goal_amount = parseFloat(banner_goal_text.replace(/\.(?![0-9])|[^\.0-9]/g, ''));
progress_amount = parseFloat(banner_progress_text.replace(/\.(?![0-9])|[^\.0-9]/g, ''));
percentage_amount = Math.min( Math.round(progress_amount / goal_amount * 1000) / 10, 100);
// add thermometer markup (with amounts)
banner_content.append('<div class="thermometer-content"><div class="thermometer"><div class="track"><div class="goal"><span class="amount">' + banner_goal_text +'</span></div><div class="progress"><span class="amount">' + banner_progress_text + '</span></div></div></div></div>');
// set the progress indicator
// darker green for over 100% stretch goals
// green for 100%
// yellow-green for 85-99%
// yellow for 30-84%
// orange for 0-29%
if ( stretch == true ) {
banner_content.find('div.track').css({
'background': '#8eb92a',
'background-image': 'linear-gradient(to bottom, #bfd255 0%, #8eb92a 50%, #72aa00 51%, #9ecb2d 100%)'
});
banner_content.find('div.progress').css({
'width': percentage_amount + '%',
'background': '#4d7c10',
'background-image': 'linear-gradient(to bottom, #6e992f 0%, #4d7c10 50%, #3b7000 51%, #5d8e13 100%)'
});
} else if (percentage_amount >= 100) {
banner_content.find('div.progress').css({
'width': '100%',
'background': '#8eb92a',
'background-image': 'linear-gradient(to bottom, #bfd255 0%, #8eb92a 50%, #72aa00 51%, #9ecb2d 100%)'
});
} else if (percentage_amount >= 85) {
banner_content.find('div.progress').css({
'width': percentage_amount + '%',
'background': '#d2e638',
'background-image': 'linear-gradient(to bottom, #e6f0a3 0%, #d2e638 50%, #c3d825 51%, #dbf043 100%)'
});
} else if (percentage_amount >= 30) {
banner_content.find('div.progress').css({
'width': percentage_amount + '%',
'background': '#fccd4d',
'background-image': 'linear-gradient(to bottom, #fceabb 0%, #fccd4d 50%, #f8b500 51%, #fbdf93 100%)'
});
} else {
banner_content.find('div.progress').css({
'width': percentage_amount + '%',
'background': '#f17432',
'background-image': 'linear-gradient(to bottom, #feccb1 0%, #f17432 50%, #ea5507 51%, #fb955e 100%)'
});
}
});
}
function updateCachedTokens() {
// we only do full page caching when users are logged out
if ($j('#small_login').length > 0) {
$j.getJSON("/token_dispenser.json", function( data ) {
var token = data.token;
// set token on fields
$j('input[name=authenticity_token]').each(function(){
$j(this).attr('value', token);
});
$j('meta[name=csrf-token]').attr('content', token);
$j.event.trigger({ type: "loadedCSRF" });
});
} else {
$j.event.trigger({ type: "loadedCSRF" });
}
}

BIN
cache/648f0179eb5fdc03a5766870d2eb6550 vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

143
cache/7bddd8efbc2b1a767d0f063375e157ce vendored Normal file
View File

@@ -0,0 +1,143 @@
/*==SANDBOX: All temporary, development, and unreviewed css lives here.
Rules will be incorporated into the main cascade by Front End before deploy.
If you've just coded up a new feature and put in some layout, put that layout here,
with a comment, and the Front End Santa will make your wishes come true.
Or if you've put in a fix and you've not yet read the Front End Docs?
Put your fix in here.
Examples:
/*Fix for issue 996
.bookmark .header {float:left;}
/*Draft layout for views/shiny/new by astolat.
It has to be purple, see wiki, ADT meeting 25
.shiny-happy-people table {display:table-cell;
background:purple; color:#555;}
/*Some helpful notes
0.875em/1.286 line height = 14px with 18px leading
0.643em = 9px, so {margin: 0.643em auto;} gives you a single blank line between block elements */
/* AO3-6869 -- Workaround for a layout bug in WebKit, triggered by `float` and LiveValidation's placeholder span */
form#new_abuse_report .footnote {
float: none;
margin-right: 0;
width: fit-content;
}
/* styling for AO3-3359 to remove top padding from the new button
and preserve the balance/symmetry of the page */
#new_work_search fieldset:first-of-type .submit {
padding-top: 0;
}
/* Issue 3243 needs nested ul to be indented in external_authors/claim
*/
.edit_external_author ul ul {
margin-left: 2.75em;
}
/* While implementing AO3-5987 it was suggested we move to non-JS share buttons.
* These are statically styled with CSS instead.
*
* Sourced from: https://sharingbuttons.io/
* See: https://github.com/otwcode/otwarchive/pull/3874#pullrequestreview-460459176
*/
a.resp-sharing-button__link,
.resp-sharing-button__icon {
display: inline-block;
}
a.resp-sharing-button__link,
a.resp-sharing-button__link:hover {
text-decoration: none;
color: #fff;
border: none;
}
.resp-sharing-button {
border-radius: 5px;
transition: 25ms ease-out;
padding: 0.5em 0.75em;
}
.resp-sharing-button__icon svg {
width: 1.25em;
height: 1.25em;
margin-right: 0.25em;
vertical-align: text-bottom;
overflow: visible;
}
/* Non solid icons get a stroke */
.resp-sharing-button__icon {
stroke: #fff;
fill: none;
}
/* Solid icons get a fill */
.resp-sharing-button__icon--solid {
fill: #fff;
stroke: none;
}
.resp-sharing-button--twitter {
background-color: #55acee;
}
.resp-sharing-button--twitter:hover,
a:focus .resp-sharing-button--twitter {
background-color: #2795e9;
}
.resp-sharing-button--tumblr {
background-color: #35465C;
}
.resp-sharing-button--tumblr:hover,
a:focus .resp-sharing-button--tumblr {
background-color: #222d3c;
}
.resp-sharing-button--bluesky {
background-color: #1185fe;
}
.resp-sharing-button--bluesky:hover,
a:focus .resp-sharing-button--bluesky {
background-color: #0168d6;
}
.resp-sharing-button--twitter {
background-color: #55acee;
border-color: #55acee;
}
.resp-sharing-button--twitter:hover,
.resp-sharing-button--twitter:active {
background-color: #2795e9;
border-color: #2795e9;
}
.resp-sharing-button--tumblr {
background-color: #35465C;
border-color: #35465C;
}
.resp-sharing-button--tumblr:hover,
.resp-sharing-button--tumblr:active {
background-color: #222d3c;
border-color: #222d3c;
}
.resp-sharing-button--bluesky {
background-color: #1185fe;
border-color: #1185fe;
}
.resp-sharing-button--bluesky:hover,
.resp-sharing-button--bluesky:active {
background-color: #0168d6;
border-color: #0168d6;
}

4491
cache/92cf3bcddf1191c3c04ceea2383c8ed9 vendored Normal file

File diff suppressed because it is too large Load Diff

66
cache/9722736b7350e3b421dd1a82f33a13a5 vendored Normal file
View File

@@ -0,0 +1,66 @@
/* MEDIA: print */
html, body, #main {
font-family: serif;
}
html, body, #main, .works-show, dl.meta, .meta, .meta dd, .meta ul, .preface blockquote, .preface p, .blurb dd ul, .blurb h4, .blurb h5, .blurb .summary blockquote {
margin: auto;
padding: 2pt;
min-height: auto;
width: auto;
color: black;
background: transparent;
border: none;
position: static;
}
#main p, #main li, #main dd {
font: normal 11pt serif;
line-height: 1.2;
margin: auto;
text-indent: 22pt;
}
#header, #footer, #dashboard, img, #skiplinks, .navigation, .meta dt, #feedback, .meta .stats, .blurb dt, .filters, .pagination, .flash, input, .landmark {
display: none;
}
.meta dd, .meta ul, .blurb dd, .blurb .stats dt, .blurb dd ul, .blurb ul li, .blurb .datetime {
font-size: 9pt;
display: inline;
}
li.blurb {
border-bottom: 3pt double black;
margin: 6pt auto;
}
a, a:link, a:visited {
color: black;
text-decoration: underline;
border: 0;
}
.meta a, .meta a:link, .meta a:visited, .blurb a:link, .blurb a:visited {
text-decoration: none;
}
.docs .userstuff summary .heading {
display: inline;
}
/* CSS3 perks, print urls after links */
a:after {
content: " (" attr(href) ") ";
font-size: 9pt;
}
a[href^="/"]:after {
content: " (http://agento3.miscs.dev" attr(href) ") ";
}
.meta a:link:after, .meta a:visited:after, .blurb a:link:after, .blurb a:link:visited, .byline a:link:after, .byline a:visited:after {
content: " ";
}

View File

@@ -0,0 +1,8 @@
/*! jquery.livequery - v1.3.6 - 2013-08-26
* Copyright (c)
* (c) 2010, Brandon Aaron (http://brandonaaron.net)
* (c) 2012 - 2013, Alexander Zaytsev (http://hazzik.ru/en)
* Dual licensed under the MIT (MIT_LICENSE.txt)
* and GPL Version 2 (GPL_LICENSE.txt) licenses.
*/
!function(a){"function"==typeof define&&define.amd?define(["jquery"],a):"object"==typeof exports?a(require("jquery")):a(jQuery)}(function(a,b){function c(a,b,c,d){return!(a.selector!=b.selector||a.context!=b.context||c&&c.$lqguid!=b.fn.$lqguid||d&&d.$lqguid!=b.fn2.$lqguid)}a.extend(a.fn,{livequery:function(b,e){var f,g=this;return a.each(d.queries,function(a,d){return c(g,d,b,e)?(f=d)&&!1:void 0}),f=f||new d(g.selector,g.context,b,e),f.stopped=!1,f.run(),g},expire:function(b,e){var f=this;return a.each(d.queries,function(a,g){c(f,g,b,e)&&!f.stopped&&d.stop(g.id)}),f}});var d=a.livequery=function(b,c,e,f){var g=this;return g.selector=b,g.context=c,g.fn=e,g.fn2=f,g.elements=a([]),g.stopped=!1,g.id=d.queries.push(g)-1,e.$lqguid=e.$lqguid||d.guid++,f&&(f.$lqguid=f.$lqguid||d.guid++),g};d.prototype={stop:function(){var b=this;b.stopped||(b.fn2&&b.elements.each(b.fn2),b.elements=a([]),b.stopped=!0)},run:function(){var b=this;if(!b.stopped){var c=b.elements,d=a(b.selector,b.context),e=d.not(c),f=c.not(d);b.elements=d,e.each(b.fn),b.fn2&&f.each(b.fn2)}}},a.extend(d,{guid:0,queries:[],queue:[],running:!1,timeout:null,registered:[],checkQueue:function(){if(d.running&&d.queue.length)for(var a=d.queue.length;a--;)d.queries[d.queue.shift()].run()},pause:function(){d.running=!1},play:function(){d.running=!0,d.run()},registerPlugin:function(){a.each(arguments,function(b,c){if(a.fn[c]&&!(a.inArray(c,d.registered)>0)){var e=a.fn[c];a.fn[c]=function(){var a=e.apply(this,arguments);return d.run(),a},d.registered.push(c)}})},run:function(c){c!==b?a.inArray(c,d.queue)<0&&d.queue.push(c):a.each(d.queries,function(b){a.inArray(b,d.queue)<0&&d.queue.push(b)}),d.timeout&&clearTimeout(d.timeout),d.timeout=setTimeout(d.checkQueue,20)},stop:function(c){c!==b?d.queries[c].stop():a.each(d.queries,d.prototype.stop)}}),d.registerPlugin("append","prepend","after","before","wrap","attr","removeAttr","addClass","removeClass","toggleClass","empty","remove","html","prop","removeProp"),a(function(){d.play()})});

924
cache/c7be43ce9c3c20e7bfed57686ab4f46e vendored Normal file
View File

@@ -0,0 +1,924 @@
// LiveValidation 1.3 (standalone version)
// Copyright (c) 2007-2008 Alec Hill (www.livevalidation.com)
// LiveValidation is licensed under the terms of the MIT License
/*********************************************** LiveValidation class ***********************************/
/**
* validates a form field in real-time based on validations you assign to it
*
* @var element {mixed} - either a dom element reference or the string id of the element to validate
* @var optionsObj {Object} - general options, see below for details
*
* optionsObj properties:
* validMessage {String} - the message to show when the field passes validation
* (DEFAULT: "Thankyou!")
* onValid {Function} - function to execute when field passes validation
* (DEFAULT: function(){ this.insertMessage(this.createMessageSpan()); this.addFieldClass(); } )
* onInvalid {Function} - function to execute when field fails validation
* (DEFAULT: function(){ this.insertMessage(this.createMessageSpan()); this.addFieldClass(); })
* insertAfterWhatNode {Int} - position to insert default message
* (DEFAULT: the field that is being validated)
* onlyOnBlur {Boolean} - whether you want it to validate as you type or only on blur
* (DEFAULT: false)
* wait {Integer} - the time you want it to pause from the last keystroke before it validates (ms)
* (DEFAULT: 0)
* onlyOnSubmit {Boolean} - whether should be validated only when the form it belongs to is submitted
* (DEFAULT: false)
*/
var LiveValidation = function(element, optionsObj){
this.initialize(element, optionsObj);
}
LiveValidation.VERSION = '1.3 standalone';
/** element types constants ****/
LiveValidation.TEXTAREA = 1;
LiveValidation.TEXT = 2;
LiveValidation.PASSWORD = 3;
LiveValidation.CHECKBOX = 4;
LiveValidation.SELECT = 5;
LiveValidation.FILE = 6;
/****** Static methods *******/
/**
* pass an array of LiveValidation objects and it will validate all of them
*
* @var validations {Array} - an array of LiveValidation objects
* @return {Bool} - true if all passed validation, false if any fail
*/
LiveValidation.massValidate = function(validations){
var returnValue = true;
for(var i = 0, len = validations.length; i < len; ++i ){
var valid = validations[i].validate();
if(returnValue) returnValue = valid;
}
return returnValue;
}
/****** prototype ******/
LiveValidation.prototype = {
validClass: 'LV_valid',
invalidClass: 'LV_invalid',
messageClass: 'LV_validation_message',
validFieldClass: 'LV_valid_field',
invalidFieldClass: 'LV_invalid_field',
/**
* initialises all of the properties and events
*
* @var - Same as constructor above
*/
initialize: function(element, optionsObj){
var self = this;
if(!element) throw new Error("LiveValidation::initialize - No element reference or element id has been provided!");
this.element = element.nodeName ? element : document.getElementById(element);
if(!this.element) throw new Error("LiveValidation::initialize - No element with reference or id of '" + element + "' exists!");
// default properties that could not be initialised above
this.validations = [];
this.elementType = this.getElementType();
this.form = this.element.form;
// options
var options = optionsObj || {};
this.validMessage = options.validMessage || ''; // AO3
var node = options.insertAfterWhatNode || this.element;
this.insertAfterWhatNode = node.nodeType ? node : document.getElementById(node);
this.onValid = options.onValid || function(){ this.insertMessage(this.createMessageSpan()); this.addFieldClass(); };
this.onInvalid = options.onInvalid || function(){ this.insertMessage(this.createMessageSpan()); this.addFieldClass(); };
this.onlyOnBlur = options.onlyOnBlur || false;
this.wait = options.wait || 0;
this.onlyOnSubmit = options.onlyOnSubmit || false;
// add to form if it has been provided
if(this.form){
this.formObj = LiveValidationForm.getInstance(this.form);
this.formObj.addField(this);
}
// events
// collect old events
this.oldOnFocus = this.element.onfocus || function(){};
this.oldOnBlur = this.element.onblur || function(){};
this.oldOnClick = this.element.onclick || function(){};
this.oldOnChange = this.element.onchange || function(){};
this.oldOnKeyup = this.element.onkeyup || function(){};
this.element.onfocus = function(e){ self.doOnFocus(e); return self.oldOnFocus.call(this, e); }
if(!this.onlyOnSubmit){
switch(this.elementType){
case LiveValidation.CHECKBOX:
this.element.onclick = function(e){ self.validate(); return self.oldOnClick.call(this, e); }
// let it run into the next to add a change event too
case LiveValidation.SELECT:
case LiveValidation.FILE:
this.element.onchange = function(e){ self.validate(); return self.oldOnChange.call(this, e); }
break;
default:
if(!this.onlyOnBlur) this.element.onkeyup = function(e){ self.deferValidation(); return self.oldOnKeyup.call(this, e); }
this.element.onblur = function(e){ self.doOnBlur(e); return self.oldOnBlur.call(this, e); }
}
}
this.validate();
},
/**
* destroys the instance's events (restoring previous ones) and removes it from any LiveValidationForms
*/
destroy: function(){
if(this.formObj){
// remove the field from the LiveValidationForm
this.formObj.removeField(this);
// destroy the LiveValidationForm if no LiveValidation fields left in it
this.formObj.destroy();
}
// remove events - set them back to the previous events
this.element.onfocus = this.oldOnFocus;
if(!this.onlyOnSubmit){
switch(this.elementType){
case LiveValidation.CHECKBOX:
this.element.onclick = this.oldOnClick;
// let it run into the next to add a change event too
case LiveValidation.SELECT:
case LiveValidation.FILE:
this.element.onchange = this.oldOnChange;
break;
default:
if(!this.onlyOnBlur) this.element.onkeyup = this.oldOnKeyup;
this.element.onblur = this.oldOnBlur;
}
}
this.validations = [];
this.removeMessageAndFieldClass();
},
/**
* adds a validation to perform to a LiveValidation object
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
* @return {Object} - the LiveValidation object itself so that calls can be chained
*/
add: function(validationFunction, validationParamsObj){
this.validations.push( {type: validationFunction, params: validationParamsObj || {} } );
return this;
},
/**
* removes a validation from a LiveValidation object - must have exactly the same arguments as used to add it
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
* @return {Object} - the LiveValidation object itself so that calls can be chained
*/
remove: function(validationFunction, validationParamsObj){
var found = false;
for( var i = 0, len = this.validations.length; i < len; i++ ){
if( this.validations[i].type == validationFunction ){
if (this.validations[i].params == validationParamsObj) {
found = true;
break;
}
}
}
if(found) this.validations.splice(i,1);
return this;
},
/**
* makes the validation wait the alotted time from the last keystroke
*/
deferValidation: function(e){
if(this.wait >= 300) this.removeMessageAndFieldClass();
var self = this;
if(this.timeout) clearTimeout(self.timeout);
this.timeout = setTimeout( function(){ self.validate() }, self.wait);
},
/**
* // AO3
* sets the focused flag to false when field loses focus and triggers TinyMCE to save content into field
*/
doOnBlur: function(e){
if (typeof(tinyMCE)!="undefined") tinyMCE.triggerSave(); // AO3
this.focused = false;
this.validate(e);
},
/**
* sets the focused flag to true when field gains focus
*/
doOnFocus: function(e){
this.focused = true;
},
/**
* gets the type of element, to check whether it is compatible
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
*/
getElementType: function(){
switch(true){
case (this.element.nodeName.toUpperCase() == 'TEXTAREA'):
return LiveValidation.TEXTAREA;
case (this.element.nodeName.toUpperCase() == 'INPUT' && this.element.type.toUpperCase() == 'TEXT'):
return LiveValidation.TEXT;
case (this.element.nodeName.toUpperCase() == 'INPUT' && this.element.type.toUpperCase() == 'PASSWORD'):
return LiveValidation.PASSWORD;
case (this.element.nodeName.toUpperCase() == 'INPUT' && this.element.type.toUpperCase() == 'CHECKBOX'):
return LiveValidation.CHECKBOX;
case (this.element.nodeName.toUpperCase() == 'INPUT' && this.element.type.toUpperCase() == 'FILE'):
return LiveValidation.FILE;
case (this.element.nodeName.toUpperCase() == 'SELECT'):
return LiveValidation.SELECT;
case (this.element.nodeName.toUpperCase() == 'INPUT'):
throw new Error('LiveValidation::getElementType - Cannot use LiveValidation on an ' + this.element.type + ' input!');
default:
throw new Error('LiveValidation::getElementType - Element must be an input, select, or textarea!');
}
},
/**
* loops through all the validations added to the LiveValidation object and checks them one by one
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
* @return {Boolean} - whether the all the validations passed or if one failed
*/
doValidations: function(){
this.validationFailed = false;
for(var i = 0, len = this.validations.length; i < len; ++i){
var validation = this.validations[i];
switch(validation.type){
case Validate.Presence:
case Validate.Confirmation:
case Validate.Acceptance:
this.displayMessageWhenEmpty = true;
this.validationFailed = !this.validateElement(validation.type, validation.params);
break;
default:
this.validationFailed = !this.validateElement(validation.type, validation.params);
break;
}
if(this.validationFailed) return false;
}
this.message = this.validMessage;
return true;
},
/**
* performs validation on the element and handles any error (validation or otherwise) it throws up
*
* @var validationFunction {Function} - validation function to be used (ie Validate.Presence )
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
* @return {Boolean} - whether the validation has passed or failed
*/
validateElement: function(validationFunction, validationParamsObj){
// AO3: we want validations to ignore leading and trailing whitespace, since it will be removed
var originalValue = (this.elementType == LiveValidation.SELECT) ? this.element.options[this.element.selectedIndex].value : this.element.value;
var value = $j.trim(originalValue);
// AO3: we also want newlines to be counted as "\r\n"s, regardless of the OS and browsers' whim;
// AO3: so we count any single "\n"s and "\r"s as "\r\n", which is what they'll end up as in the db anyway
if(typeof(value)=="string"){
value = (value.replace(/\r\n/g,"\n")).replace(/\r|\n/g,"\r\n");
}
// end AO3
if(validationFunction == Validate.Acceptance){
if(this.elementType != LiveValidation.CHECKBOX) throw new Error('LiveValidation::validateElement - Element to validate acceptance must be a checkbox!');
value = this.element.checked;
}
var isValid = true;
try{
validationFunction(value, validationParamsObj);
} catch(error) {
if(error instanceof Validate.Error){
if( value !== '' || (value === '' && this.displayMessageWhenEmpty) ){
this.validationFailed = true;
this.message = error.message;
isValid = false;
}
}else{
throw error;
}
}finally{
return isValid;
}
},
/**
* makes it do the all the validations and fires off the onValid or onInvalid callbacks
*
* @return {Boolean} - whether the all the validations passed or if one failed
*/
validate: function(){
if (this.element.disabled) return true;
var isValid = this.doValidations();
if (isValid) {
this.onValid();
if (typeof jQuery != "undefined") enableSubmit();
return true;
} else {
this.onInvalid();
return false;
}
},
/**
* enables the field
*
* @return {LiveValidation} - the LiveValidation object for chaining
*/
enable: function(){
this.element.disabled = false;
return this;
},
/**
* disables the field and removes any message and styles associated with the field
*
* @return {LiveValidation} - the LiveValidation object for chaining
*/
disable: function(){
this.element.disabled = true;
this.removeMessageAndFieldClass();
return this;
},
/** Message insertion methods ****************************
*
* These are only used in the onValid and onInvalid callback functions and so if you overide the default callbacks,
* you must either impliment your own functions to do whatever you want, or call some of these from them if you
* want to keep some of the functionality
*/
/**
* makes a span containg the passed or failed message
*
* @return {HTMLSpanObject} - a span element with the message in it
*/
createMessageSpan: function(){
var span = document.createElement('span');
var textNode = document.createTextNode(this.message);
span.appendChild(textNode);
span.role = "alert";
span.id = this.element.id + "_" + this.messageClass;
return span;
},
/**
* inserts the element containing the message in place of the element that already exists (if it does)
*
* @var elementToIsert {HTMLElementObject} - an element node to insert
*/
insertMessage: function(elementToInsert){
this.removeMessage();
var className = this.validationFailed ? this.invalidClass : this.validClass;
elementToInsert.className += ' ' + this.messageClass + ' ' + className;
if(this.insertAfterWhatNode.nextSibling){
this.insertAfterWhatNode.parentNode.insertBefore(elementToInsert, this.insertAfterWhatNode.nextSibling);
} else {
this.insertAfterWhatNode.parentNode.appendChild(elementToInsert);
}
},
/**
* changes the class of the field based on whether it is valid or not
*/
addFieldClass: function(){
this.removeFieldClass();
if(!this.validationFailed){
if(this.displayMessageWhenEmpty || this.element.value != ''){
this.element.setAttribute("aria-invalid", false);
this.element.removeAttribute("aria-describedby");
if(this.element.className.indexOf(this.validFieldClass) == -1) this.element.className += ' ' + this.validFieldClass;
}
} else {
this.element.setAttribute("aria-invalid", true);
this.element.setAttribute("aria-describedby", this.element.id + "_" + this.messageClass);
if(this.element.className.indexOf(this.invalidFieldClass) == -1) this.element.className += ' ' + this.invalidFieldClass;
}
},
/**
* removes the message element if it exists, so that the new message will replace it
*/
removeMessage: function(){
var nextEl;
var el = this.insertAfterWhatNode;
while(el.nextSibling){
if(el.nextSibling.nodeType === 1){
nextEl = el.nextSibling;
break;
}
el = el.nextSibling;
}
if(nextEl && nextEl.className.indexOf(this.messageClass) != -1) this.insertAfterWhatNode.parentNode.removeChild(nextEl);
},
/**
* removes the class that has been applied to the field to indicte if valid or not
*/
removeFieldClass: function(){
if(this.element.className.indexOf(this.invalidFieldClass) != -1) this.element.className = this.element.className.split(this.invalidFieldClass).join('');
if(this.element.className.indexOf(this.validFieldClass) != -1) this.element.className = this.element.className.split(this.validFieldClass).join(' ');
},
/**
* removes the message and the field class
*/
removeMessageAndFieldClass: function(){
this.removeMessage();
this.removeFieldClass();
}
} // end of LiveValidation class
/*************************************** LiveValidationForm class ****************************************/
/**
* This class is used internally by LiveValidation class to associate a LiveValidation field with a form it is icontained in one
*
* It will therefore not really ever be needed to be used directly by the developer, unless they want to associate a LiveValidation
* field with a form that it is not a child of
*/
/**
* handles validation of LiveValidation fields belonging to this form on its submittal
*
* @var element {HTMLFormElement} - a dom element reference to the form to turn into a LiveValidationForm
*/
var LiveValidationForm = function(element){
this.initialize(element);
}
/**
* namespace to hold instances
*/
LiveValidationForm.instances = {};
/**
* gets the instance of the LiveValidationForm if it has already been made or creates it if it doesnt exist
*
* @var element {HTMLFormElement} - a dom element reference to a form
*/
LiveValidationForm.getInstance = function(element){
var rand = Math.random() * Math.random();
if(!element.id) element.id = 'formId_' + rand.toString().replace(/\./, '') + new Date().valueOf();
if(!LiveValidationForm.instances[element.id]) LiveValidationForm.instances[element.id] = new LiveValidationForm(element);
return LiveValidationForm.instances[element.id];
}
LiveValidationForm.prototype = {
/**
* constructor for LiveValidationForm - handles validation of LiveValidation fields belonging to this form on its submittal
*
* @var element {HTMLFormElement} - a dom element reference to the form to turn into a LiveValidationForm
*/
initialize: function(element){
this.name = element.id;
this.element = element;
this.fields = [];
// preserve the old onsubmit event
// AO3: tinyMCE save needs to be triggered here so live validation recognises content in the rich text editor
this.oldOnSubmit = this.element.onsubmit || function(){};
var self = this;
this.element.onsubmit = function(e){
if (typeof(tinyMCE)!="undefined") tinyMCE.triggerSave(this.fields); // AO3
var ret = (LiveValidation.massValidate(self.fields)) ? self.oldOnSubmit.call(this, e || window.event) !== false : false;
// AO3: don't freeze the form if the user has clicked on the 'cancel' button -elz, 3/2/09, Enigel 3/7/11
var buttonClicked = document.activeElement || this.explicitOriginalTarget;
if (buttonClicked.name == 'cancel_button') ret = true;
else if (!ret) {
scrollToErrorIfFound();
enableSubmit();
}
return ret;
}
},
/**
* adds a LiveValidation field to the forms fields array
*
* @var element {LiveValidation} - a LiveValidation object
*/
addField: function(newField){
this.fields.push(newField);
},
/**
* removes a LiveValidation field from the forms fields array
*
* @var victim {LiveValidation} - a LiveValidation object
*/
removeField: function(victim){
var victimless = [];
for( var i = 0, len = this.fields.length; i < len; i++){
if(this.fields[i] !== victim) victimless.push(this.fields[i]);
}
this.fields = victimless;
},
/**
* destroy this instance and its events
*
* @var force {Boolean} - whether to force the detruction even if there are fields still associated
*/
destroy: function(force){
// only destroy if has no fields and not being forced
if (this.fields.length != 0 && !force) return false;
// remove events - set back to previous events
this.element.onsubmit = this.oldOnSubmit;
// remove from the instances namespace
LiveValidationForm.instances[this.name] = null;
return true;
}
}// end of LiveValidationForm prototype
/*************************************** Validate class ****************************************/
/**
* This class contains all the methods needed for doing the actual validation itself
*
* All methods are static so that they can be used outside the context of a form field
* as they could be useful for validating stuff anywhere you want really
*
* All of them will return true if the validation is successful, but will raise a ValidationError if
* they fail, so that this can be caught and the message explaining the error can be accessed ( as just
* returning false would leave you a bit in the dark as to why it failed )
*
* Can use validation methods alone and wrap in a try..catch statement yourself if you want to access the failure
* message and handle the error, or use the Validate::now method if you just want true or false
*/
var Validate = {
/**
* validates that the field has been filled in
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Can't be empty!")
*/
Presence: function(value, paramsObj){
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Can't be empty!";
if(value === '' || value === null || value === undefined){
Validate.fail(message);
}
return true;
},
/**
* validates that the value is numeric, does not fall within a given range of numbers
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* notANumberMessage {String} - the message to show when the validation fails when value is not a number
* (DEFAULT: "Must be a number!")
* notAnIntegerMessage {String} - the message to show when the validation fails when value is not an integer
* (DEFAULT: "Must be a number!")
* wrongNumberMessage {String} - the message to show when the validation fails when is param is used
* (DEFAULT: "Must be {is}!")
* tooLowMessage {String} - the message to show when the validation fails when minimum param is used
* (DEFAULT: "Must not be less than {minimum}!")
* tooHighMessage {String} - the message to show when the validation fails when maximum param is used
* (DEFAULT: "Must not be more than {maximum}!")
* is {Int} - the length must be this long
* minimum {Int} - the minimum length allowed
* maximum {Int} - the maximum length allowed
* onlyInteger {Boolean} - if true will only allow integers to be valid
* (DEFAULT: false)
*
* NB. can be checked if it is within a range by specifying both a minimum and a maximum
* NB. will evaluate numbers represented in scientific form (ie 2e10) correctly as numbers
*/
Numericality: function(value, paramsObj){
var suppliedValue = value;
var value = Number(value);
var paramsObj = paramsObj || {};
var minimum = ((paramsObj.minimum) || (paramsObj.minimum == 0)) ? paramsObj.minimum : null;;
var maximum = ((paramsObj.maximum) || (paramsObj.maximum == 0)) ? paramsObj.maximum : null;
var is = ((paramsObj.is) || (paramsObj.is == 0)) ? paramsObj.is : null;
var notANumberMessage = paramsObj.notANumberMessage || "Must be a number!";
var notAnIntegerMessage = paramsObj.notAnIntegerMessage || "Must be an integer!";
var wrongNumberMessage = paramsObj.wrongNumberMessage || "Must be " + is + "!";
var tooLowMessage = paramsObj.tooLowMessage || "Must not be less than " + minimum + "!";
var tooHighMessage = paramsObj.tooHighMessage || "Must not be more than " + maximum + "!";
if (!isFinite(value)) Validate.fail(notANumberMessage);
if (paramsObj.onlyInteger && (/\.0+$|\.$/.test(String(suppliedValue)) || value != parseInt(value)) ) Validate.fail(notAnIntegerMessage);
switch(true){
case (is !== null):
if( value != Number(is) ) Validate.fail(wrongNumberMessage);
break;
case (minimum !== null && maximum !== null):
Validate.Numericality(value, {tooLowMessage: tooLowMessage, minimum: minimum});
Validate.Numericality(value, {tooHighMessage: tooHighMessage, maximum: maximum});
break;
case (minimum !== null):
if( value < Number(minimum) ) Validate.fail(tooLowMessage);
break;
case (maximum !== null):
if( value > Number(maximum) ) Validate.fail(tooHighMessage);
break;
}
return true;
},
/**
* validates against a RegExp pattern
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Not valid!")
* pattern {RegExp} - the regular expression pattern
* (DEFAULT: /./)
* negate {Boolean} - if set to true, will validate true if the pattern is not matched
* (DEFAULT: false)
*
* NB. will return true for an empty string, to allow for non-required, empty fields to validate.
* If you do not want this to be the case then you must either add a LiveValidation.PRESENCE validation
* or build it into the regular expression pattern
*/
Format: function(value, paramsObj){
var value = String(value);
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Not valid!";
var pattern = paramsObj.pattern || /./;
var negate = paramsObj.negate || false;
if(!negate && !pattern.test(value)) Validate.fail(message); // normal
if(negate && pattern.test(value)) Validate.fail(message); // negated
return true;
},
/**
* validates that the field contains a valid email address
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Must be a number!" or "Must be an integer!")
*/
Email: function(value, paramsObj){
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Must be a valid email address!";
Validate.Format(value, { failureMessage: message, pattern: /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i } );
return true;
},
/**
* validates the length of the value
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* wrongLengthMessage {String} - the message to show when the fails when is param is used
* (DEFAULT: "Must be {is} characters long!")
* tooShortMessage {String} - the message to show when the fails when minimum param is used
* (DEFAULT: "Must not be less than {minimum} characters long!")
* tooLongMessage {String} - the message to show when the fails when maximum param is used
* (DEFAULT: "Must not be more than {maximum} characters long!")
* is {Int} - the length must be this long
* minimum {Int} - the minimum length allowed
* maximum {Int} - the maximum length allowed
*
* NB. can be checked if it is within a range by specifying both a minimum and a maximum
*/
Length: function(value, paramsObj){
var value = String(value);
var paramsObj = paramsObj || {};
var minimum = ((paramsObj.minimum) || (paramsObj.minimum == 0)) ? paramsObj.minimum : null;
var maximum = ((paramsObj.maximum) || (paramsObj.maximum == 0)) ? paramsObj.maximum : null;
var is = ((paramsObj.is) || (paramsObj.is == 0)) ? paramsObj.is : null;
var wrongLengthMessage = paramsObj.wrongLengthMessage || "Must be " + is + " characters long!";
var tooShortMessage = paramsObj.tooShortMessage || "Must not be less than " + minimum + " characters long!";
var tooLongMessage = paramsObj.tooLongMessage || "Must not be more than " + maximum + " characters long!";
switch(true){
case (is !== null):
if( value.length != Number(is) ) Validate.fail(wrongLengthMessage);
break;
case (minimum !== null && maximum !== null):
Validate.Length(value, {tooShortMessage: tooShortMessage, minimum: minimum});
Validate.Length(value, {tooLongMessage: tooLongMessage, maximum: maximum});
break;
case (minimum !== null):
if( value.length < Number(minimum) ) Validate.fail(tooShortMessage);
break;
case (maximum !== null):
if( value.length > Number(maximum) ) Validate.fail(tooLongMessage);
break;
default:
throw new Error("Validate::Length - Length(s) to validate against must be provided!");
}
return true;
},
/**
* validates that the value falls within a given set of values
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Must be included in the list!")
* within {Array} - an array of values that the value should fall in
* (DEFAULT: [])
* allowNull {Bool} - if true, and a null value is passed in, validates as true
* (DEFAULT: false)
* partialMatch {Bool} - if true, will not only validate against the whole value to check but also if it is a substring of the value
* (DEFAULT: false)
* caseSensitive {Bool} - if false will compare strings case insensitively
* (DEFAULT: true)
* negate {Bool} - if true, will validate that the value is not within the given set of values
* (DEFAULT: false)
*/
Inclusion: function(value, paramsObj){
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Must be included in the list!";
var caseSensitive = (paramsObj.caseSensitive === false) ? false : true;
if(paramsObj.allowNull && value == null) return true;
if(!paramsObj.allowNull && value == null) Validate.fail(message);
var within = paramsObj.within || [];
//if case insensitive, make all strings in the array lowercase, and the value too
if(!caseSensitive){
var lowerWithin = [];
for(var j = 0, length = within.length; j < length; ++j){
var item = within[j];
if(typeof item == 'string') item = item.toLowerCase();
lowerWithin.push(item);
}
within = lowerWithin;
if(typeof value == 'string') value = value.toLowerCase();
}
var found = false;
for(var i = 0, length = within.length; i < length; ++i){
if(within[i] == value) found = true;
if(paramsObj.partialMatch){
if(value.indexOf(within[i]) != -1) found = true;
}
}
if( (!paramsObj.negate && !found) || (paramsObj.negate && found) ) Validate.fail(message);
return true;
},
/**
* validates that the value does not fall within a given set of values
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Must not be included in the list!")
* within {Array} - an array of values that the value should not fall in
* (DEFAULT: [])
* allowNull {Bool} - if true, and a null value is passed in, validates as true
* (DEFAULT: false)
* partialMatch {Bool} - if true, will not only validate against the whole value to check but also if it is a substring of the value
* (DEFAULT: false)
* caseSensitive {Bool} - if false will compare strings case insensitively
* (DEFAULT: true)
*/
Exclusion: function(value, paramsObj){
var paramsObj = paramsObj || {};
paramsObj.failureMessage = paramsObj.failureMessage || "Must not be included in the list!";
paramsObj.negate = true;
Validate.Inclusion(value, paramsObj);
return true;
},
/**
* validates that the value matches that in another field
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Does not match!")
* match {String} - id of the field that this one should match
*/
Confirmation: function(value, paramsObj){
if(!paramsObj.match) throw new Error("Validate::Confirmation - Error validating confirmation: Id of element to match must be provided!");
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Does not match!";
var match = paramsObj.match.nodeName ? paramsObj.match : document.getElementById(paramsObj.match);
if(!match) throw new Error("Validate::Confirmation - There is no reference with name of, or element with id of '" + paramsObj.match + "'!");
if(value != match.value){
Validate.fail(message);
}
return true;
},
/**
* validates that the value is true (for use primarily in detemining if a checkbox has been checked)
*
* @var value {mixed} - value to be checked if true or not (usually a boolean from the checked value of a checkbox)
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Must be accepted!")
*/
Acceptance: function(value, paramsObj){
var paramsObj = paramsObj || {};
var message = paramsObj.failureMessage || "Must be accepted!";
if(!value){
Validate.fail(message);
}
return true;
},
/**
* validates against a custom function that returns true or false (or throws a Validate.Error) when passed the value
*
* @var value {mixed} - value to be checked
* @var paramsObj {Object} - parameters for this particular validation, see below for details
*
* paramsObj properties:
* failureMessage {String} - the message to show when the field fails validation
* (DEFAULT: "Not valid!")
* against {Function} - a function that will take the value and object of arguments and return true or false
* (DEFAULT: function(){ return true; })
* args {Object} - an object of named arguments that will be passed to the custom function so are accessible through this object within it
* (DEFAULT: {})
*/
Custom: function(value, paramsObj){
var paramsObj = paramsObj || {};
var against = paramsObj.against || function(){ return true; };
var args = paramsObj.args || {};
var message = paramsObj.failureMessage || "Not valid!";
if(!against(value, args)) Validate.fail(message);
return true;
},
/**
* validates whatever it is you pass in, and handles the validation error for you so it gives a nice true or false reply
*
* @var validationFunction {Function} - validation function to be used (ie Validation.validatePresence )
* @var value {mixed} - value to be checked if true or not (usually a boolean from the checked value of a checkbox)
* @var validationParamsObj {Object} - parameters for doing the validation, if wanted or necessary
*/
now: function(validationFunction, value, validationParamsObj){
if(!validationFunction) throw new Error("Validate::now - Validation function must be provided!");
var isValid = true;
try{
validationFunction(value, validationParamsObj || {});
} catch(error) {
if(error instanceof Validate.Error){
isValid = false;
}else{
throw error;
}
}finally{
return isValid
}
},
/**
* shortcut for failing throwing a validation error
*
* @var errorMessage {String} - message to display
*/
fail: function(errorMessage){
throw new Validate.Error(errorMessage);
},
Error: function(errorMessage){
this.message = errorMessage;
this.name = 'ValidationError';
}
}
function scrollToErrorIfFound() {
var errorField = $j(".LV_invalid_field").first();
if (errorField.length !== 0) {
$j("html, body").animate({
scrollTop: errorField.offset().top
}, 1000);
errorField.focus();
}
}
// Enable submit button if there are no errors
function enableSubmit() {
if ($j(".LV_invalid_field").first().length === 0) {
$j.rails.enableFormElement($j("input[data-disable-with]"));
}
}

294
cache/d1b5fde27293b4c7d5e5b78261172c48 vendored Normal file
View File

@@ -0,0 +1,294 @@
/* MEDIA: only screen and (max-width: 42em), handheld ENDMEDIA */
#outer {
background: #fff;
font-size: 0.875em;
position: relative;
}
h1, h2, h3 {
word-break: break-all;
/* not supported in all browsers, so we need break-all as a fallback */
word-break: break-word;
}
/* non-JavaScript states
.narrow-shown: should be displayed when this stylesheet is in use
*/
body .narrow-shown {
display: block;
}
.actions li.narrow-shown {
display: inline;
}
/* JavaScript states
.javascript .narrow-hidden: should not be displayed when JS is enabled and this stylesheet is in use
*/
.javascript .narrow-hidden {
display: none;
}
/* 03 region header */
#header .logo {
height: 1.75em;
}
#header .dropdown a:focus {
outline: none;
}
#header .primary > li:first-of-type {
margin-left: 0;
}
#header .dropdown a:focus, #header .dropdown .menu a:focus {
background: transparent;
color: #111;
}
#header .open a:focus {
background: #ddd;
}
#header .primary .dropdown a:focus {
color: #fff;
}
#header .primary .open a:focus {
color: #111;
}
#header .user .open a:focus {
color: #900;
}
#header h2.collections {
padding: 1%;
margin: 0;
}
#header #small_login {
margin-left: 45px;
}
#header .dropdown, #greeting .user {
position: static;
}
#header .menu {
width: 100%;
position: absolute;
left: 0;
}
/* 04 region dashboard */
#dashboard, #dashboard.own {
border-bottom-width: 7px;
border-top-width: 7px;
padding: 0.25em 0;
}
/* 05 region main */
#main, #main.dashboard {
position: static;
}
#main.errors {
background-position: center;
}
#main.session {
background-image: none;
}
#main.errors p, #main.errors .heading {
margin-right: 0;
}
#main.errors p:last-child {
margin-bottom: 500px;
}
/* once we remove the meta class from the work form, we can remove the form .meta selectors here */
.filtered .index, form.filters, form dd, form dt, form .meta dd, form .meta dt, form.inbox {
width: 100%;
max-width: 100%;
min-width: 0;
float: none;
}
.dashboard .index {
float: none;
clear: both
}
.dashboard .landmark {
clear: both;
}
/* 10 types and groups */
.blurb dl.tags dt, .blurb dl.tags dd, dl.meta dt, dl.meta dd, .alphabet .listbox li, .media .listbox {
width: auto;
float: none;
}
.blurb dl.tags dd, dl.meta dd {
margin-left: 1em;
}
.alphabet .listbox li {
display: block;
}
/* 11 group: listbox */
.listbox .index {
width: auto;
}
/* 15 group: comments */
.thread .thread {
margin-left: 1em;
}
.comment .userstuff {
min-height: 0;
}
.comment .icon {
height: 55px;
margin-bottom: 0;
width: 55px;
}
.comment .icon .anonymous {
background: url(/images/imageset.png) no-repeat -75px -395px;
}
.comment .icon .visitor {
background: url(/images/imageset.png) no-repeat -130px -395px;
}
.comment h4.byline {
padding-left: 62px;
}
/* 16 zone: system */
.splash {
padding: 0;
}
.splash div.module, .logged-in .splash div.module {
clear: both;
margin-left: 0;
margin-right: 0;
width: 100%;
}
.splash .intro {
padding-top: 0;
}
.splash .intro h2 {
font-size: 1.5em;
word-break: normal;
}
.session #signin {
margin-left: 0;
width: 100%;
}
/* 18 zone: search browse */
form.filters dl {
width: auto;
}
/* Filters with JavaScript */
.javascript {
background: #ddd;
}
.javascript form.filters {
margin: 0;
max-width: 95%;
position: absolute;
top: 0;
right: -16em;
width: 16em; /* 14em/0.875em */
z-index: 400;
}
.javascript .filters fieldset {
border: none;
margin: 0;
position: relative;
z-index: 450;
box-shadow: none;
}
.javascript .filters p.narrow-shown {
position: relative;
}
.filtering {
right: 14em;
}
.filtering .filters #leave_filters {
background: transparent none;
border-bottom: none;
position: fixed;
top: -101em;
bottom: -101em;
left: -10em;
right: -10em;
z-index: 0;
}
.filtering #leave_filters:focus {
outline: none;
}
/* 21 userstuff */
#workskin {
margin: auto;
}
/* 22 system: messages */
.announcement .userstuff {
margin: 1%;
}
.announcement p.submit {
bottom: -0.5em;
right: 1%;
}
.announcement .thermometer-content {
width: 80%;
}
.announcement .goal .amount {
display: none;
}
.announcement .thermometer .progress .amount {
left: 0;
right: auto;
}

15
cache/e8d39ac2b8645f201df2197c4f56db7f vendored Normal file
View File

@@ -0,0 +1,15 @@
/**
* jQuery Shuffle (http://mktgdept.com/jquery-shuffle)
* A jQuery plugin for shuffling a set of elements
*
* v0.0.1 - 13 November 2009
*
* Copyright (c) 2009 Chad Smith (http://twitter.com/chadsmith)
* Dual licensed under the MIT and GPL licenses.
* http://www.opensource.org/licenses/mit-license.php
* http://www.opensource.org/licenses/gpl-license.php
*
* Shuffle elements using: $(selector).shuffle() or $.shuffle(selector)
*
**/
(function(d){d.fn.shuffle=function(c){c=[];return this.each(function(){c.push(d(this).clone(true))}).each(function(a,b){d(b).replaceWith(c[a=Math.floor(Math.random()*c.length)]);c.splice(a,1)})};d.shuffle=function(a){return d(a).shuffle()}})(jQuery);

File diff suppressed because one or more lines are too long

BIN
cache/e9d6dffb2eb319ae85deae26872daf4a vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

File diff suppressed because one or more lines are too long

534
cache/f688920d715a669db37b313dd3ecffd6 vendored Normal file
View File

@@ -0,0 +1,534 @@
(function($, undefined) {
/**
* Unobtrusive scripting adapter for jQuery
* https://github.com/rails/jquery-ujs
*
* Requires jQuery 1.8.0 or later.
*
* Released under the MIT license
*
*/
// Cut down on the number of issues from people inadvertently including jquery_ujs twice
// by detecting and raising an error when it happens.
'use strict';
if ( $.rails !== undefined ) {
$.error('jquery-ujs has already been loaded!');
}
// Shorthand to make it a little easier to call public rails functions from within rails.js
var rails;
var $document = $(document);
$.rails = rails = {
// Link elements bound by jquery-ujs
linkClickSelector: 'a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]',
// Button elements bound by jquery-ujs
buttonClickSelector: 'button[data-remote]:not([form]):not(form button), button[data-confirm]:not([form]):not(form button)',
// Select elements bound by jquery-ujs
inputChangeSelector: 'select[data-remote], input[data-remote], textarea[data-remote]',
// Form elements bound by jquery-ujs
formSubmitSelector: 'form',
// Form input elements bound by jquery-ujs
formInputClickSelector: 'form input[type=submit], form input[type=image], form button[type=submit], form button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])',
// Form input elements disabled during form submission
disableSelector: 'input[data-disable-with]:enabled, button[data-disable-with]:enabled, textarea[data-disable-with]:enabled, input[data-disable]:enabled, button[data-disable]:enabled, textarea[data-disable]:enabled',
// Form input elements re-enabled after form submission
enableSelector: 'input[data-disable-with]:disabled, button[data-disable-with]:disabled, textarea[data-disable-with]:disabled, input[data-disable]:disabled, button[data-disable]:disabled, textarea[data-disable]:disabled',
// Form required input elements
requiredInputSelector: 'input[name][required]:not([disabled]), textarea[name][required]:not([disabled])',
// Form file input elements
fileInputSelector: 'input[type=file]:not([disabled])',
// Link onClick disable selector with possible reenable after remote submission
linkDisableSelector: 'a[data-disable-with], a[data-disable]',
// Button onClick disable selector with possible reenable after remote submission
buttonDisableSelector: 'button[data-remote][data-disable-with], button[data-remote][data-disable]',
// Up-to-date Cross-Site Request Forgery token
csrfToken: function() {
return $('meta[name=csrf-token]').attr('content');
},
// URL param that must contain the CSRF token
csrfParam: function() {
return $('meta[name=csrf-param]').attr('content');
},
// Make sure that every Ajax request sends the CSRF token
CSRFProtection: function(xhr) {
var token = rails.csrfToken();
if (token) xhr.setRequestHeader('X-CSRF-Token', token);
},
// Make sure that all forms have actual up-to-date tokens (cached forms contain old ones)
refreshCSRFTokens: function(){
$('form input[name="' + rails.csrfParam() + '"]').val(rails.csrfToken());
},
// Triggers an event on an element and returns false if the event result is false
fire: function(obj, name, data) {
var event = $.Event(name);
obj.trigger(event, data);
return event.result !== false;
},
// Default confirm dialog, may be overridden with custom confirm dialog in $.rails.confirm
confirm: function(message) {
return confirm(message);
},
// Default ajax function, may be overridden with custom function in $.rails.ajax
ajax: function(options) {
return $.ajax(options);
},
// Default way to get an element's href. May be overridden at $.rails.href.
href: function(element) {
return element[0].href;
},
// Checks "data-remote" if true to handle the request through a XHR request.
isRemote: function(element) {
return element.data('remote') !== undefined && element.data('remote') !== false;
},
// Submits "remote" forms and links with ajax
handleRemote: function(element) {
var method, url, data, withCredentials, dataType, options;
if (rails.fire(element, 'ajax:before')) {
withCredentials = element.data('with-credentials') || null;
dataType = element.data('type') || ($.ajaxSettings && $.ajaxSettings.dataType);
if (element.is('form')) {
method = element.data('ujs:submit-button-formmethod') || element.attr('method');
url = element.data('ujs:submit-button-formaction') || element.attr('action');
data = $(element[0].elements).serializeArray();
// memoized value from clicked submit button
var button = element.data('ujs:submit-button');
if (button) {
data.push(button);
element.data('ujs:submit-button', null);
}
element.data('ujs:submit-button-formmethod', null);
element.data('ujs:submit-button-formaction', null);
} else if (element.is(rails.inputChangeSelector)) {
method = element.data('method');
url = element.data('url');
data = element.serialize();
if (element.data('params')) data = data + '&' + element.data('params');
} else if (element.is(rails.buttonClickSelector)) {
method = element.data('method') || 'get';
url = element.data('url');
data = element.serialize();
if (element.data('params')) data = data + '&' + element.data('params');
} else {
method = element.data('method');
url = rails.href(element);
data = element.data('params') || null;
}
options = {
type: method || 'GET', data: data, dataType: dataType,
// stopping the "ajax:beforeSend" event will cancel the ajax request
beforeSend: function(xhr, settings) {
if (settings.dataType === undefined) {
xhr.setRequestHeader('accept', '*/*;q=0.5, ' + settings.accepts.script);
}
if (rails.fire(element, 'ajax:beforeSend', [xhr, settings])) {
element.trigger('ajax:send', xhr);
} else {
return false;
}
},
success: function(data, status, xhr) {
element.trigger('ajax:success', [data, status, xhr]);
},
complete: function(xhr, status) {
element.trigger('ajax:complete', [xhr, status]);
},
error: function(xhr, status, error) {
element.trigger('ajax:error', [xhr, status, error]);
},
crossDomain: rails.isCrossDomain(url)
};
// There is no withCredentials for IE6-8 when
// "Enable native XMLHTTP support" is disabled
if (withCredentials) {
options.xhrFields = {
withCredentials: withCredentials
};
}
// Only pass url to `ajax` options if not blank
if (url) { options.url = url; }
return rails.ajax(options);
} else {
return false;
}
},
// Determines if the request is a cross domain request.
isCrossDomain: function(url) {
var originAnchor = document.createElement('a');
originAnchor.href = location.href;
var urlAnchor = document.createElement('a');
try {
urlAnchor.href = url;
// This is a workaround to a IE bug.
urlAnchor.href = urlAnchor.href;
// If URL protocol is false or is a string containing a single colon
// *and* host are false, assume it is not a cross-domain request
// (should only be the case for IE7 and IE compatibility mode).
// Otherwise, evaluate protocol and host of the URL against the origin
// protocol and host.
return !(((!urlAnchor.protocol || urlAnchor.protocol === ':') && !urlAnchor.host) ||
(originAnchor.protocol + '//' + originAnchor.host ===
urlAnchor.protocol + '//' + urlAnchor.host));
} catch (e) {
// If there is an error parsing the URL, assume it is crossDomain.
return true;
}
},
// Handles "data-method" on links such as:
// <a href="/users/5" data-method="delete" rel="nofollow" data-confirm="Are you sure?">Delete</a>
handleMethod: function(link) {
var href = rails.href(link),
method = link.data('method'),
target = link.attr('target'),
csrfToken = rails.csrfToken(),
csrfParam = rails.csrfParam(),
form = $('<form method="post" action="' + href + '"></form>'),
metadataInput = '<input name="_method" value="' + method + '" type="hidden" />';
if (csrfParam !== undefined && csrfToken !== undefined && !rails.isCrossDomain(href)) {
metadataInput += '<input name="' + csrfParam + '" value="' + csrfToken + '" type="hidden" />';
}
if (target) { form.attr('target', target); }
form.hide().append(metadataInput).appendTo('body');
form.submit();
},
// Helper function that returns form elements that match the specified CSS selector
// If form is actually a "form" element this will return associated elements outside the from that have
// the html form attribute set
formElements: function(form, selector) {
return form.is('form') ? $(form[0].elements).filter(selector) : form.find(selector);
},
/* Disables form elements:
- Caches element value in 'ujs:enable-with' data store
- Replaces element text with value of 'data-disable-with' attribute
- Sets disabled property to true
*/
disableFormElements: function(form) {
rails.formElements(form, rails.disableSelector).each(function() {
rails.disableFormElement($(this));
});
},
disableFormElement: function(element) {
var method, replacement;
method = element.is('button') ? 'html' : 'val';
replacement = element.data('disable-with');
if (replacement !== undefined) {
element.data('ujs:enable-with', element[method]());
element[method](replacement);
}
element.prop('disabled', true);
element.data('ujs:disabled', true);
},
/* Re-enables disabled form elements:
- Replaces element text with cached value from 'ujs:enable-with' data store (created in `disableFormElements`)
- Sets disabled property to false
*/
enableFormElements: function(form) {
rails.formElements(form, rails.enableSelector).each(function() {
rails.enableFormElement($(this));
});
},
enableFormElement: function(element) {
var method = element.is('button') ? 'html' : 'val';
if (element.data('ujs:enable-with') !== undefined) {
element[method](element.data('ujs:enable-with'));
element.removeData('ujs:enable-with'); // clean up cache
}
element.prop('disabled', false);
element.removeData('ujs:disabled');
},
/* For 'data-confirm' attribute:
- Fires `confirm` event
- Shows the confirmation dialog
- Fires the `confirm:complete` event
Returns `true` if no function stops the chain and user chose yes; `false` otherwise.
Attaching a handler to the element's `confirm` event that returns a `falsy` value cancels the confirmation dialog.
Attaching a handler to the element's `confirm:complete` event that returns a `falsy` value makes this function
return false. The `confirm:complete` event is fired whether or not the user answered true or false to the dialog.
*/
allowAction: function(element) {
var message = element.data('confirm'),
answer = false, callback;
if (!message) { return true; }
if (rails.fire(element, 'confirm')) {
try {
answer = rails.confirm(message);
} catch (e) {
(console.error || console.log).call(console, e.stack || e);
}
callback = rails.fire(element, 'confirm:complete', [answer]);
}
return answer && callback;
},
// Helper function which checks for blank inputs in a form that match the specified CSS selector
blankInputs: function(form, specifiedSelector, nonBlank) {
var inputs = $(), input, valueToCheck,
selector = specifiedSelector || 'input,textarea',
allInputs = form.find(selector);
allInputs.each(function() {
input = $(this);
valueToCheck = input.is('input[type=checkbox],input[type=radio]') ? input.is(':checked') : !!input.val();
if (valueToCheck === nonBlank) {
// Don't count unchecked required radio if other radio with same name is checked
if (input.is('input[type=radio]') && allInputs.filter('input[type=radio]:checked[name="' + input.attr('name') + '"]').length) {
return true; // Skip to next input
}
inputs = inputs.add(input);
}
});
return inputs.length ? inputs : false;
},
// Helper function which checks for non-blank inputs in a form that match the specified CSS selector
nonBlankInputs: function(form, specifiedSelector) {
return rails.blankInputs(form, specifiedSelector, true); // true specifies nonBlank
},
// Helper function, needed to provide consistent behavior in IE
stopEverything: function(e) {
$(e.target).trigger('ujs:everythingStopped');
e.stopImmediatePropagation();
return false;
},
// Replace element's html with the 'data-disable-with' after storing original html
// and prevent clicking on it
disableElement: function(element) {
var replacement = element.data('disable-with');
if (replacement !== undefined) {
element.data('ujs:enable-with', element.html()); // store enabled state
element.html(replacement);
}
element.bind('click.railsDisable', function(e) { // prevent further clicking
return rails.stopEverything(e);
});
element.data('ujs:disabled', true);
},
// Restore element to its original state which was disabled by 'disableElement' above
enableElement: function(element) {
if (element.data('ujs:enable-with') !== undefined) {
element.html(element.data('ujs:enable-with')); // set to old enabled state
element.removeData('ujs:enable-with'); // clean up cache
}
element.unbind('click.railsDisable'); // enable element
element.removeData('ujs:disabled');
}
};
if (rails.fire($document, 'rails:attachBindings')) {
$.ajaxPrefilter(function(options, originalOptions, xhr){ if ( !options.crossDomain ) { rails.CSRFProtection(xhr); }});
// This event works the same as the load event, except that it fires every
// time the page is loaded.
//
// See https://github.com/rails/jquery-ujs/issues/357
// See https://developer.mozilla.org/en-US/docs/Using_Firefox_1.5_caching
$(window).on('pageshow.rails', function () {
$($.rails.enableSelector).each(function () {
var element = $(this);
if (element.data('ujs:disabled')) {
$.rails.enableFormElement(element);
}
});
$($.rails.linkDisableSelector).each(function () {
var element = $(this);
if (element.data('ujs:disabled')) {
$.rails.enableElement(element);
}
});
});
$document.delegate(rails.linkDisableSelector, 'ajax:complete', function() {
rails.enableElement($(this));
});
$document.delegate(rails.buttonDisableSelector, 'ajax:complete', function() {
rails.enableFormElement($(this));
});
$document.delegate(rails.linkClickSelector, 'click.rails', function(e) {
var link = $(this), method = link.data('method'), data = link.data('params'), metaClick = e.metaKey || e.ctrlKey;
if (!rails.allowAction(link)) return rails.stopEverything(e);
if (!metaClick && link.is(rails.linkDisableSelector)) rails.disableElement(link);
if (rails.isRemote(link)) {
if (metaClick && (!method || method === 'GET') && !data) { return true; }
var handleRemote = rails.handleRemote(link);
// Response from rails.handleRemote() will either be false or a deferred object promise.
if (handleRemote === false) {
rails.enableElement(link);
} else {
handleRemote.fail( function() { rails.enableElement(link); } );
}
return false;
} else if (method) {
rails.handleMethod(link);
return false;
}
});
$document.delegate(rails.buttonClickSelector, 'click.rails', function(e) {
var button = $(this);
if (!rails.allowAction(button) || !rails.isRemote(button)) return rails.stopEverything(e);
if (button.is(rails.buttonDisableSelector)) rails.disableFormElement(button);
var handleRemote = rails.handleRemote(button);
// Response from rails.handleRemote() will either be false or a deferred object promise.
if (handleRemote === false) {
rails.enableFormElement(button);
} else {
handleRemote.fail( function() { rails.enableFormElement(button); } );
}
return false;
});
$document.delegate(rails.inputChangeSelector, 'change.rails', function(e) {
var link = $(this);
if (!rails.allowAction(link) || !rails.isRemote(link)) return rails.stopEverything(e);
rails.handleRemote(link);
return false;
});
$document.delegate(rails.formSubmitSelector, 'submit.rails', function(e) {
var form = $(this),
remote = rails.isRemote(form),
blankRequiredInputs,
nonBlankFileInputs;
if (!rails.allowAction(form)) return rails.stopEverything(e);
// Skip other logic when required values are missing or file upload is present
if (form.attr('novalidate') === undefined) {
if (form.data('ujs:formnovalidate-button') === undefined) {
blankRequiredInputs = rails.blankInputs(form, rails.requiredInputSelector, false);
if (blankRequiredInputs && rails.fire(form, 'ajax:aborted:required', [blankRequiredInputs])) {
return rails.stopEverything(e);
}
} else {
// Clear the formnovalidate in case the next button click is not on a formnovalidate button
// Not strictly necessary to do here, since it is also reset on each button click, but just to be certain
form.data('ujs:formnovalidate-button', undefined);
}
}
if (remote) {
nonBlankFileInputs = rails.nonBlankInputs(form, rails.fileInputSelector);
if (nonBlankFileInputs) {
// Slight timeout so that the submit button gets properly serialized
// (make it easy for event handler to serialize form without disabled values)
setTimeout(function(){ rails.disableFormElements(form); }, 13);
var aborted = rails.fire(form, 'ajax:aborted:file', [nonBlankFileInputs]);
// Re-enable form elements if event bindings return false (canceling normal form submission)
if (!aborted) { setTimeout(function(){ rails.enableFormElements(form); }, 13); }
return aborted;
}
rails.handleRemote(form);
return false;
} else {
// Slight timeout so that the submit button gets properly serialized
setTimeout(function(){ rails.disableFormElements(form); }, 13);
}
});
$document.delegate(rails.formInputClickSelector, 'click.rails', function(event) {
var button = $(this);
if (!rails.allowAction(button)) return rails.stopEverything(event);
// Register the pressed submit button
var name = button.attr('name'),
data = name ? {name:name, value:button.val()} : null;
var form = button.closest('form');
if (form.length === 0) {
form = $('#' + button.attr('form'));
}
form.data('ujs:submit-button', data);
// Save attributes from button
form.data('ujs:formnovalidate-button', button.attr('formnovalidate'));
form.data('ujs:submit-button-formaction', button.attr('formaction'));
form.data('ujs:submit-button-formmethod', button.attr('formmethod'));
});
$document.delegate(rails.formSubmitSelector, 'ajax:send.rails', function(event) {
if (this === event.target) rails.disableFormElements($(this));
});
$document.delegate(rails.formSubmitSelector, 'ajax:complete.rails', function(event) {
if (this === event.target) rails.enableFormElements($(this));
});
$(function(){
rails.refreshCSRFTokens();
});
}
})( jQuery );

186
cache/fd994d17b56e171552534f14fc2cd24b vendored Normal file
View File

@@ -0,0 +1,186 @@
/* ============================================================
* bootstrap-dropdown.js v2.3.1
* http://twitter.github.com/bootstrap/javascript.html#dropdowns
* ============================================================
* Copyright 2012 Twitter, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ============================================================
* OTWARCHIVE DEVS:
*
* When updating to the newest version, make sure to include the
* customizations from LINES 62-70, 103, AND 177-184 and UPDATE THIS
* MESSAGE with the new line numbers. These lines ensure the code works
* without the ARIA menu role and ensure proper behavior when both JS and
* CSS hover are used for menus.
* ========================================================== */
!function ($) {
"use strict"; // jshint ;_;
/* DROPDOWN CLASS DEFINITION
* ========================= */
var toggle = '[data-toggle=dropdown]'
, Dropdown = function (element) {
var $el = $(element).on('click.dropdown.data-api', this.toggle)
$('html').on('click.dropdown.data-api', function () {
$el.parent().removeClass('open')
})
}
Dropdown.prototype = {
constructor: Dropdown
, toggle: function (e) {
var $this = $(this)
, $parent
, isActive
if ($this.is('.disabled, :disabled')) return
$parent = getParent($this)
isActive = $parent.hasClass('open')
clearMenus()
if (isActive) {
$parent.children('ul').hide()
$this.blur()
} else {
$parent
.toggleClass('open')
.children('ul').removeAttr('style')
$this.focus()
}
$this.focus()
return false
}
, keydown: function (e) {
var $this
, $items
, $active
, $parent
, isActive
, index
if (!/(38|40|27)/.test(e.keyCode)) return
$this = $(this)
e.preventDefault()
e.stopPropagation()
if ($this.is('.disabled, :disabled')) return
$parent = getParent($this)
isActive = $parent.hasClass('open')
if (!isActive || (isActive && e.keyCode == 27)) {
if (e.which == 27) $parent.find(toggle).focus()
return $this.click()
}
$items = $('ul.menu li:not(.divider):visible a', $parent)
if (!$items.length) return
index = $items.index($items.filter(':focus'))
if (e.keyCode == 38 && index > 0) index-- // up
if (e.keyCode == 40 && index < $items.length - 1) index++ // down
if (!~index) index = 0
$items
.eq(index)
.focus()
}
}
function clearMenus() {
$(toggle).each(function () {
getParent($(this)).removeClass('open')
})
}
function getParent($this) {
var selector = $this.attr('data-target')
, $parent
if (!selector) {
selector = $this.attr('href')
selector = selector && /#/.test(selector) && selector.replace(/.*(?=#[^\s]*$)/, '') //strip for ie7
}
$parent = selector && $(selector)
if (!$parent || !$parent.length) $parent = $this.parent()
return $parent
}
/* DROPDOWN PLUGIN DEFINITION
* ========================== */
var old = $.fn.dropdown
$.fn.dropdown = function (option) {
return this.each(function () {
var $this = $(this)
, data = $this.data('dropdown')
if (!data) $this.data('dropdown', (data = new Dropdown(this)))
if (typeof option == 'string') data[option].call($this)
})
}
$.fn.dropdown.Constructor = Dropdown
/* DROPDOWN NO CONFLICT
* ==================== */
$.fn.dropdown.noConflict = function () {
$.fn.dropdown = old
return this
}
/* APPLY TO STANDARD DROPDOWN ELEMENTS
* =================================== */
$(document)
.on('click.dropdown.data-api', clearMenus)
.on('click.dropdown.data-api', '.dropdown form', function (e) { e.stopPropagation() })
.on('click.dropdown-menu', function (e) { e.stopPropagation() })
.on('click.dropdown.data-api' , toggle, Dropdown.prototype.toggle)
.on('keydown.dropdown.data-api', toggle + ', ul.menu' , Dropdown.prototype.keydown)
.on('mouseenter', '.dropdown', function (e) {
var $parent = $(this)
if ($parent.siblings('.open').length) {
$parent.children('ul').hide()
}
})
.on('mouseleave', '.dropdown', function (e) { $(this).children('ul').removeAttr('') })
}(window.jQuery);

BIN
cache/fe5c4c7ec67e5277582208af3c5303b6 vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1,11 +1,12 @@
"""
异步代理池 v4Cookie 感知 + 挑战分离 + 真实统计
异步代理池 v5P2C + 状态机 + 全量 5000 代理 + 并发限流
v4 vs v3:
- 每个代理维护 cookie jarcf_clearance 等),成功请求后自动保存
- CF 挑战不再标记为代理死亡mark_challenged ≠ mark_failure
- 真实统计:维护原子计数器,不造假数据
- 更多 TLS 指纹:加入 safari15_5/safari17_0
对标 go3 的代理管理架构,适配 Python/curl_cffi
- 全量加载 proxies.txt不再按端口过滤
- P2C (Power of Two Choices) 选择算法
- 状态机: healthy → unstable → blocked → probing
- 并发限制 400Webshare 上限)
- 被动探测 + 指数退避
"""
import asyncio
import logging
@@ -20,23 +21,42 @@ from curl_cffi.requests import AsyncSession
logger = logging.getLogger("ao3-proxy-pool")
OPTIMAL_MIN_PORT = 13500
OPTIMAL_MAX_PORT = 14499
# ─── Constants ───────────────────────────────────────────────────────────────
PROXY_FILE = "/home/ubuntu/proxy.txt"
WORKING_PROXIES_FILE = "/dev/shm/working_proxies.txt"
FAST_POOL_SIZE = 50
# Concurrency limit: Webshare allows ~500 concurrent, cap at 400 for safety
MAX_CONCURRENT_REQUESTS = 400
# 被动检查:只对快池做轻量采样
SAMPLE_INTERVAL = 60
SAMPLE_BATCH_SIZE = 10
SAMPLE_TIMEOUT = 5
FAST_POOL_REFRESH_INTERVAL = 10
# Probe config (like go3: 10s interval, 10 concurrency, 2 successes to promote)
PROBE_INTERVAL = 10
PROBE_CONCURRENCY = 10
PROBE_SUCCESS_THRESHOLD = 2
PROBE_TIMEOUT = 5
# TLS fingerprints — proven against AO3 Cloudflare
# safari15_5/17_0 have highest CF bypass rate per testing
# Cooldown config
BLOCKED_COOLDOWN_BASE = 120 # First block: 2 min
BLOCKED_COOLDOWN_MAX = 1800 # Max: 30 min
UNSTABLE_COOLDOWN = 15 # Transient failure: 15s
# Outage attack detection
OUTAGE_THRESHOLD = 0.9 # 90% proxies unavailable → attack page
# Max retries for a single request
MAX_RETRIES = 5
# Ban-worthy status codes
BAN_STATUS_CODES = {403, 525}
# TLS fingerprints (proven against AO3 CF)
BROWSER_IMPS = ["safari15_5", "safari17_0", "chrome123", "chrome124"]
WARM_THRESHOLD_S = 3.0
# States
STATE_HEALTHY = "healthy"
STATE_UNSTABLE = "unstable"
STATE_BLOCKED = "blocked"
STATE_PROBING = "probing"
def _parse_cookie_expires(set_cookie: str) -> float:
@@ -55,23 +75,21 @@ def _parse_cookie_expires(set_cookie: str) -> float:
return time.time() + int(part[9:])
except Exception:
pass
return 0 # session cookie
return 0
def load_proxies_from_file() -> list[str]:
"""Load proxy list from known-working file, or full list with port filtering."""
"""Load ALL proxies from file. No port filtering in v5."""
# Prefer cached working proxies if available and fresh
# But only use as a hint — still load the full list
if os.path.exists(WORKING_PROXIES_FILE) and os.path.getsize(WORKING_PROXIES_FILE) > 0:
with open(WORKING_PROXIES_FILE) as f:
proxies = [l.strip() for l in f if l.strip() and ":" in l]
if proxies:
logger.info(f"Loaded {len(proxies)} working proxies from {WORKING_PROXIES_FILE}")
filtered = [p for p in proxies if ":" in p and OPTIMAL_MIN_PORT <= int(p.split(":")[-1]) <= OPTIMAL_MAX_PORT]
if len(filtered) >= 10:
return filtered
return proxies
proxy_file = "/home/ubuntu/proxy.txt"
cached = [l.strip() for l in f if l.strip() and ":" in l]
if len(cached) >= 100:
logger.info(f"Found {len(cached)} cached working proxies — using as seed")
proxies = []
with open(proxy_file) as f:
with open(PROXY_FILE) as f:
for line in f:
line = line.strip()
if not line or ":" not in line:
@@ -79,96 +97,133 @@ def load_proxies_from_file() -> list[str]:
if "|" in line:
line = line.split("|")[-1].strip()
proxies.append(line)
filtered = [p for p in proxies if ":" in p and OPTIMAL_MIN_PORT <= int(p.split(":")[-1]) <= OPTIMAL_MAX_PORT]
return filtered if len(filtered) >= 10 else proxies
if not proxies:
raise RuntimeError(f"No proxies found in {PROXY_FILE}")
logger.info(f"Loaded {len(proxies)} proxies from {PROXY_FILE}")
return proxies
class ProxySession:
"""A single proxy with its own AsyncSession, cookie jar, and health state."""
"""A single proxy with its own AsyncSession, cookie jar, and state machine."""
__slots__ = (
"host_port", "host", "port_str", "port",
"session", "impersonate", "alive",
"consecutive_failures", "ban_until", "last_used",
"avg_response_time", "weight", "requests_handled",
"last_sample", "sample_passed",
# v4: cookie jar
"idx", "host_port", "host", "port_str", "port",
"session", "impersonate",
# State machine
"state", # healthy / unstable / blocked / probing
# Stats
"successes", "failures", "consecutive_net_failures",
"consecutive_blocked",
"last_used", "last_success_at", "last_failure_at",
"avg_response_time", "requests_handled",
# Cooldown / block
"cooldown_until", "next_probe_at",
"probe_successes",
# v5: inflight counter
"inflight",
# Cookie jar
"_cookies", "_cookie_expires", "_lock",
)
def __init__(self, host_port: str):
def __init__(self, idx: int, host_port: str):
self.idx = idx
self.host_port = host_port
self.host, self.port_str = host_port.split(":")
self.port = int(self.port_str)
self.session: Optional[AsyncSession] = None
self.impersonate = random.choice(BROWSER_IMPS)
self.alive = True
self.consecutive_failures = 0
self.ban_until = 0.0
self.state = STATE_HEALTHY
self.successes = 0
self.failures = 0
self.consecutive_net_failures = 0
self.consecutive_blocked = 0
self.last_used = 0.0
self.last_success_at = 0.0
self.last_failure_at = 0.0
self.avg_response_time = 1.0
self.weight = 1.0
self.requests_handled = 0
self.last_sample = 0.0
self.sample_passed = True
# v4: per-proxy cookie jar (cf_clearance, etc.)
self.cooldown_until = 0.0
self.next_probe_at = 0.0
self.probe_successes = 0
self.inflight = 0
self._cookies: dict[str, str] = {}
self._cookie_expires: dict[str, float] = {}
self._lock = Lock()
# ─── Cookie management ────────────────────────────────────────────
def save_cookies(self, headers: dict) -> int:
"""Extract Set-Cookie from response headers. Returns count saved."""
saved = 0
set_cookie = headers.get("Set-Cookie", "")
if not set_cookie:
# Some servers use set-cookie (lowercase) in HTTP/2
set_cookie = headers.get("set-cookie", "")
if not set_cookie:
return 0
@property
def is_available(self) -> bool:
"""Proxy is selectable for user requests."""
now = time.time()
with self._lock:
for part in set_cookie.split(","):
# Handle comma-separated cookies (ugh)
part = part.strip()
if "=" not in part:
continue
name, _, rest = part.partition("=")
value = rest.split(";")[0].strip() if ";" in rest else rest.strip()
name = name.strip()
if not name:
continue
self._cookies[name] = value
expires = _parse_cookie_expires(part)
if expires > 0:
self._cookie_expires[name] = expires
saved += 1
if now < self.cooldown_until:
return False
if self.state == STATE_HEALTHY:
return True
if self.state == STATE_UNSTABLE and now >= self.cooldown_until:
return True
if self.state == STATE_PROBING:
return False # Don't route user traffic to probing proxies
return False # STATE_BLOCKED
# Purge expired cookies
self._purge_expired(now)
if saved:
logger.debug(f"Saved {saved} cookies for {self.host_port} (keys: {list(self._cookies.keys())})")
return saved
def get_cookie_header(self) -> str:
"""Get Cookie header string for this proxy. Returns '' if no cookies."""
now = time.time()
with self._lock:
self._purge_expired(now)
if not self._cookies:
return ""
return "; ".join(f"{k}={v}" for k, v in self._cookies.items())
@property
def is_user_selectable(self) -> bool:
"""Proxy can be chosen for sticky/user requests. Like go3's isUserSelectable."""
return self.is_available
def _purge_expired(self, now: float):
"""Remove expired cookies."""
expired = [k for k, exp in self._cookie_expires.items() if 0 < exp < now]
for k in expired:
self._cookies.pop(k, None)
self._cookie_expires.pop(k, None)
# ─── Session management ───────────────────────────────────────────
def save_cookies(self, headers: dict) -> int:
"""Extract ONLY cf_clearance from response headers. Returns count saved.
The proxy must be TRANSPARENT to AO3 — do NOT save _otwarchive_session,
user_credentials, or any other AO3 cookies. Only cf_clearance (Cloudflare
bypass) is our concern. Everything else passes through to the browser.
"""
set_cookie = headers.get("Set-Cookie", headers.get("set-cookie", ""))
if not set_cookie:
return 0
now = time.time()
with self._lock:
parts = set_cookie.split(";")
if parts:
first_part = parts[0].strip()
if "=" in first_part:
name, _, value = first_part.partition("=")
name = name.strip()
value = value.strip()
# ONLY save cf_clearance — the only cookie the proxy needs
if name == "cf_clearance":
self._cookies[name] = value
logger.info(f"[CF_SAVE] {self.host_port}: saved cf_clearance")
expires = _parse_cookie_expires(set_cookie)
if expires > 0:
self._cookie_expires[name] = expires
self._purge_expired(now)
return 1
else:
logger.info(f"[CF_SKIP] {self.host_port}: skipping {name} (not cf_clearance)")
self._purge_expired(now)
return 0
def get_cookie_header(self) -> str:
now = time.time()
with self._lock:
self._purge_expired(now)
if not self._cookies:
return ""
header = "; ".join(f"{k}={v}" for k, v in self._cookies.items())
logger.info(f"[PROXY_COOKIES] {self.host_port}: {list(self._cookies.keys())[:10]}")
return header
def has_cookies(self) -> bool:
return bool(self._cookies)
async def get_session(self) -> AsyncSession:
if self.session is None:
@@ -179,10 +234,11 @@ class ProxySession:
"http": f"http://{self.host_port}",
"https": f"http://{self.host_port}",
}
# Default headers that don't change per-request (safe to set on session)
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
@@ -197,260 +253,364 @@ class ProxySession:
pass
self.session = None
# ─── Health tracking ──────────────────────────────────────────────
def update_weight(self):
self.weight = 1.0 / max(self.avg_response_time, 0.1)
# ─── State machine (go3-compatible) ──────────────────────────────────
def mark_success(self, response_time: float):
self.alive = True
self.consecutive_failures = 0
self.ban_until = 0.0
"""Request succeeded. Promote to healthy."""
self.last_used = time.time()
self.last_success_at = time.time()
self.successes += 1
self.requests_handled += 1
self.consecutive_net_failures = 0
self.consecutive_blocked = 0
self.avg_response_time = self.avg_response_time * 0.7 + response_time * 0.3
self.update_weight()
self.cooldown_until = 0.0
self.state = STATE_HEALTHY
def mark_failure(self):
"""Proxy-level failure (connection error, timeout, etc.) — exponential backoff."""
def mark_failure(self, is_network: bool = True):
"""Connection error / timeout. Exponential backoff."""
now = time.time()
self.last_used = now
self.last_failure_at = now
self.failures += 1
self.requests_handled += 1
self.consecutive_failures += 1
backoff = min(5 * (3 ** (self.consecutive_failures - 1)), 300)
self.ban_until = time.time() + backoff
if self.consecutive_failures >= 3:
self.alive = False
self.sample_passed = False
if is_network:
self.consecutive_net_failures += 1
self.consecutive_blocked = 0
if self.consecutive_net_failures >= 3:
self.state = STATE_BLOCKED
# Exponential backoff: base * 2^(failures-1), capped at max
backoff = min(
BLOCKED_COOLDOWN_BASE * (2 ** (self.consecutive_net_failures - 3)),
BLOCKED_COOLDOWN_MAX
)
self.cooldown_until = now + backoff
self.next_probe_at = now + backoff
self.probe_successes = 0
elif self.consecutive_net_failures >= 1:
self.state = STATE_UNSTABLE
self.cooldown_until = now + UNSTABLE_COOLDOWN
# else stay healthy / unchanged
def mark_blocked(self):
"""403/525 — CF blocked. Move to blocked state."""
now = time.time()
self.last_used = now
self.last_failure_at = now
self.failures += 1
self.requests_handled += 1
self.consecutive_blocked += 1
self.consecutive_net_failures = 0
self.state = STATE_BLOCKED
backoff = min(
BLOCKED_COOLDOWN_BASE * (2 ** (self.consecutive_blocked - 1)),
BLOCKED_COOLDOWN_MAX
)
self.cooldown_until = now + backoff
self.next_probe_at = now + backoff
self.probe_successes = 0
def mark_challenged(self):
"""CF challenge detected — proxy is alive but needs cookie. NO backoff."""
# Don't increment consecutive_failures — challenge is not proxy's fault
# Don't set ban_until — proxy may work with cookies
"""CF challenge detected — proxy is alive but needs cookie. Keep healthy, don't penalize."""
self.last_used = time.time()
self.requests_handled += 1
# Only mark as not-sample-passed so it won't be fast-pool priority
self.sample_passed = False
# Don't change state — challenge is not proxy's fault
def mark_sample(self, passed: bool, response_time: float = 0):
"""Lightweight periodic check result."""
self.last_sample = time.time()
self.sample_passed = passed
if passed and not self.alive:
self.alive = True
self.consecutive_failures = max(0, self.consecutive_failures - 1)
self.avg_response_time = self.avg_response_time * 0.5 + response_time * 0.5
self.update_weight()
def mark_probe_result(self, passed: bool, response_time: float = 0):
"""Periodic probe result. If probing and enough successes, promote to healthy."""
now = time.time()
if passed:
self.probe_successes += 1
if self.probe_successes >= PROBE_SUCCESS_THRESHOLD:
self.state = STATE_HEALTHY
self.cooldown_until = 0.0
self.consecutive_net_failures = 0
self.consecutive_blocked = 0
self.avg_response_time = self.avg_response_time * 0.5 + response_time * 0.5
else:
self.probe_successes = 0
# Extend cooldown
self.cooldown_until = now + max(self.cooldown_until - now, BLOCKED_COOLDOWN_BASE)
self.next_probe_at = 0.0
@property
def is_available(self) -> bool:
return self.alive and time.time() > self.ban_until
def to_stats_dict(self) -> dict:
return {
"address": self.host_port,
"state": self.state,
"successes": self.successes,
"failures": self.failures,
"consecutive_net_failures": self.consecutive_net_failures,
"consecutive_blocked": self.consecutive_blocked,
"last_used": self.last_used,
"last_success_at": self.last_success_at,
"last_failure_at": self.last_failure_at,
"avg_response_time": round(self.avg_response_time, 3),
"requests_handled": self.requests_handled,
"cooldown_until": self.cooldown_until,
"inflight": self.inflight,
"has_cookies": bool(self._cookies),
"impersonate": self.impersonate,
}
def __repr__(self):
nc = len(self._cookies)
return f"PS({self.host_port}, alive={self.alive}, {self.avg_response_time:.1f}s, cookies={nc})"
return f"PS({self.host_port}, {self.state}, {self.avg_response_time:.2f}s, inflight={self.inflight})"
class AsyncProxyPool:
"""Tiered async proxy pool with cookie-aware session management."""
"""Proxy pool v5 — P2C selection + state machine + concurrent limiter."""
def __init__(self):
self._proxies: list[ProxySession] = []
self._fast_pool: list[ProxySession] = []
self._fast_pool_updated = 0.0
self._sample_task: Optional[asyncio.Task] = None
# v4: atomic counters for real stats (no more fake data)
self._idx_map: dict[int, ProxySession] = {}
self._addr_map: dict[str, ProxySession] = {}
# Concurrent request limiter (capped at 400)
self._concurrency_limiter = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
# Stats
self._stats_lock = Lock()
self._stats = {"alive": 0, "dead": 0, "banned": 0, "challenged": 0}
self._stats_cache = {}
self._stats_cache_ts = 0.0
# Probe task
self._probe_task: Optional[asyncio.Task] = None
self._probe_running = False
# Load proxies
self._load_proxies()
self._start_sampling()
self._start_probing()
logger.info(
f"ProxyPool v5 ready: {len(self._proxies)} proxies, "
f"concurrency={MAX_CONCURRENT_REQUESTS}, selection=p2c"
)
def _load_proxies(self):
proxies = load_proxies_from_file()
self._proxies = [ProxySession(hp) for hp in proxies]
self._refresh_fast_pool()
self._recompute_stats()
logger.info(f"ProxyPool v4 ready: {len(self._proxies)} proxies, fast={len(self._fast_pool)}")
proxy_list = load_proxies_from_file()
self._proxies = []
self._idx_map = {}
self._addr_map = {}
for i, hp in enumerate(proxy_list):
ps = ProxySession(i, hp)
self._proxies.append(ps)
self._idx_map[i] = ps
self._addr_map[hp] = ps
logger.info(f"Loaded {len(self._proxies)} proxies")
def _start_sampling(self):
def _start_probing(self):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
if self._sample_task is None or self._sample_task.done():
self._sample_task = asyncio.create_task(self._sampling_loop())
self._probe_task = asyncio.create_task(self._probe_loop())
def _refresh_fast_pool(self):
"""Select fastest N proxies, seeded immediately from all alive."""
alive = [p for p in self._proxies if p.is_available]
sampled = [p for p in alive if p.sample_passed]
unsampled = [p for p in alive if not p.sample_passed]
sampled.sort(key=lambda p: p.avg_response_time)
unsampled.sort(key=lambda p: p.avg_response_time)
combined = sampled + unsampled
self._fast_pool = combined[:FAST_POOL_SIZE]
self._fast_pool_updated = time.time()
# ─── Proxy selection (P2C — Power of Two Choices) ───────────────────
def _recompute_stats(self):
"""Accurate stats — iterate all proxies (fast, ~726 items)."""
alive = 0
dead = 0
banned = 0
for p in self._proxies:
if p.alive:
alive += 1
if not p.is_available:
banned += 1
else:
dead += 1
with self._stats_lock:
self._stats = {"alive": alive, "dead": dead, "banned": banned}
# ─── Proxy selection ──────────────────────────────────────────────
def get_fast_proxy(self) -> Optional[ProxySession]:
"""Fast proxy for interactive requests (login/register/POST)."""
def p2c_select(self, exclude_idxs: Optional[set[int]] = None) -> Optional[ProxySession]:
"""
Power of Two Choices: pick 2 random candidates, return the one with fewer inflight.
Falls back to random if P2C produces ties.
"""
exclude = exclude_idxs or set()
now = time.time()
if now - self._fast_pool_updated > FAST_POOL_REFRESH_INTERVAL:
self._refresh_fast_pool()
if not self._fast_pool:
return self.get_proxy()
total = sum(p.weight for p in self._fast_pool)
if total <= 0:
return random.choice(self._fast_pool)
r = random.uniform(0, total)
cum = 0
for p in self._fast_pool:
cum += p.weight
if r <= cum:
return p
return random.choice(self._fast_pool)
def get_proxy(self) -> Optional[ProxySession]:
"""Weighted random from all available proxies."""
available = [p for p in self._proxies if p.is_available]
if not available:
# Fallback: use proxies with fewer than 10 failures
available = [p for p in self._proxies if p.consecutive_failures < 10]
if not available:
# Build candidate list (selectable, not excluded)
candidates = []
for p in self._proxies:
if p.idx in exclude:
continue
if not p.is_user_selectable:
continue
if p.inflight >= 10: # Don't overload a single proxy
continue
candidates.append(p)
if not candidates:
# Fallback: anything with cooldown expired, even if probing
for p in self._proxies:
if p.idx in exclude:
continue
if now >= p.cooldown_until and p.inflight < 10:
candidates.append(p)
if not candidates:
# Desperate: any proxy
for p in self._proxies:
if p.idx in exclude:
continue
if p.inflight < 10:
candidates.append(p)
if not candidates:
return None
total = sum(p.weight for p in available)
if total <= 0:
return random.choice(available)
r = random.uniform(0, total)
cum = 0
for p in available:
cum += p.weight
if r <= cum:
return p
return random.choice(available)
def get_proxy_with_cookies(self) -> Optional[ProxySession]:
"""Get a proxy that has cookies saved (cf_clearance). Fallback to any available."""
available = [p for p in self._proxies if p.is_available]
with_cookies = [p for p in available if p._cookies]
if with_cookies:
total = sum(p.weight for p in with_cookies)
if total > 0:
r = random.uniform(0, total)
cum = 0
for p in with_cookies:
cum += p.weight
if r <= cum:
return p
return random.choice(with_cookies)
return self.get_proxy()
if len(candidates) == 1:
return candidates[0]
# ─── Sampling loop ────────────────────────────────────────────────
# P2C: pick 2, choose the one with lower inflight
left = random.choice(candidates)
right = random.choice(candidates)
# Avoid same proxy
for _ in range(5):
if right.idx != left.idx:
break
right = random.choice(candidates)
async def _sampling_loop(self):
"""Lightweight sampling — only test fast-pool proxies."""
logger.info("Sampling loop started (lightweight, fast-pool only)")
if left.inflight <= right.inflight:
return left
return right
def get_by_idx(self, idx: int) -> Optional[ProxySession]:
return self._idx_map.get(idx)
def get_by_addr(self, addr: str) -> Optional[ProxySession]:
return self._addr_map.get(addr)
# ─── Sticky proxy ───────────────────────────────────────────────────
def sticky_select(self, sticky_idx: int, exclude_idxs: set[int]) -> Optional[ProxySession]:
"""Try to use the sticky proxy if it's still selectable."""
p = self.get_by_idx(sticky_idx)
if p and p.is_user_selectable and p.idx not in exclude_idxs and p.inflight < 10:
return p
return None
# ─── Stats ──────────────────────────────────────────────────────────
def get_stats(self) -> dict:
total = len(self._proxies)
healthy = 0
unstable = 0
blocked = 0
probing = 0
total_inflight = 0
with_cookies = 0
total_requests = 0
response_times = []
for p in self._proxies:
if p.state == STATE_HEALTHY:
healthy += 1
elif p.state == STATE_UNSTABLE:
unstable += 1
elif p.state == STATE_BLOCKED:
blocked += 1
elif p.state == STATE_PROBING:
probing += 1
total_inflight += p.inflight
if p.has_cookies():
with_cookies += 1
total_requests += p.requests_handled
if p.requests_handled > 0:
response_times.append(p.avg_response_time)
avg_rt = sum(response_times) / max(len(response_times), 1)
return {
"total": total,
"healthy": healthy,
"unstable": unstable,
"blocked": blocked,
"probing": probing,
"available": healthy, # Only healthy are truly available
"total_inflight": total_inflight,
"with_cookies": with_cookies,
"total_requests": total_requests,
"avg_response_time_s": round(avg_rt, 3),
"pool_health": round(healthy / max(total, 1), 3),
"pool_unavailable_ratio": round((blocked + probing) / max(total, 1), 3),
}
def get_proxy_stats_list(self) -> list[dict]:
"""Detailed per-proxy stats for the monitor."""
return [p.to_stats_dict() for p in self._proxies]
def pool_available_ratio(self) -> float:
"""Fraction of proxies currently selectable."""
available = sum(1 for p in self._proxies if p.is_user_selectable)
return available / max(len(self._proxies), 1)
def pool_unavailable_ratio(self) -> float:
return 1.0 - self.pool_available_ratio()
# ─── Concurrency limiter ────────────────────────────────────────────
@property
def concurrency_limiter(self) -> asyncio.Semaphore:
return self._concurrency_limiter
# ─── Probe loop ─────────────────────────────────────────────────────
async def _probe_loop(self):
"""Periodic probe to recover blocked proxies. Like go3's probe goroutine."""
await asyncio.sleep(5) # Wait for initial startup
logger.info("Probe loop started")
while True:
try:
await asyncio.sleep(SAMPLE_INTERVAL)
pool = self._fast_pool[:] if self._fast_pool else self._proxies[:50]
if not pool:
await asyncio.sleep(PROBE_INTERVAL)
# Find proxies that need probing: blocked/probing with cooldown expired
now = time.time()
to_probe = []
for p in self._proxies:
if p.state in (STATE_BLOCKED, STATE_PROBING):
if now >= p.cooldown_until and now >= p.next_probe_at:
to_probe.append(p)
if not to_probe:
continue
alive_cnt = 0
dead_cnt = 0
for i in range(0, len(pool), SAMPLE_BATCH_SIZE):
batch = pool[i:i + SAMPLE_BATCH_SIZE]
checks = [self._check_single(p) for p in batch]
results = await asyncio.gather(*checks, return_exceptions=True)
# Probe in batches of PROBE_CONCURRENCY
random.shuffle(to_probe)
for i in range(0, len(to_probe), PROBE_CONCURRENCY):
batch = to_probe[i:i + PROBE_CONCURRENCY]
tasks = [self._probe_single(p) for p in batch]
results = await asyncio.gather(*tasks, return_exceptions=True)
for p, r in zip(batch, results):
if isinstance(r, Exception):
p.mark_sample(False)
dead_cnt += 1
elif r[0]:
p.mark_sample(True, r[1])
alive_cnt += 1
p.mark_probe_result(False)
else:
p.mark_sample(False)
dead_cnt += 1
self._refresh_fast_pool()
self._recompute_stats()
total_avg = sum(p.avg_response_time for p in pool if p.requests_handled > 0) / max(alive_cnt, 1)
logger.debug(f"Sample: {alive_cnt} alive, {dead_cnt} dead, fast={len(self._fast_pool)}, avg={total_avg*1000:.0f}ms")
p.mark_probe_result(r[0], r[1])
# Log recovery stats
recovered = sum(1 for p in to_probe if p.state == STATE_HEALTHY)
if recovered:
logger.info(f"Probe: {len(to_probe)} checked, {recovered} recovered")
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Sample error: {e}")
logger.error(f"Probe error: {e}")
async def _check_single(self, proxy: ProxySession) -> tuple[bool, float]:
"""Quick single-proxy check against AO3."""
async def _probe_single(self, proxy: ProxySession) -> tuple[bool, float]:
"""Quick HEAD check against AO3."""
start = time.time()
try:
s = await proxy.get_session()
resp = await s.head("https://archiveofourown.org/", timeout=SAMPLE_TIMEOUT)
return (200 <= resp.status_code < 500, time.time() - start)
resp = await s.head("https://archiveofourown.org/", timeout=PROBE_TIMEOUT)
ok = 200 <= resp.status_code < 500
return (ok, time.time() - start)
except Exception:
return (False, time.time() - start)
# ─── Lifecycle ────────────────────────────────────────────────────
# ─── Lifecycle ──────────────────────────────────────────────────────
async def close_all(self):
if self._sample_task and not self._sample_task.done():
self._sample_task.cancel()
if self._probe_task and not self._probe_task.done():
self._probe_task.cancel()
try:
await self._sample_task
await self._probe_task
except asyncio.CancelledError:
pass
for p in self._proxies:
await p.close()
logger.info("All sessions closed")
logger.info("All proxy sessions closed")
# ─── Stats ────────────────────────────────────────────────────────
@property
def proxies(self) -> list[ProxySession]:
return self._proxies
def get_stats(self) -> dict:
"""Accurate stats with caching (1s throttle to avoid iteration on every call)."""
now = time.time()
if self._stats_cache and now - self._stats_cache_ts < 1.0:
return self._stats_cache
self._recompute_stats()
total = len(self._proxies)
# Compute avg response from sampled proxies
sampled = [p.avg_response_time for p in self._proxies if p.requests_handled > 0]
avg_speed = sum(sampled) / max(len(sampled), 1)
# Count proxies with cookies
with_cookies = sum(1 for p in self._proxies if p._cookies)
result = {
"total": total,
"alive": self._stats["alive"],
"dead": self._stats["dead"],
"banned": self._stats["banned"],
"available": self._stats["alive"] - self._stats["banned"],
"warm": sum(1 for p in self._fast_pool if p.sample_passed),
"fast_pool": len(self._fast_pool),
"with_cookies": with_cookies,
"total_requests_handled": sum(p.requests_handled for p in self._proxies),
"avg_response_time_s": round(avg_speed, 3),
}
self._stats_cache = result
self._stats_cache_ts = now
return result
def __len__(self):
return len(self._proxies)
# ─── Singleton ────────────────────────────────────────────────────────────────

235
static/sw.js Normal file
View File

@@ -0,0 +1,235 @@
/* AO3 Mirror Service Worker v5 — Client-side cache + offline fallback + navigation intercept
*
* Deployed at /sw-YYYYMMDD.js (versioned), /sw.js redirects to latest.
* Server injects <script>navigator.serviceWorker.register('/sw.js')</script> into HTML pages.
*/
'use strict';
const MIRROR_DOMAINS = ['agento3.miscs.dev'];
const PRIMARY_DOMAIN = 'agento3.miscs.dev';
// Static asset types to cache aggressively
const STATIC_EXTENSIONS = [
'.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.ico',
'.woff', '.woff2', '.ttf', '.svg', '.webp', '.json',
];
// Cache names
const STATIC_CACHE = 'ao3-static-v1';
const HTML_CACHE = 'ao3-html-v1';
// Navigation timeout (25s, like go3)
const NAV_FETCH_TIMEOUT_MS = 25000;
// ─── Helpers ──────────────────────────────────────────────────────────
function primaryMirrorHost() {
try {
var scopeHost = new URL(self.registration.scope).hostname;
if (scopeHost && scopeHost.indexOf('.') !== -1) return scopeHost;
} catch (e) {}
for (var i = 0; i < MIRROR_DOMAINS.length; i++) {
var h = MIRROR_DOMAINS[i];
if (h && h.indexOf('.') !== -1) return h;
}
return PRIMARY_DOMAIN;
}
function isStaticAsset(url) {
var path = url.pathname.toLowerCase();
for (var i = 0; i < STATIC_EXTENSIONS.length; i++) {
if (path.endsWith(STATIC_EXTENSIONS[i])) return true;
}
return false;
}
function hostFromRequest(request) {
try {
return new URL(request.url).hostname || '';
} catch (e) {
return '';
}
}
function repairNavigationURL(url) {
var host = url.hostname;
if (!host || host.indexOf('.') !== -1 || host === 'localhost') return url;
var mirror = primaryMirrorHost();
if (!mirror) return url;
var fixed = new URL(url.toString());
fixed.hostname = mirror;
fixed.pathname = '/' + host + (fixed.pathname || '/');
return fixed;
}
// ─── Offline / mirror picker page ─────────────────────────────────────
function buildMirrorPickerPage(currentHost, mirrors) {
var mirrorItems = mirrors
.filter(function (h) { return h; })
.map(function (h) {
return '<li><a href="https://' + h + '/' + '">' + h + '</a></li>';
})
.join('');
return (
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>无法访问镜像站点</title>' +
'<style>' +
'body{margin:0;background:#f6f6f6;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#222}' +
'.wrap{max-width:680px;margin:40px auto;padding:0 16px}' +
'.card{background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:24px 28px;box-shadow:0 6px 20px rgba(0,0,0,.05)}' +
'h1{margin:0 0 8px;color:#860000;font-size:22px}' +
'.lead{color:#555;line-height:1.6;margin:0 0 20px}' +
'.steps{margin:0;padding:0;list-style:none}' +
'.steps>li{margin:0 0 20px;padding:0 0 20px;border-bottom:1px solid #eee}' +
'.steps>li:last-child{margin-bottom:0;padding-bottom:0;border-bottom:none}' +
'.step-title{display:flex;align-items:flex-start;gap:10px;font-weight:600;color:#333;margin:0 0 8px;line-height:1.5}' +
'.step-num{flex-shrink:0;width:26px;height:26px;border-radius:50%;background:#860000;color:#fff;font-size:14px;line-height:26px;text-align:center}' +
'.step-body{color:#555;line-height:1.65;margin:0;font-size:15px}' +
'.mirrors{margin:8px 0 0;padding-left:20px}' +
'.mirrors li{margin:8px 0}' +
'a{color:#900;text-decoration:none;font-weight:500}a:hover{text-decoration:underline}' +
'code{background:#f3f3f3;padding:2px 6px;border-radius:4px;font-size:.92em}' +
'.muted{color:#888;font-size:13px;line-height:1.6;margin-top:20px;padding-top:16px;border-top:1px solid #eee}' +
'</style></head><body><div class="wrap"><div class="card">' +
'<h1>无法连接到镜像站点</h1>' +
'<p class="lead">浏览器未能与 <code>' + currentHost + '</code> 建立网络连接例如断网、DNS 失败或被防火墙拦截)。请按下面顺序逐步排查。</p>' +
'<ol class="steps">' +
'<li><p class="step-title"><span class="step-num">1</span><span>先检查网络连接</span></p>' +
'<p class="step-body">确认设备已联网:可尝试打开其他网站或 App。若使用 WiFi请检查路由器是否正常。</p></li>' +
'<li><p class="step-title"><span class="step-num">2</span><span>尝试切换到移动流量</span></p>' +
'<p class="step-body">部分宽带或校园网可能对镜像域名有限制。请关闭 WiFi使用手机 <strong>4G / 5G 流量</strong> 重新访问。</p></li>' +
'<li><p class="step-title"><span class="step-num">3</span><span>尝试备用域名</span></p>' +
'<p class="step-body">点击下方备用镜像站点:</p>' +
'<ul class="mirrors">' + mirrorItems + '</ul></li>' +
'</ol>' +
'<p class="muted">页面由 AO3 Mirror Service Worker 提供。若以上步骤后仍无法打开,请稍后再试。</p>' +
'</div></div></body></html>'
);
}
function offlineGuideResponse(currentHost) {
return new Response(buildMirrorPickerPage(currentHost, MIRROR_DOMAINS), {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
// ─── Network strategies ───────────────────────────────────────────────
function handleNavigation(request) {
var currentHost = hostFromRequest(request);
var url = new URL(request.url);
// Repair malformed navigation URLs
var navUrl = repairNavigationURL(url);
var fetchInit = {};
if (navUrl.href !== request.url) {
fetchInit = {
method: request.method,
headers: request.headers,
credentials: request.credentials,
redirect: 'follow',
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
};
}
var ctrl = new AbortController();
var timer = setTimeout(function () { ctrl.abort(); }, NAV_FETCH_TIMEOUT_MS);
var fetchTarget = navUrl.href !== request.url ? new Request(navUrl.href, fetchInit) : request;
return fetch(fetchTarget, { signal: ctrl.signal })
.finally(function () { clearTimeout(timer); })
.then(function (response) {
if (response.redirected && response.url) {
return Response.redirect(response.url, 302);
}
return response;
})
.catch(function () {
return offlineGuideResponse(currentHost);
});
}
function handleStatic(request) {
// Cache-first for static assets
return caches.open(STATIC_CACHE).then(function (cache) {
return cache.match(request).then(function (cached) {
if (cached) {
// Background revalidation
fetch(request).then(function (response) {
if (response && response.ok) {
cache.put(request, response);
}
}).catch(function () {});
return cached;
}
// Network with cache fallback
return fetch(request).then(function (response) {
if (response && response.ok) {
var cloned = response.clone();
cache.put(request, cloned);
}
return response;
}).catch(function () {
// Offline — return whatever we have
return cache.match(request);
});
});
});
}
// ─── Install / Activate ───────────────────────────────────────────────
self.addEventListener('install', function (event) {
self.skipWaiting();
});
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
// Clean old caches
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.map(function (key) {
if (key !== STATIC_CACHE && key !== HTML_CACHE) {
return caches.delete(key);
}
})
);
})
);
});
// ─── Fetch handler ────────────────────────────────────────────────────
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') return;
var url = new URL(event.request.url);
// Don't intercept SW or monitor paths
if (url.pathname.startsWith('/sw') || url.pathname === '/mirror-domains.json') return;
if (url.pathname === '/_monitor' || url.pathname.startsWith('/_monitor/')) return;
if (url.pathname === '/stats' || url.pathname === '/metrics' || url.pathname === '/health') return;
// Static assets: cache-first
if (isStaticAsset(url)) {
event.respondWith(handleStatic(event.request));
return;
}
// Navigation (HTML pages): network-first with offline fallback
if (event.request.mode === 'navigate' || event.request.destination === 'document') {
event.respondWith(
handleNavigation(event.request).catch(function () {
return offlineGuideResponse(hostFromRequest(event.request));
})
);
}
// Other requests pass through to server normally
});

View File

@@ -83,6 +83,30 @@ def rewrite_response_headers(headers: dict) -> dict:
return new_headers
def rewrite_response_headers_raw(headers_raw: list) -> list:
"""Rewrite AO3 domains in raw response headers (preserves multi-value Set-Cookie).
Unlike rewrite_response_headers which works on a dict (collapsing multi-value
Set-Cookie), this operates on a list of (key, value) tuples so ALL Set-Cookie
values are preserved and individually rewritten.
"""
result = []
for key, value in headers_raw:
key_lower = key.lower()
if key_lower == "location":
value = value.replace(TARGET_DOMAIN, MIRROR_DOMAIN)
value = value.replace("http://", "https://")
elif key_lower == "set-cookie":
value = value.replace(f"domain={TARGET_DOMAIN}", f"domain={MIRROR_DOMAIN}")
value = value.replace(f"Domain={TARGET_DOMAIN}", f"Domain={MIRROR_DOMAIN}")
result.append((key, value))
return result
def rewrite_redirect_url(url: str) -> str:
"""Rewrite AO3 URLs in redirect targets."""
return url.replace(TARGET_DOMAIN, MIRROR_DOMAIN).replace("http://", "https://")