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:
119
AGENTS.md
119
AGENTS.md
@@ -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.
|
This file is for AI agents (Hermes, Claude Code, Codex) working on the AO3 reverse proxy mirror.
|
||||||
|
|
||||||
## Project Overview
|
## 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)
|
- **Domain**: agento3.miscs.dev (behind Cloudflare CDN)
|
||||||
- **Target**: archiveofourown.org
|
- **Target**: archiveofourown.org
|
||||||
- **Stack**: Python 3.11, FastAPI, uvicorn + uvloop + httptools, curl_cffi, Caddy
|
- **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
|
## Build Commands
|
||||||
|
|
||||||
@@ -26,85 +60,60 @@ sudo systemctl restart ao3-daemon
|
|||||||
# Restart Caddy only
|
# Restart Caddy only
|
||||||
sudo systemctl reload caddy
|
sudo systemctl reload caddy
|
||||||
|
|
||||||
# Scan proxy pool (MUST run in foreground!)
|
# Syntax check all Python files
|
||||||
cd /home/ubuntu/ao3-mirror && python3 scripts/scan_proxies_cffi.py
|
|
||||||
|
|
||||||
# Syntax check
|
|
||||||
python3 -m py_compile proxy_pool.py ao3_fetcher.py app.py cache.py stats.py url_rewriter.py
|
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
|
### Core Files (v5)
|
||||||
|
|
||||||
```
|
|
||||||
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
|
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `app.py` | FastAPI backend v4 — proxy handler, stats, CF challenge solving |
|
| `app.py` | FastAPI backend v5 — proxy handler, sticky sessions, SW deploy, stats |
|
||||||
| `proxy_pool.py` | Tiered async proxy pool — cookie jar, weighted selection, sampling |
|
| `proxy_pool.py` | P2C proxy pool v5 — 5000 proxies, state machine, semaphore limiter |
|
||||||
| `ao3_fetcher.py` | Async fetcher — CF detection, smart retry, cookie injection |
|
| `ao3_fetcher.py` | Single-request fetcher v5 — no internal retry, CF detection |
|
||||||
| `cache.py` | Per-worker LRU cache (5000 entries, path-differentiated TTL) |
|
| `cache.py` | Per-worker LRU cache (5000 entries, path-differentiated TTL) |
|
||||||
| `stats.py` | In-memory stats with batch SQLite flush every 60s |
|
| `stats.py` | In-memory stats with batch SQLite flush every 60s |
|
||||||
| `url_rewriter.py` | URL/header rewriting (ao3 → mirror domain) |
|
| `url_rewriter.py` | URL/header rewriting (ao3 → mirror domain) |
|
||||||
| `scripts/daemon.py` | Passive worker supervision (systemd, 30s check, 3-strike restart) |
|
| `static/sw.js` | Service Worker — client-side cache, offline fallback |
|
||||||
| `scripts/scan_proxies_cffi.py` | Proxy scanner with TLS fingerprint fallback |
|
| `Caddyfile` | Caddy config — SW routes, round-robin, 60s timeouts |
|
||||||
| `start.sh` | Startup script — kills old workers, starts new, reloads Caddy |
|
| `scripts/daemon.py` | Passive worker supervision (systemd) |
|
||||||
| `Caddyfile` | Caddy config — TLS, round-robin, 60s timeouts |
|
| `start.sh` | Startup — kills old workers, starts new, reloads Caddy |
|
||||||
|
|
||||||
## Security Baseline
|
## Security Baseline
|
||||||
|
|
||||||
- No secrets in code — proxy credentials are in `/home/ubuntu/proxy.txt`
|
- No secrets in code — proxy credentials in `/home/ubuntu/proxy.txt`
|
||||||
- Worker processes bound to 127.0.0.1 only (not exposed publicly)
|
- Workers bound to 127.0.0.1 only
|
||||||
- Cloudflare CDN terminates TLS and provides DDoS protection at edge
|
- Cloudflare CDN terminates TLS at edge
|
||||||
- CORS headers restrict cross-origin access
|
- CORS headers restrict cross-origin access
|
||||||
- stats/health/metrics endpoints are read-only, no mutation
|
- stats/health/metrics are read-only
|
||||||
- All proxied content is user-facing; no admin endpoints exposed
|
|
||||||
|
|
||||||
## Engine Guidance
|
## Engine Guidance
|
||||||
|
|
||||||
- Complex multi-file changes, architecture evolution → Hermes (here)
|
- Complex multi-file changes, architecture evolution → Hermes (here)
|
||||||
- Quick targeted fixes, single-file changes → Hermes
|
- Quick targeted fixes, single-file changes → Hermes
|
||||||
- Deploy, monitor, notify, schedule → 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
|
- Not sure? Start with Hermes — everything runs via Hermes
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
- Health endpoint: https://agento3.miscs.dev/health
|
- Health: https://agento3.miscs.dev/health
|
||||||
- Stats dashboard: https://agento3.miscs.dev/stats
|
- Stats: https://agento3.miscs.dev/stats
|
||||||
- Metrics (Prometheus): https://agento3.miscs.dev/metrics
|
- Metrics: https://agento3.miscs.dev/metrics
|
||||||
- Worker logs: /home/ubuntu/ao3-mirror/worker-0.log, worker-1.log
|
- SW: https://agento3.miscs.dev/sw.js → /sw-YYYYMMDD.js
|
||||||
- Daemon log: /home/ubuntu/ao3-mirror/daemon.log
|
|
||||||
- Systemd: `systemctl status ao3-daemon`, `journalctl -u ao3-daemon`
|
|
||||||
|
|
||||||
## Deployment
|
## CTO Goal (Persistent Focus)
|
||||||
|
|
||||||
- Single server (current host)
|
Manage akiba/agento3 as CTO. Triage issues hourly, implement top priority, get founder approval before merging. Never ship without YES.
|
||||||
- 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
|
|
||||||
|
|
||||||
## Commit Conventions
|
## Commit Conventions
|
||||||
|
|
||||||
- One commit per meaningful change
|
- One commit per meaningful change
|
||||||
- Python files only (no binaries, no .pyc, no logs, no .env)
|
- Python files only (no binaries, .pyc, logs, .env)
|
||||||
- SOUL.md updated to reflect architectural changes
|
- SOUL.md updated for architectural changes
|
||||||
- ao3-mirror skill updated when workflows change
|
- ao3-mirror skill updated when workflows change
|
||||||
|
|||||||
112
Caddyfile
112
Caddyfile
@@ -1,5 +1,5 @@
|
|||||||
# AO3 Mirror - Caddy 配置
|
# AO3 Mirror v5 - Caddy 配置
|
||||||
# 前端负载均衡 + TLS 终止 + Cloudflare CDN 集成
|
# 前端负载均衡 + TLS 终止 + Cloudflare CDN 集成 + Service Worker
|
||||||
|
|
||||||
agento3.miscs.dev {
|
agento3.miscs.dev {
|
||||||
# 全局头
|
# 全局头
|
||||||
@@ -9,6 +9,7 @@ agento3.miscs.dev {
|
|||||||
X-Frame-Options "SAMEORIGIN"
|
X-Frame-Options "SAMEORIGIN"
|
||||||
X-XSS-Protection "1; mode=block"
|
X-XSS-Protection "1; mode=block"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
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
|
-Server
|
||||||
-X-Powered-By
|
-X-Powered-By
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,60 @@ agento3.miscs.dev {
|
|||||||
# 压缩
|
# 压缩
|
||||||
encode gzip
|
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
|
# 后端负载均衡 (轮询) - 2 workers on 2-core machine
|
||||||
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
|
reverse_proxy 127.0.0.1:8081 127.0.0.1:8082 {
|
||||||
lb_policy round_robin
|
lb_policy round_robin
|
||||||
@@ -34,7 +89,7 @@ agento3.miscs.dev {
|
|||||||
health_interval 10s
|
health_interval 10s
|
||||||
health_timeout 5s
|
health_timeout 5s
|
||||||
|
|
||||||
# 超时配置
|
# 超时配置 — 代理可能需要更长时间
|
||||||
transport http {
|
transport http {
|
||||||
read_timeout 30s
|
read_timeout 30s
|
||||||
write_timeout 30s
|
write_timeout 30s
|
||||||
@@ -55,6 +110,7 @@ stats.agento3.miscs.dev {
|
|||||||
X-Content-Type-Options "nosniff"
|
X-Content-Type-Options "nosniff"
|
||||||
X-Frame-Options "SAMEORIGIN"
|
X-Frame-Options "SAMEORIGIN"
|
||||||
Referrer-Policy "strict-origin-when-cross-origin"
|
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
|
-Server
|
||||||
-X-Powered-By
|
-X-Powered-By
|
||||||
}
|
}
|
||||||
@@ -72,41 +128,67 @@ stats.agento3.miscs.dev {
|
|||||||
# 压缩
|
# 压缩
|
||||||
encode gzip
|
encode gzip
|
||||||
|
|
||||||
# 路由规则 - 只暴露 /stats 和 /metrics
|
# 路由规则 - 只暴露 /stats, /metrics, /health, /sw
|
||||||
route {
|
route {
|
||||||
# /stats 页面 - 代理到后端
|
# /stats 页面
|
||||||
reverse_proxy /stats* 127.0.0.1:8081 127.0.0.1:8082 {
|
reverse_proxy /stats* 127.0.0.1:8081 127.0.0.1:8082 {
|
||||||
lb_policy round_robin
|
lb_policy round_robin
|
||||||
|
|
||||||
health_uri /health
|
health_uri /health
|
||||||
health_interval 10s
|
health_interval 10s
|
||||||
health_timeout 5s
|
health_timeout 5s
|
||||||
|
|
||||||
transport http {
|
transport http {
|
||||||
read_timeout 30s
|
read_timeout 60s
|
||||||
write_timeout 30s
|
write_timeout 60s
|
||||||
dial_timeout 5s
|
dial_timeout 5s
|
||||||
}
|
}
|
||||||
|
|
||||||
header_up X-Forwarded-For {remote_host}
|
header_up X-Forwarded-For {remote_host}
|
||||||
header_up X-Forwarded-Proto {scheme}
|
header_up X-Forwarded-Proto {scheme}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
}
|
}
|
||||||
|
|
||||||
# /metrics 指标 - 代理到后端
|
# /metrics 指标
|
||||||
reverse_proxy /metrics* 127.0.0.1:8081 127.0.0.1:8082 {
|
reverse_proxy /metrics* 127.0.0.1:8081 127.0.0.1:8082 {
|
||||||
lb_policy round_robin
|
lb_policy round_robin
|
||||||
|
|
||||||
health_uri /health
|
health_uri /health
|
||||||
health_interval 10s
|
health_interval 10s
|
||||||
health_timeout 5s
|
health_timeout 5s
|
||||||
|
|
||||||
transport http {
|
transport http {
|
||||||
read_timeout 30s
|
read_timeout 60s
|
||||||
write_timeout 30s
|
write_timeout 60s
|
||||||
dial_timeout 5s
|
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-For {remote_host}
|
||||||
header_up X-Forwarded-Proto {scheme}
|
header_up X-Forwarded-Proto {scheme}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
|
|||||||
333
ao3_fetcher.py
333
ao3_fetcher.py
@@ -1,25 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
异步 AO3 内容抓取器 v4 — Cookie 感知 + CF 挑战检测 + 智能重试
|
异步 AO3 内容抓取器 v5 — 单次代理请求 + CF 挑战检测
|
||||||
|
|
||||||
v4 vs v3:
|
v5 vs v4:
|
||||||
- CF 挑战检测:不再把 403/503 一律当失败
|
- 去掉内部重试循环(重试逻辑迁移到 app.py 的 P2C 选择中)
|
||||||
- Cookie 注入:请求时自动携带 proxy 级别的 cookies(cf_clearance 等)
|
- 简化 API:直接接受 ProxySession 对象
|
||||||
- Cookie 保存:成功请求后自动提取 Set-Cookie 并保存到 proxy cookie jar
|
- 保留 CF 挑战检测 + 状态机反馈
|
||||||
- 智能重试:挑战时优先用带 cookie 的代理,无 cookie 则换代理
|
|
||||||
- 挑战页面透传:所有重试都遇到挑战时,返回挑战 HTML 让用户浏览器求解
|
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from proxy_pool import get_proxy_pool, ProxySession
|
from proxy_pool import ProxySession
|
||||||
|
|
||||||
logger = logging.getLogger("ao3-fetcher")
|
logger = logging.getLogger("ao3-fetcher")
|
||||||
|
|
||||||
# ─── CF Challenge Detection ───────────────────────────────────────────────
|
# ─── CF Challenge Detection ───────────────────────────────────────────────
|
||||||
|
|
||||||
# Markers in response body that indicate a Cloudflare challenge page
|
|
||||||
CF_CHALLENGE_MARKERS = [
|
CF_CHALLENGE_MARKERS = [
|
||||||
b'/cdn-cgi/challenge-platform',
|
b'/cdn-cgi/challenge-platform',
|
||||||
b'cf-challenge-running',
|
b'cf-challenge-running',
|
||||||
@@ -28,29 +25,24 @@ CF_CHALLENGE_MARKERS = [
|
|||||||
b'challenge-platform',
|
b'challenge-platform',
|
||||||
b'cf-turnstile',
|
b'cf-turnstile',
|
||||||
b'cf_chl_',
|
b'cf_chl_',
|
||||||
# Sometimes CF just returns "Just a moment..." without JS markers
|
|
||||||
b'Checking your browser',
|
b'Checking your browser',
|
||||||
b'Just a moment...',
|
b'Just a moment...',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def is_cf_challenge(status: int, body: bytes, headers: dict) -> bool:
|
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):
|
if status not in (403, 503, 429):
|
||||||
return False
|
return False
|
||||||
# Quick check: CF always sets Server header on challenge pages
|
|
||||||
server = headers.get("Server", headers.get("server", ""))
|
server = headers.get("Server", headers.get("server", ""))
|
||||||
if "cloudflare" not in server.lower():
|
if "cloudflare" not in server.lower():
|
||||||
# Check body for challenge markers
|
|
||||||
for marker in CF_CHALLENGE_MARKERS:
|
for marker in CF_CHALLENGE_MARKERS:
|
||||||
if marker in body:
|
if marker in body:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
# Server: cloudflare + non-200 status = likely challenge
|
|
||||||
for marker in CF_CHALLENGE_MARKERS:
|
for marker in CF_CHALLENGE_MARKERS:
|
||||||
if marker in body:
|
if marker in body:
|
||||||
return True
|
return True
|
||||||
# If Server is cloudflare and status is 403/503, it's almost certainly a challenge
|
|
||||||
if status in (403, 503):
|
if status in (403, 503):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
@@ -69,31 +61,29 @@ CHROME_HEADERS = {
|
|||||||
"Sec-Fetch-Site": "none",
|
"Sec-Fetch-Site": "none",
|
||||||
"Sec-Fetch-User": "?1",
|
"Sec-Fetch-User": "?1",
|
||||||
"Upgrade-Insecure-Requests": "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",
|
"DNT": "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
API_HEADERS = {
|
API_HEADERS = {
|
||||||
"Accept": "*/*",
|
"Accept": "*/*",
|
||||||
"Accept-Language": "en-US,en;q=0.9",
|
"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 ───────────────────────────────────────────────────────
|
# Timeout config
|
||||||
|
FAST_REQUEST_TIMEOUT = 8
|
||||||
FAST_REQUEST_TIMEOUT = 8 # login/register/signup
|
|
||||||
NORMAL_REQUEST_TIMEOUT = 15
|
NORMAL_REQUEST_TIMEOUT = 15
|
||||||
CONNECT_TIMEOUT = 5
|
|
||||||
MAX_RETRIES = 2
|
|
||||||
FAST_MAX_RETRIES = 1
|
|
||||||
|
|
||||||
FAST_PATHS = {
|
FAST_PATHS = {
|
||||||
"/users/login",
|
"/users/login", "/users/sign_up", "/users/new",
|
||||||
"/users/sign_up",
|
"/invitation_requests", "/token_dispenser.json", "/user_sessions",
|
||||||
"/users/new",
|
|
||||||
"/invitation_requests",
|
|
||||||
"/token_dispenser.json",
|
|
||||||
"/user_sessions",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -106,200 +96,97 @@ def _is_fast_path(url: str) -> bool:
|
|||||||
return False
|
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:
|
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:
|
if not proxy_cookies and not user_cookies:
|
||||||
return ""
|
return ""
|
||||||
if not proxy_cookies:
|
if not proxy_cookies:
|
||||||
return user_cookies
|
return user_cookies
|
||||||
if not user_cookies:
|
if not user_cookies:
|
||||||
return proxy_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}"
|
return f"{proxy_cookies}; {user_cookies}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main fetch function ──────────────────────────────────────────────────
|
||||||
|
|
||||||
async def fetch_url(
|
async def fetch_url(
|
||||||
url: str,
|
url: str,
|
||||||
|
proxy: Optional[ProxySession] = None,
|
||||||
method: str = "GET",
|
method: str = "GET",
|
||||||
headers: Optional[dict] = None,
|
headers: Optional[dict] = None,
|
||||||
body: Optional[bytes] = None,
|
body: Optional[bytes] = None,
|
||||||
cookies: Optional[dict] = None,
|
cookies: Optional[dict] = None,
|
||||||
is_api: bool = False,
|
is_api: bool = False,
|
||||||
preferred_proxy: Optional[str] = None, # v4: proxy host:port for challenge affinity
|
|
||||||
) -> dict:
|
) -> 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:
|
Args:
|
||||||
- Login/register paths → fast pool + 8s timeout + 1 retry
|
url: Target AO3 URL
|
||||||
- Normal paths → cookie-preferring proxies + 15s timeout + 2 retries
|
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:
|
Returns:
|
||||||
- CF challenge detection: don't treat challenge as proxy failure
|
dict with success/status/headers/body/cookies/elapsed/is_challenge etc.
|
||||||
- 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
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
pool = get_proxy_pool()
|
if proxy is None:
|
||||||
base_headers = API_HEADERS.copy() if is_api else CHROME_HEADERS.copy()
|
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:
|
if headers:
|
||||||
for h in ["Cookie", "Referer", "Content-Type", "X-Requested-With", "Accept",
|
for h in ["Cookie", "Content-Type", "X-Requested-With",
|
||||||
"Origin", "X-CSRF-Token", "Authorization"]:
|
"Accept", "X-CSRF-Token", "Authorization"]:
|
||||||
if h in headers:
|
if h in headers:
|
||||||
base_headers[h] = headers[h]
|
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 = ""
|
user_cookie_str = ""
|
||||||
if cookies:
|
if cookies:
|
||||||
user_cookie_str = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
user_cookie_str = "; ".join(
|
||||||
|
f"{k}={v}" for k, v in cookies.items()
|
||||||
# Determine priority level
|
if k not in FORWARD_BLOCKED
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result["success"]:
|
is_fast = _is_fast_path(url) or method in ("POST", "PUT", "PATCH")
|
||||||
# Save response cookies to proxy cookie jar for future requests
|
request_timeout = FAST_REQUEST_TIMEOUT if is_fast else NORMAL_REQUEST_TIMEOUT
|
||||||
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()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
session = await proxy.get_session()
|
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()
|
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()
|
proxy_cookie_str = proxy.get_cookie_header()
|
||||||
cookie_str = _merge_cookies(proxy_cookie_str, user_cookie_str)
|
cookie_str = _merge_cookies(proxy_cookie_str, user_cookie_str)
|
||||||
if cookie_str:
|
if cookie_str:
|
||||||
request_headers["Cookie"] = cookie_str
|
request_headers["Cookie"] = cookie_str
|
||||||
|
|
||||||
# Execute request
|
# 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":
|
if method == "GET":
|
||||||
resp = await session.get(url, timeout=request_timeout, headers=request_headers)
|
resp = await session.get(url, timeout=request_timeout, headers=request_headers)
|
||||||
elif method == "POST":
|
elif method == "POST":
|
||||||
@@ -310,89 +197,79 @@ async def _try_proxy(
|
|||||||
resp = await session.request(method, url, data=body, timeout=request_timeout,
|
resp = await session.request(method, url, data=body, timeout=request_timeout,
|
||||||
headers=request_headers)
|
headers=request_headers)
|
||||||
|
|
||||||
elapsed = _now() - start_time
|
elapsed = time.time() - start_time
|
||||||
status = resp.status_code
|
status = resp.status_code
|
||||||
resp_body = resp.content
|
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 = {}
|
resp_cookies = {}
|
||||||
if hasattr(resp, "cookies"):
|
if hasattr(resp, "cookies"):
|
||||||
for k, v in resp.cookies.items():
|
for k, v in resp.cookies.items():
|
||||||
resp_cookies[k] = v
|
resp_cookies[k] = v
|
||||||
|
|
||||||
# v4: Check for CF challenge
|
# CF challenge detection
|
||||||
if is_cf_challenge(status, resp_body, resp_headers):
|
if is_cf_challenge(status, resp_body, resp_headers):
|
||||||
proxy.mark_challenged()
|
logger.warning(f"[CF_CHALLENGE] {host_port} -> {url[:60]}: {status}")
|
||||||
logger.warning(f"[CF_CHALLENGE] {host_port} -> {url[:60]}: {status} ({elapsed:.1f}s)")
|
|
||||||
return {
|
return {
|
||||||
"status": status,
|
"status": status, "headers": resp_headers, "body": resp_body,
|
||||||
"headers": resp_headers,
|
"headers_raw": resp_headers_raw,
|
||||||
"body": resp_body,
|
|
||||||
"cookies": resp_cookies,
|
"cookies": resp_cookies,
|
||||||
"success": False,
|
"success": False, "error": f"CF_CHALLENGE_{status}",
|
||||||
"error": f"CF_CHALLENGE_{status}",
|
"elapsed": elapsed, "proxy_host": host_port,
|
||||||
"elapsed": elapsed,
|
"is_challenge": True, "challenge_body": resp_body,
|
||||||
"proxy_host": host_port,
|
|
||||||
"is_challenge": True,
|
|
||||||
"challenge_body": resp_body,
|
|
||||||
"challenge_proxy": host_port,
|
"challenge_proxy": host_port,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Success — any 2xx-4xx is proxied through
|
# Success
|
||||||
if 200 <= status < 500:
|
if 200 <= status < 500:
|
||||||
proxy.mark_success(elapsed)
|
sc_from_dict = resp_headers.get("Set-Cookie", resp_headers.get("set-cookie", "NONE"))
|
||||||
logger.debug(f"{host_port} -> {url[:60]}: {status} ({elapsed:.2f}s)")
|
logger.info(f"[SAVE_COOKIES] proxy={host_port} set_cookie_dict={sc_from_dict[:120]}...")
|
||||||
|
|
||||||
# v4: Save response cookies to proxy
|
|
||||||
proxy.save_cookies(resp_headers)
|
proxy.save_cookies(resp_headers)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": status,
|
"status": status, "headers": resp_headers, "body": resp_body,
|
||||||
"headers": resp_headers,
|
"headers_raw": resp_headers_raw,
|
||||||
"body": resp_body,
|
|
||||||
"cookies": resp_cookies,
|
"cookies": resp_cookies,
|
||||||
"success": True,
|
"success": True,
|
||||||
"elapsed": elapsed,
|
"elapsed": elapsed, "proxy_host": host_port,
|
||||||
"proxy_host": host_port,
|
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
||||||
"is_challenge": False,
|
|
||||||
"challenge_body": None,
|
|
||||||
"challenge_proxy": None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# True error status (5xx)
|
# Error status
|
||||||
proxy.mark_failure()
|
logger.warning(f"{host_port} -> {url[:60]}: {status}")
|
||||||
logger.warning(f"Attempt: {host_port} -> {url[:60]}: {status}")
|
|
||||||
return {
|
return {
|
||||||
"status": status,
|
"status": status, "headers": resp_headers, "body": resp_body,
|
||||||
"headers": resp_headers,
|
"headers_raw": resp_headers_raw,
|
||||||
"body": resp_body,
|
|
||||||
"cookies": resp_cookies,
|
"cookies": resp_cookies,
|
||||||
"success": False,
|
"success": False, "error": f"HTTP_{status}",
|
||||||
"error": f"HTTP_{status}",
|
"elapsed": elapsed, "proxy_host": host_port,
|
||||||
"elapsed": elapsed,
|
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
||||||
"proxy_host": host_port,
|
|
||||||
"is_challenge": False,
|
|
||||||
"challenge_body": None,
|
|
||||||
"challenge_proxy": None,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
proxy.mark_failure()
|
elapsed = time.time() - start_time
|
||||||
elapsed = _now() - start_time
|
logger.warning(f"TIMEOUT: {host_port} -> {url[:60]}")
|
||||||
logger.warning(f"TIMEOUT: {host_port} -> {url[:60]} ({elapsed:.1f}s, timeout={request_timeout}s)")
|
|
||||||
return {
|
return {
|
||||||
"status": 0, "headers": {}, "body": b"", "cookies": {},
|
"status": 0, "headers": {}, "body": b"", "cookies": {},
|
||||||
|
"headers_raw": [],
|
||||||
"success": False, "error": "TIMEOUT",
|
"success": False, "error": "TIMEOUT",
|
||||||
"elapsed": elapsed, "proxy_host": host_port,
|
"elapsed": elapsed, "proxy_host": host_port,
|
||||||
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
proxy.mark_failure()
|
elapsed = time.time() - start_time
|
||||||
elapsed = _now() - start_time
|
|
||||||
err_str = str(e)[:120]
|
err_str = str(e)[:120]
|
||||||
logger.debug(f"ERROR: {host_port} -> {e} ({elapsed:.1f}s)")
|
logger.debug(f"ERROR: {host_port} -> {err_str}")
|
||||||
return {
|
return {
|
||||||
"status": 0, "headers": {}, "body": b"", "cookies": {},
|
"status": 0, "headers": {}, "body": b"", "cookies": {},
|
||||||
|
"headers_raw": [],
|
||||||
"success": False, "error": err_str,
|
"success": False, "error": err_str,
|
||||||
"elapsed": elapsed, "proxy_host": host_port,
|
"elapsed": elapsed, "proxy_host": host_port,
|
||||||
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
"is_challenge": False, "challenge_body": None, "challenge_proxy": None,
|
||||||
|
|||||||
590
app.py
590
app.py
@@ -1,13 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
AO3 反代后端 v4 — Cookie 感知 + CF 挑战用户浏览器求解
|
AO3 反代后端 v5 — 粘性会话 + Service Worker + P2C 代理池 + 并发限流
|
||||||
|
|
||||||
v4 vs v3:
|
v5 vs v4:
|
||||||
- Challenge token 映射:用户浏览器求解 CF 挑战时,保证同一 proxy 亲和性
|
- 粘性会话: ao3_sessid_proxy cookie (对标 go3)
|
||||||
- 挑战页面透传:所有代理都遇到 CF 挑战时,把挑战页发给用户浏览器求解
|
- Service Worker: /sw.js, /sw-YYYYMMDD.js for client-side caching
|
||||||
- Cookie 流:成功响应自动保存 proxy cookie,后续请求自动携带
|
- HTML 注入: 自动注入 SW 注册脚本 + 镜像域名
|
||||||
- 统计面板展示 cookie-aware 代理数
|
- P2C 代理选择: Power of Two Choices (对标 go3)
|
||||||
|
- 并发限流: asyncio.Semaphore(400) (Webshare 上限)
|
||||||
|
- 全量 5000 代理: 不再按端口过滤
|
||||||
|
- 状态机: healthy/unstable/blocked/probing
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -23,9 +27,9 @@ import uvicorn
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
from proxy_pool import get_proxy_pool
|
from proxy_pool import get_proxy_pool, MAX_CONCURRENT_REQUESTS, OUTAGE_THRESHOLD
|
||||||
from ao3_fetcher import fetch_url, is_cf_challenge
|
from ao3_fetcher import fetch_url, is_cf_challenge, FAST_REQUEST_TIMEOUT, NORMAL_REQUEST_TIMEOUT
|
||||||
from url_rewriter import rewrite_body, rewrite_response_headers, needs_rewrite, MIRROR_DOMAIN
|
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 cache import get_cache, get_ttl_for_path
|
||||||
from stats import get_stats_collector
|
from stats import get_stats_collector
|
||||||
|
|
||||||
@@ -39,13 +43,15 @@ logger = logging.getLogger("ao3-backend")
|
|||||||
AO3_BASE = "https://archiveofourown.org"
|
AO3_BASE = "https://archiveofourown.org"
|
||||||
MIRROR_HOST = MIRROR_DOMAIN
|
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"}
|
LOCAL_PATHS = {"/stats", "/health", "/metrics", "/favicon.ico", "/robots.txt"}
|
||||||
|
|
||||||
# ─── Challenge Token Map (v4) ──────────────────────────────────────────────
|
# ─── Challenge Token Map (v4 retained) ──────────────────────────────────────
|
||||||
# Maps challenge_token → (method, ao3_url, headers, body, cookies, proxy_host, expires)
|
|
||||||
_challenge_map: dict[str, tuple] = {}
|
_challenge_map: dict[str, tuple] = {}
|
||||||
_challenge_lock = threading.Lock()
|
_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:
|
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,
|
def _store_challenge(token: str, method: str, ao3_url: str, headers: dict,
|
||||||
body: bytes, cookies: dict, proxy_host: str):
|
body: bytes, cookies: dict, proxy_host: str):
|
||||||
with _challenge_lock:
|
with _challenge_lock:
|
||||||
# Clean expired tokens
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
expired = [k for k, v in _challenge_map.items() if v[6] < now]
|
expired = [k for k, v in _challenge_map.items() if v[6] < now]
|
||||||
for k in expired:
|
for k in expired:
|
||||||
@@ -71,16 +76,38 @@ def _get_challenge(token: str) -> tuple | None:
|
|||||||
del _challenge_map[token]
|
del _challenge_map[token]
|
||||||
return entry
|
return entry
|
||||||
if entry:
|
if entry:
|
||||||
del _challenge_map[token] # expired
|
del _challenge_map[token]
|
||||||
return None
|
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 ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="AO3 Mirror",
|
title="AO3 Mirror",
|
||||||
description="AO3 reverse proxy mirror for Chinese users",
|
description="AO3 reverse proxy mirror for Chinese users",
|
||||||
version="4.0.0",
|
version="5.0.0",
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
)
|
)
|
||||||
@@ -106,31 +133,51 @@ def build_ao3_url(path: str, query: str = "") -> str:
|
|||||||
# ─── Challenge page URL rewriting ──────────────────────────────────────────
|
# ─── Challenge page URL rewriting ──────────────────────────────────────────
|
||||||
|
|
||||||
def _rewrite_challenge_page(body: bytes, proxy_host: str, token: str) -> bytes:
|
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."""
|
"""Rewrite CF challenge page — inject retry UI + auto cookie."""
|
||||||
import re
|
# Rewrite domain
|
||||||
|
|
||||||
# Basic AO3 domain rewrite
|
|
||||||
body = body.replace(b"archiveofourown.org", MIRROR_DOMAIN.encode())
|
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
|
# Inject cookie + retry overlay
|
||||||
# These are CF's internal challenge platform URLs
|
retry_overlay = (
|
||||||
body = body.replace(
|
f'<script>document.cookie="_cf_token={token};path=/;max-age={CHALLENGE_TOKEN_TTL}";</script>'.encode()
|
||||||
b"/cdn-cgi/challenge-platform",
|
+ 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}'
|
||||||
f"/cdn-cgi/challenge-platform".encode()
|
+ 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>'
|
||||||
)
|
)
|
||||||
|
body = body.replace(b"<body", retry_overlay + b"<body", 1)
|
||||||
# Inject a marker meta tag so we can detect challenge pages coming back
|
if b"<body" not in body:
|
||||||
marker = f'<meta name="cf-challenge-proxy" content="{proxy_host}">'.encode()
|
body = retry_overlay + body
|
||||||
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
|
|
||||||
|
|
||||||
return 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 ───────────────────────────────────────────────
|
# ─── Response header helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
def filter_response_headers(headers: dict) -> dict:
|
def filter_response_headers(headers: dict) -> dict:
|
||||||
@@ -149,7 +196,7 @@ def filter_response_headers(headers: dict) -> dict:
|
|||||||
if key.lower().startswith("cf-"):
|
if key.lower().startswith("cf-"):
|
||||||
continue
|
continue
|
||||||
result[key] = value
|
result[key] = value
|
||||||
result["X-Proxy"] = "AO3-Mirror/4.0"
|
result["X-Proxy"] = "AO3-Mirror/5.0"
|
||||||
result["X-Cache"] = "MISS"
|
result["X-Cache"] = "MISS"
|
||||||
result["Cache-Control"] = "public, max-age=60, s-maxage=60"
|
result["Cache-Control"] = "public, max-age=60, s-maxage=60"
|
||||||
return result
|
return result
|
||||||
@@ -169,15 +216,12 @@ def add_cors(response: Response):
|
|||||||
async def health():
|
async def health():
|
||||||
pool = get_proxy_pool()
|
pool = get_proxy_pool()
|
||||||
stats = pool.get_stats()
|
stats = pool.get_stats()
|
||||||
resp = JSONResponse({
|
return JSONResponse({
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"timestamp": time.time(),
|
"timestamp": time.time(),
|
||||||
"version": "4.0.0",
|
"version": "5.0.0",
|
||||||
"proxy_pool": stats,
|
"proxy_pool": stats,
|
||||||
})
|
})
|
||||||
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
|
||||||
resp.headers["Pragma"] = "no-cache"
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/robots.txt")
|
@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 ───────────────────────────────────────────────────────
|
# ─── Stats Dashboard ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/stats", response_class=HTMLResponse)
|
@app.get("/stats", response_class=HTMLResponse)
|
||||||
@@ -213,7 +294,7 @@ async def stats_page():
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AO3 Mirror - 统计面板 v4</title>
|
<title>AO3 Mirror - 统计面板 v5</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
<style>
|
<style>
|
||||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
* {{ 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 {{ 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-green {{ background: rgba(74, 222, 128, 0.15); color: #4ade80; }}
|
||||||
.badge-red {{ background: rgba(248, 113, 113, 0.15); color: #f87171; }}
|
.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; }} }}
|
@media (max-width: 768px) {{ .chart-row {{ grid-template-columns: 1fr; }} }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>🛡 AO3 Mirror 统计面板 v4</h1>
|
<h1>🛡 AO3 Mirror 统计面板 v5</h1>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -275,14 +357,14 @@ async def stats_page():
|
|||||||
<div class="sub">P99: {stats['p99_elapsed_ms']} ms</div>
|
<div class="sub">P99: {stats['p99_elapsed_ms']} ms</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">代理池</div>
|
<div class="label">代理池 ({proxy_stats['total']})</div>
|
||||||
<div class="value green">{proxy_stats['alive']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></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['available']} | 受损: {proxy_stats['dead']} | Banned: {proxy_stats['banned']}</div>
|
<div class="sub">不稳定: {proxy_stats['unstable']} | 封禁: {proxy_stats['blocked']} | 探测: {proxy_stats['probing']}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">Cookie 代理</div>
|
<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="value orange">{proxy_stats['with_cookies']}<span style="font-size:0.5em;color:#888;">/{proxy_stats['total']}</span></div>
|
||||||
<div class="sub">已持有 cf_clearance</div>
|
<div class="sub">inflight: {proxy_stats['total_inflight']}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="label">本地缓存</div>
|
<div class="label">本地缓存</div>
|
||||||
@@ -306,7 +388,7 @@ async def stats_page():
|
|||||||
<h2>热门路径</h2>
|
<h2>热门路径</h2>
|
||||||
<table>
|
<table>
|
||||||
<tr><th>路径</th><th>请求数</th><th>成功</th><th>平均延迟</th></tr>
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -394,11 +476,13 @@ async def metrics():
|
|||||||
f'ao3_mirror_avg_elapsed_ms {stats["avg_elapsed_ms"]}',
|
f'ao3_mirror_avg_elapsed_ms {stats["avg_elapsed_ms"]}',
|
||||||
"# HELP ao3_mirror_proxy_pool Proxy pool status",
|
"# HELP ao3_mirror_proxy_pool Proxy pool status",
|
||||||
"# TYPE ao3_mirror_proxy_pool gauge",
|
"# TYPE ao3_mirror_proxy_pool gauge",
|
||||||
f'ao3_mirror_proxy_alive {proxy_stats["alive"]}',
|
f'ao3_mirror_proxy_total {proxy_stats["total"]}',
|
||||||
f'ao3_mirror_proxy_dead {proxy_stats["dead"]}',
|
f'ao3_mirror_proxy_healthy {proxy_stats["healthy"]}',
|
||||||
f'ao3_mirror_proxy_banned {proxy_stats["banned"]}',
|
f'ao3_mirror_proxy_unstable {proxy_stats["unstable"]}',
|
||||||
f'ao3_mirror_proxy_available {proxy_stats["available"]}',
|
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_with_cookies {proxy_stats["with_cookies"]}',
|
||||||
|
f'ao3_mirror_proxy_inflight {proxy_stats["total_inflight"]}',
|
||||||
"# HELP ao3_mirror_cache_size Current cache size",
|
"# HELP ao3_mirror_cache_size Current cache size",
|
||||||
"# TYPE ao3_mirror_cache_size gauge",
|
"# TYPE ao3_mirror_cache_size gauge",
|
||||||
f'ao3_mirror_cache_size {cache_stats["size"]}',
|
f'ao3_mirror_cache_size {cache_stats["size"]}',
|
||||||
@@ -406,11 +490,11 @@ async def metrics():
|
|||||||
return PlainTextResponse("\n".join(lines))
|
return PlainTextResponse("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
# ─── Proxy Core (v4) ──────────────────────────────────────────────────────
|
# ─── Proxy handler (v5) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH"])
|
@app.api_route("/{path:path}", methods=["GET", "POST", "HEAD", "OPTIONS", "PUT", "DELETE", "PATCH"])
|
||||||
async def proxy_handler(request: Request, path: str):
|
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()
|
start_time = time.time()
|
||||||
client_ip = get_client_ip(request)
|
client_ip = get_client_ip(request)
|
||||||
|
|
||||||
@@ -418,88 +502,93 @@ async def proxy_handler(request: Request, path: str):
|
|||||||
path = ""
|
path = ""
|
||||||
|
|
||||||
full_path = f"/{path}" if path else "/"
|
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)
|
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":
|
if request.method == "OPTIONS":
|
||||||
resp = Response()
|
resp = Response()
|
||||||
add_cors(resp)
|
add_cors(resp)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
# ── v4: Challenge token check ──────────────────────────────────────────
|
pool = get_proxy_pool()
|
||||||
# If this request has a _cf_token cookie, it's part of a challenge resolution flow
|
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")
|
cf_token = request.cookies.get("_cf_token") or request.query_params.get("_cf_token")
|
||||||
preferred_proxy = None
|
|
||||||
if cf_token:
|
if cf_token:
|
||||||
challenge_entry = _get_challenge(cf_token)
|
challenge_entry = _get_challenge(cf_token)
|
||||||
if challenge_entry:
|
if challenge_entry:
|
||||||
orig_method, orig_url, orig_headers, orig_body, orig_cookies, proxy_host, _ = 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}")
|
||||||
logger.info(f"Challenge resolution: using proxy {proxy_host} for {orig_url[:60]}")
|
challenge_proxy_obj = pool.get_by_addr(proxy_host)
|
||||||
# Re-fetch the original request with the challenge proxy
|
|
||||||
result = await fetch_url(
|
result = await fetch_url(
|
||||||
url=orig_url, method=orig_method,
|
url=orig_url, method=orig_method,
|
||||||
|
proxy=challenge_proxy_obj,
|
||||||
headers=dict(request.headers),
|
headers=dict(request.headers),
|
||||||
body=await request.body() if orig_method in ("POST", "PUT", "PATCH") else None,
|
body=await request.body() if orig_method in ("POST", "PUT", "PATCH") else None,
|
||||||
cookies=orig_cookies,
|
cookies=orig_cookies,
|
||||||
is_api="/api/" in orig_url,
|
is_api="/api/" in orig_url,
|
||||||
preferred_proxy=preferred_proxy,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
# Challenge solved! Proxy now has cf_clearance. Return content.
|
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
rewritten_body = rewrite_body(result["body"],
|
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"])
|
rewritten_headers = rewrite_response_headers(result["headers"])
|
||||||
final_headers = filter_response_headers(rewritten_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-Cache"] = "MISS"
|
||||||
final_headers["X-CF-Status"] = "solved"
|
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"],
|
resp = Response(content=rewritten_body, status_code=result["status"],
|
||||||
headers=final_headers)
|
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="/")
|
resp.delete_cookie("_cf_token", path="/")
|
||||||
|
|
||||||
collector = get_stats_collector()
|
collector = get_stats_collector()
|
||||||
collector.log_request(method=orig_method, path=full_path,
|
collector.log_request(method=orig_method, path=full_path,
|
||||||
status=result["status"], elapsed=elapsed,
|
status=result["status"], elapsed=elapsed,
|
||||||
cached=False, proxy_host=preferred_proxy,
|
cached=False, proxy_host=proxy_host,
|
||||||
client_ip=client_ip)
|
client_ip=client_ip)
|
||||||
add_cors(resp)
|
add_cors(resp)
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
# ── Normal request flow ────────────────────────────────────────────────
|
# ── Static cache check (static assets only) ────────────────────
|
||||||
|
from cache import is_static_path
|
||||||
query_string = request.url.query
|
|
||||||
ao3_url = build_ao3_url(f"/{path}" if path else "/", query_string)
|
|
||||||
|
|
||||||
# Check cache
|
|
||||||
cache = get_cache()
|
cache = get_cache()
|
||||||
cached = cache.get(ao3_url, dict(request.headers))
|
if request.method == "GET" and is_static_path(full_path):
|
||||||
if cached:
|
cached = cache.get(ao3_url)
|
||||||
body, resp_headers, status = cached
|
if cached:
|
||||||
elapsed = time.time() - start_time
|
body, resp_headers, status = cached
|
||||||
collector = get_stats_collector()
|
elapsed = time.time() - start_time
|
||||||
collector.log_request(method=request.method, path=full_path, status=status,
|
collector = get_stats_collector()
|
||||||
elapsed=elapsed, cached=True, client_ip=client_ip)
|
collector.log_request(method=request.method, path=full_path, status=status,
|
||||||
headers = filter_response_headers(resp_headers)
|
elapsed=elapsed, cached=True, client_ip=client_ip)
|
||||||
headers["X-Cache"] = "HIT"
|
return Response(content=body, status_code=status, headers=resp_headers)
|
||||||
return Response(content=body, status_code=status, headers=headers)
|
|
||||||
|
|
||||||
# Prepare request
|
# ── Prepare request ──────────────────────────────────────────────────
|
||||||
method = request.method
|
method = request.method
|
||||||
client_headers = dict(request.headers)
|
client_headers = dict(request.headers)
|
||||||
req_body = None
|
req_body = None
|
||||||
@@ -514,101 +603,167 @@ async def proxy_handler(request: Request, path: str):
|
|||||||
k, v = pair.split("=", 1)
|
k, v = pair.split("=", 1)
|
||||||
cookies[k.strip()] = v.strip()
|
cookies[k.strip()] = v.strip()
|
||||||
|
|
||||||
# Forward to AO3
|
# ── v5: Sticky proxy + P2C selection with semaphore ──────────────────
|
||||||
result = await fetch_url(
|
sticky_idx, had_sticky = get_sticky_proxy_idx(request)
|
||||||
url=ao3_url, method=method,
|
final_proxy_idx = sticky_idx
|
||||||
headers=client_headers, body=req_body, cookies=cookies,
|
tried_idxs: set[int] = set()
|
||||||
is_api="/api/" in full_path or full_path.startswith("/api/"),
|
|
||||||
preferred_proxy=preferred_proxy,
|
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
|
elapsed = time.time() - start_time
|
||||||
|
|
||||||
# ── v4: CF Challenge handling ──────────────────────────────────────────
|
# ── All proxies exhausted — outage ───────────────────────────────────
|
||||||
if result.get("is_challenge") and result.get("challenge_body"):
|
if not result or not result["success"]:
|
||||||
challenge_proxy = result.get("challenge_proxy", "unknown")
|
if result and result.get("is_challenge") and result.get("challenge_body"):
|
||||||
challenge_body = result["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
|
# Check if we hit outage threshold
|
||||||
token = _make_challenge_token()
|
if pool.pool_unavailable_ratio() >= OUTAGE_THRESHOLD:
|
||||||
_store_challenge(token, method, ao3_url, client_headers,
|
logger.error(f"OUTAGE: {pool.pool_unavailable_ratio():.1%} proxies unavailable")
|
||||||
req_body or b"", cookies, challenge_proxy)
|
return HTMLResponse(
|
||||||
|
content=_outage_page(), status_code=503
|
||||||
|
)
|
||||||
|
|
||||||
# Rewrite challenge page for user's browser
|
logger.error(f"Failed: {ao3_url[:80]}: all retries exhausted")
|
||||||
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')}")
|
|
||||||
collector = get_stats_collector()
|
collector = get_stats_collector()
|
||||||
collector.log_request(method=method, path=full_path, status=502,
|
collector.log_request(method=method, path=full_path, status=502,
|
||||||
elapsed=elapsed, cached=False, client_ip=client_ip)
|
elapsed=elapsed, cached=False, client_ip=client_ip)
|
||||||
|
return HTMLResponse(content=_transient_error_page(), status_code=502)
|
||||||
|
|
||||||
error_html = f"""<!DOCTYPE html>
|
# ── Success ──────────────────────────────────────────────────────────
|
||||||
<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 ────────────────────────────────────────────────────────────
|
|
||||||
ao3_status = result["status"]
|
ao3_status = result["status"]
|
||||||
ao3_headers = result.get("headers", {})
|
ao3_headers = result.get("headers", {})
|
||||||
|
ao3_headers_raw = result.get("headers_raw", [])
|
||||||
raw_body = result.get("body", b"")
|
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)
|
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)
|
rewritten_headers = rewrite_response_headers(ao3_headers)
|
||||||
final_headers = filter_response_headers(rewritten_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"
|
final_headers["X-Cache"] = "MISS"
|
||||||
|
|
||||||
# Cache successful GET responses
|
# Cache successful static GET responses (disk MD5 cache, like go3)
|
||||||
if method == "GET" and 200 <= ao3_status < 400:
|
if method == "GET" and ao3_status == 200 and is_static_path(full_path):
|
||||||
ttl = get_ttl_for_path(full_path)
|
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status)
|
||||||
cache.set(ao3_url, rewritten_body, rewritten_headers, ao3_status, ttl=ttl)
|
|
||||||
|
|
||||||
# Handle redirects
|
# Handle redirects
|
||||||
if 300 <= ao3_status < 400 and "location" in rewritten_headers:
|
if 300 <= ao3_status < 400 and "location" in rewritten_headers:
|
||||||
@@ -624,27 +779,94 @@ h1 {{ color: #990000; }}
|
|||||||
client_ip=client_ip)
|
client_ip=client_ip)
|
||||||
|
|
||||||
resp = Response(content=rewritten_body, status_code=ao3_status, headers=final_headers)
|
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():
|
# Append ALL Set-Cookie headers from raw (preserves multi-value — pitfall #18a)
|
||||||
if k.lower() == "set-cookie":
|
# Filter: strip AO3 CF cookies — our own CF edge handles these
|
||||||
v_rewritten = v.replace("domain=archiveofourown.org",
|
SKIP_COOKIES = {"__cf_bm", "_cfuvid"}
|
||||||
f"domain={MIRROR_DOMAIN}")
|
if ao3_headers_raw:
|
||||||
v_rewritten = v_rewritten.replace("domain=.archiveofourown.org",
|
rewritten_raw = rewrite_response_headers_raw(ao3_headers_raw)
|
||||||
f"domain=.{MIRROR_DOMAIN}")
|
for key, value in rewritten_raw:
|
||||||
resp.headers.add("Set-Cookie", v_rewritten)
|
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)
|
add_cors(resp)
|
||||||
return 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 ────────────────────────────────────────────────────
|
# ─── Startup / Shutdown ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
logger.info("AO3 Mirror backend v4 starting up...")
|
logger.info("AO3 Mirror backend v5 starting up...")
|
||||||
get_proxy_pool()
|
pool = get_proxy_pool()
|
||||||
get_cache()
|
get_cache()
|
||||||
get_stats_collector()
|
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")
|
@app.on_event("shutdown")
|
||||||
@@ -660,7 +882,7 @@ async def shutdown():
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
port = int(os.environ.get("PORT", "8080"))
|
port = int(os.environ.get("PORT", "8080"))
|
||||||
workers = int(os.environ.get("WORKERS", "4"))
|
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(
|
uvicorn.run(
|
||||||
"app:app",
|
"app:app",
|
||||||
|
|||||||
225
cache.py
225
cache.py
@@ -1,123 +1,164 @@
|
|||||||
"""
|
"""
|
||||||
简单高效的缓存层
|
静态资源磁盘缓存 v5 — 对标 go3
|
||||||
- 内存 LRU 缓存,每个 worker 独立
|
|
||||||
- 短 TTL 避免内容过时
|
|
||||||
- 针对不同路径设置不同 TTL
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
只缓存明确是静态资源的路径(CSS/JS/图片/字体)。
|
||||||
|
动态 HTML / API / 登录流程 零缓存 — 直接透传。
|
||||||
|
缓存文件以 MD5(URL) 命名,存储在 cache/ 目录。
|
||||||
|
|
||||||
|
go3 参考: staticExtensions map + MD5 cache
|
||||||
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import OrderedDict
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("ao3-cache")
|
||||||
|
|
||||||
class LRUCache:
|
CACHE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache")
|
||||||
"""Thread-safe LRU cache with TTL support."""
|
|
||||||
|
|
||||||
def __init__(self, capacity: int = 2000, default_ttl: int = 30):
|
# Only cache these extensions — everything else passes through
|
||||||
self.capacity = capacity
|
STATIC_EXTENSIONS = {
|
||||||
self.default_ttl = default_ttl
|
".css", ".js", ".jpg", ".jpeg", ".png", ".gif", ".ico",
|
||||||
self._cache: OrderedDict[str, tuple[float, bytes, dict, int]] = OrderedDict()
|
".woff", ".woff2", ".ttf", ".svg", ".webp",
|
||||||
# (expiry_time, body, headers, status)
|
}
|
||||||
self._lock = threading.RLock()
|
|
||||||
|
|
||||||
def _make_key(self, url: str, headers: Optional[dict] = None) -> str:
|
# Path prefixes that are always static
|
||||||
"""Generate cache key from URL and key headers."""
|
STATIC_PATH_PREFIXES = (
|
||||||
# Use URL + relevant headers
|
"/stylesheets/", "/javascripts/", "/images/", "/media/",
|
||||||
accept = ""
|
"/skins/", "/assets/", "/favicon.ico",
|
||||||
if headers:
|
)
|
||||||
accept = headers.get("Accept-Encoding", "")
|
|
||||||
raw = f"{url}|{accept}"
|
|
||||||
return hashlib.md5(raw.encode()).hexdigest()
|
|
||||||
|
|
||||||
def get(self, url: str, headers: Optional[dict] = None) -> Optional[tuple[bytes, dict, int]]:
|
# Cache TTL: 7 days for static assets (like go3's static_cache_ttl_seconds)
|
||||||
"""Get cached response. Returns (body, headers, status) or None."""
|
STATIC_TTL = 604800 # 7 days
|
||||||
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)
|
|
||||||
|
|
||||||
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:
|
def is_static_path(path: str) -> bool:
|
||||||
self._cache[key] = (expiry, body, headers, status)
|
"""Check if a URL path points to a static asset."""
|
||||||
self._cache.move_to_end(key)
|
path_lower = path.lower()
|
||||||
if len(self._cache) > self.capacity:
|
for ext in STATIC_EXTENSIONS:
|
||||||
self._cache.popitem(last=False)
|
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):
|
class StaticCache:
|
||||||
with self._lock:
|
"""Disk-based static file cache. No LRU, no TTL on dynamic content."""
|
||||||
self._cache.clear()
|
|
||||||
|
|
||||||
@property
|
def __init__(self):
|
||||||
def size(self) -> int:
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
||||||
with self._lock:
|
self._lock = threading.Lock()
|
||||||
return len(self._cache)
|
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:
|
def get_stats(self) -> dict:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
return {
|
try:
|
||||||
"size": len(self._cache),
|
files = os.listdir(CACHE_DIR)
|
||||||
"capacity": self.capacity,
|
total_size = sum(
|
||||||
"usage_pct": round(len(self._cache) / self.capacity * 100, 1) if self.capacity else 0,
|
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 策略:不同路径不同缓存时间
|
def _guess_content_type(url: str) -> str:
|
||||||
PATH_TTL = {
|
url_lower = url.lower()
|
||||||
"/": 30, # 首页 30s
|
if url_lower.endswith(".css"):
|
||||||
"/works": 60, # 作品列表 60s
|
return "text/css; charset=utf-8"
|
||||||
"/chapters": 120, # 章节内容 120s
|
if url_lower.endswith(".js"):
|
||||||
"/series": 60, # 系列 60s
|
return "application/javascript; charset=utf-8"
|
||||||
"/collections": 60,
|
if url_lower.endswith(".png"):
|
||||||
"/tags": 60,
|
return "image/png"
|
||||||
"/users": 30,
|
if url_lower.endswith(".jpg") or url_lower.endswith(".jpeg"):
|
||||||
"/pseuds": 30,
|
return "image/jpeg"
|
||||||
"/bookmarks": 60,
|
if url_lower.endswith(".gif"):
|
||||||
"/skins": 300, # CSS 皮肤缓存 5 分钟
|
return "image/gif"
|
||||||
"/stylesheets": 300,
|
if url_lower.endswith(".svg"):
|
||||||
"/images": 600, # 图片缓存 10 分钟
|
return "image/svg+xml"
|
||||||
"/media": 600,
|
if url_lower.endswith(".ico"):
|
||||||
"/javascripts": 300,
|
return "image/x-icon"
|
||||||
"/api": 15, # API 响应 15s
|
if url_lower.endswith(".woff"):
|
||||||
"/external_links": 30,
|
return "font/woff"
|
||||||
# Default: 30s
|
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:
|
def get_ttl_for_path(path: str) -> int:
|
||||||
"""Determine cache TTL based on URL path."""
|
"""Only used for static path TTL. Returns 0 for dynamic paths."""
|
||||||
for prefix, ttl in PATH_TTL.items():
|
return STATIC_TTL if is_static_path(path) else 0
|
||||||
if path.startswith(prefix):
|
|
||||||
return ttl
|
|
||||||
return 30 # default
|
|
||||||
|
|
||||||
|
|
||||||
# 全局缓存实例
|
# ─── Singleton ──────────────────────────────────────────────────────────────
|
||||||
_cache: Optional[LRUCache] = None
|
|
||||||
|
_cache: Optional[StaticCache] = None
|
||||||
|
|
||||||
|
|
||||||
def get_cache() -> LRUCache:
|
def get_cache() -> StaticCache:
|
||||||
global _cache
|
global _cache
|
||||||
if _cache is None:
|
if _cache is None:
|
||||||
_cache = LRUCache(capacity=5000, default_ttl=30)
|
_cache = StaticCache()
|
||||||
return _cache
|
return _cache
|
||||||
|
|||||||
8
cache/0b56e3783141862ec51eaed10f75076a
vendored
Normal file
8
cache/0b56e3783141862ec51eaed10f75076a
vendored
Normal 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
152
cache/0ff52d5b7b9f8180daa067cf312b4baf
vendored
Normal 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
BIN
cache/17342e82684c267ba4f1e531738eb513
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
BIN
cache/2f7d84701e69c2978451a4693dbf6b35
vendored
Normal file
BIN
cache/2f7d84701e69c2978451a4693dbf6b35
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
cache/306b4df560405cad691843c852971d0a
vendored
Normal file
1
cache/306b4df560405cad691843c852971d0a
vendored
Normal 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
76
cache/331a4a4d15181598c366618812c89376
vendored
Normal 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;
|
||||||
|
}
|
||||||
1
cache/34a22b671f9a37f8e9c84a7871acf424
vendored
Normal file
1
cache/34a22b671f9a37f8e9c84a7871acf424
vendored
Normal 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);
|
||||||
8
cache/3623ba2552ea8194422c2bc087667423
vendored
Normal file
8
cache/3623ba2552ea8194422c2bc087667423
vendored
Normal 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);
|
||||||
7
cache/36f64a27d9f23f25aeaee0dba47e0872
vendored
Normal file
7
cache/36f64a27d9f23f25aeaee0dba47e0872
vendored
Normal 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
641
cache/4853d7f210093728ff9ad1ca4d54f9e4
vendored
Normal 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
BIN
cache/648f0179eb5fdc03a5766870d2eb6550
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
143
cache/7bddd8efbc2b1a767d0f063375e157ce
vendored
Normal file
143
cache/7bddd8efbc2b1a767d0f063375e157ce
vendored
Normal 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
4491
cache/92cf3bcddf1191c3c04ceea2383c8ed9
vendored
Normal file
File diff suppressed because it is too large
Load Diff
66
cache/9722736b7350e3b421dd1a82f33a13a5
vendored
Normal file
66
cache/9722736b7350e3b421dd1a82f33a13a5
vendored
Normal 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: " ";
|
||||||
|
}
|
||||||
8
cache/b81bf588f2cb4661c6acbcbe2b661368
vendored
Normal file
8
cache/b81bf588f2cb4661c6acbcbe2b661368
vendored
Normal 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
924
cache/c7be43ce9c3c20e7bfed57686ab4f46e
vendored
Normal 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
294
cache/d1b5fde27293b4c7d5e5b78261172c48
vendored
Normal 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
15
cache/e8d39ac2b8645f201df2197c4f56db7f
vendored
Normal 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);
|
||||||
1
cache/e9ae0b4af5e27fb4b4cdc84ff13fd450
vendored
Normal file
1
cache/e9ae0b4af5e27fb4b4cdc84ff13fd450
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
cache/e9d6dffb2eb319ae85deae26872daf4a
vendored
Normal file
BIN
cache/e9d6dffb2eb319ae85deae26872daf4a
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
1
cache/edd3bc844352c94eeb1108ac4fc79394
vendored
Normal file
1
cache/edd3bc844352c94eeb1108ac4fc79394
vendored
Normal file
File diff suppressed because one or more lines are too long
534
cache/f688920d715a669db37b313dd3ecffd6
vendored
Normal file
534
cache/f688920d715a669db37b313dd3ecffd6
vendored
Normal 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
186
cache/fd994d17b56e171552534f14fc2cd24b
vendored
Normal 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
BIN
cache/fe5c4c7ec67e5277582208af3c5303b6
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
730
proxy_pool.py
730
proxy_pool.py
@@ -1,11 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
异步代理池 v4 — Cookie 感知 + 挑战分离 + 真实统计
|
异步代理池 v5 — P2C + 状态机 + 全量 5000 代理 + 并发限流
|
||||||
|
|
||||||
v4 vs v3:
|
对标 go3 的代理管理架构,适配 Python/curl_cffi:
|
||||||
- 每个代理维护 cookie jar(cf_clearance 等),成功请求后自动保存
|
- 全量加载 proxies.txt(不再按端口过滤)
|
||||||
- CF 挑战不再标记为代理死亡(mark_challenged ≠ mark_failure)
|
- P2C (Power of Two Choices) 选择算法
|
||||||
- 真实统计:维护原子计数器,不造假数据
|
- 状态机: healthy → unstable → blocked → probing
|
||||||
- 更多 TLS 指纹:加入 safari15_5/safari17_0
|
- 并发限制 400(Webshare 上限)
|
||||||
|
- 被动探测 + 指数退避
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
@@ -20,23 +21,42 @@ from curl_cffi.requests import AsyncSession
|
|||||||
|
|
||||||
logger = logging.getLogger("ao3-proxy-pool")
|
logger = logging.getLogger("ao3-proxy-pool")
|
||||||
|
|
||||||
OPTIMAL_MIN_PORT = 13500
|
# ─── Constants ───────────────────────────────────────────────────────────────
|
||||||
OPTIMAL_MAX_PORT = 14499
|
|
||||||
|
PROXY_FILE = "/home/ubuntu/proxy.txt"
|
||||||
WORKING_PROXIES_FILE = "/dev/shm/working_proxies.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
|
||||||
|
|
||||||
# 被动检查:只对快池做轻量采样
|
# Probe config (like go3: 10s interval, 10 concurrency, 2 successes to promote)
|
||||||
SAMPLE_INTERVAL = 60
|
PROBE_INTERVAL = 10
|
||||||
SAMPLE_BATCH_SIZE = 10
|
PROBE_CONCURRENCY = 10
|
||||||
SAMPLE_TIMEOUT = 5
|
PROBE_SUCCESS_THRESHOLD = 2
|
||||||
FAST_POOL_REFRESH_INTERVAL = 10
|
PROBE_TIMEOUT = 5
|
||||||
|
|
||||||
# TLS fingerprints — proven against AO3 Cloudflare
|
# Cooldown config
|
||||||
# safari15_5/17_0 have highest CF bypass rate per testing
|
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"]
|
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:
|
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:])
|
return time.time() + int(part[9:])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return 0 # session cookie
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def load_proxies_from_file() -> list[str]:
|
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:
|
if os.path.exists(WORKING_PROXIES_FILE) and os.path.getsize(WORKING_PROXIES_FILE) > 0:
|
||||||
with open(WORKING_PROXIES_FILE) as f:
|
with open(WORKING_PROXIES_FILE) as f:
|
||||||
proxies = [l.strip() for l in f if l.strip() and ":" in l]
|
cached = [l.strip() for l in f if l.strip() and ":" in l]
|
||||||
if proxies:
|
if len(cached) >= 100:
|
||||||
logger.info(f"Loaded {len(proxies)} working proxies from {WORKING_PROXIES_FILE}")
|
logger.info(f"Found {len(cached)} cached working proxies — using as seed")
|
||||||
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"
|
|
||||||
proxies = []
|
proxies = []
|
||||||
with open(proxy_file) as f:
|
with open(PROXY_FILE) as f:
|
||||||
for line in f:
|
for line in f:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line or ":" not in line:
|
if not line or ":" not in line:
|
||||||
@@ -79,96 +97,133 @@ def load_proxies_from_file() -> list[str]:
|
|||||||
if "|" in line:
|
if "|" in line:
|
||||||
line = line.split("|")[-1].strip()
|
line = line.split("|")[-1].strip()
|
||||||
proxies.append(line)
|
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:
|
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__ = (
|
__slots__ = (
|
||||||
"host_port", "host", "port_str", "port",
|
"idx", "host_port", "host", "port_str", "port",
|
||||||
"session", "impersonate", "alive",
|
"session", "impersonate",
|
||||||
"consecutive_failures", "ban_until", "last_used",
|
# State machine
|
||||||
"avg_response_time", "weight", "requests_handled",
|
"state", # healthy / unstable / blocked / probing
|
||||||
"last_sample", "sample_passed",
|
# Stats
|
||||||
# v4: cookie jar
|
"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",
|
"_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_port = host_port
|
||||||
self.host, self.port_str = host_port.split(":")
|
self.host, self.port_str = host_port.split(":")
|
||||||
self.port = int(self.port_str)
|
self.port = int(self.port_str)
|
||||||
self.session: Optional[AsyncSession] = None
|
self.session: Optional[AsyncSession] = None
|
||||||
self.impersonate = random.choice(BROWSER_IMPS)
|
self.impersonate = random.choice(BROWSER_IMPS)
|
||||||
self.alive = True
|
|
||||||
self.consecutive_failures = 0
|
self.state = STATE_HEALTHY
|
||||||
self.ban_until = 0.0
|
self.successes = 0
|
||||||
|
self.failures = 0
|
||||||
|
self.consecutive_net_failures = 0
|
||||||
|
self.consecutive_blocked = 0
|
||||||
self.last_used = 0.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.avg_response_time = 1.0
|
||||||
self.weight = 1.0
|
|
||||||
self.requests_handled = 0
|
self.requests_handled = 0
|
||||||
self.last_sample = 0.0
|
self.cooldown_until = 0.0
|
||||||
self.sample_passed = True
|
self.next_probe_at = 0.0
|
||||||
# v4: per-proxy cookie jar (cf_clearance, etc.)
|
self.probe_successes = 0
|
||||||
|
self.inflight = 0
|
||||||
|
|
||||||
self._cookies: dict[str, str] = {}
|
self._cookies: dict[str, str] = {}
|
||||||
self._cookie_expires: dict[str, float] = {}
|
self._cookie_expires: dict[str, float] = {}
|
||||||
self._lock = Lock()
|
self._lock = Lock()
|
||||||
|
|
||||||
# ─── Cookie management ────────────────────────────────────────────
|
@property
|
||||||
|
def is_available(self) -> bool:
|
||||||
def save_cookies(self, headers: dict) -> int:
|
"""Proxy is selectable for user requests."""
|
||||||
"""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
|
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with self._lock:
|
if now < self.cooldown_until:
|
||||||
for part in set_cookie.split(","):
|
return False
|
||||||
# Handle comma-separated cookies (ugh)
|
if self.state == STATE_HEALTHY:
|
||||||
part = part.strip()
|
return True
|
||||||
if "=" not in part:
|
if self.state == STATE_UNSTABLE and now >= self.cooldown_until:
|
||||||
continue
|
return True
|
||||||
name, _, rest = part.partition("=")
|
if self.state == STATE_PROBING:
|
||||||
value = rest.split(";")[0].strip() if ";" in rest else rest.strip()
|
return False # Don't route user traffic to probing proxies
|
||||||
name = name.strip()
|
return False # STATE_BLOCKED
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
self._cookies[name] = value
|
|
||||||
expires = _parse_cookie_expires(part)
|
|
||||||
if expires > 0:
|
|
||||||
self._cookie_expires[name] = expires
|
|
||||||
saved += 1
|
|
||||||
|
|
||||||
# Purge expired cookies
|
@property
|
||||||
self._purge_expired(now)
|
def is_user_selectable(self) -> bool:
|
||||||
if saved:
|
"""Proxy can be chosen for sticky/user requests. Like go3's isUserSelectable."""
|
||||||
logger.debug(f"Saved {saved} cookies for {self.host_port} (keys: {list(self._cookies.keys())})")
|
return self.is_available
|
||||||
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())
|
|
||||||
|
|
||||||
def _purge_expired(self, now: float):
|
def _purge_expired(self, now: float):
|
||||||
"""Remove expired cookies."""
|
|
||||||
expired = [k for k, exp in self._cookie_expires.items() if 0 < exp < now]
|
expired = [k for k, exp in self._cookie_expires.items() if 0 < exp < now]
|
||||||
for k in expired:
|
for k in expired:
|
||||||
self._cookies.pop(k, None)
|
self._cookies.pop(k, None)
|
||||||
self._cookie_expires.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:
|
async def get_session(self) -> AsyncSession:
|
||||||
if self.session is None:
|
if self.session is None:
|
||||||
@@ -179,10 +234,11 @@ class ProxySession:
|
|||||||
"http": f"http://{self.host_port}",
|
"http": f"http://{self.host_port}",
|
||||||
"https": 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({
|
self.session.headers.update({
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
"User-Agent": (
|
||||||
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
"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": "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-Language": "en-US,en;q=0.9",
|
||||||
"Accept-Encoding": "gzip, deflate, br",
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
@@ -197,260 +253,364 @@ class ProxySession:
|
|||||||
pass
|
pass
|
||||||
self.session = None
|
self.session = None
|
||||||
|
|
||||||
# ─── Health tracking ──────────────────────────────────────────────
|
# ─── State machine (go3-compatible) ──────────────────────────────────
|
||||||
|
|
||||||
def update_weight(self):
|
|
||||||
self.weight = 1.0 / max(self.avg_response_time, 0.1)
|
|
||||||
|
|
||||||
def mark_success(self, response_time: float):
|
def mark_success(self, response_time: float):
|
||||||
self.alive = True
|
"""Request succeeded. Promote to healthy."""
|
||||||
self.consecutive_failures = 0
|
self.last_used = time.time()
|
||||||
self.ban_until = 0.0
|
self.last_success_at = time.time()
|
||||||
|
self.successes += 1
|
||||||
self.requests_handled += 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.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):
|
def mark_failure(self, is_network: bool = True):
|
||||||
"""Proxy-level failure (connection error, timeout, etc.) — exponential backoff."""
|
"""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.requests_handled += 1
|
||||||
self.consecutive_failures += 1
|
|
||||||
backoff = min(5 * (3 ** (self.consecutive_failures - 1)), 300)
|
if is_network:
|
||||||
self.ban_until = time.time() + backoff
|
self.consecutive_net_failures += 1
|
||||||
if self.consecutive_failures >= 3:
|
self.consecutive_blocked = 0
|
||||||
self.alive = False
|
|
||||||
self.sample_passed = False
|
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):
|
def mark_challenged(self):
|
||||||
"""CF challenge detected — proxy is alive but needs cookie. NO backoff."""
|
"""CF challenge detected — proxy is alive but needs cookie. Keep healthy, don't penalize."""
|
||||||
# Don't increment consecutive_failures — challenge is not proxy's fault
|
self.last_used = time.time()
|
||||||
# Don't set ban_until — proxy may work with cookies
|
|
||||||
self.requests_handled += 1
|
self.requests_handled += 1
|
||||||
# Only mark as not-sample-passed so it won't be fast-pool priority
|
# Don't change state — challenge is not proxy's fault
|
||||||
self.sample_passed = False
|
|
||||||
|
|
||||||
def mark_sample(self, passed: bool, response_time: float = 0):
|
def mark_probe_result(self, passed: bool, response_time: float = 0):
|
||||||
"""Lightweight periodic check result."""
|
"""Periodic probe result. If probing and enough successes, promote to healthy."""
|
||||||
self.last_sample = time.time()
|
now = time.time()
|
||||||
self.sample_passed = passed
|
if passed:
|
||||||
if passed and not self.alive:
|
self.probe_successes += 1
|
||||||
self.alive = True
|
if self.probe_successes >= PROBE_SUCCESS_THRESHOLD:
|
||||||
self.consecutive_failures = max(0, self.consecutive_failures - 1)
|
self.state = STATE_HEALTHY
|
||||||
self.avg_response_time = self.avg_response_time * 0.5 + response_time * 0.5
|
self.cooldown_until = 0.0
|
||||||
self.update_weight()
|
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 to_stats_dict(self) -> dict:
|
||||||
def is_available(self) -> bool:
|
return {
|
||||||
return self.alive and time.time() > self.ban_until
|
"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):
|
def __repr__(self):
|
||||||
nc = len(self._cookies)
|
return f"PS({self.host_port}, {self.state}, {self.avg_response_time:.2f}s, inflight={self.inflight})"
|
||||||
return f"PS({self.host_port}, alive={self.alive}, {self.avg_response_time:.1f}s, cookies={nc})"
|
|
||||||
|
|
||||||
|
|
||||||
class AsyncProxyPool:
|
class AsyncProxyPool:
|
||||||
"""Tiered async proxy pool with cookie-aware session management."""
|
"""Proxy pool v5 — P2C selection + state machine + concurrent limiter."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._proxies: list[ProxySession] = []
|
self._proxies: list[ProxySession] = []
|
||||||
self._fast_pool: list[ProxySession] = []
|
self._idx_map: dict[int, ProxySession] = {}
|
||||||
self._fast_pool_updated = 0.0
|
self._addr_map: dict[str, ProxySession] = {}
|
||||||
self._sample_task: Optional[asyncio.Task] = None
|
|
||||||
# v4: atomic counters for real stats (no more fake data)
|
# Concurrent request limiter (capped at 400)
|
||||||
|
self._concurrency_limiter = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
|
||||||
|
|
||||||
|
# Stats
|
||||||
self._stats_lock = Lock()
|
self._stats_lock = Lock()
|
||||||
self._stats = {"alive": 0, "dead": 0, "banned": 0, "challenged": 0}
|
|
||||||
self._stats_cache = {}
|
# Probe task
|
||||||
self._stats_cache_ts = 0.0
|
self._probe_task: Optional[asyncio.Task] = None
|
||||||
|
self._probe_running = False
|
||||||
|
|
||||||
|
# Load proxies
|
||||||
self._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):
|
def _load_proxies(self):
|
||||||
proxies = load_proxies_from_file()
|
proxy_list = load_proxies_from_file()
|
||||||
self._proxies = [ProxySession(hp) for hp in proxies]
|
self._proxies = []
|
||||||
self._refresh_fast_pool()
|
self._idx_map = {}
|
||||||
self._recompute_stats()
|
self._addr_map = {}
|
||||||
logger.info(f"ProxyPool v4 ready: {len(self._proxies)} proxies, fast={len(self._fast_pool)}")
|
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:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
if self._sample_task is None or self._sample_task.done():
|
self._probe_task = asyncio.create_task(self._probe_loop())
|
||||||
self._sample_task = asyncio.create_task(self._sampling_loop())
|
|
||||||
|
|
||||||
def _refresh_fast_pool(self):
|
# ─── Proxy selection (P2C — Power of Two Choices) ───────────────────
|
||||||
"""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()
|
|
||||||
|
|
||||||
def _recompute_stats(self):
|
def p2c_select(self, exclude_idxs: Optional[set[int]] = None) -> Optional[ProxySession]:
|
||||||
"""Accurate stats — iterate all proxies (fast, ~726 items)."""
|
"""
|
||||||
alive = 0
|
Power of Two Choices: pick 2 random candidates, return the one with fewer inflight.
|
||||||
dead = 0
|
Falls back to random if P2C produces ties.
|
||||||
banned = 0
|
"""
|
||||||
for p in self._proxies:
|
exclude = exclude_idxs or set()
|
||||||
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)."""
|
|
||||||
now = time.time()
|
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]:
|
# Build candidate list (selectable, not excluded)
|
||||||
"""Weighted random from all available proxies."""
|
candidates = []
|
||||||
available = [p for p in self._proxies if p.is_available]
|
for p in self._proxies:
|
||||||
if not available:
|
if p.idx in exclude:
|
||||||
# Fallback: use proxies with fewer than 10 failures
|
continue
|
||||||
available = [p for p in self._proxies if p.consecutive_failures < 10]
|
if not p.is_user_selectable:
|
||||||
if not available:
|
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
|
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]:
|
if len(candidates) == 1:
|
||||||
"""Get a proxy that has cookies saved (cf_clearance). Fallback to any available."""
|
return candidates[0]
|
||||||
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()
|
|
||||||
|
|
||||||
# ─── 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):
|
if left.inflight <= right.inflight:
|
||||||
"""Lightweight sampling — only test fast-pool proxies."""
|
return left
|
||||||
logger.info("Sampling loop started (lightweight, fast-pool only)")
|
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:
|
while True:
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(SAMPLE_INTERVAL)
|
await asyncio.sleep(PROBE_INTERVAL)
|
||||||
pool = self._fast_pool[:] if self._fast_pool else self._proxies[:50]
|
# Find proxies that need probing: blocked/probing with cooldown expired
|
||||||
if not pool:
|
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
|
continue
|
||||||
alive_cnt = 0
|
|
||||||
dead_cnt = 0
|
# Probe in batches of PROBE_CONCURRENCY
|
||||||
for i in range(0, len(pool), SAMPLE_BATCH_SIZE):
|
random.shuffle(to_probe)
|
||||||
batch = pool[i:i + SAMPLE_BATCH_SIZE]
|
for i in range(0, len(to_probe), PROBE_CONCURRENCY):
|
||||||
checks = [self._check_single(p) for p in batch]
|
batch = to_probe[i:i + PROBE_CONCURRENCY]
|
||||||
results = await asyncio.gather(*checks, return_exceptions=True)
|
tasks = [self._probe_single(p) for p in batch]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
for p, r in zip(batch, results):
|
for p, r in zip(batch, results):
|
||||||
if isinstance(r, Exception):
|
if isinstance(r, Exception):
|
||||||
p.mark_sample(False)
|
p.mark_probe_result(False)
|
||||||
dead_cnt += 1
|
|
||||||
elif r[0]:
|
|
||||||
p.mark_sample(True, r[1])
|
|
||||||
alive_cnt += 1
|
|
||||||
else:
|
else:
|
||||||
p.mark_sample(False)
|
p.mark_probe_result(r[0], r[1])
|
||||||
dead_cnt += 1
|
|
||||||
self._refresh_fast_pool()
|
# Log recovery stats
|
||||||
self._recompute_stats()
|
recovered = sum(1 for p in to_probe if p.state == STATE_HEALTHY)
|
||||||
total_avg = sum(p.avg_response_time for p in pool if p.requests_handled > 0) / max(alive_cnt, 1)
|
if recovered:
|
||||||
logger.debug(f"Sample: {alive_cnt} alive, {dead_cnt} dead, fast={len(self._fast_pool)}, avg={total_avg*1000:.0f}ms")
|
logger.info(f"Probe: {len(to_probe)} checked, {recovered} recovered")
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception as e:
|
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]:
|
async def _probe_single(self, proxy: ProxySession) -> tuple[bool, float]:
|
||||||
"""Quick single-proxy check against AO3."""
|
"""Quick HEAD check against AO3."""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
try:
|
try:
|
||||||
s = await proxy.get_session()
|
s = await proxy.get_session()
|
||||||
resp = await s.head("https://archiveofourown.org/", timeout=SAMPLE_TIMEOUT)
|
resp = await s.head("https://archiveofourown.org/", timeout=PROBE_TIMEOUT)
|
||||||
return (200 <= resp.status_code < 500, time.time() - start)
|
ok = 200 <= resp.status_code < 500
|
||||||
|
return (ok, time.time() - start)
|
||||||
except Exception:
|
except Exception:
|
||||||
return (False, time.time() - start)
|
return (False, time.time() - start)
|
||||||
|
|
||||||
# ─── Lifecycle ────────────────────────────────────────────────────
|
# ─── Lifecycle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def close_all(self):
|
async def close_all(self):
|
||||||
if self._sample_task and not self._sample_task.done():
|
if self._probe_task and not self._probe_task.done():
|
||||||
self._sample_task.cancel()
|
self._probe_task.cancel()
|
||||||
try:
|
try:
|
||||||
await self._sample_task
|
await self._probe_task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
for p in self._proxies:
|
for p in self._proxies:
|
||||||
await p.close()
|
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:
|
def __len__(self):
|
||||||
"""Accurate stats with caching (1s throttle to avoid iteration on every call)."""
|
return len(self._proxies)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Singleton ────────────────────────────────────────────────────────────────
|
# ─── Singleton ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
235
static/sw.js
Normal file
235
static/sw.js
Normal 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。若使用 Wi‑Fi,请检查路由器是否正常。</p></li>' +
|
||||||
|
'<li><p class="step-title"><span class="step-num">2</span><span>尝试切换到移动流量</span></p>' +
|
||||||
|
'<p class="step-body">部分宽带或校园网可能对镜像域名有限制。请关闭 Wi‑Fi,使用手机 <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
|
||||||
|
});
|
||||||
@@ -83,6 +83,30 @@ def rewrite_response_headers(headers: dict) -> dict:
|
|||||||
return new_headers
|
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:
|
def rewrite_redirect_url(url: str) -> str:
|
||||||
"""Rewrite AO3 URLs in redirect targets."""
|
"""Rewrite AO3 URLs in redirect targets."""
|
||||||
return url.replace(TARGET_DOMAIN, MIRROR_DOMAIN).replace("http://", "https://")
|
return url.replace(TARGET_DOMAIN, MIRROR_DOMAIN).replace("http://", "https://")
|
||||||
|
|||||||
Reference in New Issue
Block a user