commit 3bd14554ec2244cb1b5ddfd40616801f62a88213 Author: chenxing Date: Wed Aug 5 09:53:49 2026 +0800 init: CasSsoService CAS 5.3 overlay (cas.war via LFS) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a89a111 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docker/cas.war filter=lfs diff=lfs merge=lfs -text diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..7579450 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,42 @@ +name: deploy-cassso-service + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-deploy: + runs-on: ubuntu-latest + # docker:24 自带 docker CLI;宿主 docker.sock 默认挂入 job 容器。 + container: + image: docker:24 + steps: + - name: Install git + git-lfs (alpine) + run: apk add --no-cache git git-lfs + + - name: Checkout (manual, internal Gitea) + LFS pull + # cas.war (174MB) 走 Git LFS 存储,clone 后需 git lfs pull 取回真实文件 + run: | + git clone --depth 1 --branch main http://192.168.10.160:3000/${GITHUB_REPOSITORY}.git /tmp/src + cd /tmp/src && git lfs pull + cp -a /tmp/src/. . + rm -rf .git .gitea + rm -rf /tmp/src + + - name: Login to Nexus docker registry + run: echo '${{ secrets.NEXUS_PASSWORD }}' | docker login -u '${{ secrets.NEXUS_USER }}' --password-stdin nexus:8083 + + - name: Build image (copies docker/cas.war) + run: docker build -f docker/Dockerfile -t nexus:8083/docker-hosted/cassso-service:latest . + + - name: Push image + run: docker push nexus:8083/docker-hosted/cassso-service:latest + + - name: Deploy on host (infra-infra-net, CAS 端口 8443) + run: | + docker rm -f cas-sso 2>/dev/null || true + docker run -d --name cas-sso --network infra-infra-net -p 8443:8443 --restart unless-stopped nexus:8083/docker-hosted/cassso-service:latest + + - name: Done + run: 'echo "CasSsoService deployed -> https://192.168.10.160:8443/login"' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb48172 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +docker/live_cas.war +docker/diag*.py +docker/verify*.py +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..bacf45a --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +CAS Overlay Template +============================ + +Generic CAS WAR overlay to exercise the latest versions of CAS. This overlay could be freely used as a starting template for local CAS war overlays. The CAS services management overlay is available [here](https://github.com/apereo/cas-services-management-overlay). + +# Versions + +```xml +5.3.x +``` + +# Requirements + +* JDK 1.8+ + +# Configuration + +The `etc` directory contains the configuration files and directories that need to be copied to `/etc/cas/config`. + +# Build + +To see what commands are available to the build script, run: + +```bash +./build.sh help +``` + +To package the final web application, run: + +```bash +./build.sh package +``` + +To update `SNAPSHOT` versions run: + +```bash +./build.sh package -U +``` + +# Deployment + +- Create a keystore file `thekeystore` under `/etc/cas`. Use the password `changeit` for both the keystore and the key/certificate entries. +- Ensure the keystore is loaded up with keys and certificates of the server. + +On a successful deployment via the following methods, CAS will be available at: + +* `http://cas.server.name:8080/cas` +* `https://cas.server.name:8443/cas` + +## Executable WAR + +Run the CAS web application as an executable WAR. + +```bash +./build.sh run +``` + +## Spring Boot + +Run the CAS web application as an executable WAR via Spring Boot. This is most useful during development and testing. + +```bash +./build.sh bootrun +``` + +### Warning! + +Be careful with this method of deployment. `bootRun` is not designed to work with already executable WAR artifacts such that CAS server web application. YMMV. Today, uses of this mode ONLY work when there is **NO OTHER** dependency added to the build script and the `cas-server-webapp` is the only present module. See [this issue](https://github.com/spring-projects/spring-boot/issues/8320) for more info. + + +## Spring Boot App Server Selection + +There is an app.server property in the `pom.xml` that can be used to select a spring boot application server. +It defaults to `-tomcat` but `-jetty` and `-undertow` are supported. + +It can also be set to an empty value (nothing) if you want to deploy CAS to an external application server of your choice. + +```xml +-tomcat +``` + +## Windows Build + +If you are building on windows, try `build.cmd` instead of `build.sh`. Arguments are similar but for usage, run: + +``` +build.cmd help +``` + +## External + +Deploy resultant `target/cas.war` to a servlet container of choice. + + +## Command Line Shell + +Invokes the CAS Command Line Shell. For a list of commands either use no arguments or use `-h`. To enter the interactive shell use `-sh`. + +```bash +./build.sh cli +``` \ No newline at end of file diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..1c3c888 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,19 @@ +# 本地推送 / 远端部署 用的环境变量示例 +# 用法:复制为 .env 或直接 export 后执行脚本 + +# ---- ACR 阿里云镜像仓库 ---- +ACR_NAMESPACE=your-namespace # 必填:阿里云镜像命名空间 +ACR_USERNAME=your-acr-username # 必填:阿里云仓库登录用户名 +ACR_PASSWORD=your-acr-password # 必填:阿里云仓库独立登录密码 +ACR_REGISTRY_PUBLIC=registry.cn-hangzhou.aliyuncs.com + +# ---- 部署到远端 ---- +DEPLOY_HOST=192.168.10.157 # ECS 可达地址 +DEPLOY_PORT=22 +DEPLOY_USER=yunhai +REMOTE_DIR=/opt/docker/apps/cas-sso +SSHPASS=123456 # 远端登录密码(remote_run/upload 用) + +# ---- CAS 自带 MySQL 密码 ---- +MYSQL_PASSWORD=Cas@123456 +MYSQL_ROOT_PASSWORD=Root@123456 diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..73d5596 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,23 @@ +# CAS 5.3.16 镜像(基于已构建好的 target/cas.war) +# 构建上下文 = 项目根目录: docker build -f docker/Dockerfile -t . +FROM eclipse-temurin:8-jre + +# CAS 5.3 默认要求 HTTPS(安全 Cookie),这里生成自签名密钥库(密码固定 changeit,过渡期用) +RUN mkdir -p /etc/cas/config \ + && keytool -genkeypair -alias cas -keyalg RSA -keysize 2048 -validity 3650 \ + -storetype JKS -keystore /etc/cas/cas.keystore \ + -storepass changeit -keypass changeit -dname "CN=cas.example.org" 2>/dev/null || true \ + && apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY docker/cas.war /app/cas.war +COPY docker/cas.properties /etc/cas/config/cas.properties +COPY docker/run-cas.sh /app/run-cas.sh +COPY etc/cas/config/log4j2.xml /etc/cas/config/log4j2.xml +RUN chmod +x /app/run-cas.sh + +# 镜像内 cas.properties 仅作「nacos 不可达时的兜底默认」; +# 正常启动由 run-cas.sh 从 nacos 拉取线上配置(dataId=cas-prod.properties)后覆盖。 +EXPOSE 8443 +ENTRYPOINT ["/app/run-cas.sh"] diff --git a/docker/base-common.properties b/docker/base-common.properties new file mode 100644 index 0000000..3dbc1a9 --- /dev/null +++ b/docker/base-common.properties @@ -0,0 +1,31 @@ +# ============================================================ +# 公共基础配置 base-common +# 部署位置:Nacos namespace=public group=DEFAULT_GROUP dataId=base-common.properties +# 用途:所有服务共享的 mysql / redis / rabbitmq 连接信息(只放连接,不放业务库名) +# CAS 的 run-cas.sh 会读取 spring.datasource.* 拼出 JDBC URL, +# 库名在 cas-prod.properties 的 cas.authn.jdbc.query[0].database 中指定(如 bsch_userbase) +# ============================================================ + +# ---------- MySQL(公共 mysql8,服务名 mysql8,位于 infra-infra-net)---------- +spring.datasource.host=mysql8 +spring.datasource.port=3306 +spring.datasource.username=__SHARED_MYSQL_USER__ +spring.datasource.password=__SHARED_MYSQL_PASSWORD__ +spring.datasource.params=useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + +# ---------- Redis ---------- +spring.redis.host=__REDIS_HOST__ +spring.redis.port=6379 +spring.redis.password=__SHARED_REDIS_PASSWORD__ +spring.redis.database=0 +spring.redis.timeout=5000 +spring.redis.jedis.pool.max-active=8 +spring.redis.jedis.pool.max-idle=8 +spring.redis.jedis.pool.min-idle=0 + +# ---------- RabbitMQ ---------- +spring.rabbitmq.host=__RABBITMQ_HOST__ +spring.rabbitmq.port=5672 +spring.rabbitmq.username=__SHARED_RABBIT_USER__ +spring.rabbitmq.password=__SHARED_RABBIT_PASSWORD__ +spring.rabbitmq.virtual-host=/ diff --git a/docker/build-push.sh b/docker/build-push.sh new file mode 100644 index 0000000..3db47ec --- /dev/null +++ b/docker/build-push.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# 本地构建 CAS 镜像并推送到阿里云「公网」 registry +# 用法: ./build-push.sh +# 前置环境变量: ACR_NAMESPACE, ACR_USERNAME, ACR_PASSWORD +set -euo pipefail +VERSION="${1:-latest}" +REGISTRY="${ACR_REGISTRY_PUBLIC:-registry.cn-hangzhou.aliyuncs.com}" +NAMESPACE="${ACR_NAMESPACE:?请设置 ACR_NAMESPACE(阿里云镜像命名空间)}" +IMG="$REGISTRY/$NAMESPACE/yunhai:$VERSION" + +cd "$(dirname "$0")/.." # 切到项目根(构建上下文需含 target/cas.war) +echo "== build $IMG ==" +docker build -f docker/Dockerfile -t "$IMG" . +echo "== login $REGISTRY ==" +echo "$ACR_PASSWORD" | docker login --username="$ACR_USERNAME" --password-stdin "$REGISTRY" +echo "== push ==" +docker push "$IMG" +echo "PUSHED $IMG" diff --git a/docker/cas-prod.properties.example b/docker/cas-prod.properties.example new file mode 100644 index 0000000..e915945 --- /dev/null +++ b/docker/cas-prod.properties.example @@ -0,0 +1,29 @@ +# CAS 自身配置(部署到 Nacos:namespace=cas, group=CAS, dataId=cas-prod.properties) +# 说明: +# - JDBC 的连接信息(URL / 账号 / 密码)由公共 base-common 注入,这里【不要】写 url/user/password +# - 这里只写「库名」(database) 与查询/密码编码相关项 +# - 其余 CAS 专属配置(tgc / webflow / server / custom.rsa 等)保持你现有的不变 + +cas.server.name: https://localhost:8443 +cas.server.prefix: https://localhost:8443/cas + +cas.serviceRegistry.initFromJson: true +cas.logout.followServiceRedirects: true + +# ===== JDBC 认证(库名写这里,连接来自 base-common)===== +cas.authn.jdbc.query[0].database=bsch_userbase +cas.authn.jdbc.query[0].sql=select * from sys_user where username=? +cas.authn.jdbc.query[0].fieldPassword=password +cas.authn.jdbc.query[0].fieldDisabled=disabled +cas.authn.jdbc.query[0].fieldExpired=expired +cas.authn.jdbc.query[0].passwordEncoder.type=BCRYPT +cas.authn.jdbc.query[0].passwordEncoder.strength=10 + +# 自定义 /api/login 登录成功后的默认跳转地址(URL 不带 service 参数时生效)。 +# - 支持绝对地址,如 http://sso.yunhaixingtong.com(绝对地址会直接作为跳转目标,不再拼 CAS 自身 host) +# - 也支持相对路径,如 / 或 /cas/login +# - 不配此键时,代码内置默认值 = http://sso.yunhaixingtong.com +# 带 service 参数时,仍按原逻辑跳转该 service(控制器返回带 ticket 的 service 地址)。 +custom.login.default-redirect-url=http://sso.yunhaixingtong.com + +# ... 其余 CAS 配置(tgc / webflow / custom.rsa / custom.login 等)同你现有内容 ... diff --git a/docker/cas.properties b/docker/cas.properties new file mode 100644 index 0000000..505c502 --- /dev/null +++ b/docker/cas.properties @@ -0,0 +1,28 @@ +# CAS 烤进镜像的兜底配置(仅当 Nacos 不可用时由 run-cas.sh 回退使用) +# 正常情况下以 Nacos 的 cas-prod.properties(连接信息来自 base-common)为准 +cas.server.name: https://localhost:8443 +cas.server.prefix: https://localhost:8443/cas + +cas.adminPagesSecurity.ip=127\.0\.0\.1 + +logging.config: file:/etc/cas/config/log4j2.xml + +server.port: 8443 +server.ssl.enabled: true +server.ssl.key-store: /etc/cas/cas.keystore +server.ssl.key-store-password: changeit +server.ssl.key-password: changeit +server.ssl.key-alias: cas + +# ===== 数据库认证(兜底:连公共 mysql8 上的 bsch_userbase)===== +# 注意:以下 user/password 为占位符;Nacos 不可用时需改为公共 mysql8 真实账号 +cas.authn.jdbc.query[0].sql: select * from sys_user where username=? +cas.authn.jdbc.query[0].url: jdbc:mysql://mysql8:3306/bsch_userbase?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true +cas.authn.jdbc.query[0].user: __SHARED_MYSQL_USER__ +cas.authn.jdbc.query[0].password: __SHARED_MYSQL_PASSWORD__ +cas.authn.jdbc.query[0].driverClass: com.mysql.jdbc.Driver +cas.authn.jdbc.query[0].fieldPassword: password +cas.authn.jdbc.query[0].fieldDisabled: disabled +cas.authn.jdbc.query[0].fieldExpired: expired +cas.authn.jdbc.query[0].passwordEncoder.type: BCRYPT +cas.authn.jdbc.query[0].passwordEncoder.strength: 10 diff --git a/docker/cas.war b/docker/cas.war new file mode 100644 index 0000000..cafceb5 --- /dev/null +++ b/docker/cas.war @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d777752b9fa45e17dd5e96aafef6ca9ae16f5fb30a9d042424c2a33b739747b6 +size 173749577 diff --git a/docker/deploy.sh b/docker/deploy.sh new file mode 100644 index 0000000..2c29d14 --- /dev/null +++ b/docker/deploy.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# 一键部署:上传编排文件 -> SSH 到 ECS 拉取镜像并启动(CAS,依赖公共 mysql8/redis/rabbitmq) +# 用法: ./deploy.sh +# 前置环境变量: +# DEPLOY_HOST (必填) ECS 公网/私网 IP 或域名(SSH 目标,不是 ACR 仓库地址!) +# SSHPASS (必填) ECS 上 $DEPLOY_USER 的 SSH 密码 +# ACR_NAMESPACE (必填) 阿里云镜像命名空间,如 damaomi_docker +# ACR_PASSWORD (可选) 若提供则在 ECS 上自动 docker login,避免 ECS 未登录导致 pull 失败 +# ACR_USERNAME (可选) 默认 15121037482 +# DEPLOY_USER / DEPLOY_PORT / REMOTE_DIR 均有默认值 +set -euo pipefail +VERSION="${1:-latest}" +HOST="${DEPLOY_HOST:?请设置 DEPLOY_HOST(ECS 的 IP/域名,不是 ACR 仓库地址)}" +PORT="${DEPLOY_PORT:-22}" +USER="${DEPLOY_USER:-yunhai}" +REMOTE_DIR="${REMOTE_DIR:-/opt/docker/apps/cas-sso}" +ACR_NAMESPACE="${ACR_NAMESPACE:?请设置 ACR_NAMESPACE(如 damaomi_docker)}" +ACR_USERNAME="${ACR_USERNAME:-15121037482}" +ACR_PASSWORD="${ACR_PASSWORD:-}" +# 镜像走公网端点(内网 registry-vpc 在不在 VPC 的 ECS 上连不通) +ACR_REGISTRY="${ACR_REGISTRY:-registry.cn-hangzhou.aliyuncs.com}" +cd "$(dirname "$0")" + +echo "== 1/2 上传 compose + run-cas.sh + init.sql + cas.war 到 $HOST:$REMOTE_DIR ==" +python3 upload.py "$HOST" "$PORT" "$USER" \ + docker-compose.cas.yml "$REMOTE_DIR/docker-compose.cas.yml" \ + run-cas.sh "$REMOTE_DIR/run-cas.sh" \ + init.sql "$REMOTE_DIR/init.sql" + +# 若提供了 ACR 密码,生成自动登录命令(避免 ECS 未登录导致 pull 失败) +LOGIN_CMD="" +if [ -n "$ACR_PASSWORD" ]; then + LOGIN_CMD="docker login $ACR_REGISTRY -u $ACR_USERNAME -p $ACR_PASSWORD" +fi + +echo "== 2/2 远端拉取镜像并启动 ==" +python3 remote_run.py "$HOST" "$PORT" "$USER" < .env <&1 | grep -n -A 25 '建立 SSO 会话失败\\|createTicketGrantingTicket\\|AbstractTicketException\\|AuthenticationException\\|Cannot\\|Exception' | tail -60") +print(out if out.strip() else "(no exception traces found)") +# also check redis ticket registry config +print("\n=== redis ticket registry config? ===") +print(run("docker exec cas-sso sh -c 'grep -ri ticket.registry /etc/cas/config/cas.properties 2>/dev/null; grep -ri ticket.registry /app/*.properties 2>/dev/null' || echo none")) +ssh.close() diff --git a/docker/find_redirect_src.py b/docker/find_redirect_src.py new file mode 100644 index 0000000..2fadc66 --- /dev/null +++ b/docker/find_redirect_src.py @@ -0,0 +1,15 @@ +import paramiko +ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20) +def run(c): + i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "") + +print("=== 1) env vars containing REDIRECT / CUSTOM / CAS_LOGIN ===") +print(run("docker exec cas-sso env 2>/dev/null | grep -iE 'REDIRECT|CUSTOM|LOGIN|CAS_SERVER' ")) +print("\n=== 2) full grep default-redirect-url in container FS ===") +print(run("docker exec cas-sso grep -rni 'default-redirect-url' /etc/cas /app 2>/dev/null | head")) +print("\n=== 3) run-cas.sh: how is cas.properties built? any hardcode of redirect/cas/login ===") +print(run("docker exec cas-sso grep -niE 'redirect|default-redirect|cas/login|cas.properties|NACOS|dataId|cat >' /app/run-cas.sh 2>/dev/null | head -40")) +print("\n=== 4) the effective cas.properties custom.* block ===") +print(run("docker exec cas-sso sh -c 'grep -niE \"^custom|^cas\" /etc/cas/config/cas.properties 2>/dev/null | head -40'")) +ssh.close() diff --git a/docker/init.sql b/docker/init.sql new file mode 100644 index 0000000..ff620d1 --- /dev/null +++ b/docker/init.sql @@ -0,0 +1,19 @@ +-- CAS 专用 MySQL 初始化(仅首次启动执行一次) +-- 库/用户由容器环境变量 MYSQL_DATABASE / MYSQL_USER / MYSQL_PASSWORD 创建,这里只建表与种子数据 + +CREATE DATABASE IF NOT EXISTS cas CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE cas; + +CREATE TABLE IF NOT EXISTS sys_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password VARCHAR(100) NOT NULL, + disabled TINYINT(1) DEFAULT 0, + expired TINYINT(1) DEFAULT 0, + UNIQUE KEY uk_username (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- 默认管理员:admin / admin123 (BCrypt,前缀 $2a$ 与 Spring Security 兼容) +INSERT INTO sys_user(username, password, disabled, expired) VALUES +('admin', '$2a$10$kRc0qMMC9NGbE.46fEWZvOlpcy.pjKef2ZA9yoFdKFfBw.nEJEUZO', 0, 0) +ON DUPLICATE KEY UPDATE password = VALUES(password); diff --git a/docker/inject2.py b/docker/inject2.py new file mode 100644 index 0000000..1136969 --- /dev/null +++ b/docker/inject2.py @@ -0,0 +1,38 @@ +import zipfile, os, shutil + +PROJ = "D:/SoftProject/yunhai/CasSso/cas-overlay-template-5.3" +WAR = os.path.join(PROJ, "docker/cas.war") + +# (entry_name_in_war, local_source_file) +REPL = [ + ("WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class", + os.path.join(PROJ, "target/cas/WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class")), + ("WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController$LocalAuthenticationResult.class", + os.path.join(PROJ, "target/cas/WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController$LocalAuthenticationResult.class")), + ("front/index.html", + os.path.join(PROJ, "src/main/webapp/front/index.html")), +] + +repl_map = {name: open(src, "rb").read() for name, src in REPL} + +tmp = WAR + ".tmp" +zin = zipfile.ZipFile(WAR) +zout = zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) +for item in zin.infolist(): + if item.filename in repl_map: + zout.writestr(item, repl_map[item.filename]) + else: + zout.writestr(item, zin.read(item.filename)) +zin.close() +zout.close() +shutil.move(tmp, WAR) + +z = zipfile.ZipFile(WAR) +print("testzip:", z.testzip()) +html = z.read("front/index.html").decode("utf-8", "replace") +print("has pwdMgrForm:", "pwdMgrForm" in html) +print("has name=\"username\":", 'name="username"' in html) +print("has name=\"password\":", 'name="password"' in html) +cls = z.read("WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class") +print("default url in class:", b"front/index.html" in cls) +print("entries replaced OK") diff --git a/docker/inject3.py b/docker/inject3.py new file mode 100644 index 0000000..0502b8c --- /dev/null +++ b/docker/inject3.py @@ -0,0 +1,47 @@ +import zipfile, os, shutil + +WAR = "docker/cas.war" +CLS_DIR = "target/cas/WEB-INF/classes/org/apereo/cas/web/custom/controller" +SRC_INDEX = "src/main/webapp/front/index.html" +IDX_ENTRY = "front/index.html" + +files = { + os.path.join(CLS_DIR, "LoginController.class"): "WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class", + os.path.join(CLS_DIR, "LoginController$LocalAuthenticationResult.class"): "WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController$LocalAuthenticationResult.class", + SRC_INDEX: IDX_ENTRY, + "src/main/resources/application.yml": "WEB-INF/classes/application.yml", +} + +tmp = WAR + ".tmp" +zin = zipfile.ZipFile(WAR) +zout = zipfile.ZipFile(tmp, "w", zipfile.ZIP_DEFLATED) +for item in zin.infolist(): + if item.filename in files.values(): + continue + zout.writestr(item, zin.read(item.filename)) +zin.close() +for src, entry in files.items(): + data = open(src, "rb").read() + # 用原条目元数据保持权限/时间戳 + zi = zipfile.ZipInfo(entry) + zi.date_time = (2026, 8, 3, 18, 10, 0) + zi.compress_type = zipfile.ZIP_DEFLATED + zi.external_attr = 0o644 << 16 + zout.writestr(zi, data) +zout.close() +os.replace(tmp, WAR) + +# 校验 +z = zipfile.ZipFile(WAR) +print("testzip:", z.testzip()) +idx = z.read(IDX_ENTRY).decode("utf-8", "replace") +cls = z.read("WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class").decode("latin1") +yml = z.read("WEB-INF/classes/application.yml").decode("utf-8", "replace") +print("index.html has ?v=1.8:", "?v=1.8" in idx) +print("index.html has real form action /api/login:", 'action="/api/login"' in idx) +print("index.html has loginname hidden:", 'name="loginname"' in idx) +print("class has window.location.href (v1.8 success):", "window.location.href" in cls) +print("class has guard /cas/login fallback:", 'contains("/cas/login")' in cls) +yml_ok = "default-redirect-url: /front/index.html" in yml and "default-redirect-url: /cas/login" not in yml +print("application.yml default-redirect-url fixed (/front/index.html):", yml_ok) +print("WAR size:", os.path.getsize(WAR)) diff --git a/docker/live_login_ok.py b/docker/live_login_ok.py new file mode 100644 index 0000000..5408cc7 --- /dev/null +++ b/docker/live_login_ok.py @@ -0,0 +1,51 @@ +import urllib.request, ssl, paramiko, json, base64, bcrypt, socket +from cryptography.hazmat.primitives.serialization import load_der_public_key +from cryptography.hazmat.primitives.asymmetric import padding + +BASE="http://sso.yunhaixingtong.com"; HOST="sso.yunhaixingtong.com"; PORT=80 +ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE +PLAIN="SsoTest@2026" +h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode() +if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1) +USER="ssotest_tmp" + +ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20) +def run(c): + i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "") +PWD="CVf^x$sW*b6CGHxh" + +# write SQL via sftp (avoid shell $ expansion of the BCrypt hash) +seed=f"DELETE FROM sys_user WHERE username='{USER}';\nINSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);\n" +sftp=ssh.open_sftp(); sftp.open('/tmp/seed.sql','w').write(seed); sftp.close() +print("seed.sql:\n", seed) +print("import result:", run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/seed.sql 2>&1").strip()) + +pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"] +rsa_pub=load_der_public_key(base64.b64decode(pub)) +def rsa_enc(s): return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode() + +def raw_post(extra=None): + body=f"loginname={rsa_enc(USER)}&pwd={rsa_enc(PLAIN)}" + hh={"Host":HOST,"Content-Type":"application/x-www-form-urlencoded","Content-Length":str(len(body))} + if extra: hh.update(extra) + req="POST /api/login HTTP/1.1\r\n"+"\r\n".join(f"{k}: {v}" for k,v in hh.items())+"\r\nConnection: close\r\n\r\n"+body + s=socket.create_connection((HOST,PORT),timeout=20); s.sendall(req.encode()); data=b"" + while True: + c=s.recv(4096) + if not c: break + data+=c + s.close(); return data.decode('utf-8','ignore') + +print("\n=== SUCCESS login, NATIVE FORM (no XHR) ===") +r=raw_post(); print(r.split("\r\n\r\n",1)[0][:1000]); print("BODY:", r.split("\r\n\r\n",1)[1][:300] if "\r\n\r\n" in r else "(no body)") + +print("\n=== SUCCESS login, AJAX (X-Requested-With) ===") +r2=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"}) +print(r2.split("\r\n\r\n",1)[0][:400]); print("BODY:", r2.split("\r\n\r\n",1)[1][:200] if "\r\n\r\n" in r2 else "") + +# cleanup +sftp=ssh.open_sftp(); sftp.open('/tmp/del.sql','w').write(f"DELETE FROM sys_user WHERE username='{USER}';\n"); sftp.close() +run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/del.sql 2>&1") +print("\ncleanup done") +ssh.close() diff --git a/docker/live_login_real.py b/docker/live_login_real.py new file mode 100644 index 0000000..f9053a7 --- /dev/null +++ b/docker/live_login_real.py @@ -0,0 +1,65 @@ +import urllib.request, ssl, paramiko, json, base64, bcrypt +from cryptography.hazmat.primitives.serialization import load_der_public_key +from cryptography.hazmat.primitives.asymmetric import padding + +BASE="http://sso.yunhaixingtong.com" +HOST="sso.yunhaixingtong.com"; PORT=80 +ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE + +# ---- 1) known password + BCrypt hash (strength 10, prefix $2a$ to match Spring) ---- +PLAIN="SsoTest@2026" +h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode() +if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1) +print("BCrypt hash:", h) + +# ---- 2) insert temp user into bsch_userbase ---- +ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20) +def run(c): + i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "") +PWD="CVf^x$sW*b6CGHxh" +USER="ssotest_tmp" +run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"DELETE FROM sys_user WHERE username='{USER}';\" 2>&1") +ins=run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"INSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);\" 2>&1") +print("INSERT result:", ins.strip()) + +# ---- 3) RSA public key ---- +pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"] +der=base64.b64decode(pub) +rsa_pub=load_der_public_key(der) +def rsa_enc(s): + return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode() + +# ---- 4) raw socket POST (native form, NO X-Requested-With) ---- +def raw_post(headers_extra=None): + body=f"loginname={rsa_enc(USER)}&pwd={rsa_enc(PLAIN)}" + h={"Host":HOST,"Content-Type":"application/x-www-form-urlencoded", + "Content-Length":str(len(body))} + if headers_extra: h.update(headers_extra) + req="POST /api/login HTTP/1.1\r\n"+"\r\n".join(f"{k}: {v}" for k,v in h.items())+"\r\nConnection: close\r\n\r\n"+body + s=socket_create(); s.sendall(req.encode()) + data=b"" + while True: + c=s.recv(4096) + if not c: break + data+=c + s.close() + return data.decode('utf-8','ignore') + +import socket +def socket_create(): return socket.create_connection((HOST,PORT),timeout=20) + +print("\n=== SUCCESS login, NATIVE FORM (no XHR) -> expect 200 HTML w/ window.location.href ===") +resp=raw_post() +head=resp.split("\r\n\r\n",1)[0] +print(head[:1200]) + +print("\n=== SUCCESS login, AJAX (X-Requested-With) -> expect JSON {code:0,data:{url}} ===") +resp2=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"}) +head2=resp2.split("\r\n\r\n",1)[0] +print(head2[:800]) + +# cleanup +run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"DELETE FROM sys_user WHERE username='{USER}';\" 2>&1") +print("\ncleanup done") +ssh.close() diff --git a/docker/live_login_test.py b/docker/live_login_test.py new file mode 100644 index 0000000..58fb8b1 --- /dev/null +++ b/docker/live_login_test.py @@ -0,0 +1,60 @@ +import urllib.request, json, base64, ssl +from cryptography.hazmat.primitives.asymmetric import padding as asym_padding +from cryptography.hazmat.primitives.serialization import load_pem_public_key + +HOST_HDR = "sso.yunhaixingtong.com" +BASE = "http://192.168.10.157:80" + +def req(method, path, data=None, headers=None): + h = {"Host": HOST_HDR} + if headers: h.update(headers) + if data is not None: + data = urllib.parse.urlencode(data).encode() + h["Content-Type"] = "application/x-www-form-urlencoded" + r = urllib.request.Request(BASE+path, data=data, headers=h, method=method) + try: + resp = urllib.request.urlopen(r, timeout=20) + body = resp.read().decode("utf-8","replace") + return resp.status, dict(resp.getheaders()), body + except urllib.error.HTTPError as e: + return e.code, dict(e.headers), e.read().decode("utf-8","replace") + +# 1) 取公钥 +st, hd, body = req("GET", "/api/public-key") +print("=== /api/public-key ===") +print("status", st) +print("body", body[:600]) +pk = json.loads(body)["data"]["publicKey"] +print("publicKey len", len(pk)) +print("publicKey head:", pk[:60]) + +# 重组为合法 PEM(端点把空白去掉了,补回换行) +b64 = pk.replace("-----BEGIN PUBLIC KEY-----","").replace("-----END PUBLIC KEY-----","").replace("-----BEGIN RSA PUBLIC KEY-----","").replace("-----END RSA PUBLIC KEY-----","").replace("\n","").replace(" ","") +pem = "-----BEGIN PUBLIC KEY-----\n" + "\n".join(b64[i:i+64] for i in range(0,len(b64),64)) + "\n-----END PUBLIC KEY-----" +pub = load_pem_public_key(pem.encode()) + +def enc(pt): + ct = pub.encrypt(pt.encode("utf-8"), asym_padding.PKCS1v15()) + return base64.b64encode(ct).decode() + +# 用户名/密码:用已知可登录的 yunhaisch(密码需正确才能成功;若不知道正确密码,可先看错误分支) +USERNAME = "yunhaisch" +PASSWORD = input("请输入 yunhaisch 的密码(仅本地测试用,不会外发): ").strip() + +euser = enc(USERNAME) +epwd = enc(PASSWORD) + +# 2) 登录(不带 service) +st, hd, body = req("POST", "/api/login", data={ + "loginname": euser, "pwd": epwd +}) +print("\n=== POST /api/login (无 service) ===") +print("status", st) +print("Set-Cookie:", hd.get("Set-Cookie")) +print("body", body[:800]) +try: + j = json.loads(body) + print(">>> code=", j.get("code"), "msg=", j.get("msg")) + print(">>> data.url=", (j.get("data") or {}).get("url")) +except Exception as e: + print("parse err", e) diff --git a/docker/live_login_ua.py b/docker/live_login_ua.py new file mode 100644 index 0000000..a912c5c --- /dev/null +++ b/docker/live_login_ua.py @@ -0,0 +1,50 @@ +import urllib.request, ssl, paramiko, json, base64, bcrypt, socket +from cryptography.hazmat.primitives.serialization import load_der_public_key +from cryptography.hazmat.primitives.asymmetric import padding + +BASE="http://sso.yunhaixingtong.com"; HOST="sso.yunhaixingtong.com"; PORT=80 +ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE +PLAIN="SsoTest@2026" +h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode() +if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1) +USER="ssotest_tmp" +UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" + +ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20) +def run(c): + i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "") +PWD="CVf^x$sW*b6CGHxh" +seed=f"DELETE FROM sys_user WHERE username='{USER}';\nINSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);\n" +sftp=ssh.open_sftp(); sftp.open('/tmp/seed.sql','w').write(seed); sftp.close() +run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/seed.sql 2>&1") + +pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"] +rsa_pub=load_der_public_key(base64.b64decode(pub)) +def rsa_enc(s): return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode() + +def raw_post(extra=None): + body=f"loginname={rsa_enc(USER)}&pwd={rsa_enc(PLAIN)}" + hh={"Host":HOST,"User-Agent":UA,"Content-Type":"application/x-www-form-urlencoded","Content-Length":str(len(body))} + if extra: hh.update(extra) + req="POST /api/login HTTP/1.1\r\n"+"\r\n".join(f"{k}: {v}" for k,v in hh.items())+"\r\nConnection: close\r\n\r\n"+body + s=socket.create_connection((HOST,PORT),timeout=20); s.sendall(req.encode()); data=b"" + while True: + c=s.recv(4096) + if not c: break + data+=c + s.close(); return data.decode('utf-8','ignore') + +print("=== SUCCESS login w/ User-Agent, NATIVE FORM ===") +r=raw_post() +print("HEAD:\n", r.split("\r\n\r\n",1)[0][:700]) +print("BODY:\n", r.split("\r\n\r\n",1)[1][:400] if "\r\n\r\n" in r else "(no body)") + +print("\n=== SUCCESS login w/ User-Agent, AJAX ===") +r2=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"}) +print("HEAD:\n", r2.split("\r\n\r\n",1)[0][:400]) +print("BODY:\n", r2.split("\r\n\r\n",1)[1][:300] if "\r\n\r\n" in r2 else "") + +sftp=ssh.open_sftp(); sftp.open('/tmp/del.sql','w').write(f"DELETE FROM sys_user WHERE username='{USER}';\n"); sftp.close() +run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/del.sql 2>&1") +print("\ncleanup done"); ssh.close() diff --git a/docker/live_verify.py b/docker/live_verify.py new file mode 100644 index 0000000..331c7bb --- /dev/null +++ b/docker/live_verify.py @@ -0,0 +1,37 @@ +import urllib.request, urllib.error, ssl + +BASE = "http://sso.yunhaixingtong.com" +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +def req(method, path, data=None, headers=None): + url = BASE + path + h = headers or {} + r = urllib.request.Request(url, data=data, method=method, headers=h) + try: + resp = urllib.request.urlopen(r, timeout=20, context=ctx) + body = resp.read().decode('utf-8', 'ignore') + return resp.status, dict(resp.getheaders()), body + except urllib.error.HTTPError as e: + body = e.read().decode('utf-8', 'ignore') + return e.code, dict(e.headers), body + +print("=== 1) /front/index.html WITHOUT auth (is it behind CAS?) ===") +st, hd, body = req("GET", "/front/index.html") +print("status:", st, "| Location:", hd.get("Location")) +print("body has /cas/login:", "/cas/login" in body, "| ?v=1.7:", "v=1.7" in body, + "| action=/api/login:", 'action="/api/login"' in body, "| name=username:", 'name="username"' in body) + +print("\n=== 2) POST /api/login BOGUS (native form, no XHR header) ===") +form = "loginname=AAA&pwd=BBB&captcha=".encode() +st, hd, body = req("POST", "/api/login", data=form, headers={"Content-Type":"application/x-www-form-urlencoded"}) +print("status:", st, "| Location:", hd.get("Location")) + +print("\n=== 3) POST /api/login BOGUS (AJAX X-Requested-With) ===") +st, hd, body = req("POST", "/api/login", data=form, headers={"Content-Type":"application/x-www-form-urlencoded","X-Requested-With":"XMLHttpRequest"}) +print("status:", st, "| body[:180]:", body[:180]) + +print("\n=== 4) /api/public-key ===") +st, hd, body = req("GET", "/api/public-key") +print("status:", st, "| body[:160]:", body[:160]) diff --git a/docker/login_debug.py b/docker/login_debug.py new file mode 100644 index 0000000..b56c28e --- /dev/null +++ b/docker/login_debug.py @@ -0,0 +1,39 @@ +import urllib.request, ssl, paramiko, json, base64, bcrypt, socket +from cryptography.hazmat.primitives.serialization import load_der_public_key +from cryptography.hazmat.primitives.asymmetric import padding + +BASE="http://sso.yunhaixingtong.com"; HOST="sso.yunhaixingtong.com"; PORT=80 +ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE +PLAIN="SsoTest@2026" +h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode() +if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1) +print("hash self-check (python):", bcrypt.checkpw(PLAIN.encode(), h.encode())) + +ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20) +def run(c): + i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "") +PWD="CVf^x$sW*b6CGHxh"; USER="ssotest_tmp" +run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"DELETE FROM sys_user WHERE username='{USER}'; INSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);\" 2>&1") + +pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"] +rsa_pub=load_der_public_key(base64.b64decode(pub)) +def rsa_enc(s): return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode() + +body=f"loginname={rsa_enc(USER)}&pwd={rsa_enc(PLAIN)}" +req="POST /api/login HTTP/1.1\r\nHost: %s\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s"%(HOST,len(body),body) +s=socket.create_connection((HOST,PORT),timeout=20); s.sendall(req.encode()) +data=b"" +while True: + c=s.recv(4096) + if not c: break + data+=c +s.close() +print("\nRESPONSE HEAD:\n", data.decode('utf-8','ignore').split("\r\n\r\n",1)[0][:600]) + +# fetch logs mentioning ssotest_tmp +print("\n=== CAS logs (ssotest_tmp) ===") +logs=run("docker logs --tail 60 cas-sso 2>&1 | grep -i 'ssotest_tmp\\|尝试登录\\|密码错误\\|Padding\\|Decryption\\|Illegal' | tail -20") +print(logs if logs.strip() else "(no matching lines)") +run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e \"DELETE FROM sys_user WHERE username='{USER}';\" 2>&1") +ssh.close() diff --git a/docker/probe.py b/docker/probe.py new file mode 100644 index 0000000..cd2b09f --- /dev/null +++ b/docker/probe.py @@ -0,0 +1,22 @@ +import urllib.request, ssl, paramiko, json + +BASE="http://sso.yunhaixingtong.com" +ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE + +# 1) live RSA public key +r=urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"), timeout=20, context=ctx) +pub=json.loads(r.read().decode())["data"]["publicKey"] +print("PUBLIC_KEY_LEN:", len(pub)) +print("PUBLIC_KEY_HEAD:", pub[:40], "...") + +# 2) sys_user schema + sample usernames (no passwords) +ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20) +def run(c): + i,o,e=ssh.exec_command(c); return o.read().decode('utf-8','ignore')+("ERR:"+e.read().decode('utf-8','ignore') if e.read() else "") +PWD="CVf^x$sW*b6CGHxh" +print("\n=== DESCRIBE sys_user ===") +print(run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e 'DESCRIBE sys_user;' 2>&1 | head -40")) +print("\n=== sample usernames (limit 10) ===") +print(run(f"docker exec mysql8 mysql -uroot -p'{PWD}' bsch_userbase -e 'SELECT username, disabled, expired FROM sys_user LIMIT 10;' 2>&1 | head -20")) +ssh.close() diff --git a/docker/probe_now.py b/docker/probe_now.py new file mode 100644 index 0000000..6c4bbf2 --- /dev/null +++ b/docker/probe_now.py @@ -0,0 +1,58 @@ +import urllib.request, ssl, paramiko, json, base64, bcrypt, socket, time, re +from cryptography.hazmat.primitives.serialization import load_der_public_key +from cryptography.hazmat.primitives.asymmetric import padding + +BASE="http://sso.yunhaixingtong.com"; HOST="sso.yunhaixingtong.com"; PORT=80 +ctx=ssl.create_default_context(); ctx.check_hostname=False; ctx.verify_mode=ssl.CERT_NONE +PLAIN="SsoTest@2026" +h=bcrypt.hashpw(PLAIN.encode(), bcrypt.gensalt(rounds=10)).decode() +if h.startswith("$2b$"): h=h.replace("$2b$","$2a$",1) +USER="ssotest_tmp"; UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36" + +ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect("192.168.10.157", port=22, username="yunhai", password="123456", timeout=20) +def run(c): + i,o,e=ssh.exec_command(c); out=o.read().decode('utf-8','ignore'); err=e.read().decode('utf-8','ignore'); return out+("ERR:"+err if err else "") +PWD="CVf^x$sW*b6CGHxh" +seed=f"DELETE FROM sys_user WHERE username='{USER}'; INSERT INTO sys_user(username,create_time,create_user,password,disabled,expired) VALUES('{USER}',NOW(),0,'{h}',0,0);" +sftp=ssh.open_sftp(); sftp.open('/tmp/seed.sql','w').write(seed); sftp.close() +print("seed:", run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/seed.sql 2>&1").strip()) + +def raw_post(extra=None): + pub=json.loads(urllib.request.urlopen(urllib.request.Request(BASE+"/api/public-key"),timeout=20,context=ctx).read().decode())["data"]["publicKey"] + rsa_pub=load_der_public_key(base64.b64decode(pub)) + def enc(s): return base64.b64encode(rsa_pub.encrypt(s.encode(), padding.PKCS1v15())).decode() + body=f"loginname={enc(USER)}&pwd={enc(PLAIN)}" + hh={"Host":HOST,"User-Agent":UA,"Content-Type":"application/x-www-form-urlencoded","Content-Length":str(len(body))} + if extra: hh.update(extra) + req="POST /api/login HTTP/1.1\r\n"+"\r\n".join(f"{k}: {v}" for k,v in hh.items())+"\r\nConnection: close\r\n\r\n"+body + s=socket.create_connection((HOST,PORT),timeout=20); s.sendall(req.encode()); data=b"" + while True: + c=s.recv(4096) + if not c: break + data+=c + s.close(); return data.decode('utf-8','ignore') + +for _ in range(20): + try: + r=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"}) + if "200 OK" in r.split("\r\n\r\n",1)[0]: break + except Exception: pass + time.sleep(6) + +print("\n=== AJAX (what the page SHOULD use) ===") +r=raw_post({"X-Requested-With":"XMLHttpRequest","Accept":"application/json"}) +body=r.split("\r\n\r\n",1)[1] if "\r\n\r\n" in r else "" +print("RAW BODY:", body[:300]) +m=re.search(r'"url"\s*:\s*"([^"]*)"', body) +print("-> data.url:", m.group(1) if m else "NONE / NOT PRESENT") + +print("\n=== NATIVE FORM (what index.html currently does) ===") +r2=raw_post() +head=r2.split("\r\n\r\n",1)[0]; body2=r2.split("\r\n\r\n",1)[1] if "\r\n\r\n" in r2 else "" +print("STATUS LINE:", head.split("\r\n",1)[0]) +print("BODY[:400]:", body2[:400]) + +sftp=ssh.open_sftp(); sftp.open('/tmp/del.sql','w').write(f"DELETE FROM sys_user WHERE username='{USER}';\n"); sftp.close() +print("cleanup:", run(f"docker exec -i mysql8 mysql -uroot -p'{PWD}' bsch_userbase < /tmp/del.sql 2>&1").strip()) +ssh.close() diff --git a/docker/raw_post.py b/docker/raw_post.py new file mode 100644 index 0000000..84ba6ea --- /dev/null +++ b/docker/raw_post.py @@ -0,0 +1,25 @@ +import socket +HOST="sso.yunhaixingtong.com"; PORT=80 +body="loginname=AAA&pwd=BBB&captcha=" +req=( + "POST /api/login HTTP/1.1\r\n" + f"Host: {HOST}\r\n" + "Content-Type: application/x-www-form-urlencoded\r\n" + f"Content-Length: {len(body)}\r\n" + "Connection: close\r\n" + "\r\n" + + body +) +s=socket.create_connection((HOST,PORT),timeout=20) +s.sendall(req.encode()) +data=b"" +while True: + chunk=s.recv(4096) + if not chunk: break + data+=chunk +s.close() +text=data.decode('utf-8','ignore') +# print status line + headers (up to first blank line) +head=text.split("\r\n\r\n",1)[0] +print("=== RAW RESPONSE HEAD (native form POST) ===") +print(head[:1500]) diff --git a/docker/remote_run.py b/docker/remote_run.py new file mode 100644 index 0000000..e30aa5a --- /dev/null +++ b/docker/remote_run.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# 通过 SSH 在远端以 sudo 执行脚本(从 stdin 读取) +# 用法: python3 remote_run.py < script.sh +# 依赖环境变量 SSHPASS +import os, sys, paramiko + +def main(): + host = sys.argv[1] + port = int(sys.argv[2]) if len(sys.argv) > 2 else 22 + user = sys.argv[3] if len(sys.argv) > 3 else "root" + pw = os.environ.get("SSHPASS") + if not pw: + sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n") + sys.exit(1) + script = sys.stdin.read() + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(host, port, user, password=pw, timeout=30) + stdin, stdout, stderr = c.exec_command("sudo -S bash -s", timeout=1800) + stdin.write(pw + "\n") + stdin.write(script) + stdin.flush() + stdin.channel.shutdown_write() + sys.stdout.write(stdout.read().decode(errors="replace")) + err = stderr.read().decode(errors="replace") + if err.strip(): + sys.stderr.write("STDERR:\n" + err) + c.close() + +if __name__ == "__main__": + main() diff --git a/docker/repack.py b/docker/repack.py new file mode 100644 index 0000000..cec3841 --- /dev/null +++ b/docker/repack.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# 将重编译后的 LoginController.class + 修复后的前端文件注入好包 target/cas.war +# 其余条目原样保留,确保 WAR 结构/压缩方式不变。 +import os, zipfile, shutil + +ROOT = r"D:\SoftProject\yunhai\CasSso\cas-overlay-template-5.3" +BASE = os.path.join(ROOT, "target", "cas.war") +NEW = os.path.join(ROOT, "target", "cas.war.new") + +CLASSES_DIR = os.path.join(ROOT, "target", "cas", "WEB-INF", "classes", + "org", "apereo", "cas", "web", "custom", "controller") +FRONT_JS = os.path.join(ROOT, "src", "main", "webapp", "front", "js", "conf.js") +FRONT_IDX = os.path.join(ROOT, "src", "main", "webapp", "front", "index.html") + +replace = { + "WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class": + os.path.join(CLASSES_DIR, "LoginController.class"), + "WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController$LocalAuthenticationResult.class": + os.path.join(CLASSES_DIR, "LoginController$LocalAuthenticationResult.class"), + "front/js/conf.js": FRONT_JS, + "front/index.html": FRONT_IDX, +} + +# 校验待替换源文件存在 +for k, v in replace.items(): + if not os.path.isfile(v): + raise SystemExit("缺少源文件: %s -> %s" % (k, v)) + +with zipfile.ZipFile(BASE, "r") as z: + names = set(z.namelist()) + # 确认条目存在(打印实际命中) + for k in replace: + print("命中" if k in names else "缺失(!)", k) + for k in list(replace): + if k not in names: + raise SystemExit("基包中找不到条目: " + k) + with zipfile.ZipFile(NEW, "w", zipfile.ZIP_DEFLATED) as out: + for info in z.infolist(): + if info.filename in replace: + data = open(replace[info.filename], "rb").read() + ni = zipfile.ZipInfo(info.filename) + ni.date_time = info.date_time + ni.external_attr = info.external_attr + ni.compress_type = zipfile.ZIP_DEFLATED + out.writestr(ni, data) + print("替换:", info.filename, "->", len(data), "bytes") + else: + out.writestr(info, z.read(info.filename)) +print("写盘:", NEW) + +# 校验新包 +with zipfile.ZipFile(NEW, "r") as z: + bad = z.testzip() + if bad: + raise SystemExit("WAR 损坏条目: " + bad) + nl = z.namelist() + assert "WEB-INF/lib/" in nl or any(n.startswith("WEB-INF/lib/") for n in nl), "缺少 WEB-INF/lib" + mf = z.read("META-INF/MANIFEST.MF").decode("utf-8", "replace") + print("MANIFEST Main-Class:", [l for l in mf.splitlines() if l.startswith("Main-Class")]) + for k in replace: + print("存在" if k in nl else "丢失(!)", k) + +# 原子替换 +bak = BASE + ".bak7_31" +if not os.path.exists(bak): + shutil.move(BASE, bak) +shutil.move(NEW, BASE) +print("已生成新 target/cas.war, 旧包备份为 cas.war.bak7_31") diff --git a/docker/run-cas.sh b/docker/run-cas.sh new file mode 100644 index 0000000..08caa1a --- /dev/null +++ b/docker/run-cas.sh @@ -0,0 +1,130 @@ +#!/bin/sh +# CAS 启动入口: +# 1) 从 Nacos 拉取「公共基础配置」(common.properties) 与「CAS 自身配置」(cas-prod.properties) +# 2) 用 common.properties 的 MySQL 连接 + cas-prod.properties 的 database 拼出完整 JDBC URL +# 3) 写出 /etc/cas/config/cas.properties 后启动 CAS +# 说明:CAS 不自建 MySQL,统一读 Nacos 公共配置;库名写在 CAS 自己的配置里。 +set -e + +CONFIG_PATH=/etc/cas/config/cas.properties +# 备份镜像内烤好的兜底配置,供 Nacos 不可用时回退 +cp -f "$CONFIG_PATH" "${CONFIG_PATH}.baked" 2>/dev/null || true + +NACOS_ADDR="${NACOS_ADDR:-nacos:8848}" +NACOS_USER="${NACOS_USER:-nacos}" +NACOS_PASS="${NACOS_PASS:-nacos}" + +# 公共基础配置(含 mysql / redis / rabbitmq 连接信息) +BASE_DATA_ID="${CAS_BASE_CONFIG_DATA_ID:-common.properties}" +BASE_GROUP="${CAS_BASE_CONFIG_GROUP:-DEFAULT_GROUP}" +BASE_NS="${CAS_BASE_CONFIG_NAMESPACE:-public}" +# CAS 自身配置 +CAS_DATA_ID="${CAS_CONFIG_DATA_ID:-cas-prod.properties}" +CAS_GROUP="${CAS_CONFIG_GROUP:-CAS}" +CAS_NS="${CAS_CONFIG_NAMESPACE:-cas}" + +TOKEN="" + +# 取 Nacos 登录 token(鉴权关闭时返回空,fetch 会自动降级) +get_token() { + TOKEN=$(curl -s -m 10 -X POST "http://${NACOS_ADDR}/nacos/v1/auth/login?username=${NACOS_USER}&password=${NACOS_PASS}" \ + | sed -n 's/.*"accessToken":"\([^"]*\)".*/\1/p') +} + +# 拉取单个配置到 $4;带 token 失败则自动不带 token 重试 +fetch() { + local code + if [ -n "$TOKEN" ]; then + code=$(curl -s -m 10 -o "$4" -w "%{http_code}" "http://${NACOS_ADDR}/nacos/v1/cs/configs?dataId=$1&group=$2&tenant=$3&accessToken=${TOKEN}") + fi + if [ -z "$TOKEN" ] || [ "$code" != "200" ] || [ ! -s "$4" ]; then + code=$(curl -s -m 10 -o "$4" -w "%{http_code}" "http://${NACOS_ADDR}/nacos/v1/cs/configs?dataId=$1&group=$2&tenant=$3") + fi + if [ "$code" = "200" ] && [ -s "$4" ]; then + echo "[run-cas] 已加载 $1 (group=$2, ns=$3)" + return 0 + fi + echo "[run-cas] 拉取 $1 失败 http=${code}" + return 1 +} + +# 精确读取 key=value 中的 value(兼容 value 内含 =) +val() { + awk -v k="$2" 'index($0,"=")>0 && substr($0,1,index($0,"=")-1)==k {print substr($0,index($0,"=")+1); exit}' "$1" +} + +# 读键:优先 spring.datasource.*,回退 mysql.* 风格(兼容两种命名习惯) +readkey() { + local f="$1" k="$2" v + v=$(val "$f" "$k"); [ -n "$v" ] && { echo "$v"; return; } + v=$(val "$f" "mysql.${k#spring.datasource.}"); [ -n "$v" ] && { echo "$v"; return; } + [ "$k" = "spring.datasource.username" ] && val "$f" "mysql.user" +} + +restore_baked() { + echo "[run-cas] 回退到镜像内烤好的默认配置" + cp -f "${CONFIG_PATH}.baked" "$CONFIG_PATH" + exec java -jar /app/cas.war +} + +get_token + +BASE_TMP="${CONFIG_PATH}.base" +CAS_TMP="${CONFIG_PATH}.cas" + +# 1) 公共基础配置(MySQL 连接必须在此) +if ! fetch "$BASE_DATA_ID" "$BASE_GROUP" "$BASE_NS" "$BASE_TMP"; then + echo "[run-cas] 公共配置缺失,无法拼 JDBC,回退兜底" + restore_baked +fi + +DS_HOST=$(readkey "$BASE_TMP" "spring.datasource.host") +DS_PORT=$(readkey "$BASE_TMP" "spring.datasource.port") +[ -z "$DS_PORT" ] && DS_PORT=3306 +DS_USER=$(readkey "$BASE_TMP" "spring.datasource.username") +DS_PASS=$(readkey "$BASE_TMP" "spring.datasource.password") +DS_PARAMS=$(readkey "$BASE_TMP" "spring.datasource.params") +[ -z "$DS_PARAMS" ] && DS_PARAMS="useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true" + +# 占位符保护:common.properties 未替换占位符时明确报错,避免静默用假凭据导致连不上 MySQL +case "${DS_USER}${DS_PASS}" in + *__SHARED_*) echo "[run-cas] 致命:common.properties 中 MySQL 用户名/密码仍为占位符(__SHARED_*),请在 Nacos 填入真实 mysql8 凭据后再启动"; exit 1 ;; +esac + +# 2) CAS 自身配置(库名 database 在此;缺省回退 bsch_userbase) +DB_NAME=bsch_userbase +if fetch "$CAS_DATA_ID" "$CAS_GROUP" "$CAS_NS" "$CAS_TMP"; then + _db=$(val "$CAS_TMP" "cas.authn.jdbc.query[0].database") + [ -n "$_db" ] && DB_NAME="$_db" +else + echo "[run-cas] CAS 自身配置缺失,database 用默认 bsch_userbase" +fi + +if [ -z "$DS_HOST" ]; then + echo "[run-cas] 公共配置缺少 MySQL host,无法拼 JDBC,回退兜底" + restore_baked +fi + +# 3) 拼完整 JDBC URL +JDBC_URL="jdbc:mysql://${DS_HOST}:${DS_PORT}/${DB_NAME}?${DS_PARAMS}" + +# 4) 合并:CAS 配置为主,过滤其中旧的 JDBC 连接项,注入公共连接 +{ + if [ -f "$CAS_TMP" ]; then + grep -vF 'cas.authn.jdbc.query[0].url=' "$CAS_TMP" \ + | grep -vF 'cas.authn.jdbc.query[0].user=' \ + | grep -vF 'cas.authn.jdbc.query[0].password=' \ + | grep -vF 'cas.authn.jdbc.query[0].driverClass=' + fi + echo "cas.authn.jdbc.query[0].url=${JDBC_URL}" + [ -n "$DS_USER" ] && echo "cas.authn.jdbc.query[0].user=${DS_USER}" + [ -n "$DS_PASS" ] && echo "cas.authn.jdbc.query[0].password=${DS_PASS}" + echo "cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver" + # 透传公共配置里的 redis / rabbitmq 连接(自定义 /api/login 的 loginService 依赖 Redis 存验证码/失败计数/账号锁定) + grep -E '^spring\.redis\.' "$BASE_TMP" 2>/dev/null || true + grep -E '^spring\.rabbitmq\.' "$BASE_TMP" 2>/dev/null || true +} > "$CONFIG_PATH" + +echo "[run-cas] 生效 JDBC URL: ${JDBC_URL}" +echo "[run-cas] 启动 CAS ..." +exec java -jar /app/cas.war diff --git a/docker/ssh_diag.py b/docker/ssh_diag.py new file mode 100644 index 0000000..f2567e0 --- /dev/null +++ b/docker/ssh_diag.py @@ -0,0 +1,28 @@ +import paramiko + +HOST = "192.168.10.157"; PORT = 22; USER = "yunhai"; PASS = "123456" +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=20) + +def run(cmd): + stdin, stdout, stderr = ssh.exec_command(cmd) + out = stdout.read().decode('utf-8', 'ignore') + err = stderr.read().decode('utf-8', 'ignore') + return out + ("ERR:" + err if err else "") + +print("=== 1) running container image & status ===") +print(run("docker ps --filter name=cas-sso --format '{{.Image}} | {{.Status}}'")) +print("inspect image id:") +print(run("docker inspect cas-sso --format '{{.Image}}' 2>/dev/null")) + +print("\n=== 2) effective cas.properties default-redirect-url (container) ===") +print(run("docker exec cas-sso grep -ri 'default-redirect-url' /etc/cas/config/ 2>/dev/null || echo 'NOT FOUND in /etc/cas/config'")) +print(run("docker exec cas-sso sh -c \"cat /etc/cas/config/cas.properties 2>/dev/null | grep -i redirect\" || echo none")) + +print("\n=== 3) controller class feature in RUNNING war (v1.8 has window.location.href) ===") +print(run("docker cp cas-sso:/app/cas.war /tmp/live_cas.war 2>&1 && echo COPIED")) +# analyze with python on the box? simpler: grep for the literal bytecode string in the war zip entry +print(run("docker exec cas-sso sh -c \"cd /tmp && python3 - <<'PY'\nimport zipfile\nz=zipfile.ZipFile('/tmp/live_cas.war')\nc=z.read('WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class').decode('latin1')\nprint('has window.location.href :', 'window.location.href' in c)\nprint('has sendRedirect :', 'sendRedirect' in c)\nprint('has text/html :', 'text/html' in c)\nPY\n\" 2>&1 || echo 'python3 not on box'")) + +ssh.close() diff --git a/docker/ssh_diag2.py b/docker/ssh_diag2.py new file mode 100644 index 0000000..5a604ce --- /dev/null +++ b/docker/ssh_diag2.py @@ -0,0 +1,36 @@ +import paramiko, zipfile + +HOST="192.168.10.157"; USER="yunhai"; PASS="123456" +LOCAL = "D:/SoftProject/yunhai/CasSso/cas-overlay-template-5.3/docker/live_cas.war" +ssh=paramiko.SSHClient(); ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, port=22, username=USER, password=PASS, timeout=20) + +def run(c): + i,o,e=ssh.exec_command(c) + return o.read().decode('utf-8','ignore') + (("ERR:"+e.read().decode('utf-8','ignore')) if e.read() else "") + +print(run("docker cp cas-sso:/app/cas.war /tmp/live_cas.war && echo COPIED_OK")) +sftp=ssh.open_sftp() +sftp.get('/tmp/live_cas.war', LOCAL) +sftp.close() +print("local war size:", __import__('os').path.getsize(LOCAL)) + +z=zipfile.ZipFile(LOCAL) +c=z.read('WEB-INF/classes/org/apereo/cas/web/custom/controller/LoginController.class').decode('latin1') +print("\n=== RUNNING WAR LoginController features ===") +print("has window.location.href (v1.8 success):", 'window.location.href' in c) +print("has sendRedirect (error path):", 'sendRedirect' in c) +print("has text/html (v1.8 success):", 'text/html' in c) +idx=z.read('front/index.html').decode('utf-8','replace') +print("\n=== RUNNING WAR index.html ===") +print("has action=/api/login:", 'action="/api/login"' in idx) +print("has ?v=1.8:", '?v=1.8' in idx) +print("has name=username:", 'name="username"' in idx) + +print("\n=== container filesystem: any default-redirect-url? ===") +print(run("grep -rni 'default-redirect-url' /etc/cas 2>/dev/null; grep -rni 'default-redirect-url' /app/*.properties 2>/dev/null; echo '---env---'; docker exec cas-sso env 2>/dev/null | grep -i 'REDIRECT\\|TGC\\|CAS_CUSTOM' ")) +print("\n=== effective cas.properties (head) ===") +print(run("docker exec cas-sso sh -c 'cat /etc/cas/config/cas.properties 2>/dev/null | head -60'")) +print("\n=== JDBC url in container ===") +print(run("docker exec cas-sso sh -c 'cat /etc/cas/config/cas.properties 2>/dev/null | grep -i jdbc'")) +ssh.close() diff --git a/docker/ssh_logs.py b/docker/ssh_logs.py new file mode 100644 index 0000000..49efa3e --- /dev/null +++ b/docker/ssh_logs.py @@ -0,0 +1,8 @@ +import paramiko +HOST="192.168.10.157"; PORT=22; USER="yunhai"; PASS="123456" +ssh = paramiko.SSHClient() +ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +ssh.connect(HOST, port=PORT, username=USER, password=PASS, timeout=20) +stdin, stdout, stderr = ssh.exec_command("docker logs --tail 80 cas-sso 2>&1 | tail -80") +print(stdout.read().decode('utf-8','ignore')) +ssh.close() diff --git a/docker/upload.py b/docker/upload.py new file mode 100644 index 0000000..0dcaafa --- /dev/null +++ b/docker/upload.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# 通过 SFTP 上传文件到远端(参数: host port user local remote [local remote ...]) +# 依赖环境变量 SSHPASS +import os, sys, paramiko + +def main(): + host, port, user = sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 22, (sys.argv[3] if len(sys.argv) > 3 else "root") + pw = os.environ.get("SSHPASS") + if not pw: + sys.stderr.write("ERROR: 请先设置环境变量 SSHPASS\n"); sys.exit(1) + t = paramiko.Transport((host, port)) + t.connect(username=user, password=pw) + sftp = paramiko.SFTPClient.from_transport(t) + args = sys.argv[4:] + for i in range(0, len(args), 2): + local, remote = args[i], args[i+1] + parts = [p for p in os.path.dirname(remote).split("/") if p] + cur = "" + for p in parts: + cur += "/" + p + try: sftp.stat(cur) + except IOError: + try: sftp.mkdir(cur) + except IOError: pass + sftp.put(local, remote) + print(f"PUT {local} -> {remote}") + t.close() + +if __name__ == "__main__": + main() diff --git a/etc/cas/cas.keystore b/etc/cas/cas.keystore new file mode 100644 index 0000000..6ea02da Binary files /dev/null and b/etc/cas/cas.keystore differ diff --git a/etc/cas/config/cas.properties b/etc/cas/config/cas.properties new file mode 100644 index 0000000..12f70dc --- /dev/null +++ b/etc/cas/config/cas.properties @@ -0,0 +1,6 @@ +cas.server.name: https://cas.example.org:8443 +cas.server.prefix: https://cas.example.org:8443/cas + +cas.adminPagesSecurity.ip=127\.0\.0\.1 + +logging.config: file:/etc/cas/config/log4j2.xml diff --git a/etc/cas/config/log4j2.xml b/etc/cas/config/log4j2.xml new file mode 100644 index 0000000..e688cc0 --- /dev/null +++ b/etc/cas/config/log4j2.xml @@ -0,0 +1,117 @@ + + + + + + . + + warn + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f1fd85d --- /dev/null +++ b/pom.xml @@ -0,0 +1,366 @@ + + + 4.0.0 + org.apereo.cas + cas-overlay + war + 1.0 + + + 5.3.16 + 1.5.18.RELEASE + -tomcat + org.springframework.boot.loader.WarLauncher + false + ${project.build.directory}/war/work/org.apereo.cas/cas-server-webapp${app.server}/META-INF/MANIFEST.MF + 1.8 + 1.8 + UTF-8 + + 3.0.11.RELEASE + 3.0.11.RELEASE + + 4.3.25.RELEASE + 2.9.9 + 8.5.47 + 2.12.1 + 3.2.0 + 4.2.10.RELEASE + 2.6.2 + 1.11 + 3.2.5 + + + + + + + org.springframework.boot + spring-boot-dependencies + ${springboot.version} + pom + import + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-core + + + org.apache.logging.log4j + log4j-slf4j-impl + + + + + + + + + + com.rimerosolutions.maven.plugins + wrapper-maven-plugin + 0.0.5 + + true + MD5 + + + + org.springframework.boot + spring-boot-maven-plugin + ${springboot.version} + + ${mainClassName} + true + ${isExecutable} + WAR + + + + + repackage + + + + + + org.apache.maven.plugins + maven-war-plugin + 2.6 + + cas + false + false + + false + ${manifestFileToUse} + + + + org.apereo.cas + cas-server-webapp${app.server} + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.3 + + + cas + + + + + sonatype-releases + http://oss.sonatype.org/content/repositories/releases/ + + false + + + true + + + + sonatype-snapshots + https://oss.sonatype.org/content/repositories/snapshots/ + + true + + + false + + + + shibboleth-releases + https://build.shibboleth.net/nexus/content/repositories/releases + + + + + + + true + + default + + + + + org.apereo.cas + cas-server-webapp${app.server} + ${cas.version} + war + runtime + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-core + + + org.apache.logging.log4j + log4j-slf4j-impl + + + + + + + org.apereo.cas + cas-server-core-api-authentication + ${cas.version} + provided + + + + org.apereo.cas + cas-server-core-authentication-api + ${cas.version} + provided + + + + org.apereo.cas + cas-server-core-configuration-api + ${cas.version} + provided + + + + org.apereo.cas + cas-server-core-api-ticket + ${cas.version} + provided + + + + org.apereo.cas + cas-server-core-configuration + ${cas.version} + provided + + + + + org.apereo.cas + cas-server-core-api + ${cas.version} + provided + + + + + org.apereo.cas + cas-server-core-cookie-api + ${cas.version} + provided + + + + + org.apereo.cas + cas-server-core-services-authentication + ${cas.version} + provided + + + + + + + org.springframework.boot + spring-boot-starter-web + provided + + + ch.qos.logback + logback-classic + + + org.springframework.boot + spring-boot-starter-logging + + + + + + + org.springframework.boot + spring-boot-starter-log4j2 + ${springboot.version} + + + + + org.springframework.boot + spring-boot-starter-data-redis + provided + + + ch.qos.logback + logback-classic + + + org.springframework.boot + spring-boot-starter-logging + + + + + + + com.github.penggle + kaptcha + 2.3.2 + + + + + org.bouncycastle + bcprov-jdk15on + 1.68 + + + + + com.alibaba + fastjson + 1.2.83 + + + + + + org.apereo.cas + cas-server-support-jdbc + ${cas.version} + + + com.zaxxer + HikariCP + + + + + + org.apereo.cas + cas-server-support-jdbc-drivers + ${cas.version} + + + com.zaxxer + HikariCP + + + + + + + com.zaxxer + HikariCP + 3.2.0 + provided + + + + + mysql + mysql-connector-java + 5.1.46 + + + + + org.springframework.security + spring-security-config + + + + + org.springframework.security + spring-security-web + + + + + org.springframework.security + spring-security-crypto + 4.2.10.RELEASE + provided + + + + + diff --git a/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationConfiguration.java b/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationConfiguration.java new file mode 100644 index 0000000..8687521 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationConfiguration.java @@ -0,0 +1,33 @@ +package org.apereo.cas.web.custom.auth; + +import org.apereo.cas.authentication.AuthenticationEventExecutionPlan; +import org.apereo.cas.authentication.AuthenticationEventExecutionPlanConfigurer; +import org.apereo.cas.authentication.principal.PrincipalFactory; +import org.apereo.cas.services.ServicesManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 将自定义数据库认证处理器注册进 CAS 认证执行计划, + * 使标准登录表单(/cas/login)的认证链包含数据库校验。 + */ +@Configuration("DbAuthenticationConfiguration") +public class DbAuthenticationConfiguration implements AuthenticationEventExecutionPlanConfigurer { + + @Autowired + private ServicesManager servicesManager; + + @Autowired + private PrincipalFactory principalFactory; + + @Bean + public DbAuthenticationHandler dbAuthenticationHandler() { + return new DbAuthenticationHandler("dbAuthenticationHandler", servicesManager, principalFactory); + } + + @Override + public void configureAuthenticationExecutionPlan(AuthenticationEventExecutionPlan plan) { + plan.registerAuthenticationHandler(dbAuthenticationHandler()); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationHandler.java b/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationHandler.java new file mode 100644 index 0000000..5cd2613 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/auth/DbAuthenticationHandler.java @@ -0,0 +1,118 @@ +package org.apereo.cas.web.custom.auth; + +import org.apereo.cas.authentication.AuthenticationHandlerExecutionResult; +import org.apereo.cas.authentication.UsernamePasswordCredential; +import org.apereo.cas.authentication.handler.support.AbstractUsernamePasswordAuthenticationHandler; +import org.apereo.cas.authentication.principal.PrincipalFactory; +import org.apereo.cas.services.ServicesManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import javax.annotation.PostConstruct; +import javax.security.auth.login.FailedLoginException; +import java.security.GeneralSecurityException; +import java.util.ArrayList; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** + * 自定义数据库认证处理器:让 CAS 标准登录表单(/cas/login)也走数据库校验。 + * 校验逻辑与 /api/login 完全一致(BCrypt + sys_user 表),保证两套登录行为统一。 + * 用原生 DriverManager 直连,不依赖 CAS 内置 JdbcAuthenticationHandler 的连接池/模板, + * 因此不受 WAR 中 spring-jdbc 双版本等潜在初始化问题影响。 + */ +public class DbAuthenticationHandler extends AbstractUsernamePasswordAuthenticationHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(DbAuthenticationHandler.class); + + @Value("${cas.authn.jdbc.query[0].sql:select * from sys_user where username=?}") + private String sql; + @Value("${cas.authn.jdbc.query[0].url:}") + private String url; + @Value("${cas.authn.jdbc.query[0].user:}") + private String dbUser; + @Value("${cas.authn.jdbc.query[0].password:}") + private String dbPassword; + @Value("${cas.authn.jdbc.query[0].driverClass:}") + private String driverClass; + @Value("${cas.authn.jdbc.query[0].fieldPassword:password}") + private String fieldPassword; + @Value("${cas.authn.jdbc.query[0].fieldDisabled:disabled}") + private String fieldDisabled; + @Value("${cas.authn.jdbc.query[0].fieldExpired:expired}") + private String fieldExpired; + + private final BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder(); + + public DbAuthenticationHandler(String name, ServicesManager servicesManager, PrincipalFactory principalFactory) { + super(name, servicesManager, principalFactory, 0); + } + + @PostConstruct + public void init() { + if (driverClass != null && !driverClass.trim().isEmpty()) { + try { + Class.forName(driverClass); + } catch (Exception e) { + LOGGER.warn("加载 JDBC 驱动失败: {}", driverClass, e); + } + } + } + + @Override + protected AuthenticationHandlerExecutionResult authenticateUsernamePasswordInternal( + UsernamePasswordCredential credential, String transformedCredentialPassword) + throws GeneralSecurityException, org.apereo.cas.authentication.PreventedException { + final String username = credential.getUsername(); + final String password = credential.getPassword(); + if (username == null || password == null) { + throw new FailedLoginException("用户名或密码错误"); + } + try (Connection conn = DriverManager.getConnection(url, dbUser, dbPassword); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, username); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + throw new FailedLoginException("用户名或密码错误"); + } + Object disabled = rs.getObject(fieldDisabled); + if (isTruthy(disabled)) { + throw new FailedLoginException("账号已被禁用"); + } + Object expired = rs.getObject(fieldExpired); + if (isTruthy(expired)) { + throw new FailedLoginException("账号已过期"); + } + String dbPwd = rs.getString(fieldPassword); + if (dbPwd == null || !bcrypt.matches(password, dbPwd)) { + throw new FailedLoginException("用户名或密码错误"); + } + return createHandlerResult(credential, this.principalFactory.createPrincipal(username), new ArrayList<>()); + } + } catch (FailedLoginException e) { + throw e; + } catch (Exception e) { + LOGGER.error("数据库认证异常: {}", username, e); + throw new org.apereo.cas.authentication.PreventedException("数据库认证异常", e); + } + } + + private boolean isTruthy(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + String text = String.valueOf(value).trim(); + return "1".equals(text) || "true".equalsIgnoreCase(text) + || "y".equalsIgnoreCase(text) || "yes".equalsIgnoreCase(text); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/config/RSAUtils.java b/src/main/java/org/apereo/cas/web/custom/config/RSAUtils.java new file mode 100644 index 0000000..4b25066 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/config/RSAUtils.java @@ -0,0 +1,92 @@ +package org.apereo.cas.web.custom.config; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import javax.crypto.Cipher; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.Security; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +public class RSAUtils { + + public static final String KEY_ALGORITHM = "RSA"; + public static final String SIGNATURE_ALGORITHM = "MD5withRSA"; + private static final String PUBLIC_KEY = "RSAPublicKey"; + private static final String PRIVATE_KEY = "RSAPrivateKey"; + private static final int KEY_SIZE = 2048; + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + /** + * 生成 RSA 密钥对 + */ + public static Map generateKeyPair() throws Exception { + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance(KEY_ALGORITHM); + keyPairGen.initialize(KEY_SIZE); + KeyPair keyPair = keyPairGen.generateKeyPair(); + RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); + RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); + Map keyMap = new HashMap<>(); + keyMap.put(PUBLIC_KEY, Base64.getEncoder().encodeToString(publicKey.getEncoded())); + keyMap.put(PRIVATE_KEY, Base64.getEncoder().encodeToString(privateKey.getEncoded())); + return keyMap; + } + + /** + * 公钥加密 + */ + public static String encryptByPublicKey(String data, String publicKey) throws Exception { + byte[] keyBytes = Base64.getDecoder().decode(normalizeKeyBase64(publicKey)); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + RSAPublicKey pubKey = (RSAPublicKey) keyFactory.generatePublic(keySpec); + Cipher cipher = Cipher.getInstance(KEY_ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, pubKey); + byte[] encrypted = cipher.doFinal(data.getBytes("UTF-8")); + return Base64.getEncoder().encodeToString(encrypted); + } + + /** + * 私钥解密 + */ + public static String decryptByPrivateKey(String data, String privateKey) throws Exception { + byte[] keyBytes = Base64.getDecoder().decode(normalizeKeyBase64(privateKey)); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance(KEY_ALGORITHM); + RSAPrivateKey priKey = (RSAPrivateKey) keyFactory.generatePrivate(keySpec); + Cipher cipher = Cipher.getInstance(KEY_ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, priKey); + byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(normalizeCipherBase64(data))); + return new String(decrypted, "UTF-8"); + } + + private static String normalizeKeyBase64(String base64) { + if (base64 == null) { + return ""; + } + return base64 + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s+", ""); + } + + private static String normalizeCipherBase64(String base64) { + if (base64 == null) { + return ""; + } + // GET 参数中的 '+' 可能会被解码成空格,这里兼容这种情况。 + return base64.replace(' ', '+').replaceAll("\\s+", ""); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/config/RedisConfig.java b/src/main/java/org/apereo/cas/web/custom/config/RedisConfig.java new file mode 100644 index 0000000..cd7310b --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/config/RedisConfig.java @@ -0,0 +1,40 @@ +package org.apereo.cas.web.custom.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.connection.jedis.JedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Value("${spring.redis.host:localhost}") + private String host; + + @Value("${spring.redis.port:6379}") + private int port; + + @Value("${spring.redis.password:}") + private String password; + + @Value("${spring.redis.database:0}") + private int database; + + @Bean + public JedisConnectionFactory redisConnectionFactory() { + JedisConnectionFactory factory = new JedisConnectionFactory(); + factory.setHostName(host); + factory.setPort(port); + factory.setDatabase(database); + if (password != null && !password.trim().isEmpty()) { + factory.setPassword(password); + } + return factory; + } + + @Bean + public StringRedisTemplate stringRedisTemplate(JedisConnectionFactory redisConnectionFactory) { + return new StringRedisTemplate(redisConnectionFactory); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java b/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java new file mode 100644 index 0000000..9196f10 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/controller/LoginController.java @@ -0,0 +1,480 @@ +package org.apereo.cas.web.custom.controller; + +import com.alibaba.fastjson.JSONObject; +import org.apereo.cas.web.custom.service.LoginService; +import org.apereo.cas.web.custom.config.RSAUtils; +import org.apereo.cas.web.custom.utils.CaptchaUtils; +import org.apereo.cas.CentralAuthenticationService; +import org.apereo.cas.authentication.Authentication; +import org.apereo.cas.authentication.AuthenticationException; +import org.apereo.cas.authentication.AuthenticationResult; +import org.apereo.cas.authentication.DefaultAuthenticationBuilder; +import org.apereo.cas.authentication.DefaultAuthenticationResultBuilder; +import org.apereo.cas.authentication.DefaultPrincipalElectionStrategy; +import org.apereo.cas.authentication.AuthenticationSystemSupport; +import org.apereo.cas.authentication.Credential; +import org.apereo.cas.authentication.UsernamePasswordCredential; +import org.apereo.cas.authentication.principal.Principal; +import org.apereo.cas.authentication.principal.Service; +import org.apereo.cas.authentication.principal.DefaultPrincipalFactory; +import org.apereo.cas.authentication.principal.WebApplicationServiceFactory; +import org.apereo.cas.ticket.AbstractTicketException; +import org.apereo.cas.ticket.ServiceTicket; +import org.apereo.cas.ticket.TicketGrantingTicket; +import org.apereo.cas.web.support.CookieRetrievingCookieGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.web.bind.annotation.*; + +import javax.imageio.ImageIO; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.net.URLEncoder; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +@RestController +@RequestMapping("/api") +public class LoginController { + + private static final Logger logger = LoggerFactory.getLogger(LoginController.class); + + @Autowired + private LoginService loginService; + + @Autowired + private CentralAuthenticationService centralAuthenticationService; + + @Autowired + private AuthenticationSystemSupport authenticationSystemSupport; + + @Autowired + @Qualifier("ticketGrantingTicketCookieGenerator") + private CookieRetrievingCookieGenerator ticketGrantingTicketCookieGenerator; + + @Value("${custom.login.default-redirect-url:http://sso.yunhaixingtong.com/front/index.html}") + private String defaultRedirectUrl; + + // 是否强制每次登录都必须校验验证码。默认 false = 维持原逻辑(仅会话失败达到阈值后才要求); + // 设为 true 时,任何登录(含首次)都必须携带并校验验证码,用于彻底阻断批量账号枚举探测。 + @Value("${custom.login.always-captcha:false}") + private boolean alwaysCaptcha; + + @Value("${custom.rsa.private-key:}") + private String rsaPrivateKey; + + private final BCryptPasswordEncoder bcryptEncoder = new BCryptPasswordEncoder(); + + // 账号不存在/禁用/过期等“提前返回”分支原本不会经过 BCrypt,导致响应时长明显短于 + // “账号存在但密码错误”(会执行一次 BCrypt 约 100ms)。攻击者可用时长差枚举账号。 + // 这里用一份固定的 BCrypt 哈希做“空跑”,对齐各分支耗时,消除时序侧信道。 + private static final String DUMMY_BCRYPT_HASH = + "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZd9qJ6jFm"; + + @Value("${custom.rsa.public-key:}") + private String rsaPublicKey; + + @Value("${cas.authn.jdbc.query[0].sql:select * from sys_user where username=?}") + private String jdbcAuthSql; + + @Value("${cas.authn.jdbc.query[0].url:}") + private String jdbcUrl; + + @Value("${cas.authn.jdbc.query[0].user:}") + private String jdbcUser; + + @Value("${cas.authn.jdbc.query[0].password:}") + private String jdbcPassword; + + @Value("${cas.authn.jdbc.query[0].driverClass:}") + private String jdbcDriverClass; + + @Value("${cas.authn.jdbc.query[0].fieldPassword:password}") + private String jdbcPasswordField; + + @Value("${cas.authn.jdbc.query[0].fieldDisabled:disabled}") + private String jdbcDisabledField; + + @Value("${cas.authn.jdbc.query[0].fieldExpired:expired}") + private String jdbcExpiredField; + + @PostMapping("/login") + public void login( + @RequestParam("loginname") String encryptedLoginName, + @RequestParam("pwd") String encryptedPassword, + @RequestParam(value = "captcha", required = false) String captchaText, + @RequestParam(value = "service", required = false) String service, + HttpServletRequest request, + HttpServletResponse response) throws IOException { + + HttpSession session = request.getSession(); + + try { + if (rsaPrivateKey == null || rsaPrivateKey.trim().isEmpty()) { + respondError(request, response, "RSA私钥未配置"); + return; + } + + String loginName = RSAUtils.decryptByPrivateKey(encryptedLoginName, rsaPrivateKey); + String password = RSAUtils.decryptByPrivateKey(encryptedPassword, rsaPrivateKey); + + logger.info("用户 {} 尝试登录,Session ID: {}", loginName, session.getId()); + + if (loginService.isAccountLocked(loginName)) { + respondError(request, response, + "账号已被锁定,请" + loginService.getAccountLockRemainingText(loginName) + "后重试"); + return; + } + + if (alwaysCaptcha || loginService.needCaptcha(session)) { + if (captchaText == null || captchaText.trim().isEmpty()) { + respondError(request, response, "请输入验证码", true); + return; + } + if (!loginService.verifyCaptcha(session, captchaText)) { + respondError(request, response, "验证码错误", true); + return; + } + } + + LocalAuthenticationResult authResult = authenticate(loginName, password); + + if (authResult.success) { + loginService.clearSessionFail(session); + loginService.clearAccountFail(loginName); + + logger.info("用户 {} 登录成功", loginName); + + // 建立真正的 SSO 会话:创建 TGT 并下发 TGC Cookie + String ssoUrl = establishSsoSession(loginName, password, service, request, response); + if (ssoUrl == null) { + respondError(request, response, "登录成功,但建立会话失败,请重试"); + return; + } + // 成功:浏览器原生表单提交场景下用 302 跳转(同时让浏览器记住密码); + // AJAX 调用(带 X-Requested-With)仍返回 JSON 由前端手动跳转。 + respondSuccess(request, response, ssoUrl); + return; + } + + if (!authResult.countFailure) { + // 非凭证类失败(账号禁用/过期/系统异常等):不计入失败次数与锁定, + // 但对外提示与“用户名或密码错误”完全一致,避免攻击者借返回差异枚举账号。 + respondError(request, response, "用户名或密码错误", alwaysCaptcha); + return; + } + + // 凭证类失败(账号不存在/密码错误):计入失败次数与锁定。统一只返回“用户名或密码错误”, + // 不回显“剩余尝试次数”,否则又会因文案长短差异暴露账号是否存在。 + int sessionFailCount = loginService.recordSessionFail(session); + boolean accountLocked = loginService.recordAccountFail(loginName); + + logger.warn("用户 {} 登录失败,Session 失败次数: {}, 账号锁定: {}", loginName, sessionFailCount, accountLocked); + + if (accountLocked) { + respondError(request, response, + "账号已锁定,请" + loginService.getAccountLockRemainingText(loginName) + "后重试"); + return; + } + + respondError(request, response, "用户名或密码错误", sessionFailCount >= 2 || alwaysCaptcha); + } catch (Exception e) { + String msg = e.getMessage() == null ? "登录失败" : e.getMessage(); + try { + respondError(request, response, msg); + } catch (Exception ignored) { + } + } + } + + /** + * 获取验证码(图片) + */ + @GetMapping(value = { "/captcha" }, produces = "image/png") + public void getCaptcha( + @RequestParam(value = "k", required = false) String k, + HttpServletRequest request, + HttpServletResponse response) throws IOException { + HttpSession session = request.getSession(); + + String captchaText = CaptchaUtils.generateText(); + loginService.saveCaptcha(session, captchaText); + + BufferedImage image = CaptchaUtils.generateImage(captchaText); + response.setContentType("image/png"); + response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"); + response.setHeader("Pragma", "no-cache"); + response.setDateHeader("Expires", 0L); + ImageIO.write(image, "png", response.getOutputStream()); + response.flushBuffer(); + } + + @GetMapping("/need-captcha") + public JSONObject needCaptcha(HttpServletRequest request) { + HttpSession session = request.getSession(); + JSONObject data = new JSONObject(); + data.put("needCaptcha", alwaysCaptcha || loginService.needCaptcha(session)); + return buildResponse(0, data, ""); + } + + @GetMapping("/public-key") + public JSONObject publicKey() { + if (rsaPublicKey == null || rsaPublicKey.trim().isEmpty()) { + return buildResponse(1, new JSONObject(), "RSA公钥未配置"); + } + rsaPublicKey = rsaPublicKey.replaceAll("\\s+", ""); + JSONObject data = new JSONObject(); + data.put("publicKey", rsaPublicKey); + return buildResponse(0, data, ""); + } + + private JSONObject buildResponse(int code, JSONObject data, String msg) { + JSONObject result = new JSONObject(); + result.put("code", code); + result.put("data", data == null ? new JSONObject() : data); + result.put("msg", msg); + return result; + } + + private String toPem(String type, String base64) { + String b64 = base64 == null ? "" : base64.replaceAll("\\s+", ""); + StringBuilder sb = new StringBuilder(); + sb.append("-----BEGIN ").append(type).append("-----\n"); + for (int i = 0; i < b64.length(); i += 64) { + int end = Math.min(i + 64, b64.length()); + sb.append(b64, i, end).append('\n'); + } + sb.append("-----END ").append(type).append("-----"); + return sb.toString(); + } + + private void writeJson(HttpServletResponse response, JSONObject json) throws IOException { + response.setStatus(HttpServletResponse.SC_OK); + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(json.toJSONString()); + response.flushBuffer(); + } + + private boolean isAjax(HttpServletRequest request) { + return "XMLHttpRequest".equalsIgnoreCase(request.getHeader("X-Requested-With")) + || "application/json".equalsIgnoreCase(request.getHeader("Accept")); + } + + private void respondSuccess(HttpServletRequest request, HttpServletResponse response, String url) throws IOException { + // 始终返回 JSON {code:0, data:{url}},由前端 JS 读取 data.url 后手动跳转(满足“接口返回地址、JS 手动跳转”)。 + JSONObject data = new JSONObject(); + data.put("url", url); + writeJson(response, buildResponse(0, data, null)); + } + + private void respondError(HttpServletRequest request, HttpServletResponse response, String msg) throws IOException { + respondError(request, response, msg, false); + } + + private void respondError(HttpServletRequest request, HttpServletResponse response, String msg, boolean needCaptcha) + throws IOException { + if (isAjax(request)) { + JSONObject data = new JSONObject(); + if (needCaptcha) { + data.put("needCaptcha", true); + } + writeJson(response, buildResponse(1, data, msg)); + } else { + // 原生表单提交:302 回登录页并带错误提示;同时让浏览器在成功时记住密码。 + String target = "/front/index.html?error=" + URLEncoder.encode(msg, "UTF-8"); + response.sendRedirect(target); + } + } + + /** + * 自定义认证逻辑 + */ + private LocalAuthenticationResult authenticate(String username, String password) { + if (jdbcUrl == null || jdbcUrl.trim().isEmpty()) { + logger.error("用户 {} 登录失败:未配置JDBC认证地址", username); + return LocalAuthenticationResult.failure(false); + } + try { + if (jdbcDriverClass != null && !jdbcDriverClass.trim().isEmpty()) { + Class.forName(jdbcDriverClass); + } + try (Connection connection = DriverManager.getConnection(jdbcUrl, jdbcUser, jdbcPassword); + PreparedStatement statement = connection.prepareStatement(jdbcAuthSql)) { + statement.setString(1, username); + try (ResultSet rs = statement.executeQuery()) { + if (!rs.next()) { + // 时序对齐:账号不存在也空跑一次 BCrypt,避免响应时长差异被利用枚举账号 + bcryptEncoder.matches(password, DUMMY_BCRYPT_HASH); + logger.info("用户 {} 登录失败:账号不存在或密码错误", username); + return LocalAuthenticationResult.failure(true); + } + Object disabledValue = rs.getObject(jdbcDisabledField); + if (isTruthy(disabledValue)) { + // 时序对齐 + bcryptEncoder.matches(password, DUMMY_BCRYPT_HASH); + logger.info("用户 {} 登录失败:账号状态异常(禁用/过期等)", username); + return LocalAuthenticationResult.failure(false); + } + Object expiredValue = rs.getObject(jdbcExpiredField); + if (isTruthy(expiredValue)) { + // 时序对齐 + bcryptEncoder.matches(password, DUMMY_BCRYPT_HASH); + logger.info("用户 {} 登录失败:账号状态异常(禁用/过期等)", username); + return LocalAuthenticationResult.failure(false); + } + String dbPassword = rs.getString(jdbcPasswordField); + boolean matched; + if (dbPassword == null) { + matched = false; + } else { + try { + matched = bcryptEncoder.matches(password, dbPassword); + } catch (Exception ex) { + // 库中密码不是合法 BCrypt 哈希(例如仍是明文)时视为不匹配, + // 避免抛出“认证服务异常”掩盖真实问题 + logger.warn("账号 {} 的密码不是合法 BCrypt 哈希,请确认已用 BCrypt 加密存储", username); + matched = false; + } + } + if (matched) { + return LocalAuthenticationResult.success(); + } + logger.info("用户 {} 登录失败:账号不存在或密码错误", username); + return LocalAuthenticationResult.failure(true); + } + } + } catch (Exception e) { + logger.error("用户 {} JDBC认证异常", username, e); + return LocalAuthenticationResult.failure(false); + } + } + + private boolean isTruthy(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + String text = String.valueOf(value).trim(); + return "1".equals(text) || "true".equalsIgnoreCase(text) || "y".equalsIgnoreCase(text) + || "yes".equalsIgnoreCase(text); + } + + /** + * 建立真正的 SSO 会话:基于自定义登录已校验过的身份直接签发 TGT 并下发 TGC Cookie。 + * 密码已在 authenticate() 中用 BCrypt 校验通过,这里不再走 CAS 的认证 handler 链 + * (否则会触发 CAS 用自己的密码编码器重复校验而失败),而是直接构造已认证的 Authentication。 + * 返回应跳转的地址(带 ticket 的 service 或默认地址);失败返回 null。 + */ + private String establishSsoSession(String username, String password, String service, + HttpServletRequest request, HttpServletResponse response) { + try { + Principal principal = new DefaultPrincipalFactory().createPrincipal(username); + Authentication authentication = new DefaultAuthenticationBuilder(principal).build(); + + DefaultAuthenticationResultBuilder resultBuilder = new DefaultAuthenticationResultBuilder(); + resultBuilder.collect(authentication); + + Service svc = (service != null && !service.trim().isEmpty()) + ? new WebApplicationServiceFactory().createService(service.trim()) + : null; + + AuthenticationResult authResult = (svc != null) + ? resultBuilder.build(new DefaultPrincipalElectionStrategy(), svc) + : resultBuilder.build(new DefaultPrincipalElectionStrategy()); + + // 创建 TGT(不再重新校验密码) + TicketGrantingTicket tgt = centralAuthenticationService.createTicketGrantingTicket(authResult); + // 下发 TGC Cookie,浏览器凭此在访问其他接入 CAS 的应用时实现单点登录 + ticketGrantingTicketCookieGenerator.addCookie(request, response, tgt.getId()); + + if (svc != null) { + // 为有 service 的接入方签发 ServiceTicket 并带 ticket 跳转,完成单点登录 + ServiceTicket st = centralAuthenticationService.grantServiceTicket(tgt.getId(), svc, authResult); + String svcUrl = svc.getId(); + return svcUrl + (svcUrl.contains("?") ? "&" : "?") + "ticket=" + st.getId(); + } + // 返回绝对 URL,确保前端无论从哪个 origin 打开都能正确跳回 CAS 同源, + // 从而把 TGC Cookie 一起带过去(相对路径 /cas/login 在非同源前端下会跳错 origin)。 + // 防御:默认落点若未配置、或误配成 CAS 原生登录页路径,则强制回退到前端首页, + // 避免出现“登录成功却被甩回 /cas/login”的死循环(曾因 application.yml 误配踩过)。 + String target = defaultRedirectUrl; + if (target == null || target.trim().isEmpty() || target.contains("/cas/login")) { + target = "/front/index.html"; + } + return buildCasAbsoluteUrl(request, target); + } catch (AuthenticationException | AbstractTicketException e) { + logger.error("用户 {} 建立 SSO 会话失败", username, e); + return null; + } catch (Exception e) { + logger.error("用户 {} 建立 SSO 会话异常", username, e); + return null; + } + } + + /** + * 基于当前请求推断 CAS 自身的访问基地址(scheme://host:port),用于构造绝对跳转 URL, + * 避免前端页面不在 CAS 同源时相对路径跳转错 origin,导致 TGC Cookie 无法随跳转携带。 + */ + private String buildCasAbsoluteUrl(HttpServletRequest request, String relativePath) { + // 若已是绝对地址(如 Nacos 配置的默认跳转站点 http(s)://...),直接返回, + // 避免被拼到 CAS 自身 host 前面变成 https://cas-host:8443/http://xxx 这类错误地址。 + if (relativePath != null + && (relativePath.startsWith("http://") || relativePath.startsWith("https://"))) { + return relativePath; + } + String scheme = request.getScheme(); + String host = request.getServerName(); + int port = request.getServerPort(); + StringBuilder sb = new StringBuilder(); + sb.append(scheme).append("://").append(host); + boolean needPort = (scheme.equalsIgnoreCase("http") && port != 80) + || (scheme.equalsIgnoreCase("https") && port != 443); + if (needPort) { + sb.append(":").append(port); + } + if (relativePath == null || relativePath.isEmpty()) { + return sb.toString(); + } + if (!relativePath.startsWith("/")) { + sb.append("/"); + } + sb.append(relativePath); + return sb.toString(); + } + + private static final class LocalAuthenticationResult { + private final boolean success; + private final boolean countFailure; + + private LocalAuthenticationResult(boolean success, boolean countFailure) { + this.success = success; + this.countFailure = countFailure; + } + + private static LocalAuthenticationResult success() { + return new LocalAuthenticationResult(true, false); + } + + // countFailure=true 表示应计入失败次数与锁定(账号不存在/密码错误等凭证类失败); + // false 表示不计入(账号禁用/过期/系统异常等)。两类失败对客户端统一返回“用户名或密码错误”。 + private static LocalAuthenticationResult failure(boolean countFailure) { + return new LocalAuthenticationResult(false, countFailure); + } + } + +} diff --git a/src/main/java/org/apereo/cas/web/custom/service/LoginService.java b/src/main/java/org/apereo/cas/web/custom/service/LoginService.java new file mode 100644 index 0000000..f9cd273 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/service/LoginService.java @@ -0,0 +1,181 @@ +package org.apereo.cas.web.custom.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpSession; +import java.util.concurrent.TimeUnit; + +@Service +public class LoginService { + + @Autowired + private StringRedisTemplate redisTemplate; + + // ==================== Redis 相关(账号级别) ==================== + private static final String LOCK_KEY_PREFIX = "login:lock:"; + private static final String ACCOUNT_FAIL_KEY_PREFIX = "login:account:fail:"; + + // ==================== Session 相关(浏览器级别) ==================== + private static final String SESSION_FAIL_KEY = "SESSION_LOGIN_FAIL_COUNT"; + + // 阀值 + private static final int CAPTCHA_THRESHOLD = 2; // Session 失败 >= 2 次,需要验证码 + private static final int LOCK_THRESHOLD = 5; // 账号累计失败 5 次,锁定 10 分钟 + private static final int LOCK_DURATION = 10; // 锁定 10 分钟(单位:分钟) + + // ==================== 账号级别(Redis) ==================== + + /** + * 检查账号是否被锁定(Redis) + */ + public boolean isAccountLocked(String loginName) { + String lockKey = LOCK_KEY_PREFIX + loginName; + return Boolean.TRUE.equals(redisTemplate.hasKey(lockKey)); + } + + public long getAccountLockRemainingSeconds(String loginName) { + String lockKey = LOCK_KEY_PREFIX + loginName; + Long seconds = redisTemplate.getExpire(lockKey, TimeUnit.SECONDS); + if (seconds == null || seconds < 0) { + return 0L; + } + return seconds; + } + + public String getAccountLockRemainingText(String loginName) { + long seconds = getAccountLockRemainingSeconds(loginName); + if (seconds >= 60) { + return (seconds / 60) + "分钟"; + } + if (seconds > 0) { + return seconds + "秒"; + } + return "0秒"; + } + + /** + * 获取账号累计失败次数(Redis) + */ + public int getAccountFailCount(String loginName) { + String failKey = ACCOUNT_FAIL_KEY_PREFIX + loginName; + String count = redisTemplate.opsForValue().get(failKey); + return count == null ? 0 : Integer.parseInt(count); + } + + /** + * 记录账号失败(Redis),如果达到 5 次则锁定 + * + * @return 是否达到锁定阈值 + */ + public boolean recordAccountFail(String loginName) { + String failKey = ACCOUNT_FAIL_KEY_PREFIX + loginName; + String lockKey = LOCK_KEY_PREFIX + loginName; + + // 如果已经锁定,直接返回 true + if (Boolean.TRUE.equals(redisTemplate.hasKey(lockKey))) { + return true; + } + + // 增加失败次数 + Long failCount = redisTemplate.opsForValue().increment(failKey, 1L); + if (failCount == 1L) { + // 第一次失败,设置 24 小时过期(窗口期) + redisTemplate.expire(failKey, 24, TimeUnit.HOURS); + } + + // 达到 5 次,锁定账号 10 分钟 + if (failCount != null && failCount >= LOCK_THRESHOLD) { + long unlockAt = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(LOCK_DURATION); + redisTemplate.opsForValue().set(lockKey, String.valueOf(unlockAt), LOCK_DURATION, TimeUnit.MINUTES); + // 清除失败计数(锁释放后重新计数) + redisTemplate.delete(failKey); + return true; + } + return false; + } + + /** + * 登录成功,清除账号失败计数和锁定(Redis) + */ + public void clearAccountFail(String loginName) { + String failKey = ACCOUNT_FAIL_KEY_PREFIX + loginName; + String lockKey = LOCK_KEY_PREFIX + loginName; + redisTemplate.delete(failKey); + redisTemplate.delete(lockKey); + } + + // ==================== Session 级别 ==================== + + /** + * 获取 Session 失败次数 + */ + public int getSessionFailCount(HttpSession session) { + Integer count = (Integer) session.getAttribute(SESSION_FAIL_KEY); + return count == null ? 0 : count; + } + + /** + * 记录 Session 失败次数 + * + * @return 当前 Session 失败次数 + */ + public int recordSessionFail(HttpSession session) { + int failCount = getSessionFailCount(session) + 1; + session.setAttribute(SESSION_FAIL_KEY, failCount); + return failCount; + } + + /** + * 清除 Session 失败计数(登录成功后调用) + */ + public void clearSessionFail(HttpSession session) { + session.removeAttribute(SESSION_FAIL_KEY); + } + + /** + * 判断当前 Session 是否需要验证码(失败 >= 3 次) + */ + public boolean needCaptcha(HttpSession session) { + return getSessionFailCount(session) >= CAPTCHA_THRESHOLD; + } + + /** + * 保存验证码到 Session + */ + public void saveCaptcha(HttpSession session, String captchaText) { + session.setAttribute("CAPTCHA_TEXT", captchaText); + // 设置 5 分钟过期(Session 本身会过期,这里额外控制) + } + + /** + * 校验验证码(从 Session 中对比) + */ + public boolean verifyCaptcha(HttpSession session, String captchaText) { + String savedText = (String) session.getAttribute("CAPTCHA_TEXT"); + if (savedText == null) { + return false; + } + if (captchaText == null) { + session.removeAttribute("CAPTCHA_TEXT"); + return false; + } + // 验证码一次性使用,校验后清除 + session.removeAttribute("CAPTCHA_TEXT"); + return savedText.equalsIgnoreCase(captchaText); + } + + /** + * 综合判断:是否需要验证码(Session 失败 >= 3 次) + * 如果账号已锁定,也返回 true(但 controller 会先检查锁定) + */ + public boolean needCaptcha(HttpSession session, String loginName) { + // Session 级别失败 >= 3 次,需要验证码 + if (getSessionFailCount(session) >= CAPTCHA_THRESHOLD) { + return true; + } + // 账号被锁定也需要验证码(但 controller 会直接拒绝) + return isAccountLocked(loginName); + } +} diff --git a/src/main/java/org/apereo/cas/web/custom/utils/CaptchaUtils.java b/src/main/java/org/apereo/cas/web/custom/utils/CaptchaUtils.java new file mode 100644 index 0000000..f5d07c8 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/utils/CaptchaUtils.java @@ -0,0 +1,68 @@ +package org.apereo.cas.web.custom.utils; + +import com.google.code.kaptcha.Producer; +import com.google.code.kaptcha.impl.DefaultKaptcha; +import com.google.code.kaptcha.util.Config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.util.Base64; +import java.util.Properties; + +public class CaptchaUtils { + + private static final Logger logger = LoggerFactory.getLogger(CaptchaUtils.class); + + private static final Producer KAPTCHA_PRODUCER; + + static { + DefaultKaptcha defaultKaptcha = new DefaultKaptcha(); + Properties properties = new Properties(); + properties.setProperty("kaptcha.border", "no"); + properties.setProperty("kaptcha.textproducer.font.color", "black"); + properties.setProperty("kaptcha.textproducer.char.space", "5"); + properties.setProperty("kaptcha.textproducer.char.length", "4"); + properties.setProperty("kaptcha.textproducer.font.size", "30"); + properties.setProperty("kaptcha.image.width", "120"); + properties.setProperty("kaptcha.image.height", "40"); + properties.setProperty("kaptcha.noise.impl", "com.google.code.kaptcha.impl.DefaultNoise"); + properties.setProperty("kaptcha.obscurificator.impl", "com.google.code.kaptcha.impl.WaterRipple"); + Config config = new Config(properties); + defaultKaptcha.setConfig(config); + KAPTCHA_PRODUCER = defaultKaptcha; + } + + /** + * 生成验证码文本 + */ + public static String generateText() { + return KAPTCHA_PRODUCER.createText(); + } + + /** + * 生成验证码图片 + */ + public static BufferedImage generateImage(String text) { + return KAPTCHA_PRODUCER.createImage(text); + } + + /** + * BufferedImage 转 Base64 PNG + */ + public static String imageToBase64(BufferedImage image) { + try (ByteArrayOutputStream os = new ByteArrayOutputStream()) { + ImageIO.write(image, "png", os); + return "data:image/png;base64," + Base64.getEncoder().encodeToString(os.toByteArray()); + } catch (Exception e) { + logger.error("验证码图片转Base64失败", e); + return ""; + } + } + + private CaptchaUtils() { + // 工具类禁止实例化 + } +} \ No newline at end of file diff --git a/src/main/java/org/apereo/cas/web/custom/utils/PasswordHashUtil.java b/src/main/java/org/apereo/cas/web/custom/utils/PasswordHashUtil.java new file mode 100644 index 0000000..ee477f3 --- /dev/null +++ b/src/main/java/org/apereo/cas/web/custom/utils/PasswordHashUtil.java @@ -0,0 +1,25 @@ +package org.apereo.cas.web.custom.utils; + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +/** + * BCrypt 密码哈希生成工具。 + * 用于把数据库里现有的明文密码批量/手动替换为 BCrypt 哈希。 + * + * 用法(IDEA 直接 Run 此 main,或命令行): + * java -cp "<编译输出目录>;" org.apereo.cas.web.custom.utils.PasswordHashUtil 123456 + * 可一次传入多个明文,逐行输出 明文 -> 哈希 + */ +public class PasswordHashUtil { + + public static void main(String[] args) { + BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(); + if (args == null || args.length == 0) { + System.out.println("用法: PasswordHashUtil <明文密码> [明文密码2 ...]"); + return; + } + for (String raw : args) { + System.out.println(raw + " -> " + encoder.encode(raw)); + } + } +} diff --git a/src/main/resources/application.properties.txt b/src/main/resources/application.properties.txt new file mode 100644 index 0000000..f7ad064 --- /dev/null +++ b/src/main/resources/application.properties.txt @@ -0,0 +1,60 @@ +# ==================== ½ûÓà HTTPS£¨¿ª·¢»·¾³£© ==================== +server.ssl.enabled=false +server.port=8443 + +# ==================== CAS ºËÐÄÅäÖà ==================== +# ÔÊÐíͨ¹ý HTTP ´«Êä TGC Cookie +cas.tgc.secure=false +# ÏÔʽָ¶¨ TGC Cookie µÄ Path Ϊ /cas£»·ñÔòĬÈÏÁô¿գ¬ä¯ÀÀÆ÷°´¡°ÇëÇóĿ¼¡±ȡֵ£¬ +# ×Զ¨ÒåµÇ¼ÔÚ /cas/api/login ÖÖ Cookie »á±ä³É Path=/cas/api/£¬Ìøµ½ /cas/login ʱä¯ÀÀÆ÷²»´ø´Ë Cookie£¬µ¼Ö»¹ҪÔٵǼ +cas.tgc.path=/cas + +# ´Ó JSON Îļþ¼ÓÔطþÎñע²áÐÅϢ +cas.serviceRegistry.initFromJson=true + +# ==================== ÈÏ֤ÅäÖà ==================== +# ¾²̬Óû§Áбí£¨¸ñʽ£ºusername::password£© +cas.authn.accept.users=casuser::Mellon + +# ==================== ÈÕ־ÅäÖ㨿Éѡ£© ==================== +# ÉèÖÃÈÕ־¼¶±𣬱ãÓڵ÷ÊÔ +logging.level.org.apereo.cas=DEBUG + +# ==================== Redis ÅäÖã¨Spring Boot ±ê׼ÅäÖã© ==================== + +spring.redis.host=localhost +spring.redis.port=6379 +spring.redis.password=123456 +spring.redis.database=0 +spring.redis.timeout=5000 +spring.redis.jedis.pool.max-active=8 +spring.redis.jedis.pool.max-idle=8 +spring.redis.jedis.pool.min-idle=0 + + + +# ==================== JDBC ÈÏ֤ÅäÖà ==================== +# ²éѯ SQL£¬¸ù¾ÝÓû§Ãû»ñȡÓû§ÐÅϢ +cas.authn.jdbc.query[0].sql=select * from sys_user where username=? +# ÃÜÂë×ֶÎÃû +cas.authn.jdbc.query[0].fieldPassword=password +# Õ˺ŽûÓÃ×ֶΣ¨¿Éѡ£¬1±íʾ½ûÓã© +cas.authn.jdbc.query[0].fieldDisabled=disabled +# ÃÜÂë¹ýÆÚ×ֶΣ¨¿Éѡ£¬1±íʾ¹ýÆڣ© +cas.authn.jdbc.query[0].fieldExpired=expired + +# Êý¾ݿâÁ¬½ÓÐÅϢ +cas.authn.jdbc.query[0].url=jdbc:mysql://127.0.0.1:3306/yunhai_base?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai +cas.authn.jdbc.query[0].user=root +cas.authn.jdbc.query[0].password=123456 +cas.authn.jdbc.query[0].driverClass=com.mysql.jdbc.Driver +# Êý¾ݿⷽÑÔ +cas.authn.jdbc.query[0].dialect=org.hibernate.dialect.MySQLDialect + +# ==================== ÃÜÂë¼ÓÃܲßÂԣ¨¸ù¾Ýʵ¼ÊÇé¿öÅäÖã© ==================== +# Èç¹ûÊý¾ݿâÃÜÂëÊÇÃ÷Îģ¬ÔòÉèΪ NONE +cas.authn.jdbc.query[0].passwordEncoder.type=NONE +# Èç¹ûÊÇ MD5 ¼ÓÃܣ¬ÔòʹÓÃÒÔÏÂÅäÖà +# cas.authn.jdbc.query[0].passwordEncoder.type=DEFAULT +# cas.authn.jdbc.query[0].passwordEncoder.encodingAlgorithm=MD5 +#cas.authn.sso.excluded-paths=/api/** diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..a3ba522 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,111 @@ +server: + ssl: + enabled: false + port: 8443 + use-forward-headers: false + +cas: + # 显式声明 CAS 自身访问地址,避免根路径 / 或 /index.html 被重定向到错误端口(曾出现跳到 8080 的问题)。 + # CAS 的默认值是 https://cas.example.org:8443,若运行时被外部配置覆盖成 8080,根页面重定向就会错位。 + server: + name: http://localhost:8443 + prefix: http://localhost:8443/cas + # CAS 核心配置 + tgc: + secure: false + # 显式指定 TGC Cookie 的 Path,必须覆盖 /cas 下所有路径(含自定义 /cas/api/login)。 + # 若不配置,CAS 默认会把 Path 留空,浏览器按“请求目录”取值: + # - 标准登录在 /cas/login 种 Cookie -> Path=/cas/ -> 正常 + # - 自定义登录在 /cas/api/login 种 Cookie -> Path=/cas/api/ -> 跳到 /cas/login 时浏览器不带此 Cookie,导致还要再登录 + path: /cas + # 固定 TGC 加密/签名密钥。不配置的话每次重启都会自动生成新密钥, + # 导致浏览器里旧 TGC Cookie 解密失败(表现为启动后首次访问的 NPE DEBUG 噪声),且启动日志反复 WARN。 + crypto: + encryption: + key: 7qLRih0bq6I_A7MehkJVtD4c9foKUFUgU0Py8BvmX9w + signing: + key: RVYx5M5u-Wu6WPNz1C-PSpCDPlnJ7Z9rvaVfaWLJlbddhcKnUhiluW4Z2S4XgnV3ya6DydtBeS55cnKwFkgzZw + # 固定 Webflow 加密/签名密钥,理由同上 + webflow: + crypto: + encryption: + key: VgGmJuzf2Xbk2Pn0ZrEMVw + signing: + key: 48ES4odoN40R9JPlIIcxbOWCHBngCTolqEypXuS86WUjNa1eccI6_f7e3GoQur04ki9Tu7PbFs1XO3FdN1Q_Mw + serviceRegistry: + initFromJson: true + # 登出后允许按 ?service= 参数跳转到指定地址(如回到登录页) + logout: + followServiceRedirects: true + # 认证配置 + authn: + # JDBC 认证配置 + jdbc: + query[0]: + sql: select * from sys_user where username=? + fieldPassword: password + fieldDisabled: disabled + fieldExpired: expired + url: jdbc:mysql://localhost:3306/bsch_userbase?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai + user: root + password: 123456 + driverClass: com.mysql.jdbc.Driver + dialect: org.hibernate.dialect.MySQLDialect + # 密码加密策略:与自定义登录保持一致,使用 BCrypt(strength 10) + # 这样 establishSsoSession 调用 CAS 认证链重新校验密码时也能通过 + passwordEncoder: + type: BCRYPT + strength: 10 + +spring: + # 排除 Spring Boot 的 SecurityAutoConfiguration。 + # 原因:overlay 的 pom 把 spring-security-config/web 设成 compile 进了 war, + # 而 CasWebApplication 的 @SpringBootApplication(exclude=...) 里并未排除 SecurityAutoConfiguration + # (CAS 原本不依赖 spring-security-config,webapp 不自带它)。 + # 一旦 spring-security-config 在 classpath,SecurityAutoConfiguration 就会激活, + # 注册默认 SecurityFilterChain 拦截 /cas/login,用默认登录页 + 默认凭据(user/随机密码)接管认证, + # 导致 CAS webflow 的 accept-users/JDBC handler 完全失效,登录必报 "Reason: none"。 + # 排除后没有任何 Spring Security filter chain,/cas/login 由 CAS webflow 正常处理。 + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.security.SecurityAutoConfiguration + - org.springframework.boot.autoconfigure.security.SecurityFilterAutoConfiguration + - org.springframework.boot.actuate.autoconfigure.ManagementWebSecurityAutoConfiguration + redis: + host: localhost + port: 6379 + password: 123456 + database: 0 + timeout: 5000 + jedis: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 + +logging: + level: + # 排查问题时可临时改为 DEBUG;DEBUG 会打出海量日志,Windows 控制台同步输出会把启动拖慢到几分钟 + org.apereo.cas: INFO + +custom: + rsa: + public-key: | + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqytbHgQWZQmkwmp6WncV + aUmUNgExYDB0Sp8h1qfyKfufbX2MEM6UzSPQHIwurFqkzcHljdrGDCXWZ2Xv8c8C + 27+MOPLf4VyYQ7invqtvmEwS6c3km4QqQVdrh0DJjVYhgO8reE8LhlDFc9Vh9Fwy + Sb8pbC1EM0s2lN6ig7/9NR8+CqxW61ku+7VYnLzcRgpc0cF3K/mQwAjVf1pKjGtP + G1mpLNcHpT2yp5u950GfFtdqYgAte7k4CDlwMDKfpf+2Pg5kZAzogO5DTq/QolOW + qjZfgT2wBNpYVT+3glbhhRbc9T3/SqFeT9KlXcamBXLvKI6WuKxlAEeKTiosUgMK + CQIDAQAB + private-key: MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrK1seBBZlCaTCanpadxVpSZQ2ATFgMHRKnyHWp/Ip+59tfYwQzpTNI9AcjC6sWqTNweWN2sYMJdZnZe/xzwLbv4w48t/hXJhDuKe+q2+YTBLpzeSbhCpBV2uHQMmNViGA7yt4TwuGUMVz1WH0XDJJvylsLUQzSzaU3qKDv/01Hz4KrFbrWS77tVicvNxGClzRwXcr+ZDACNV/WkqMa08bWaks1welPbKnm73nQZ8W12piAC17uTgIOXAwMp+l/7Y+DmRkDOiA7kNOr9CiU5aqNl+BPbAE2lhVP7eCVuGFFtz1Pf9KoV5P0qVdxqYFcu8ojpa4rGUAR4pOKixSAwoJAgMBAAECggEBAJsnYJ9zmw0Y0bS8qY2I9eqLHIJbUHNdWHwvhI+7ikHN6HRp4eV2K+FK/iByXPgz7cDoafjMfsqSHad5+iZYu8GO/j68wHzZ3tc78Sle4nfNNTOjQv5Irbiu0jQ2YLvxf8sMgH28QLkOS/y0RecnDxpT4etBmNlZ6u7EY9FC3PUhkTCb2Ioy+xhA9FNZLc9awmzuqLZMkhUThspRmEFoBDgQV/U0o9OVVi3nEecEUvOCQpv3kwpDZMJECNPxY+qnGs3uKl6hbWXVhhqO9XKOu3wN2AJmakugcBSD6NpFu+C+BOah/kMlcrUXgVQUeObhwXfQjrw4CNyrD1AVvtVWfwECgYEA4Q38R60oZtZqEXz2KvhOM4uvD3def+IrLa6xnjHtqOQ1m7Ok1wLvhVW3P37/HyYyuq3EhRo2drD1qI58wZ9QAvFSnrNMHwqbaYew4OciFs28zPcYgRR1dLEOcz/WBbqhixClrJX22imil/eqxktDamDetSlkKo9F+rInzQukWykCgYEAwrSWRGLjHdQYmeBShOFKb+OPAR2198QTZPN5DmNXVgRfp+cb7FQ7Mw8ZndpDlfu7ARTwhNv0y3piCj8bJtm7BgXaQowwTR0AxRDkqF6fUNVqKEyE25G1hhE2NCD/4b+rAO8IaCCNnjSvAWhXjg7sQHBngJhFxYY68NQx6wuv8+ECgYA9KcLda/bl8/HBrPdt+CuwoI7EIknhcYMQmECXYLRZSSQ0m575/EdCMmhp7kbBnWz+BQm6rsQMf81TsN3A9V4WXqV4kfvnF8fi50iv5sEB94oPHjxfoELHovLtaXTt+6m5lJ2iCOOcqOIScPHNkFjTCV6dV5Fsa6G2H0Ln2/kf+QKBgBJVZMzdrtsRLJvYpoyCljrA56dIP+krmlPoJR74tbGQyd0KgvOW3tgo9NWLRs/pmys36InqGDvMs0yZspnO6LBhfrLDdozcTUVBGPbNqhy3a76Nrfjq2HfeSQdRkCaWoKh+2YDMh+k8trngNFZZC1/p9aUDMZau0//3SxgByrihAoGAPyOKhvPuoQfkbe6kyqGOZoPmctr5mQiHAPzNoKQkGD0pAGrId77KfDjPY8qM0ZTCLuYUJHy3ISefgHKANHLBgPlQasHo6t2f5Wk/173ju+cruW07APe3imXeGkpL4cg4n1YWvSAkwr4GoDXvTd6uL15zM452oxcW8p4hdoITaP0= + login: + # 登录成功(无 service 参数)时的默认落点。必须是静态前端页,绝不能指向 CAS 原生登录页 + # (/cas/login 或 /login)——否则登录成功会被甩回 CAS 登录页形成死循环。 + # 该值会被打包进 WAR 的 classpath application.yml,且会覆盖 LoginController 中 @Value 的默认值, + # 所以这里必须显式写成前端页路径。业务系统带 service 参数时仍走各自回跳地址。 + default-redirect-url: /front/index.html + # 是否强制每次登录都必须校验验证码。 + # false(默认)/ 不配置:维持原逻辑,仅当同一会话登录失败达到阈值后才要求验证码。 + # true:任何登录(含首次)都必须携带并校验验证码,用于彻底阻断批量账号枚举探测。 + always-captcha: false diff --git a/src/main/resources/services/HTTPSandIMAPS-10000001.json b/src/main/resources/services/HTTPSandIMAPS-10000001.json new file mode 100644 index 0000000..cc3ac35 --- /dev/null +++ b/src/main/resources/services/HTTPSandIMAPS-10000001.json @@ -0,0 +1,7 @@ +{ + "@class": "org.apereo.cas.services.RegexRegisteredService", + "serviceId": "^(https|imaps|http)://.*", + "name": "HTTPS and HTTP and IMAPS", + "id": 10000001, + "description": "ÔÊÐíËùÓÐ HTTP/HTTPS ЭÒéµķþÎñ" +} diff --git a/src/main/webapp/front/.gitignore b/src/main/webapp/front/.gitignore new file mode 100644 index 0000000..2c08486 --- /dev/null +++ b/src/main/webapp/front/.gitignore @@ -0,0 +1,2 @@ +/index_.html +/.idea diff --git a/src/main/webapp/front/css/base.css b/src/main/webapp/front/css/base.css new file mode 100644 index 0000000..08bc5ab --- /dev/null +++ b/src/main/webapp/front/css/base.css @@ -0,0 +1 @@ +a{text-decoration:none;color:#337ab7}a:hover{text-decoration:underline}*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;outline:none}html{font-size:14px}body{background-color:#eff2f6} \ No newline at end of file diff --git a/src/main/webapp/front/css/bscroll.css b/src/main/webapp/front/css/bscroll.css new file mode 100644 index 0000000..a2381d9 --- /dev/null +++ b/src/main/webapp/front/css/bscroll.css @@ -0,0 +1 @@ +*::-webkit-scrollbar{width:10px;height:10px}*::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.05);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*:hover::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.2);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*::-webkit-scrollbar-thumb:hover{background-color:rgba(0,0,0,0.3);-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*::-webkit-scrollbar-track{border-radius:10px;-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0);background-color:white}*::-webkit-scrollbar-track:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4);background-color:rgba(0,0,0,0.01)}.scroll2::-webkit-scrollbar{width:10px;height:10px}.scroll2::-webkit-scrollbar-track{border-radius:10px;-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0);background-color:white}.scroll2::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.05);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}.scroll2:hover::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.2);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}.scroll2::-webkit-scrollbar-track:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4)}.scroll2::-webkit-scrollbar-thumb:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4)} \ No newline at end of file diff --git a/src/main/webapp/front/css/index.css b/src/main/webapp/front/css/index.css new file mode 100644 index 0000000..f3cf088 --- /dev/null +++ b/src/main/webapp/front/css/index.css @@ -0,0 +1 @@ +.html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline;line-height:1.5}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:"";content:none}table{border-collapse:collapse;border-spacing:0}html,body{width:100%;height:100%;margin:0;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}*::-webkit-scrollbar{width:10px;height:10px}*::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.05);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*:hover::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.2);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*::-webkit-scrollbar-thumb:hover{background-color:rgba(0,0,0,0.3);-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}*::-webkit-scrollbar-track{border-radius:10px;-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0);background-color:white}*::-webkit-scrollbar-track:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4);background-color:rgba(0,0,0,0.01)}.scroll2::-webkit-scrollbar{width:10px;height:10px}.scroll2::-webkit-scrollbar-track{border-radius:10px;-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0);background-color:white}.scroll2::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.05);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}.scroll2:hover::-webkit-scrollbar-thumb{background-color:rgba(0,0,0,0.2);border-radius:10px;-webkit-box-shadow:inset 1px 1px 0 rgba(0,0,0,0.1)}.scroll2::-webkit-scrollbar-track:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4)}.scroll2::-webkit-scrollbar-thumb:hover{-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,0.4)}.align-middle{position:relative;top:50%;transform:translateY(-50%)}.align-middle-wrap{display:flex;align-items:center}.text-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}*{font-family:"Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","微软雅黑",Arial,sans-serif}html{font-size:14px;color:#272829}body{background-color:#f8f9fa;min-width:1002px;line-height:1.5;font-size:14px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;scroll-behavior:smooth;-webkit-overflow-scrolling:touch}[v-cloak]{display:none}.fl{float:left !important}.fr{float:right !important}.fix{*zoom:1}.fix:after{display:table;content:"";clear:both}.hide{display:none}.main-wrapper{width:1050px;margin:0 auto;position:relative}.base-slider{width:48px;position:absolute;left:7px;bottom:-1px;z-index:1;transition:transform .3s ease-in-out;transform-origin:0 0}.base-slider .indicator{width:40px;height:2px;background:#272829;margin:0 auto}header.header-wrapper{height:88px;background:#fff;position:fixed;top:0;width:100%;z-index:10;box-shadow:0 10px 20px 0 rgba(239,239,239,0.09)}header.header-wrapper .logo-box{height:35px;max-height:60px;overflow:hidden}header.header-wrapper .logo-box img{height:100%}header.header-wrapper .schname-box{line-height:32px;height:32px;margin-left:8px;font-size:20px;font-weight:bold;font-weight:700;font-family:SimSun;font-family:SourceHanSerifCN-Bold,SourceHanSerifCN}header.header-wrapper .app-down{margin-right:5px;font-size:15px;height:35px;line-height:35px;font-weight:400}header.header-wrapper .app-down a{color:#272829;text-decoration:none}header.header-wrapper .down-app-box{width:200px;height:222px;box-sizing:border-box;position:absolute;top:65px;right:0px;background:#fff;border:1px solid #e8e8e8;text-align:center;border-radius:2px;display:none}header.header-wrapper .down-app-box .tips-box{position:absolute;top:0px;right:40px}header.header-wrapper .down-app-box .tips-box::after{display:block;content:"";position:absolute;left:0px;top:-9px;width:0;height:0;border-right:8px solid transparent;border-bottom:8px solid #e8e8e8;border-left:8px solid transparent;z-index:2}header.header-wrapper .down-app-box .tips-box::before{display:block;content:"";position:absolute;left:0px;top:-8px;width:0;height:0;border-right:8px solid transparent;border-bottom:8px solid #fff;border-left:8px solid transparent;z-index:4}header.header-wrapper .down-app-box h3{height:20px;font-size:15px;font-weight:400;color:#272829;line-height:20px;margin-top:5px}header.header-wrapper .down-app-box img{margin-top:26px;width:140px;height:140px}header.header-wrapper .main-wrapper{height:100%}.banner-wrapper{width:100%;height:480px;min-height:480px;position:relative;padding-top:88px}.banner-wrapper .main-wrapper{height:100%}.banner-wrapper .banner-box{position:absolute;width:100%;left:0;top:88px;bottom:0;z-index:1;transform:translate3d(0, 0, 0);overflow:hidden}.banner-wrapper .banner-box img{width:100%;height:100%;object-fit:cover;font-family:"object-fit: cover;"}.banner-wrapper .warning-box span{color:#ff0000}.banner-wrapper .qrcode-box{margin-top:35px;margin-bottom:35px;cursor:pointer}.banner-wrapper .login-wrapper{width:320px;background:#ffffff;border-radius:6px;box-sizing:border-box;position:relative;z-index:3}.banner-wrapper .login-header{border-bottom:1px solid #f6f6f6;padding-top:10px;position:relative;margin:0 30px}.banner-wrapper .login-header ul{padding:14px 0}.banner-wrapper .login-header ul li{user-select:none;cursor:pointer;height:20px;line-height:20px;margin-right:14px;color:#656667;font-weight:550}.banner-wrapper .login-header ul li.active{color:#272829}.banner-wrapper .login-header .header-slider{width:48px;position:absolute;left:7px;bottom:-1px;z-index:1;transition:transform .3s ease-in-out;transform-origin:0 0;left:0}.banner-wrapper .login-header .header-slider .indicator{width:40px;height:2px;background:#272829;margin:0 auto}.banner-wrapper .login-header .header-slider .indicator{float:left}.banner-wrapper .login-header .header-slider.active-1{transform:translateX(0)}.banner-wrapper .login-header .header-slider.active-2{transform:translateX(70px)}.banner-wrapper .login-header .header-slider.active-3{transform:translateX(140px)}.banner-wrapper .login-body{position:relative}.banner-wrapper .login-footer{height:22px;margin:15px 40px}.banner-wrapper .login-form{margin:40px 30px 20px}.banner-wrapper .login-form .form-item{margin-top:17px;width:100%;height:40px;background:#fafafa;border-radius:4px;border:1px solid #f1f1f1}.banner-wrapper .login-form .icon-box{background:#fff;width:48px;height:40px;float:left;display:flex;align-items:center;justify-content:center;border-right:1px solid #f1f1f1;box-sizing:border-box}.banner-wrapper .login-form p.input-box{height:40px;box-sizing:border-box}.banner-wrapper .login-form p.input-box input{width:100%;height:40px;padding:4px 10px 4px 16px;box-sizing:border-box;border:none;outline:none;background-color:#fafafa}.banner-wrapper .login-form p.input-box #password_input{width:calc(100% - 48px)}.banner-wrapper .login-form p.input-box #password_input::-ms-clear{display:none}.banner-wrapper .login-form p.input-box #password_input::-ms-reveal{display:none}.banner-wrapper .login-form p.input-box .pwd-icon-box{background:#fff;width:48px;height:40px;float:left;display:flex;align-items:center;justify-content:center;border-right:1px solid #f1f1f1;box-sizing:border-box;float:right;background-color:#fafafa;cursor:pointer;user-select:none}.banner-wrapper .login-form .operate-box{margin:12px 0;*zoom:1}.banner-wrapper .login-form .operate-box label{color:#b1b1b1;font-size:12px;user-select:none}.banner-wrapper .login-form .operate-box .login-checkbox{outline:none;cursor:pointer;position:relative;top:-1px;vertical-align:middle}.banner-wrapper .login-form .operate-box a{font-size:12px;float:right;color:#6d8cf0}.banner-wrapper .btn-submit{display:inline-block;line-height:42px;height:40px;width:100%;font-size:14px;color:#fff;cursor:pointer;text-decoration:none;outline:none;padding:0px 20px;position:relative;border-radius:4px;overflow:hidden;background:#6b8cf4;border:none;margin-bottom:10px}.banner-wrapper .btn-submit::before{position:absolute;top:0;right:0;bottom:0;left:0px;background:rgba(0,0,0,0.1);box-sizing:content-box}.banner-wrapper .btn-submit::after{position:absolute;top:0;right:0;bottom:0;left:0;background:rgba(255,255,255,0.2)}.banner-wrapper .btn-submit:hover::before{content:""}.banner-wrapper .btn-submit:active::after{content:""}.banner-wrapper .btn-vercode{overflow:hidden;background:#6b8cf4;color:#fff;cursor:pointer;text-decoration:none;border:none;outline:none;width:100px;height:36px;line-height:36px;border-radius:36px;margin:0;margin-top:17px}.banner-wrapper .btn-vercode[disabled]{background:#b6c5f7}.banner-wrapper .vercode-wrap .form-item{width:calc(100% - 116px)}.banner-wrapper .qrcode-box{text-align:center}.banner-wrapper .qrcode-box img{margin:42px auto 25px;width:130px;height:130px}.banner-wrapper .qrcode-box p{font-size:13px;color:#656667;margin:0 40px}.banner-wrapper .forgetpw-wrapper{width:320px;background:#ffffff;border-radius:6px;box-sizing:border-box;position:relative;z-index:3}.banner-wrapper .forgetpw-wrapper .login-form{margin-top:10px}.banner-wrapper .forgetpw-stats-box{text-align:center}.banner-wrapper .forgetpw-stats-box img{width:90px;height:90px;object-fit:cover;margin:60px 0 30px}.banner-wrapper .forgetpw-stats-box h4{height:32px;font-size:24px;font-weight:500;color:rgba(0,0,0,0.85000002);line-height:32px}.banner-wrapper .forgetpw-stats-box p{height:22px;font-size:14px;font-weight:400;color:rgba(0,0,0,0.44999999);line-height:22px;margin:10px 0}.banner-wrapper .forgetpw-stats-box button{width:260px;margin:30px}.banner-wrapper .tips-info-box{color:#858585;font-size:12px;margin:15px 30px}.tabs{height:40px;margin-top:15px;border-bottom:1px solid #ececec;box-sizing:border-box;padding:7px;position:relative}.tabs .tabs-nav li{float:left;color:#666;font-size:12px;user-select:none;height:25px;line-height:25px;cursor:pointer}.tabs .tabs-nav li.spacer-li{width:16px}.tabs .tabs-nav li.spacer-li span{margin-left:6px}.tabs .tabs-nav li.active{color:#272829;font-size:13px;font-weight:bold}.tabs .tabs-slider{width:48px;position:absolute;left:7px;bottom:-1px;z-index:1;transition:transform .3s ease-in-out;transform-origin:0 0}.tabs .tabs-slider .indicator{width:40px;height:2px;background:#272829;margin:0 auto}.app-ul{padding-left:60px;background:#fff;padding-bottom:38px}.app-ul>li{float:left;height:100px;width:16.66666667%;padding-right:60px;margin-top:38px;text-align:center;box-sizing:border-box}.app-ul>li a{*zoom:1;display:block;text-decoration:none;color:#272829;height:64px;width:64px;margin:0 auto;border-radius:4px;overflow:hidden;outline:none;background:linear-gradient(180deg, #f8f8f9 0%, #ffffff 21%, #f7f7f7 100%);box-shadow:0 10px 20px 0 rgba(227,230,236,0.33);border-radius:10px;border:1px solid #eaedf1}.app-ul>li a .icon-box{width:44px;height:44px;text-align:center;margin:11px}.app-ul>li a .icon-box img{width:100%;height:100%;object-fit:cover;border-radius:50%}.app-ul>li h4.info{font-size:14px;line-height:16px;height:16px;margin-top:12px;color:#272829;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-list-wrapper{width:1050px;margin:0 auto;position:relative;box-shadow:0 10px 20px 0 rgba(0,0,0,0.04);border-radius:4px;margin-top:18px;margin-bottom:20px}.wait-app-wrapper{width:1050px;margin:0 auto;position:relative;box-shadow:0 10px 20px 0 rgba(0,0,0,0.04);border-radius:4px;margin-top:20px;margin-bottom:30px}.wait-app-wrapper h3.title{height:61px;line-height:25px;font-size:18px;font-weight:bold;background:#fff;text-align:center;padding:18px;box-sizing:border-box;border-bottom:1px solid #ececec}.footer-container{background:#374a5f;width:100%;height:98px}.footer-container p{font-size:12px;font-weight:400;color:rgba(255,255,255,0.35);text-align:center;font-family:SimSun;font-family:SourceHanSansCN-Regular,SourceHanSansCN}.footer-container p a{color:rgba(255,255,255,0.35);text-decoration:none}.footer-container.theme-light{background:#fff}.footer-container.theme-light p{color:#272829}.footer-container.theme-light p a{color:#272829;text-decoration:none}.swiper-container{min-height:480px}.swiper-slide{transform:translate3d(0, 0, 0)}.swiper-pagination{position:absolute;left:0;text-align:center;bottom:5px;width:100%}.swiper-pagination-bullet{margin:0 3px;width:8px;height:8px;display:inline-block;border-radius:50%;background:#000;opacity:.2}.swiper-pagination-bullet-active{background:#f1f1f1;opacity:1} \ No newline at end of file diff --git a/src/main/webapp/front/css/mobile.css b/src/main/webapp/front/css/mobile.css new file mode 100644 index 0000000..17df4e6 --- /dev/null +++ b/src/main/webapp/front/css/mobile.css @@ -0,0 +1 @@ +@media (max-width:768px){body{min-width:100%}html,body,#app{height:100%}.app-down,.banner-box{display:none}.footer-container{height:58px}header.header-wrapper{height:58px;padding:0 10px}.banner-wrapper{padding-top:48px;height:calc(100% - 105px)}.banner-box,.tabs,.app-list-wrapper,.wait-app-wrapper{display:none !important}.main-wrapper{width:100% !important;margin:0 auto !important}.login-wrapper{width:90% !important;margin:0 auto !important ;float:none !important}.login-header ul{padding:14px 0 !important}.login-header ul li{margin-right:10px !important}.login-form{margin:20px 15px !important}.login-form .form-item{margin-top:15px !important}.login-form .icon-box{width:40px !important;height:40px !important}.login-form p.input-box input{padding:4px 10px 4px 12px !important}.operate-box{margin:12px 0 !important}.operate-box a{font-size:12px !important}.btn-submit{height:40px !important;line-height:40px !important;font-size:14px !important;margin-bottom:10px !important}} \ No newline at end of file diff --git a/src/main/webapp/front/css/normalize.css b/src/main/webapp/front/css/normalize.css new file mode 100644 index 0000000..77feb20 --- /dev/null +++ b/src/main/webapp/front/css/normalize.css @@ -0,0 +1 @@ +article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block;}audio,canvas,video{display:inline-block;}audio:not([controls]){display:none;height:0;}[hidden]{display:none;}html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%;}body{margin:0;}a:focus{outline:thin dotted;}a:active,a:hover{outline:0;}h1{font-size:2em;margin:0.67em 0;}abbr[title]{border-bottom:1px dotted;}b,strong{font-weight:bold;}dfn{font-style:italic;}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0;}mark{background:#ff0;color:#000;}code,kbd,pre,samp{font-family:monospace,serif;font-size:1em;}pre{white-space:pre-wrap;}q{quotes:"\201C" "\201D" "\2018" "\2019";}small{font-size:80%;}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline;}sup{top:-0.5em;}sub{bottom:-0.25em;}img{border:0;}svg:not(:root){overflow:hidden;}figure{margin:0;}fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:0.35em 0.625em 0.75em;}legend{border:0;padding:0;}button,input,select,textarea{font-family:inherit;font-size:100%;margin:0;}button,input{line-height:normal;}button,select{text-transform:none;}button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer;}button[disabled],html input[disabled]{cursor:default;}input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0;}input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box;}input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none;}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0;}textarea{overflow:auto;vertical-align:top;}table{border-collapse:collapse;border-spacing:0;} \ No newline at end of file diff --git a/src/main/webapp/front/css/reset.css b/src/main/webapp/front/css/reset.css new file mode 100644 index 0000000..fbebfc3 --- /dev/null +++ b/src/main/webapp/front/css/reset.css @@ -0,0 +1 @@ +.html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline;line-height:1.5}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:"";content:none}table{border-collapse:collapse;border-spacing:0}html,body{width:100%;height:100%;margin:0;scroll-behavior:smooth;-webkit-overflow-scrolling:touch} \ No newline at end of file diff --git a/src/main/webapp/front/css/reset.less b/src/main/webapp/front/css/reset.less new file mode 100644 index 0000000..15e60d5 --- /dev/null +++ b/src/main/webapp/front/css/reset.less @@ -0,0 +1,2 @@ +/* http://meyerweb.com/eric/tools/css/reset/ v2.0 | 20110126 License: none (public domain)*/ +.html, body, div, span, applet, object, iframe,h1, h2, h3, h4, h5, h6, p, blockquote, pre,a, abbr, acronym, address, big, cite, code,del, dfn, em, img, ins, kbd, q, s, samp,small, strike, strong, sub, sup, tt, var,b, u, i, center,dl, dt, dd, ol, ul, li,fieldset, form, label, legend,table, caption, tbody, tfoot, thead, tr, th, td,article, aside, canvas, details, embed,figure, figcaption, footer, header, hgroup,menu, nav, output, ruby, section, summary,time, mark, audio, video { margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline;line-height: 1.5;}/* HTML5 display-role reset for older browsers */article, aside, details, figcaption, figure,footer, header, hgroup, menu, nav, section { display: block;}body { line-height: 1;}ol, ul { list-style: none;}blockquote, q { quotes: none;}blockquote:before, blockquote:after,q:before, q:after { content: ''; content: none;}table { border-collapse: collapse; border-spacing: 0;}html, body { width: 100%; height: 100%; overflow: auto; margin: 0; scroll-behavior: smooth; -webkit-overflow-scrolling: touch;} \ No newline at end of file diff --git a/src/main/webapp/front/css/tab.css b/src/main/webapp/front/css/tab.css new file mode 100644 index 0000000..66142d4 --- /dev/null +++ b/src/main/webapp/front/css/tab.css @@ -0,0 +1,295 @@ +/* @import "https://fonts.googleapis.com/css?family=Montserrat:400,700|Raleway:300,400"; */ +/* colors */ +/* tab setting */ +/* breakpoints */ +/* selectors relative to radio inputs */ +html { + width: 100%; + height: 100%; +} + +body { + /*background: #efefef;*/ + color: #333; + /* font-family: "Raleway"; */ + height: 100%; +} +body h1 { + text-align: center; + color: #428BFF; + font-weight: 300; + padding: 40px 0 20px 0; + margin: 0; +} + +.tabs { + left: 50%; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); + position: relative; + background: white; + padding: 50px; + padding-bottom: 80px; + margin-top: 50px; + width: 60%; + /*height: 250px;*/ + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); + border-radius: 5px; + min-width: 240px; +} +.tabs input[name="tab-control"] { + display: none; +} +.tabs .content section h2, +.tabs ul li label { + font-family: "Montserrat"; + font-weight: bold; + font-size: 18px; + color: #428BFF; +} +.tabs ul { + list-style-type: none; + padding-left: 0; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-orient: horizontal; + -webkit-box-direction: normal; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + margin-bottom: 10px; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + -webkit-box-align: end; + -webkit-align-items: flex-end; + -ms-flex-align: end; + align-items: flex-end; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; +} +.tabs ul li { + box-sizing: border-box; + -webkit-box-flex: 1; + -webkit-flex: 1; + -ms-flex: 1; + flex: 1; + width: 25%; + padding: 0 10px; + text-align: center; +} +.tabs ul li label { + -webkit-transition: all 0.3s ease-in-out; + transition: all 0.3s ease-in-out; + color: #929daf; + padding: 5px auto; + overflow: hidden; + text-overflow: ellipsis; + display: block; + cursor: pointer; + -webkit-transition: all 0.2s ease-in-out; + transition: all 0.2s ease-in-out; + white-space: nowrap; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} +.tabs ul li label br { + display: none; +} +.tabs ul li label svg { + fill: #929daf; + height: 1.2em; + vertical-align: bottom; + margin-right: 0.2em; + -webkit-transition: all 0.2s ease-in-out; + transition: all 0.2s ease-in-out; +} +.tabs ul li label:hover, .tabs ul li label:focus, .tabs ul li label:active { + outline: 0; + color: #bec5cf; +} +.tabs ul li label:hover svg, .tabs ul li label:focus svg, .tabs ul li label:active svg { + fill: #bec5cf; +} +.tabs .slider { + position: relative; + width: 25%; + -webkit-transition: all 0.33s cubic-bezier(0.38, 0.8, 0.32, 1.07); + transition: all 0.33s cubic-bezier(0.38, 0.8, 0.32, 1.07); +} +.tabs .slider .indicator { + position: relative; + width: 50px; + max-width: 100%; + margin: 0 auto; + height: 4px; + background: #428BFF; + border-radius: 1px; +} +.tabs .content { + margin-top: 30px; +} +.tabs .content section { + display: none; + -webkit-animation-name: content; + animation-name: content; + -webkit-animation-direction: normal; + animation-direction: normal; + -webkit-animation-duration: 0.3s; + animation-duration: 0.3s; + -webkit-animation-timing-function: ease-in-out; + animation-timing-function: ease-in-out; + -webkit-animation-iteration-count: 1; + animation-iteration-count: 1; + line-height: 1.4; +} +.tabs .content section h2 { + color: #428BFF; + display: none; +} +.tabs .content section h2::after { + content: ""; + position: relative; + display: block; + width: 30px; + height: 3px; + background: #428BFF; + margin-top: 5px; + left: 1px; +} +.tabs input[name="tab-control"]:nth-of-type(1):checked ~ ul > li:nth-child(1) > label { + cursor: default; + color: #428BFF; +} +.tabs input[name="tab-control"]:nth-of-type(1):checked ~ ul > li:nth-child(1) > label svg { + fill: #428BFF; +} +@media (max-width: 600px) { + .tabs input[name="tab-control"]:nth-of-type(1):checked ~ ul > li:nth-child(1) > label { + background: rgba(0, 0, 0, 0.08); + } +} +.tabs input[name="tab-control"]:nth-of-type(1):checked ~ .slider { + -webkit-transform: translateX(0%); + transform: translateX(0%); +} +.tabs input[name="tab-control"]:nth-of-type(1):checked ~ .content > section:nth-child(1) { + display: block; +} +.tabs input[name="tab-control"]:nth-of-type(2):checked ~ ul > li:nth-child(2) > label { + cursor: default; + color: #428BFF; +} +.tabs input[name="tab-control"]:nth-of-type(2):checked ~ ul > li:nth-child(2) > label svg { + fill: #428BFF; +} +@media (max-width: 600px) { + .tabs input[name="tab-control"]:nth-of-type(2):checked ~ ul > li:nth-child(2) > label { + background: rgba(0, 0, 0, 0.08); + } +} +.tabs input[name="tab-control"]:nth-of-type(2):checked ~ .slider { + -webkit-transform: translateX(100%); + transform: translateX(100%); +} +.tabs input[name="tab-control"]:nth-of-type(2):checked ~ .content > section:nth-child(2) { + display: block; +} +.tabs input[name="tab-control"]:nth-of-type(3):checked ~ ul > li:nth-child(3) > label { + cursor: default; + color: #428BFF; +} +.tabs input[name="tab-control"]:nth-of-type(3):checked ~ ul > li:nth-child(3) > label svg { + fill: #428BFF; +} +@media (max-width: 600px) { + .tabs input[name="tab-control"]:nth-of-type(3):checked ~ ul > li:nth-child(3) > label { + background: rgba(0, 0, 0, 0.08); + } +} +.tabs input[name="tab-control"]:nth-of-type(3):checked ~ .slider { + -webkit-transform: translateX(200%); + transform: translateX(200%); +} +.tabs input[name="tab-control"]:nth-of-type(3):checked ~ .content > section:nth-child(3) { + display: block; +} +.tabs input[name="tab-control"]:nth-of-type(4):checked ~ ul > li:nth-child(4) > label { + cursor: default; + color: #428BFF; +} +.tabs input[name="tab-control"]:nth-of-type(4):checked ~ ul > li:nth-child(4) > label svg { + fill: #428BFF; +} +@media (max-width: 600px) { + .tabs input[name="tab-control"]:nth-of-type(4):checked ~ ul > li:nth-child(4) > label { + background: rgba(0, 0, 0, 0.08); + } +} +.tabs input[name="tab-control"]:nth-of-type(4):checked ~ .slider { + -webkit-transform: translateX(300%); + transform: translateX(300%); +} +.tabs input[name="tab-control"]:nth-of-type(4):checked ~ .content > section:nth-child(4) { + display: block; +} +@-webkit-keyframes content { + from { + opacity: 0; + -webkit-transform: translateY(5%); + transform: translateY(5%); + } + to { + opacity: 1; + -webkit-transform: translateY(0%); + transform: translateY(0%); + } +} +@keyframes content { + from { + opacity: 0; + -webkit-transform: translateY(5%); + transform: translateY(5%); + } + to { + opacity: 1; + -webkit-transform: translateY(0%); + transform: translateY(0%); + } +} +@media (max-width: 1000px) { + .tabs ul li label { + white-space: initial; + } + .tabs ul li label br { + display: initial; + } + .tabs ul li label svg { + height: 1.5em; + } +} +@media (max-width: 600px) { + .tabs ul li label { + padding: 5px; + border-radius: 5px; + } + .tabs ul li label span { + display: none; + } + .tabs .slider { + display: none; + } + .tabs .content { + margin-top: 20px; + } + .tabs .content section h2 { + display: block; + } +} \ No newline at end of file diff --git a/src/main/webapp/front/css/utils.css b/src/main/webapp/front/css/utils.css new file mode 100644 index 0000000..619f6c2 --- /dev/null +++ b/src/main/webapp/front/css/utils.css @@ -0,0 +1 @@ +.text-black{color:#000}.text-dark{color:#333}.text-deep{color:#555}.text-weak{color:#B3B3B3}.text-white{color:#fff}.tl{text-align:left}.tc{text-align:center}.tr{text-align:right}.fl{float:left}.fr{float:right}.fix{*zoom:1}.fix:after{display:table;content:'';clear:both}.dn{display:none}.di{display:inline}.db{display:block}.dib{display:inline-block}.dt{display:table}div.dib{*display:inline;*zoom:1}.vm{vertical-align:middle}.vib{display:inline-block;vertical-align:middle}.pr{position:relative}.pa{position:absolute}.pf{position:fixed}.ml-0{margin-left:0 !important}.mr-0{margin-right:0 !important}.mt-0{margin-top:0 !important}.mb-0{margin-bottom:0 !important}.ml-1{margin-left:1px !important}.mr-1{margin-right:1px !important}.mt-1{margin-top:1px !important}.mb-1{margin-bottom:1px !important}.ml-2{margin-left:2px !important}.mr-2{margin-right:2px !important}.mt-2{margin-top:2px !important}.mb-2{margin-bottom:2px !important}.ml-3{margin-left:3px !important}.mr-3{margin-right:3px !important}.mt-3{margin-top:3px !important}.mb-3{margin-bottom:3px !important}.ml-4{margin-left:4px !important}.mr-4{margin-right:4px !important}.mt-4{margin-top:4px !important}.mb-4{margin-bottom:4px !important}.ml-5{margin-left:5px !important}.mr-5{margin-right:5px !important}.mt-5{margin-top:5px !important}.mb-5{margin-bottom:5px !important}.ml-6{margin-left:6px !important}.mr-6{margin-right:6px !important}.mt-6{margin-top:6px !important}.mb-6{margin-bottom:6px !important}.ml-7{margin-left:7px !important}.mr-7{margin-right:7px !important}.mt-7{margin-top:7px !important}.mb-7{margin-bottom:7px !important}.ml-8{margin-left:8px !important}.mr-8{margin-right:8px !important}.mt-8{margin-top:8px !important}.mb-8{margin-bottom:8px !important}.ml-9{margin-left:9px !important}.mr-9{margin-right:9px !important}.mt-9{margin-top:9px !important}.mb-9{margin-bottom:9px !important}.ml-10{margin-left:10px !important}.mr-10{margin-right:10px !important}.mt-10{margin-top:10px !important}.mb-10{margin-bottom:10px !important}.ml-11{margin-left:11px !important}.mr-11{margin-right:11px !important}.mt-11{margin-top:11px !important}.mb-11{margin-bottom:11px !important}.ml-12{margin-left:12px !important}.mr-12{margin-right:12px !important}.mt-12{margin-top:12px !important}.mb-12{margin-bottom:12px !important}.ml-13{margin-left:13px !important}.mr-13{margin-right:13px !important}.mt-13{margin-top:13px !important}.mb-13{margin-bottom:13px !important}.ml-14{margin-left:14px !important}.mr-14{margin-right:14px !important}.mt-14{margin-top:14px !important}.mb-14{margin-bottom:14px !important}.ml-15{margin-left:15px !important}.mr-15{margin-right:15px !important}.mt-15{margin-top:15px !important}.mb-15{margin-bottom:15px !important}.ml-16{margin-left:16px !important}.mr-16{margin-right:16px !important}.mt-16{margin-top:16px !important}.mb-16{margin-bottom:16px !important}.ml-17{margin-left:17px !important}.mr-17{margin-right:17px !important}.mt-17{margin-top:17px !important}.mb-17{margin-bottom:17px !important}.ml-18{margin-left:18px !important}.mr-18{margin-right:18px !important}.mt-18{margin-top:18px !important}.mb-18{margin-bottom:18px !important}.ml-19{margin-left:19px !important}.mr-19{margin-right:19px !important}.mt-19{margin-top:19px !important}.mb-19{margin-bottom:19px !important}.ml-20{margin-left:20px !important}.mr-20{margin-right:20px !important}.mt-20{margin-top:20px !important}.mb-20{margin-bottom:20px !important}.ml-21{margin-left:21px !important}.mr-21{margin-right:21px !important}.mt-21{margin-top:21px !important}.mb-21{margin-bottom:21px !important}.ml-22{margin-left:22px !important}.mr-22{margin-right:22px !important}.mt-22{margin-top:22px !important}.mb-22{margin-bottom:22px !important}.ml-23{margin-left:23px !important}.mr-23{margin-right:23px !important}.mt-23{margin-top:23px !important}.mb-23{margin-bottom:23px !important}.ml-24{margin-left:24px !important}.mr-24{margin-right:24px !important}.mt-24{margin-top:24px !important}.mb-24{margin-bottom:24px !important}.ml-25{margin-left:25px !important}.mr-25{margin-right:25px !important}.mt-25{margin-top:25px !important}.mb-25{margin-bottom:25px !important}.ml-26{margin-left:26px !important}.mr-26{margin-right:26px !important}.mt-26{margin-top:26px !important}.mb-26{margin-bottom:26px !important}.ml-27{margin-left:27px !important}.mr-27{margin-right:27px !important}.mt-27{margin-top:27px !important}.mb-27{margin-bottom:27px !important}.ml-28{margin-left:28px !important}.mr-28{margin-right:28px !important}.mt-28{margin-top:28px !important}.mb-28{margin-bottom:28px !important}.ml-29{margin-left:29px !important}.mr-29{margin-right:29px !important}.mt-29{margin-top:29px !important}.mb-29{margin-bottom:29px !important}.ml-30{margin-left:30px !important}.mr-30{margin-right:30px !important}.mt-30{margin-top:30px !important}.mb-30{margin-bottom:30px !important}.ml-31{margin-left:31px !important}.mr-31{margin-right:31px !important}.mt-31{margin-top:31px !important}.mb-31{margin-bottom:31px !important}.ml-32{margin-left:32px !important}.mr-32{margin-right:32px !important}.mt-32{margin-top:32px !important}.mb-32{margin-bottom:32px !important}.ml-33{margin-left:33px !important}.mr-33{margin-right:33px !important}.mt-33{margin-top:33px !important}.mb-33{margin-bottom:33px !important}.ml-34{margin-left:34px !important}.mr-34{margin-right:34px !important}.mt-34{margin-top:34px !important}.mb-34{margin-bottom:34px !important}.ml-35{margin-left:35px !important}.mr-35{margin-right:35px !important}.mt-35{margin-top:35px !important}.mb-35{margin-bottom:35px !important}.ml-36{margin-left:36px !important}.mr-36{margin-right:36px !important}.mt-36{margin-top:36px !important}.mb-36{margin-bottom:36px !important}.ml-37{margin-left:37px !important}.mr-37{margin-right:37px !important}.mt-37{margin-top:37px !important}.mb-37{margin-bottom:37px !important}.ml-38{margin-left:38px !important}.mr-38{margin-right:38px !important}.mt-38{margin-top:38px !important}.mb-38{margin-bottom:38px !important}.ml-39{margin-left:39px !important}.mr-39{margin-right:39px !important}.mt-39{margin-top:39px !important}.mb-39{margin-bottom:39px !important}.ml-40{margin-left:40px !important}.mr-40{margin-right:40px !important}.mt-40{margin-top:40px !important}.mb-40{margin-bottom:40px !important}.ml-41{margin-left:41px !important}.mr-41{margin-right:41px !important}.mt-41{margin-top:41px !important}.mb-41{margin-bottom:41px !important}.ml-42{margin-left:42px !important}.mr-42{margin-right:42px !important}.mt-42{margin-top:42px !important}.mb-42{margin-bottom:42px !important}.ml-43{margin-left:43px !important}.mr-43{margin-right:43px !important}.mt-43{margin-top:43px !important}.mb-43{margin-bottom:43px !important}.ml-44{margin-left:44px !important}.mr-44{margin-right:44px !important}.mt-44{margin-top:44px !important}.mb-44{margin-bottom:44px !important}.ml-45{margin-left:45px !important}.mr-45{margin-right:45px !important}.mt-45{margin-top:45px !important}.mb-45{margin-bottom:45px !important}.ml-46{margin-left:46px !important}.mr-46{margin-right:46px !important}.mt-46{margin-top:46px !important}.mb-46{margin-bottom:46px !important}.ml-47{margin-left:47px !important}.mr-47{margin-right:47px !important}.mt-47{margin-top:47px !important}.mb-47{margin-bottom:47px !important}.ml-48{margin-left:48px !important}.mr-48{margin-right:48px !important}.mt-48{margin-top:48px !important}.mb-48{margin-bottom:48px !important}.ml-49{margin-left:49px !important}.mr-49{margin-right:49px !important}.mt-49{margin-top:49px !important}.mb-49{margin-bottom:49px !important}.ml-50{margin-left:50px !important}.mr-50{margin-right:50px !important}.mt-50{margin-top:50px !important}.mb-50{margin-bottom:50px !important}.text-ellipsis{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.col-12{width:100%}.col-11{width:91.66666667%}.col-10{width:83.33333333%}.col-9{width:75%}.col-8{width:66.66666667%}.col-7{width:58.33333333%}.col-6{width:50%}.col-5{width:41.66666667%}.col-4{width:33.33333333%}.col-3{width:25%}.col-2{width:16.66666667%}.col-1{width:8.33333333%}.col-4,.col-8,.col-12{position:relative;min-height:1px;padding:10px} \ No newline at end of file diff --git a/src/main/webapp/front/image/banner-bg.png b/src/main/webapp/front/image/banner-bg.png new file mode 100644 index 0000000..748be60 Binary files /dev/null and b/src/main/webapp/front/image/banner-bg.png differ diff --git a/src/main/webapp/front/image/banner-bg2.png b/src/main/webapp/front/image/banner-bg2.png new file mode 100644 index 0000000..a2f7541 Binary files /dev/null and b/src/main/webapp/front/image/banner-bg2.png differ diff --git a/src/main/webapp/front/image/banner-bg3.png b/src/main/webapp/front/image/banner-bg3.png new file mode 100644 index 0000000..992cf03 Binary files /dev/null and b/src/main/webapp/front/image/banner-bg3.png differ diff --git a/src/main/webapp/front/image/banner-bg4.png b/src/main/webapp/front/image/banner-bg4.png new file mode 100644 index 0000000..e2ac7bc Binary files /dev/null and b/src/main/webapp/front/image/banner-bg4.png differ diff --git a/src/main/webapp/front/image/banner-bg5.png b/src/main/webapp/front/image/banner-bg5.png new file mode 100644 index 0000000..485a727 Binary files /dev/null and b/src/main/webapp/front/image/banner-bg5.png differ diff --git a/src/main/webapp/front/image/login-sprites.png b/src/main/webapp/front/image/login-sprites.png new file mode 100644 index 0000000..eeb72e1 Binary files /dev/null and b/src/main/webapp/front/image/login-sprites.png differ diff --git a/src/main/webapp/front/image/login-sprites2.png b/src/main/webapp/front/image/login-sprites2.png new file mode 100644 index 0000000..6b0dd99 Binary files /dev/null and b/src/main/webapp/front/image/login-sprites2.png differ diff --git a/src/main/webapp/front/image/logo.png b/src/main/webapp/front/image/logo.png new file mode 100644 index 0000000..76c93e6 Binary files /dev/null and b/src/main/webapp/front/image/logo.png differ diff --git a/src/main/webapp/front/image/logo2.png b/src/main/webapp/front/image/logo2.png new file mode 100644 index 0000000..171fdce Binary files /dev/null and b/src/main/webapp/front/image/logo2.png differ diff --git a/src/main/webapp/front/image/success-stats.png b/src/main/webapp/front/image/success-stats.png new file mode 100644 index 0000000..d19cf0f Binary files /dev/null and b/src/main/webapp/front/image/success-stats.png differ diff --git a/src/main/webapp/front/image/wem3.png b/src/main/webapp/front/image/wem3.png new file mode 100644 index 0000000..c25c3ac Binary files /dev/null and b/src/main/webapp/front/image/wem3.png differ diff --git a/src/main/webapp/front/index.html b/src/main/webapp/front/index.html new file mode 100644 index 0000000..f430a14 --- /dev/null +++ b/src/main/webapp/front/index.html @@ -0,0 +1,281 @@ + + + + + + + + + + 登录 + + + + + + + + + + + + + + + + + + + diff --git a/src/main/webapp/front/index.old.html b/src/main/webapp/front/index.old.html new file mode 100644 index 0000000..45c3640 --- /dev/null +++ b/src/main/webapp/front/index.old.html @@ -0,0 +1,746 @@ + + + + + + + + + + + 登录 + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ +
+
+ +

{{downInfo}}

+
+
+
+ + +
+
    + +
+
+
+
+
+
+ +
+ + +
+ + + + + + diff --git a/src/main/webapp/front/js/bschlogin.js b/src/main/webapp/front/js/bschlogin.js new file mode 100644 index 0000000..22962c5 --- /dev/null +++ b/src/main/webapp/front/js/bschlogin.js @@ -0,0 +1,164 @@ +function BschLogin(ssoServerHost) { + this.isDebug = false; + this.ssoServerHost = ssoServerHost; + this.captchaUrl = ssoServerHost + "api/captcha"; + this.loginUrl = ssoServerHost + "api/login"; + this.publicKeyUrl = ssoServerHost + "api/public-key"; + this.needCaptchaUrl = ssoServerHost + "api/need-captcha"; + this.publicKey = ""; + // this.setCookie("loginPageTag", loginPageTag, 30); +} + +BschLogin.prototype = { + getPublicKey: function () { + var _this = this; + if (_this.isDebug) { + _this.publicKey = + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqytbHgQWZQmkwmp6WncVaUmUNgExYDB0Sp8h1qfyKfufbX2MEM6UzSPQHIwurFqkzcHljdrGDCXWZ2Xv8c8C27+MOPLf4VyYQ7invqtvmEwS6c3km4QqQVdrh0DJjVYhgO8reE8LhlDFc9Vh9FwySb8pbC1EM0s2lN6ig7/9NR8+CqxW61ku+7VYnLzcRgpc0cF3K/mQwAjVf1pKjGtPG1mpLNcHpT2yp5u950GfFtdqYgAte7k4CDlwMDKfpf+2Pg5kZAzogO5DTq/QolOWqjZfgT2wBNpYVT+3glbhhRbc9T3/SqFeT9KlXcamBXLvKI6WuKxlAEeKTiosUgMKCQIDAQAB"; + return; + } + $.get( + _this.publicKeyUrl, + function (resp) { + _this.publicKey = resp.data.publicKey; + }, + "json", + ); + }, + //是否需要验证校验码 + isNeedCaptcha: function (callback) { + if (this.isDebug) return callback(false); + $.get( + this.needCaptchaUrl, + function (resp) { + if (resp.code == 0) { + var needCaptcha = resp.data.needCaptcha; + callback(needCaptcha); + } else { + callback(true); + } + }, + "json", + ); + }, + + //登录 + login: function (username, pwd, captcha, onSuccess, onFailed) { + var service = this.getQueryVariable("service"); + var encrypt = new JSEncrypt(); + encrypt.setPublicKey(this.publicKey); + username = encrypt.encrypt(username); + pwd = encrypt.encrypt(pwd); + + var params = { + loginname: username, + pwd: pwd, + captcha: captcha, + }; + if (typeof service === "string") params.service = service; + console.log(JSON.stringify(params)); + + $.ajax({ + url: this.loginUrl, + type: "POST", + data: params, + dataType: "json", + success: function (resp, textStatus, jqXHR) { + if (resp && typeof resp.code !== "undefined") { + if (resp.code == 0) { + var url = (resp.data && resp.data.url) || ""; + // 兜底:后端未返回有效地址或返回了 CAS 原生登录页路径时,跳回同源根 + if (!url || url.indexOf("/cas/login") === 0) { + url = "/"; + } + console.log("login success, redirect =>", url); + onSuccess(url); + } else { + var needCaptcha = resp.data ? resp.data.needCaptcha : false; + onFailed(resp.msg, needCaptcha); + } + return; + } + onSuccess(jqXHR.responseURL || ""); + }, + error: function (jqXHR) { + var resp = jqXHR.responseJSON; + if (resp && typeof resp.code !== "undefined") { + var needCaptcha = resp.data ? resp.data.needCaptcha : false; + onFailed(resp.msg, needCaptcha); + return; + } + onFailed("登录请求失败", false); + }, + }); + }, + //请求短信验证码 + requestSmsCode: function (phoneCode, onSuccess, onFailed) { + var params = { + phone: phoneCode, + }; + + $.get( + this.smsCodeUrl, + params, + function (resp) { + if (resp.code == 0) { + onSuccess(); + } else { + onFailed(resp.msg); + } + }, + "json", + ); + }, + + //按短信验证码,修改密码 + updatePwd: function (phoneCode, smsCode, newPwd, onSuccess, onFailed) { + var params = { + phone: phoneCode, + smsCode: smsCode, + newPwd: newPwd, + }; + + $.post( + this.updatePwdUrl, + params, + function (resp) { + if (resp.code == 0) { + onSuccess(); + } else { + onFailed(resp.msg); + } + }, + "json", + ); + }, + + captchaSrc: function () { + return this.captchaUrl + "?k=" + new Date().getTime(); + }, + + getQueryVariable: function (paramName) { + var query = window.location.search.substring(1); + var params = query.split("&"); + for (var i = 0; i < params.length; i++) { + var param = params[i]; + + var pos = param.indexOf("="); + if (pos == -1) continue; + + var key = param.slice(0, pos); + var value = param.slice(pos + 1, param.length); + + if (key == paramName) return value; + } + return ""; + }, + + setCookie: function (c_name, value, expiredays) { + var exdate = new Date(); + exdate.setDate(exdate.getDate() + expiredays); + document.cookie = + c_name + "=" + escape(value) + (expiredays == null ? "" : ";expires=" + exdate.toGMTString()) + ";path=/"; //+";domain="; + }, +}; diff --git a/src/main/webapp/front/js/conf.js b/src/main/webapp/front/js/conf.js new file mode 100644 index 0000000..a541cd1 --- /dev/null +++ b/src/main/webapp/front/js/conf.js @@ -0,0 +1,7 @@ +// CAS context-path 为根 "/",前端由 CAS 同源托管在 /front/,接口在 /api/*。 +// 用同源相对路径,避免写死 host/协议,且天然规避跨域(LoginController 未开 CORS)。 +window.ssoServerHost = "/cas/"; + +window.beianText = "©2021 bestsch.com inc All Rights reserved"; +window.beianLink = "【沪ICP备13000443号-1】"; +window.logoHeight = 35; diff --git a/src/main/webapp/front/js/jquery.js b/src/main/webapp/front/js/jquery.js new file mode 100644 index 0000000..eed1777 --- /dev/null +++ b/src/main/webapp/front/js/jquery.js @@ -0,0 +1,9210 @@ +/*! + * jQuery JavaScript Library v2.1.4 + * http://jquery.com/ + * + * Includes Sizzle.js + * http://sizzlejs.com/ + * + * Copyright 2005, 2014 jQuery Foundation, Inc. and other contributors + * Released under the MIT license + * http://jquery.org/license + * + * Date: 2015-04-28T16:01Z + */ + +(function( global, factory ) { + + if ( typeof module === "object" && typeof module.exports === "object" ) { + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket #14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Support: Firefox 18+ +// Can't be in strict mode, several libs including ASP.NET trace +// the stack via arguments.caller.callee and Firefox dies if +// you try to trace through "use strict" call chains. (#13335) +// + +var arr = []; + +var slice = arr.slice; + +var concat = arr.concat; + +var push = arr.push; + +var indexOf = arr.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var support = {}; + + + +var + // Use the correct document accordingly with window argument (sandbox) + document = window.document, + + version = "2.1.4", + + // Define a local copy of jQuery + jQuery = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); + }, + + // Support: Android<4.1 + // Make sure we trim BOM and NBSP + rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, + + // Matches dashed string for camelizing + rmsPrefix = /^-ms-/, + rdashAlpha = /-([\da-z])/gi, + + // Used by jQuery.camelCase as callback to replace() + fcamelCase = function( all, letter ) { + return letter.toUpperCase(); + }; + +jQuery.fn = jQuery.prototype = { + // The current version of jQuery being used + jquery: version, + + constructor: jQuery, + + // Start with an empty selector + selector: "", + + // The default length of a jQuery object is 0 + length: 0, + + toArray: function() { + return slice.call( this ); + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + return num != null ? + + // Return just the one element from the set + ( num < 0 ? this[ num + this.length ] : this[ num ] ) : + + // Return all the elements in a clean array + slice.call( this ); + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems ) { + + // Build a new jQuery matched element set + var ret = jQuery.merge( this.constructor(), elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + ret.context = this.context; + + // Return the newly-formed element set + return ret; + }, + + // Execute a callback for every element in the matched set. + // (You can seed the arguments with an array of args, but this is + // only used internally.) + each: function( callback, args ) { + return jQuery.each( this, callback, args ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map(this, function( elem, i ) { + return callback.call( elem, i, elem ); + })); + }, + + slice: function() { + return this.pushStack( slice.apply( this, arguments ) ); + }, + + first: function() { + return this.eq( 0 ); + }, + + last: function() { + return this.eq( -1 ); + }, + + eq: function( i ) { + var len = this.length, + j = +i + ( i < 0 ? len : 0 ); + return this.pushStack( j >= 0 && j < len ? [ this[j] ] : [] ); + }, + + end: function() { + return this.prevObject || this.constructor(null); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: push, + sort: arr.sort, + splice: arr.splice +}; + +jQuery.extend = jQuery.fn.extend = function() { + var options, name, src, copy, copyIsArray, clone, + target = arguments[0] || {}, + i = 1, + length = arguments.length, + deep = false; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + + // Skip the boolean and the target + target = arguments[ i ] || {}; + i++; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !jQuery.isFunction(target) ) { + target = {}; + } + + // Extend jQuery itself if only one argument is passed + if ( i === length ) { + target = this; + i--; + } + + for ( ; i < length; i++ ) { + // Only deal with non-null/undefined values + if ( (options = arguments[ i ]) != null ) { + // Extend the base object + for ( name in options ) { + src = target[ name ]; + copy = options[ name ]; + + // Prevent never-ending loop + if ( target === copy ) { + continue; + } + + // Recurse if we're merging plain objects or arrays + if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { + if ( copyIsArray ) { + copyIsArray = false; + clone = src && jQuery.isArray(src) ? src : []; + + } else { + clone = src && jQuery.isPlainObject(src) ? src : {}; + } + + // Never move original objects, clone them + target[ name ] = jQuery.extend( deep, clone, copy ); + + // Don't bring in undefined values + } else if ( copy !== undefined ) { + target[ name ] = copy; + } + } + } + } + + // Return the modified object + return target; +}; + +jQuery.extend({ + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), + + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); + }, + + noop: function() {}, + + isFunction: function( obj ) { + return jQuery.type(obj) === "function"; + }, + + isArray: Array.isArray, + + isWindow: function( obj ) { + return obj != null && obj === obj.window; + }, + + isNumeric: function( obj ) { + // parseFloat NaNs numeric-cast false positives (null|true|false|"") + // ...but misinterprets leading-number strings, particularly hex literals ("0x...") + // subtraction forces infinities to NaN + // adding 1 corrects loss of precision from parseFloat (#15100) + return !jQuery.isArray( obj ) && (obj - parseFloat( obj ) + 1) >= 0; + }, + + isPlainObject: function( obj ) { + // Not plain objects: + // - Any object or value whose internal [[Class]] property is not "[object Object]" + // - DOM nodes + // - window + if ( jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { + return false; + } + + if ( obj.constructor && + !hasOwn.call( obj.constructor.prototype, "isPrototypeOf" ) ) { + return false; + } + + // If the function hasn't returned already, we're confident that + // |obj| is a plain object, created by {} or constructed with new Object + return true; + }, + + isEmptyObject: function( obj ) { + var name; + for ( name in obj ) { + return false; + } + return true; + }, + + type: function( obj ) { + if ( obj == null ) { + return obj + ""; + } + // Support: Android<4.0, iOS<6 (functionish RegExp) + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call(obj) ] || "object" : + typeof obj; + }, + + // Evaluates a script in a global context + globalEval: function( code ) { + var script, + indirect = eval; + + code = jQuery.trim( code ); + + if ( code ) { + // If the code includes a valid, prologue position + // strict mode pragma, execute code by injecting a + // script tag into the document. + if ( code.indexOf("use strict") === 1 ) { + script = document.createElement("script"); + script.text = code; + document.head.appendChild( script ).parentNode.removeChild( script ); + } else { + // Otherwise, avoid the DOM node creation, insertion + // and removal by using an indirect global eval + indirect( code ); + } + } + }, + + // Convert dashed to camelCase; used by the css and data modules + // Support: IE9-11+ + // Microsoft forgot to hump their vendor prefix (#9572) + camelCase: function( string ) { + return string.replace( rmsPrefix, "ms-" ).replace( rdashAlpha, fcamelCase ); + }, + + nodeName: function( elem, name ) { + return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); + }, + + // args is for internal usage only + each: function( obj, callback, args ) { + var value, + i = 0, + length = obj.length, + isArray = isArraylike( obj ); + + if ( args ) { + if ( isArray ) { + for ( ; i < length; i++ ) { + value = callback.apply( obj[ i ], args ); + + if ( value === false ) { + break; + } + } + } else { + for ( i in obj ) { + value = callback.apply( obj[ i ], args ); + + if ( value === false ) { + break; + } + } + } + + // A special, fast, case for the most common use of each + } else { + if ( isArray ) { + for ( ; i < length; i++ ) { + value = callback.call( obj[ i ], i, obj[ i ] ); + + if ( value === false ) { + break; + } + } + } else { + for ( i in obj ) { + value = callback.call( obj[ i ], i, obj[ i ] ); + + if ( value === false ) { + break; + } + } + } + } + + return obj; + }, + + // Support: Android<4.1 + trim: function( text ) { + return text == null ? + "" : + ( text + "" ).replace( rtrim, "" ); + }, + + // results is for internal usage only + makeArray: function( arr, results ) { + var ret = results || []; + + if ( arr != null ) { + if ( isArraylike( Object(arr) ) ) { + jQuery.merge( ret, + typeof arr === "string" ? + [ arr ] : arr + ); + } else { + push.call( ret, arr ); + } + } + + return ret; + }, + + inArray: function( elem, arr, i ) { + return arr == null ? -1 : indexOf.call( arr, elem, i ); + }, + + merge: function( first, second ) { + var len = +second.length, + j = 0, + i = first.length; + + for ( ; j < len; j++ ) { + first[ i++ ] = second[ j ]; + } + + first.length = i; + + return first; + }, + + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], + i = 0, + length = elems.length, + callbackExpect = !invert; + + // Go through the array, only saving the items + // that pass the validator function + for ( ; i < length; i++ ) { + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); + } + } + + return matches; + }, + + // arg is for internal usage only + map: function( elems, callback, arg ) { + var value, + i = 0, + length = elems.length, + isArray = isArraylike( elems ), + ret = []; + + // Go through the array, translating each of the items to their new values + if ( isArray ) { + for ( ; i < length; i++ ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + + // Go through every key on the object, + } else { + for ( i in elems ) { + value = callback( elems[ i ], i, arg ); + + if ( value != null ) { + ret.push( value ); + } + } + } + + // Flatten any nested arrays + return concat.apply( [], ret ); + }, + + // A global GUID counter for objects + guid: 1, + + // Bind a function to a context, optionally partially applying any + // arguments. + proxy: function( fn, context ) { + var tmp, args, proxy; + + if ( typeof context === "string" ) { + tmp = fn[ context ]; + context = fn; + fn = tmp; + } + + // Quick check to determine if target is callable, in the spec + // this throws a TypeError, but we will just return undefined. + if ( !jQuery.isFunction( fn ) ) { + return undefined; + } + + // Simulated bind + args = slice.call( arguments, 2 ); + proxy = function() { + return fn.apply( context || this, args.concat( slice.call( arguments ) ) ); + }; + + // Set the guid of unique handler to the same of original handler, so it can be removed + proxy.guid = fn.guid = fn.guid || jQuery.guid++; + + return proxy; + }, + + now: Date.now, + + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +}); + +// Populate the class2type map +jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) { + class2type[ "[object " + name + "]" ] = name.toLowerCase(); +}); + +function isArraylike( obj ) { + + // Support: iOS 8.2 (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = "length" in obj && obj.length, + type = jQuery.type( obj ); + + if ( type === "function" || jQuery.isWindow( obj ) ) { + return false; + } + + if ( obj.nodeType === 1 && length ) { + return true; + } + + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; +} +var Sizzle = +/*! + * Sizzle CSS Selector Engine v2.2.0-pre + * http://sizzlejs.com/ + * + * Copyright 2008, 2014 jQuery Foundation, Inc. and other contributors + * Released under the MIT license + * http://jquery.org/license + * + * Date: 2014-12-16 + */ +(function( window ) { + +var i, + support, + Expr, + getText, + isXML, + tokenize, + compile, + select, + outermostContext, + sortInput, + hasDuplicate, + + // Local document vars + setDocument, + document, + docElem, + documentIsHTML, + rbuggyQSA, + rbuggyMatches, + matches, + contains, + + // Instance-specific data + expando = "sizzle" + 1 * new Date(), + preferredDoc = window.document, + dirruns = 0, + done = 0, + classCache = createCache(), + tokenCache = createCache(), + compilerCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, + + // General-purpose constants + MAX_NEGATIVE = 1 << 31, + + // Instance methods + hasOwn = ({}).hasOwnProperty, + arr = [], + pop = arr.pop, + push_native = arr.push, + push = arr.push, + slice = arr.slice, + // Use a stripped-down indexOf as it's faster than native + // http://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { + var i = 0, + len = list.length; + for ( ; i < len; i++ ) { + if ( list[i] === elem ) { + return i; + } + } + return -1; + }, + + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", + + // Regular expressions + + // Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace + whitespace = "[\\x20\\t\\r\\n\\f]", + // http://www.w3.org/TR/css3-syntax/#characters + characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", + + // Loosely modeled on CSS identifier characters + // An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors + // Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + identifier = characterEncoding.replace( "w", "w#" ), + + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + characterEncoding + ")(?:" + whitespace + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace + + "*\\]", + + pseudos = ":(" + characterEncoding + ")(?:\\((" + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", + + // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), + rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), + + rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), + rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), + + rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ), + + rpseudo = new RegExp( pseudos ), + ridentifier = new RegExp( "^" + identifier + "$" ), + + matchExpr = { + "ID": new RegExp( "^#(" + characterEncoding + ")" ), + "CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ), + "TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ), + "ATTR": new RegExp( "^" + attributes ), + "PSEUDO": new RegExp( "^" + pseudos ), + "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), + // For use in libraries implementing .is() + // We use this for POS matching in `select` + "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + + whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) + }, + + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, + + rnative = /^[^{]+\{\s*\[native \w/, + + // Easily-parseable/retrievable ID or TAG or CLASS selectors + rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, + + rsibling = /[+~]/, + rescape = /'|\\/g, + + // CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters + runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ), + funescape = function( _, escaped, escapedWhitespace ) { + var high = "0x" + escaped - 0x10000; + // NaN means non-codepoint + // Support: Firefox<24 + // Workaround erroneous numeric interpretation of +"0x" + return high !== high || escapedWhitespace ? + escaped : + high < 0 ? + // BMP codepoint + String.fromCharCode( high + 0x10000 ) : + // Supplemental Plane codepoint (surrogate pair) + String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); + }; + +// Optimize for push.apply( _, NodeList ) +try { + push.apply( + (arr = slice.call( preferredDoc.childNodes )), + preferredDoc.childNodes + ); + // Support: Android<4.0 + // Detect silently failing push.apply + arr[ preferredDoc.childNodes.length ].nodeType; +} catch ( e ) { + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + push_native.apply( target, slice.call(els) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + // Can't trust NodeList.length + while ( (target[j++] = els[i++]) ) {} + target.length = j - 1; + } + }; +} + +function Sizzle( selector, context, results, seed ) { + var match, elem, m, nodeType, + // QSA vars + i, groups, old, nid, newContext, newSelector; + + if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { + setDocument( context ); + } + + context = context || document; + results = results || []; + nodeType = context.nodeType; + + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + if ( !seed && documentIsHTML ) { + + // Try to shortcut find operations when possible (e.g., not under DocumentFragment) + if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { + // Speed-up: Sizzle("#ID") + if ( (m = match[1]) ) { + if ( nodeType === 9 ) { + elem = context.getElementById( m ); + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document (jQuery #6963) + if ( elem && elem.parentNode ) { + // Handle the case where IE, Opera, and Webkit return items + // by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + } else { + // Context is not a document + if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) && + contains( context, elem ) && elem.id === m ) { + results.push( elem ); + return results; + } + } + + // Speed-up: Sizzle("TAG") + } else if ( match[2] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Speed-up: Sizzle(".CLASS") + } else if ( (m = match[3]) && support.getElementsByClassName ) { + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // QSA path + if ( support.qsa && (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { + nid = old = expando; + newContext = context; + newSelector = nodeType !== 1 && selector; + + // qSA works strangely on Element-rooted queries + // We can work around this by specifying an extra ID on the root + // and working up from there (Thanks to Andrew Dupont for the technique) + // IE 8 doesn't work on object elements + if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { + groups = tokenize( selector ); + + if ( (old = context.getAttribute("id")) ) { + nid = old.replace( rescape, "\\$&" ); + } else { + context.setAttribute( "id", nid ); + } + nid = "[id='" + nid + "'] "; + + i = groups.length; + while ( i-- ) { + groups[i] = nid + toSelector( groups[i] ); + } + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || context; + newSelector = groups.join(","); + } + + if ( newSelector ) { + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch(qsaError) { + } finally { + if ( !old ) { + context.removeAttribute("id"); + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); +} + +/** + * Create key-value caches of limited size + * @returns {Function(string, Object)} Returns the Object data after storing it on itself with + * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) + * deleting the oldest entry + */ +function createCache() { + var keys = []; + + function cache( key, value ) { + // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) + if ( keys.push( key + " " ) > Expr.cacheLength ) { + // Only keep the most recent entries + delete cache[ keys.shift() ]; + } + return (cache[ key + " " ] = value); + } + return cache; +} + +/** + * Mark a function for special use by Sizzle + * @param {Function} fn The function to mark + */ +function markFunction( fn ) { + fn[ expando ] = true; + return fn; +} + +/** + * Support testing using an element + * @param {Function} fn Passed the created div and expects a boolean result + */ +function assert( fn ) { + var div = document.createElement("div"); + + try { + return !!fn( div ); + } catch (e) { + return false; + } finally { + // Remove from its parent by default + if ( div.parentNode ) { + div.parentNode.removeChild( div ); + } + // release memory in IE + div = null; + } +} + +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split("|"), + i = attrs.length; + + while ( i-- ) { + Expr.attrHandle[ arr[i] ] = handler; + } +} + +/** + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + ( ~b.sourceIndex || MAX_NEGATIVE ) - + ( ~a.sourceIndex || MAX_NEGATIVE ); + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( (cur = cur.nextSibling) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return (name === "input" || name === "button") && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction(function( argument ) { + argument = +argument; + return markFunction(function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ (j = matchIndexes[i]) ] ) { + seed[j] = !(matches[j] = seed[j]); + } + } + }); + }); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes + * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node + */ +isXML = Sizzle.isXML = function( elem ) { + // documentElement is verified for cases where it doesn't yet exist + // (such as loading iframes in IE - #4833) + var documentElement = elem && (elem.ownerDocument || elem).documentElement; + return documentElement ? documentElement.nodeName !== "HTML" : false; +}; + +/** + * Sets document-related variables once based on the current document + * @param {Element|Object} [doc] An element or document object to use to set the document + * @returns {Object} Returns the current document + */ +setDocument = Sizzle.setDocument = function( node ) { + var hasCompare, parent, + doc = node ? node.ownerDocument || node : preferredDoc; + + // If no document and documentElement is available, return + if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { + return document; + } + + // Set our document + document = doc; + docElem = doc.documentElement; + parent = doc.defaultView; + + // Support: IE>8 + // If iframe document is assigned to "document" variable and if iframe has been reloaded, + // IE will throw "permission denied" error when accessing "document" variable, see jQuery #13936 + // IE6-8 do not support the defaultView property so parent will be undefined + if ( parent && parent !== parent.top ) { + // IE11 does not have attachEvent, so all must suffer + if ( parent.addEventListener ) { + parent.addEventListener( "unload", unloadHandler, false ); + } else if ( parent.attachEvent ) { + parent.attachEvent( "onunload", unloadHandler ); + } + } + + /* Support tests + ---------------------------------------------------------------------- */ + documentIsHTML = !isXML( doc ); + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert(function( div ) { + div.className = "i"; + return !div.getAttribute("className"); + }); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ + + // Check if getElementsByTagName("*") returns only elements + support.getElementsByTagName = assert(function( div ) { + div.appendChild( doc.createComment("") ); + return !div.getElementsByTagName("*").length; + }); + + // Support: IE<9 + support.getElementsByClassName = rnative.test( doc.getElementsByClassName ); + + // Support: IE<10 + // Check if getElementById returns elements by name + // The broken getElementById methods don't pick up programatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert(function( div ) { + docElem.appendChild( div ).id = expando; + return !doc.getElementsByName || !doc.getElementsByName( expando ).length; + }); + + // ID find and filter + if ( support.getById ) { + Expr.find["ID"] = function( id, context ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { + var m = context.getElementById( id ); + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document #6963 + return m && m.parentNode ? [ m ] : []; + } + }; + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + return elem.getAttribute("id") === attrId; + }; + }; + } else { + // Support: IE6/7 + // getElementById is not reliable as a find shortcut + delete Expr.find["ID"]; + + Expr.filter["ID"] = function( id ) { + var attrId = id.replace( runescape, funescape ); + return function( elem ) { + var node = typeof elem.getAttributeNode !== "undefined" && elem.getAttributeNode("id"); + return node && node.value === attrId; + }; + }; + } + + // Tag + Expr.find["TAG"] = support.getElementsByTagName ? + function( tag, context ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { + return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); + } + } : + + function( tag, context ) { + var elem, + tmp = [], + i = 0, + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too + results = context.getElementsByTagName( tag ); + + // Filter out possible comments + if ( tag === "*" ) { + while ( (elem = results[i++]) ) { + if ( elem.nodeType === 1 ) { + tmp.push( elem ); + } + } + + return tmp; + } + return results; + }; + + // Class + Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { + if ( documentIsHTML ) { + return context.getElementsByClassName( className ); + } + }; + + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + + // QSA and matchesSelector support + + // matchesSelector(:active) reports false when true (IE9/Opera 11.5) + rbuggyMatches = []; + + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See http://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; + + if ( (support.qsa = rnative.test( doc.querySelectorAll )) ) { + // Build QSA regex + // Regex strategy adopted from Diego Perini + assert(function( div ) { + // Select is set to empty string on purpose + // This is to test IE's treatment of not explicitly + // setting a boolean content attribute, + // since its presence should be enough + // http://bugs.jquery.com/ticket/12359 + docElem.appendChild( div ).innerHTML = "" + + ""; + + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( div.querySelectorAll("[msallowcapture^='']").length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly + if ( !div.querySelectorAll("[selected]").length ) { + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.2+, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.7+ + if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push("~="); + } + + // Webkit/Opera - :checked should return selected option elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + // IE8 throws error here and will not see later tests + if ( !div.querySelectorAll(":checked").length ) { + rbuggyQSA.push(":checked"); + } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibing-combinator selector` fails + if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push(".#.+[+~]"); + } + }); + + assert(function( div ) { + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = doc.createElement("input"); + input.setAttribute( "type", "hidden" ); + div.appendChild( input ).setAttribute( "name", "D" ); + + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( div.querySelectorAll("[name=d]").length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); + } + + // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) + // IE8 throws error here and will not see later tests + if ( !div.querySelectorAll(":enabled").length ) { + rbuggyQSA.push( ":enabled", ":disabled" ); + } + + // Opera 10-11 does not throw on post-comma invalid pseudos + div.querySelectorAll("*,:x"); + rbuggyQSA.push(",.*:"); + }); + } + + if ( (support.matchesSelector = rnative.test( (matches = docElem.matches || + docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || + docElem.oMatchesSelector || + docElem.msMatchesSelector) )) ) { + + assert(function( div ) { + // Check to see if it's possible to do matchesSelector + // on a disconnected node (IE 9) + support.disconnectedMatch = matches.call( div, "div" ); + + // This should fail with an exception + // Gecko does not error, returns false instead + matches.call( div, "[s!='']:x" ); + rbuggyMatches.push( "!=", pseudos ); + }); + } + + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); + + // Element contains another + // Purposefully does not implement inclusive descendent + // As in, an element does not contain itself + contains = hasCompare || rnative.test( docElem.contains ) ? + function( a, b ) { + var adown = a.nodeType === 9 ? a.documentElement : a, + bup = b && b.parentNode; + return a === bup || !!( bup && bup.nodeType === 1 && ( + adown.contains ? + adown.contains( bup ) : + a.compareDocumentPosition && a.compareDocumentPosition( bup ) & 16 + )); + } : + function( a, b ) { + if ( b ) { + while ( (b = b.parentNode) ) { + if ( b === a ) { + return true; + } + } + } + return false; + }; + + /* Sorting + ---------------------------------------------------------------------- */ + + // Document order sorting + sortOrder = hasCompare ? + function( a, b ) { + + // Flag for duplicate removal + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + // Sort on method existence if only one input has compareDocumentPosition + var compare = !a.compareDocumentPosition - !b.compareDocumentPosition; + if ( compare ) { + return compare; + } + + // Calculate position if both inputs belong to the same document + compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ? + a.compareDocumentPosition( b ) : + + // Otherwise we know they are disconnected + 1; + + // Disconnected nodes + if ( compare & 1 || + (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { + + // Choose the first element that is related to our preferred document + if ( a === doc || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { + return -1; + } + if ( b === doc || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { + return 1; + } + + // Maintain original order + return sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + } + + return compare & 4 ? -1 : 1; + } : + function( a, b ) { + // Exit early if the nodes are identical + if ( a === b ) { + hasDuplicate = true; + return 0; + } + + var cur, + i = 0, + aup = a.parentNode, + bup = b.parentNode, + ap = [ a ], + bp = [ b ]; + + // Parentless nodes are either documents or disconnected + if ( !aup || !bup ) { + return a === doc ? -1 : + b === doc ? 1 : + aup ? -1 : + bup ? 1 : + sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + + // If the nodes are siblings, we can do a quick check + } else if ( aup === bup ) { + return siblingCheck( a, b ); + } + + // Otherwise we need full lists of their ancestors for comparison + cur = a; + while ( (cur = cur.parentNode) ) { + ap.unshift( cur ); + } + cur = b; + while ( (cur = cur.parentNode) ) { + bp.unshift( cur ); + } + + // Walk down the tree looking for a discrepancy + while ( ap[i] === bp[i] ) { + i++; + } + + return i ? + // Do a sibling check if the nodes have a common ancestor + siblingCheck( ap[i], bp[i] ) : + + // Otherwise nodes in our document sort first + ap[i] === preferredDoc ? -1 : + bp[i] === preferredDoc ? 1 : + 0; + }; + + return doc; +}; + +Sizzle.matches = function( expr, elements ) { + return Sizzle( expr, null, null, elements ); +}; + +Sizzle.matchesSelector = function( elem, expr ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + // Make sure that attribute selectors are quoted + expr = expr.replace( rattributeQuotes, "='$1']" ); + + if ( support.matchesSelector && documentIsHTML && + ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && + ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { + + try { + var ret = matches.call( elem, expr ); + + // IE 9's matchesSelector returns false on disconnected nodes + if ( ret || support.disconnectedMatch || + // As well, disconnected nodes are said to be in a document + // fragment in IE 9 + elem.document && elem.document.nodeType !== 11 ) { + return ret; + } + } catch (e) {} + } + + return Sizzle( expr, document, null, [ elem ] ).length > 0; +}; + +Sizzle.contains = function( context, elem ) { + // Set document vars if needed + if ( ( context.ownerDocument || context ) !== document ) { + setDocument( context ); + } + return contains( context, elem ); +}; + +Sizzle.attr = function( elem, name ) { + // Set document vars if needed + if ( ( elem.ownerDocument || elem ) !== document ) { + setDocument( elem ); + } + + var fn = Expr.attrHandle[ name.toLowerCase() ], + // Don't get fooled by Object.prototype properties (jQuery #13807) + val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ? + fn( elem, name, !documentIsHTML ) : + undefined; + + return val !== undefined ? + val : + support.attributes || !documentIsHTML ? + elem.getAttribute( name ) : + (val = elem.getAttributeNode(name)) && val.specified ? + val.value : + null; +}; + +Sizzle.error = function( msg ) { + throw new Error( "Syntax error, unrecognized expression: " + msg ); +}; + +/** + * Document sorting and removing duplicates + * @param {ArrayLike} results + */ +Sizzle.uniqueSort = function( results ) { + var elem, + duplicates = [], + j = 0, + i = 0; + + // Unless we *know* we can detect duplicates, assume their presence + hasDuplicate = !support.detectDuplicates; + sortInput = !support.sortStable && results.slice( 0 ); + results.sort( sortOrder ); + + if ( hasDuplicate ) { + while ( (elem = results[i++]) ) { + if ( elem === results[ i ] ) { + j = duplicates.push( i ); + } + } + while ( j-- ) { + results.splice( duplicates[ j ], 1 ); + } + } + + // Clear input after sorting to release objects + // See https://github.com/jquery/sizzle/pull/225 + sortInput = null; + + return results; +}; + +/** + * Utility function for retrieving the text value of an array of DOM nodes + * @param {Array|Element} elem + */ +getText = Sizzle.getText = function( elem ) { + var node, + ret = "", + i = 0, + nodeType = elem.nodeType; + + if ( !nodeType ) { + // If no nodeType, this is expected to be an array + while ( (node = elem[i++]) ) { + // Do not traverse comment nodes + ret += getText( node ); + } + } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { + // Use textContent for elements + // innerText usage removed for consistency of new lines (jQuery #11153) + if ( typeof elem.textContent === "string" ) { + return elem.textContent; + } else { + // Traverse its children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + ret += getText( elem ); + } + } + } else if ( nodeType === 3 || nodeType === 4 ) { + return elem.nodeValue; + } + // Do not include comment or processing instruction nodes + + return ret; +}; + +Expr = Sizzle.selectors = { + + // Can be adjusted by the user + cacheLength: 50, + + createPseudo: markFunction, + + match: matchExpr, + + attrHandle: {}, + + find: {}, + + relative: { + ">": { dir: "parentNode", first: true }, + " ": { dir: "parentNode" }, + "+": { dir: "previousSibling", first: true }, + "~": { dir: "previousSibling" } + }, + + preFilter: { + "ATTR": function( match ) { + match[1] = match[1].replace( runescape, funescape ); + + // Move the given value to match[3] whether quoted or unquoted + match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape ); + + if ( match[2] === "~=" ) { + match[3] = " " + match[3] + " "; + } + + return match.slice( 0, 4 ); + }, + + "CHILD": function( match ) { + /* matches from matchExpr["CHILD"] + 1 type (only|nth|...) + 2 what (child|of-type) + 3 argument (even|odd|\d*|\d*n([+-]\d+)?|...) + 4 xn-component of xn+y argument ([+-]?\d*n|) + 5 sign of xn-component + 6 x of xn-component + 7 sign of y-component + 8 y of y-component + */ + match[1] = match[1].toLowerCase(); + + if ( match[1].slice( 0, 3 ) === "nth" ) { + // nth-* requires argument + if ( !match[3] ) { + Sizzle.error( match[0] ); + } + + // numeric x and y parameters for Expr.filter.CHILD + // remember that false/true cast respectively to 0/1 + match[4] = +( match[4] ? match[5] + (match[6] || 1) : 2 * ( match[3] === "even" || match[3] === "odd" ) ); + match[5] = +( ( match[7] + match[8] ) || match[3] === "odd" ); + + // other types prohibit arguments + } else if ( match[3] ) { + Sizzle.error( match[0] ); + } + + return match; + }, + + "PSEUDO": function( match ) { + var excess, + unquoted = !match[6] && match[2]; + + if ( matchExpr["CHILD"].test( match[0] ) ) { + return null; + } + + // Accept quoted arguments as-is + if ( match[3] ) { + match[2] = match[4] || match[5] || ""; + + // Strip excess characters from unquoted arguments + } else if ( unquoted && rpseudo.test( unquoted ) && + // Get excess from tokenize (recursively) + (excess = tokenize( unquoted, true )) && + // advance to the next closing parenthesis + (excess = unquoted.indexOf( ")", unquoted.length - excess ) - unquoted.length) ) { + + // excess is a negative index + match[0] = match[0].slice( 0, excess ); + match[2] = unquoted.slice( 0, excess ); + } + + // Return only captures needed by the pseudo filter method (type and argument) + return match.slice( 0, 3 ); + } + }, + + filter: { + + "TAG": function( nodeNameSelector ) { + var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase(); + return nodeNameSelector === "*" ? + function() { return true; } : + function( elem ) { + return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; + }; + }, + + "CLASS": function( className ) { + var pattern = classCache[ className + " " ]; + + return pattern || + (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) && + classCache( className, function( elem ) { + return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" ); + }); + }, + + "ATTR": function( name, operator, check ) { + return function( elem ) { + var result = Sizzle.attr( elem, name ); + + if ( result == null ) { + return operator === "!="; + } + if ( !operator ) { + return true; + } + + result += ""; + + return operator === "=" ? result === check : + operator === "!=" ? result !== check : + operator === "^=" ? check && result.indexOf( check ) === 0 : + operator === "*=" ? check && result.indexOf( check ) > -1 : + operator === "$=" ? check && result.slice( -check.length ) === check : + operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 : + operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" : + false; + }; + }, + + "CHILD": function( type, what, argument, first, last ) { + var simple = type.slice( 0, 3 ) !== "nth", + forward = type.slice( -4 ) !== "last", + ofType = what === "of-type"; + + return first === 1 && last === 0 ? + + // Shortcut for :nth-*(n) + function( elem ) { + return !!elem.parentNode; + } : + + function( elem, context, xml ) { + var cache, outerCache, node, diff, nodeIndex, start, + dir = simple !== forward ? "nextSibling" : "previousSibling", + parent = elem.parentNode, + name = ofType && elem.nodeName.toLowerCase(), + useCache = !xml && !ofType; + + if ( parent ) { + + // :(first|last|only)-(child|of-type) + if ( simple ) { + while ( dir ) { + node = elem; + while ( (node = node[ dir ]) ) { + if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) { + return false; + } + } + // Reverse direction for :only-* (if we haven't yet done so) + start = dir = type === "only" && !start && "nextSibling"; + } + return true; + } + + start = [ forward ? parent.firstChild : parent.lastChild ]; + + // non-xml :nth-child(...) stores cache data on `parent` + if ( forward && useCache ) { + // Seek `elem` from a previously-cached index + outerCache = parent[ expando ] || (parent[ expando ] = {}); + cache = outerCache[ type ] || []; + nodeIndex = cache[0] === dirruns && cache[1]; + diff = cache[0] === dirruns && cache[2]; + node = nodeIndex && parent.childNodes[ nodeIndex ]; + + while ( (node = ++nodeIndex && node && node[ dir ] || + + // Fallback to seeking `elem` from the start + (diff = nodeIndex = 0) || start.pop()) ) { + + // When found, cache indexes on `parent` and break + if ( node.nodeType === 1 && ++diff && node === elem ) { + outerCache[ type ] = [ dirruns, nodeIndex, diff ]; + break; + } + } + + // Use previously-cached element index if available + } else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) { + diff = cache[1]; + + // xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...) + } else { + // Use the same loop as above to seek `elem` from the start + while ( (node = ++nodeIndex && node && node[ dir ] || + (diff = nodeIndex = 0) || start.pop()) ) { + + if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) { + // Cache the index of each encountered element + if ( useCache ) { + (node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ]; + } + + if ( node === elem ) { + break; + } + } + } + } + + // Incorporate the offset, then check against cycle size + diff -= last; + return diff === first || ( diff % first === 0 && diff / first >= 0 ); + } + }; + }, + + "PSEUDO": function( pseudo, argument ) { + // pseudo-class names are case-insensitive + // http://www.w3.org/TR/selectors/#pseudo-classes + // Prioritize by case sensitivity in case custom pseudos are added with uppercase letters + // Remember that setFilters inherits from pseudos + var args, + fn = Expr.pseudos[ pseudo ] || Expr.setFilters[ pseudo.toLowerCase() ] || + Sizzle.error( "unsupported pseudo: " + pseudo ); + + // The user may use createPseudo to indicate that + // arguments are needed to create the filter function + // just as Sizzle does + if ( fn[ expando ] ) { + return fn( argument ); + } + + // But maintain support for old signatures + if ( fn.length > 1 ) { + args = [ pseudo, pseudo, "", argument ]; + return Expr.setFilters.hasOwnProperty( pseudo.toLowerCase() ) ? + markFunction(function( seed, matches ) { + var idx, + matched = fn( seed, argument ), + i = matched.length; + while ( i-- ) { + idx = indexOf( seed, matched[i] ); + seed[ idx ] = !( matches[ idx ] = matched[i] ); + } + }) : + function( elem ) { + return fn( elem, 0, args ); + }; + } + + return fn; + } + }, + + pseudos: { + // Potentially complex pseudos + "not": markFunction(function( selector ) { + // Trim the selector passed to compile + // to avoid treating leading and trailing + // spaces as combinators + var input = [], + results = [], + matcher = compile( selector.replace( rtrim, "$1" ) ); + + return matcher[ expando ] ? + markFunction(function( seed, matches, context, xml ) { + var elem, + unmatched = matcher( seed, null, xml, [] ), + i = seed.length; + + // Match elements unmatched by `matcher` + while ( i-- ) { + if ( (elem = unmatched[i]) ) { + seed[i] = !(matches[i] = elem); + } + } + }) : + function( elem, context, xml ) { + input[0] = elem; + matcher( input, null, xml, results ); + // Don't keep the element (issue #299) + input[0] = null; + return !results.pop(); + }; + }), + + "has": markFunction(function( selector ) { + return function( elem ) { + return Sizzle( selector, elem ).length > 0; + }; + }), + + "contains": markFunction(function( text ) { + text = text.replace( runescape, funescape ); + return function( elem ) { + return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1; + }; + }), + + // "Whether an element is represented by a :lang() selector + // is based solely on the element's language value + // being equal to the identifier C, + // or beginning with the identifier C immediately followed by "-". + // The matching of C against the element's language value is performed case-insensitively. + // The identifier C does not have to be a valid language name." + // http://www.w3.org/TR/selectors/#lang-pseudo + "lang": markFunction( function( lang ) { + // lang value must be a valid identifier + if ( !ridentifier.test(lang || "") ) { + Sizzle.error( "unsupported lang: " + lang ); + } + lang = lang.replace( runescape, funescape ).toLowerCase(); + return function( elem ) { + var elemLang; + do { + if ( (elemLang = documentIsHTML ? + elem.lang : + elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) { + + elemLang = elemLang.toLowerCase(); + return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0; + } + } while ( (elem = elem.parentNode) && elem.nodeType === 1 ); + return false; + }; + }), + + // Miscellaneous + "target": function( elem ) { + var hash = window.location && window.location.hash; + return hash && hash.slice( 1 ) === elem.id; + }, + + "root": function( elem ) { + return elem === docElem; + }, + + "focus": function( elem ) { + return elem === document.activeElement && (!document.hasFocus || document.hasFocus()) && !!(elem.type || elem.href || ~elem.tabIndex); + }, + + // Boolean properties + "enabled": function( elem ) { + return elem.disabled === false; + }, + + "disabled": function( elem ) { + return elem.disabled === true; + }, + + "checked": function( elem ) { + // In CSS3, :checked should return both checked and selected elements + // http://www.w3.org/TR/2011/REC-css3-selectors-20110929/#checked + var nodeName = elem.nodeName.toLowerCase(); + return (nodeName === "input" && !!elem.checked) || (nodeName === "option" && !!elem.selected); + }, + + "selected": function( elem ) { + // Accessing this property makes selected-by-default + // options in Safari work properly + if ( elem.parentNode ) { + elem.parentNode.selectedIndex; + } + + return elem.selected === true; + }, + + // Contents + "empty": function( elem ) { + // http://www.w3.org/TR/selectors/#empty-pseudo + // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5), + // but not by others (comment: 8; processing instruction: 7; etc.) + // nodeType < 6 works because attributes (2) do not appear as children + for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { + if ( elem.nodeType < 6 ) { + return false; + } + } + return true; + }, + + "parent": function( elem ) { + return !Expr.pseudos["empty"]( elem ); + }, + + // Element/input types + "header": function( elem ) { + return rheader.test( elem.nodeName ); + }, + + "input": function( elem ) { + return rinputs.test( elem.nodeName ); + }, + + "button": function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === "button" || name === "button"; + }, + + "text": function( elem ) { + var attr; + return elem.nodeName.toLowerCase() === "input" && + elem.type === "text" && + + // Support: IE<8 + // New HTML5 attribute values (e.g., "search") appear with elem.type === "text" + ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" ); + }, + + // Position-in-collection + "first": createPositionalPseudo(function() { + return [ 0 ]; + }), + + "last": createPositionalPseudo(function( matchIndexes, length ) { + return [ length - 1 ]; + }), + + "eq": createPositionalPseudo(function( matchIndexes, length, argument ) { + return [ argument < 0 ? argument + length : argument ]; + }), + + "even": createPositionalPseudo(function( matchIndexes, length ) { + var i = 0; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "odd": createPositionalPseudo(function( matchIndexes, length ) { + var i = 1; + for ( ; i < length; i += 2 ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "lt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? argument + length : argument; + for ( ; --i >= 0; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }), + + "gt": createPositionalPseudo(function( matchIndexes, length, argument ) { + var i = argument < 0 ? argument + length : argument; + for ( ; ++i < length; ) { + matchIndexes.push( i ); + } + return matchIndexes; + }) + } +}; + +Expr.pseudos["nth"] = Expr.pseudos["eq"]; + +// Add button/input type pseudos +for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) { + Expr.pseudos[ i ] = createInputPseudo( i ); +} +for ( i in { submit: true, reset: true } ) { + Expr.pseudos[ i ] = createButtonPseudo( i ); +} + +// Easy API for creating new setFilters +function setFilters() {} +setFilters.prototype = Expr.filters = Expr.pseudos; +Expr.setFilters = new setFilters(); + +tokenize = Sizzle.tokenize = function( selector, parseOnly ) { + var matched, match, tokens, type, + soFar, groups, preFilters, + cached = tokenCache[ selector + " " ]; + + if ( cached ) { + return parseOnly ? 0 : cached.slice( 0 ); + } + + soFar = selector; + groups = []; + preFilters = Expr.preFilter; + + while ( soFar ) { + + // Comma and first run + if ( !matched || (match = rcomma.exec( soFar )) ) { + if ( match ) { + // Don't consume trailing commas as valid + soFar = soFar.slice( match[0].length ) || soFar; + } + groups.push( (tokens = []) ); + } + + matched = false; + + // Combinators + if ( (match = rcombinators.exec( soFar )) ) { + matched = match.shift(); + tokens.push({ + value: matched, + // Cast descendant combinators to space + type: match[0].replace( rtrim, " " ) + }); + soFar = soFar.slice( matched.length ); + } + + // Filters + for ( type in Expr.filter ) { + if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || + (match = preFilters[ type ]( match ))) ) { + matched = match.shift(); + tokens.push({ + value: matched, + type: type, + matches: match + }); + soFar = soFar.slice( matched.length ); + } + } + + if ( !matched ) { + break; + } + } + + // Return the length of the invalid excess + // if we're just parsing + // Otherwise, throw an error or return tokens + return parseOnly ? + soFar.length : + soFar ? + Sizzle.error( selector ) : + // Cache the tokens + tokenCache( selector, groups ).slice( 0 ); +}; + +function toSelector( tokens ) { + var i = 0, + len = tokens.length, + selector = ""; + for ( ; i < len; i++ ) { + selector += tokens[i].value; + } + return selector; +} + +function addCombinator( matcher, combinator, base ) { + var dir = combinator.dir, + checkNonElements = base && dir === "parentNode", + doneName = done++; + + return combinator.first ? + // Check against closest ancestor/preceding element + function( elem, context, xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + return matcher( elem, context, xml ); + } + } + } : + + // Check against all ancestor/preceding elements + function( elem, context, xml ) { + var oldCache, outerCache, + newCache = [ dirruns, doneName ]; + + // We can't set arbitrary data on XML nodes, so they don't benefit from dir caching + if ( xml ) { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + if ( matcher( elem, context, xml ) ) { + return true; + } + } + } + } else { + while ( (elem = elem[ dir ]) ) { + if ( elem.nodeType === 1 || checkNonElements ) { + outerCache = elem[ expando ] || (elem[ expando ] = {}); + if ( (oldCache = outerCache[ dir ]) && + oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) { + + // Assign to newCache so results back-propagate to previous elements + return (newCache[ 2 ] = oldCache[ 2 ]); + } else { + // Reuse newcache so results back-propagate to previous elements + outerCache[ dir ] = newCache; + + // A match means we're done; a fail means we have to keep checking + if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) { + return true; + } + } + } + } + } + }; +} + +function elementMatcher( matchers ) { + return matchers.length > 1 ? + function( elem, context, xml ) { + var i = matchers.length; + while ( i-- ) { + if ( !matchers[i]( elem, context, xml ) ) { + return false; + } + } + return true; + } : + matchers[0]; +} + +function multipleContexts( selector, contexts, results ) { + var i = 0, + len = contexts.length; + for ( ; i < len; i++ ) { + Sizzle( selector, contexts[i], results ); + } + return results; +} + +function condense( unmatched, map, filter, context, xml ) { + var elem, + newUnmatched = [], + i = 0, + len = unmatched.length, + mapped = map != null; + + for ( ; i < len; i++ ) { + if ( (elem = unmatched[i]) ) { + if ( !filter || filter( elem, context, xml ) ) { + newUnmatched.push( elem ); + if ( mapped ) { + map.push( i ); + } + } + } + } + + return newUnmatched; +} + +function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postSelector ) { + if ( postFilter && !postFilter[ expando ] ) { + postFilter = setMatcher( postFilter ); + } + if ( postFinder && !postFinder[ expando ] ) { + postFinder = setMatcher( postFinder, postSelector ); + } + return markFunction(function( seed, results, context, xml ) { + var temp, i, elem, + preMap = [], + postMap = [], + preexisting = results.length, + + // Get initial elements from seed or context + elems = seed || multipleContexts( selector || "*", context.nodeType ? [ context ] : context, [] ), + + // Prefilter to get matcher input, preserving a map for seed-results synchronization + matcherIn = preFilter && ( seed || !selector ) ? + condense( elems, preMap, preFilter, context, xml ) : + elems, + + matcherOut = matcher ? + // If we have a postFinder, or filtered seed, or non-seed postFilter or preexisting results, + postFinder || ( seed ? preFilter : preexisting || postFilter ) ? + + // ...intermediate processing is necessary + [] : + + // ...otherwise use results directly + results : + matcherIn; + + // Find primary matches + if ( matcher ) { + matcher( matcherIn, matcherOut, context, xml ); + } + + // Apply postFilter + if ( postFilter ) { + temp = condense( matcherOut, postMap ); + postFilter( temp, [], context, xml ); + + // Un-match failing elements by moving them back to matcherIn + i = temp.length; + while ( i-- ) { + if ( (elem = temp[i]) ) { + matcherOut[ postMap[i] ] = !(matcherIn[ postMap[i] ] = elem); + } + } + } + + if ( seed ) { + if ( postFinder || preFilter ) { + if ( postFinder ) { + // Get the final matcherOut by condensing this intermediate into postFinder contexts + temp = []; + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) ) { + // Restore matcherIn since elem is not yet a final match + temp.push( (matcherIn[i] = elem) ); + } + } + postFinder( null, (matcherOut = []), temp, xml ); + } + + // Move matched elements from seed to results to keep them synchronized + i = matcherOut.length; + while ( i-- ) { + if ( (elem = matcherOut[i]) && + (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) { + + seed[temp] = !(results[temp] = elem); + } + } + } + + // Add elements to results, through postFinder if defined + } else { + matcherOut = condense( + matcherOut === results ? + matcherOut.splice( preexisting, matcherOut.length ) : + matcherOut + ); + if ( postFinder ) { + postFinder( null, results, matcherOut, xml ); + } else { + push.apply( results, matcherOut ); + } + } + }); +} + +function matcherFromTokens( tokens ) { + var checkContext, matcher, j, + len = tokens.length, + leadingRelative = Expr.relative[ tokens[0].type ], + implicitRelative = leadingRelative || Expr.relative[" "], + i = leadingRelative ? 1 : 0, + + // The foundational matcher ensures that elements are reachable from top-level context(s) + matchContext = addCombinator( function( elem ) { + return elem === checkContext; + }, implicitRelative, true ), + matchAnyContext = addCombinator( function( elem ) { + return indexOf( checkContext, elem ) > -1; + }, implicitRelative, true ), + matchers = [ function( elem, context, xml ) { + var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( + (checkContext = context).nodeType ? + matchContext( elem, context, xml ) : + matchAnyContext( elem, context, xml ) ); + // Avoid hanging onto element (issue #299) + checkContext = null; + return ret; + } ]; + + for ( ; i < len; i++ ) { + if ( (matcher = Expr.relative[ tokens[i].type ]) ) { + matchers = [ addCombinator(elementMatcher( matchers ), matcher) ]; + } else { + matcher = Expr.filter[ tokens[i].type ].apply( null, tokens[i].matches ); + + // Return special upon seeing a positional matcher + if ( matcher[ expando ] ) { + // Find the next relative operator (if any) for proper handling + j = ++i; + for ( ; j < len; j++ ) { + if ( Expr.relative[ tokens[j].type ] ) { + break; + } + } + return setMatcher( + i > 1 && elementMatcher( matchers ), + i > 1 && toSelector( + // If the preceding token was a descendant combinator, insert an implicit any-element `*` + tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" }) + ).replace( rtrim, "$1" ), + matcher, + i < j && matcherFromTokens( tokens.slice( i, j ) ), + j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), + j < len && toSelector( tokens ) + ); + } + matchers.push( matcher ); + } + } + + return elementMatcher( matchers ); +} + +function matcherFromGroupMatchers( elementMatchers, setMatchers ) { + var bySet = setMatchers.length > 0, + byElement = elementMatchers.length > 0, + superMatcher = function( seed, context, xml, results, outermost ) { + var elem, j, matcher, + matchedCount = 0, + i = "0", + unmatched = seed && [], + setMatched = [], + contextBackup = outermostContext, + // We must always have either seed elements or outermost context + elems = seed || byElement && Expr.find["TAG"]( "*", outermost ), + // Use integer dirruns iff this is the outermost matcher + dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1), + len = elems.length; + + if ( outermost ) { + outermostContext = context !== document && context; + } + + // Add elements passing elementMatchers directly to results + // Keep `i` a string if there are no elements so `matchedCount` will be "00" below + // Support: IE<9, Safari + // Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id + for ( ; i !== len && (elem = elems[i]) != null; i++ ) { + if ( byElement && elem ) { + j = 0; + while ( (matcher = elementMatchers[j++]) ) { + if ( matcher( elem, context, xml ) ) { + results.push( elem ); + break; + } + } + if ( outermost ) { + dirruns = dirrunsUnique; + } + } + + // Track unmatched elements for set filters + if ( bySet ) { + // They will have gone through all possible matchers + if ( (elem = !matcher && elem) ) { + matchedCount--; + } + + // Lengthen the array for every element, matched or not + if ( seed ) { + unmatched.push( elem ); + } + } + } + + // Apply set filters to unmatched elements + matchedCount += i; + if ( bySet && i !== matchedCount ) { + j = 0; + while ( (matcher = setMatchers[j++]) ) { + matcher( unmatched, setMatched, context, xml ); + } + + if ( seed ) { + // Reintegrate element matches to eliminate the need for sorting + if ( matchedCount > 0 ) { + while ( i-- ) { + if ( !(unmatched[i] || setMatched[i]) ) { + setMatched[i] = pop.call( results ); + } + } + } + + // Discard index placeholder values to get only actual matches + setMatched = condense( setMatched ); + } + + // Add matches to results + push.apply( results, setMatched ); + + // Seedless set matches succeeding multiple successful matchers stipulate sorting + if ( outermost && !seed && setMatched.length > 0 && + ( matchedCount + setMatchers.length ) > 1 ) { + + Sizzle.uniqueSort( results ); + } + } + + // Override manipulation of globals by nested matchers + if ( outermost ) { + dirruns = dirrunsUnique; + outermostContext = contextBackup; + } + + return unmatched; + }; + + return bySet ? + markFunction( superMatcher ) : + superMatcher; +} + +compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) { + var i, + setMatchers = [], + elementMatchers = [], + cached = compilerCache[ selector + " " ]; + + if ( !cached ) { + // Generate a function of recursive functions that can be used to check each element + if ( !match ) { + match = tokenize( selector ); + } + i = match.length; + while ( i-- ) { + cached = matcherFromTokens( match[i] ); + if ( cached[ expando ] ) { + setMatchers.push( cached ); + } else { + elementMatchers.push( cached ); + } + } + + // Cache the compiled function + cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); + + // Save selector and tokenization + cached.selector = selector; + } + return cached; +}; + +/** + * A low-level selection function that works with Sizzle's compiled + * selector functions + * @param {String|Function} selector A selector or a pre-compiled + * selector function built with Sizzle.compile + * @param {Element} context + * @param {Array} [results] + * @param {Array} [seed] A set of elements to match against + */ +select = Sizzle.select = function( selector, context, results, seed ) { + var i, tokens, token, type, find, + compiled = typeof selector === "function" && selector, + match = !seed && tokenize( (selector = compiled.selector || selector) ); + + results = results || []; + + // Try to minimize operations if there is no seed and only one group + if ( match.length === 1 ) { + + // Take a shortcut and set the context if the root selector is an ID + tokens = match[0] = match[0].slice( 0 ); + if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && + support.getById && context.nodeType === 9 && documentIsHTML && + Expr.relative[ tokens[1].type ] ) { + + context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0]; + if ( !context ) { + return results; + + // Precompiled matchers will still verify ancestry, so step up a level + } else if ( compiled ) { + context = context.parentNode; + } + + selector = selector.slice( tokens.shift().value.length ); + } + + // Fetch a seed set for right-to-left matching + i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; + while ( i-- ) { + token = tokens[i]; + + // Abort if we hit a combinator + if ( Expr.relative[ (type = token.type) ] ) { + break; + } + if ( (find = Expr.find[ type ]) ) { + // Search, expanding context for leading sibling combinators + if ( (seed = find( + token.matches[0].replace( runescape, funescape ), + rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context + )) ) { + + // If seed is empty or no tokens remain, we can return early + tokens.splice( i, 1 ); + selector = seed.length && toSelector( tokens ); + if ( !selector ) { + push.apply( results, seed ); + return results; + } + + break; + } + } + } + } + + // Compile and execute a filtering function if one is not provided + // Provide `match` to avoid retokenization if we modified the selector above + ( compiled || compile( selector, match ) )( + seed, + context, + !documentIsHTML, + results, + rsibling.test( selector ) && testContext( context.parentNode ) || context + ); + return results; +}; + +// One-time assignments + +// Sort stability +support.sortStable = expando.split("").sort( sortOrder ).join("") === expando; + +// Support: Chrome 14-35+ +// Always assume duplicates if they aren't passed to the comparison function +support.detectDuplicates = !!hasDuplicate; + +// Initialize against the default document +setDocument(); + +// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27) +// Detached nodes confoundingly follow *each other* +support.sortDetached = assert(function( div1 ) { + // Should return 1, but returns 4 (following) + return div1.compareDocumentPosition( document.createElement("div") ) & 1; +}); + +// Support: IE<8 +// Prevent attribute/property "interpolation" +// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx +if ( !assert(function( div ) { + div.innerHTML = ""; + return div.firstChild.getAttribute("href") === "#" ; +}) ) { + addHandle( "type|href|height|width", function( elem, name, isXML ) { + if ( !isXML ) { + return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 ); + } + }); +} + +// Support: IE<9 +// Use defaultValue in place of getAttribute("value") +if ( !support.attributes || !assert(function( div ) { + div.innerHTML = ""; + div.firstChild.setAttribute( "value", "" ); + return div.firstChild.getAttribute( "value" ) === ""; +}) ) { + addHandle( "value", function( elem, name, isXML ) { + if ( !isXML && elem.nodeName.toLowerCase() === "input" ) { + return elem.defaultValue; + } + }); +} + +// Support: IE<9 +// Use getAttributeNode to fetch booleans when getAttribute lies +if ( !assert(function( div ) { + return div.getAttribute("disabled") == null; +}) ) { + addHandle( booleans, function( elem, name, isXML ) { + var val; + if ( !isXML ) { + return elem[ name ] === true ? name.toLowerCase() : + (val = elem.getAttributeNode( name )) && val.specified ? + val.value : + null; + } + }); +} + +return Sizzle; + +})( window ); + + + +jQuery.find = Sizzle; +jQuery.expr = Sizzle.selectors; +jQuery.expr[":"] = jQuery.expr.pseudos; +jQuery.unique = Sizzle.uniqueSort; +jQuery.text = Sizzle.getText; +jQuery.isXMLDoc = Sizzle.isXML; +jQuery.contains = Sizzle.contains; + + + +var rneedsContext = jQuery.expr.match.needsContext; + +var rsingleTag = (/^<(\w+)\s*\/?>(?:<\/\1>|)$/); + + + +var risSimple = /^.[^:#\[\.,]*$/; + +// Implement the identical functionality for filter and not +function winnow( elements, qualifier, not ) { + if ( jQuery.isFunction( qualifier ) ) { + return jQuery.grep( elements, function( elem, i ) { + /* jshint -W018 */ + return !!qualifier.call( elem, i, elem ) !== not; + }); + + } + + if ( qualifier.nodeType ) { + return jQuery.grep( elements, function( elem ) { + return ( elem === qualifier ) !== not; + }); + + } + + if ( typeof qualifier === "string" ) { + if ( risSimple.test( qualifier ) ) { + return jQuery.filter( qualifier, elements, not ); + } + + qualifier = jQuery.filter( qualifier, elements ); + } + + return jQuery.grep( elements, function( elem ) { + return ( indexOf.call( qualifier, elem ) >= 0 ) !== not; + }); +} + +jQuery.filter = function( expr, elems, not ) { + var elem = elems[ 0 ]; + + if ( not ) { + expr = ":not(" + expr + ")"; + } + + return elems.length === 1 && elem.nodeType === 1 ? + jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] : + jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { + return elem.nodeType === 1; + })); +}; + +jQuery.fn.extend({ + find: function( selector ) { + var i, + len = this.length, + ret = [], + self = this; + + if ( typeof selector !== "string" ) { + return this.pushStack( jQuery( selector ).filter(function() { + for ( i = 0; i < len; i++ ) { + if ( jQuery.contains( self[ i ], this ) ) { + return true; + } + } + }) ); + } + + for ( i = 0; i < len; i++ ) { + jQuery.find( selector, self[ i ], ret ); + } + + // Needed because $( selector, context ) becomes $( context ).find( selector ) + ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret ); + ret.selector = this.selector ? this.selector + " " + selector : selector; + return ret; + }, + filter: function( selector ) { + return this.pushStack( winnow(this, selector || [], false) ); + }, + not: function( selector ) { + return this.pushStack( winnow(this, selector || [], true) ); + }, + is: function( selector ) { + return !!winnow( + this, + + // If this is a positional/relative selector, check membership in the returned set + // so $("p:first").is("p:last") won't return true for a doc with two "p". + typeof selector === "string" && rneedsContext.test( selector ) ? + jQuery( selector ) : + selector || [], + false + ).length; + } +}); + + +// Initialize a jQuery object + + +// A central reference to the root jQuery(document) +var rootjQuery, + + // A simple way to check for HTML strings + // Prioritize #id over to avoid XSS via location.hash (#9521) + // Strict HTML recognition (#11290: must start with <) + rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/, + + init = jQuery.fn.init = function( selector, context ) { + var match, elem; + + // HANDLE: $(""), $(null), $(undefined), $(false) + if ( !selector ) { + return this; + } + + // Handle HTML strings + if ( typeof selector === "string" ) { + if ( selector[0] === "<" && selector[ selector.length - 1 ] === ">" && selector.length >= 3 ) { + // Assume that strings that start and end with <> are HTML and skip the regex check + match = [ null, selector, null ]; + + } else { + match = rquickExpr.exec( selector ); + } + + // Match html or make sure no context is specified for #id + if ( match && (match[1] || !context) ) { + + // HANDLE: $(html) -> $(array) + if ( match[1] ) { + context = context instanceof jQuery ? context[0] : context; + + // Option to run scripts is true for back-compat + // Intentionally let the error be thrown if parseHTML is not present + jQuery.merge( this, jQuery.parseHTML( + match[1], + context && context.nodeType ? context.ownerDocument || context : document, + true + ) ); + + // HANDLE: $(html, props) + if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) { + for ( match in context ) { + // Properties of context are called as methods if possible + if ( jQuery.isFunction( this[ match ] ) ) { + this[ match ]( context[ match ] ); + + // ...and otherwise set as attributes + } else { + this.attr( match, context[ match ] ); + } + } + } + + return this; + + // HANDLE: $(#id) + } else { + elem = document.getElementById( match[2] ); + + // Support: Blackberry 4.6 + // gEBID returns nodes no longer in the document (#6963) + if ( elem && elem.parentNode ) { + // Inject the element directly into the jQuery object + this.length = 1; + this[0] = elem; + } + + this.context = document; + this.selector = selector; + return this; + } + + // HANDLE: $(expr, $(...)) + } else if ( !context || context.jquery ) { + return ( context || rootjQuery ).find( selector ); + + // HANDLE: $(expr, context) + // (which is just equivalent to: $(context).find(expr) + } else { + return this.constructor( context ).find( selector ); + } + + // HANDLE: $(DOMElement) + } else if ( selector.nodeType ) { + this.context = this[0] = selector; + this.length = 1; + return this; + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) { + return typeof rootjQuery.ready !== "undefined" ? + rootjQuery.ready( selector ) : + // Execute immediately if ready is not present + selector( jQuery ); + } + + if ( selector.selector !== undefined ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return jQuery.makeArray( selector, this ); + }; + +// Give the init function the jQuery prototype for later instantiation +init.prototype = jQuery.fn; + +// Initialize central reference +rootjQuery = jQuery( document ); + + +var rparentsprev = /^(?:parents|prev(?:Until|All))/, + // Methods guaranteed to produce a unique set when starting from a unique set + guaranteedUnique = { + children: true, + contents: true, + next: true, + prev: true + }; + +jQuery.extend({ + dir: function( elem, dir, until ) { + var matched = [], + truncate = until !== undefined; + + while ( (elem = elem[ dir ]) && elem.nodeType !== 9 ) { + if ( elem.nodeType === 1 ) { + if ( truncate && jQuery( elem ).is( until ) ) { + break; + } + matched.push( elem ); + } + } + return matched; + }, + + sibling: function( n, elem ) { + var matched = []; + + for ( ; n; n = n.nextSibling ) { + if ( n.nodeType === 1 && n !== elem ) { + matched.push( n ); + } + } + + return matched; + } +}); + +jQuery.fn.extend({ + has: function( target ) { + var targets = jQuery( target, this ), + l = targets.length; + + return this.filter(function() { + var i = 0; + for ( ; i < l; i++ ) { + if ( jQuery.contains( this, targets[i] ) ) { + return true; + } + } + }); + }, + + closest: function( selectors, context ) { + var cur, + i = 0, + l = this.length, + matched = [], + pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ? + jQuery( selectors, context || this.context ) : + 0; + + for ( ; i < l; i++ ) { + for ( cur = this[i]; cur && cur !== context; cur = cur.parentNode ) { + // Always skip document fragments + if ( cur.nodeType < 11 && (pos ? + pos.index(cur) > -1 : + + // Don't pass non-elements to Sizzle + cur.nodeType === 1 && + jQuery.find.matchesSelector(cur, selectors)) ) { + + matched.push( cur ); + break; + } + } + } + + return this.pushStack( matched.length > 1 ? jQuery.unique( matched ) : matched ); + }, + + // Determine the position of an element within the set + index: function( elem ) { + + // No argument, return index in parent + if ( !elem ) { + return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1; + } + + // Index in selector + if ( typeof elem === "string" ) { + return indexOf.call( jQuery( elem ), this[ 0 ] ); + } + + // Locate the position of the desired element + return indexOf.call( this, + + // If it receives a jQuery object, the first element is used + elem.jquery ? elem[ 0 ] : elem + ); + }, + + add: function( selector, context ) { + return this.pushStack( + jQuery.unique( + jQuery.merge( this.get(), jQuery( selector, context ) ) + ) + ); + }, + + addBack: function( selector ) { + return this.add( selector == null ? + this.prevObject : this.prevObject.filter(selector) + ); + } +}); + +function sibling( cur, dir ) { + while ( (cur = cur[dir]) && cur.nodeType !== 1 ) {} + return cur; +} + +jQuery.each({ + parent: function( elem ) { + var parent = elem.parentNode; + return parent && parent.nodeType !== 11 ? parent : null; + }, + parents: function( elem ) { + return jQuery.dir( elem, "parentNode" ); + }, + parentsUntil: function( elem, i, until ) { + return jQuery.dir( elem, "parentNode", until ); + }, + next: function( elem ) { + return sibling( elem, "nextSibling" ); + }, + prev: function( elem ) { + return sibling( elem, "previousSibling" ); + }, + nextAll: function( elem ) { + return jQuery.dir( elem, "nextSibling" ); + }, + prevAll: function( elem ) { + return jQuery.dir( elem, "previousSibling" ); + }, + nextUntil: function( elem, i, until ) { + return jQuery.dir( elem, "nextSibling", until ); + }, + prevUntil: function( elem, i, until ) { + return jQuery.dir( elem, "previousSibling", until ); + }, + siblings: function( elem ) { + return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem ); + }, + children: function( elem ) { + return jQuery.sibling( elem.firstChild ); + }, + contents: function( elem ) { + return elem.contentDocument || jQuery.merge( [], elem.childNodes ); + } +}, function( name, fn ) { + jQuery.fn[ name ] = function( until, selector ) { + var matched = jQuery.map( this, fn, until ); + + if ( name.slice( -5 ) !== "Until" ) { + selector = until; + } + + if ( selector && typeof selector === "string" ) { + matched = jQuery.filter( selector, matched ); + } + + if ( this.length > 1 ) { + // Remove duplicates + if ( !guaranteedUnique[ name ] ) { + jQuery.unique( matched ); + } + + // Reverse order for parents* and prev-derivatives + if ( rparentsprev.test( name ) ) { + matched.reverse(); + } + } + + return this.pushStack( matched ); + }; +}); +var rnotwhite = (/\S+/g); + + + +// String to Object options format cache +var optionsCache = {}; + +// Convert String-formatted options into Object-formatted ones and store in cache +function createOptions( options ) { + var object = optionsCache[ options ] = {}; + jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) { + object[ flag ] = true; + }); + return object; +} + +/* + * Create a callback list using the following parameters: + * + * options: an optional list of space-separated options that will change how + * the callback list behaves or a more traditional option object + * + * By default a callback list will act like an event callback list and can be + * "fired" multiple times. + * + * Possible options: + * + * once: will ensure the callback list can only be fired once (like a Deferred) + * + * memory: will keep track of previous values and will call any callback added + * after the list has been fired right away with the latest "memorized" + * values (like a Deferred) + * + * unique: will ensure a callback can only be added once (no duplicate in the list) + * + * stopOnFalse: interrupt callings when a callback returns false + * + */ +jQuery.Callbacks = function( options ) { + + // Convert options from String-formatted to Object-formatted if needed + // (we check in cache first) + options = typeof options === "string" ? + ( optionsCache[ options ] || createOptions( options ) ) : + jQuery.extend( {}, options ); + + var // Last fire value (for non-forgettable lists) + memory, + // Flag to know if list was already fired + fired, + // Flag to know if list is currently firing + firing, + // First callback to fire (used internally by add and fireWith) + firingStart, + // End of the loop when firing + firingLength, + // Index of currently firing callback (modified by remove if needed) + firingIndex, + // Actual callback list + list = [], + // Stack of fire calls for repeatable lists + stack = !options.once && [], + // Fire callbacks + fire = function( data ) { + memory = options.memory && data; + fired = true; + firingIndex = firingStart || 0; + firingStart = 0; + firingLength = list.length; + firing = true; + for ( ; list && firingIndex < firingLength; firingIndex++ ) { + if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) { + memory = false; // To prevent further calls using add + break; + } + } + firing = false; + if ( list ) { + if ( stack ) { + if ( stack.length ) { + fire( stack.shift() ); + } + } else if ( memory ) { + list = []; + } else { + self.disable(); + } + } + }, + // Actual Callbacks object + self = { + // Add a callback or a collection of callbacks to the list + add: function() { + if ( list ) { + // First, we save the current length + var start = list.length; + (function add( args ) { + jQuery.each( args, function( _, arg ) { + var type = jQuery.type( arg ); + if ( type === "function" ) { + if ( !options.unique || !self.has( arg ) ) { + list.push( arg ); + } + } else if ( arg && arg.length && type !== "string" ) { + // Inspect recursively + add( arg ); + } + }); + })( arguments ); + // Do we need to add the callbacks to the + // current firing batch? + if ( firing ) { + firingLength = list.length; + // With memory, if we're not firing then + // we should call right away + } else if ( memory ) { + firingStart = start; + fire( memory ); + } + } + return this; + }, + // Remove a callback from the list + remove: function() { + if ( list ) { + jQuery.each( arguments, function( _, arg ) { + var index; + while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { + list.splice( index, 1 ); + // Handle firing indexes + if ( firing ) { + if ( index <= firingLength ) { + firingLength--; + } + if ( index <= firingIndex ) { + firingIndex--; + } + } + } + }); + } + return this; + }, + // Check if a given callback is in the list. + // If no argument is given, return whether or not list has callbacks attached. + has: function( fn ) { + return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length ); + }, + // Remove all callbacks from the list + empty: function() { + list = []; + firingLength = 0; + return this; + }, + // Have the list do nothing anymore + disable: function() { + list = stack = memory = undefined; + return this; + }, + // Is it disabled? + disabled: function() { + return !list; + }, + // Lock the list in its current state + lock: function() { + stack = undefined; + if ( !memory ) { + self.disable(); + } + return this; + }, + // Is it locked? + locked: function() { + return !stack; + }, + // Call all callbacks with the given context and arguments + fireWith: function( context, args ) { + if ( list && ( !fired || stack ) ) { + args = args || []; + args = [ context, args.slice ? args.slice() : args ]; + if ( firing ) { + stack.push( args ); + } else { + fire( args ); + } + } + return this; + }, + // Call all the callbacks with the given arguments + fire: function() { + self.fireWith( this, arguments ); + return this; + }, + // To know if the callbacks have already been called at least once + fired: function() { + return !!fired; + } + }; + + return self; +}; + + +jQuery.extend({ + + Deferred: function( func ) { + var tuples = [ + // action, add listener, listener list, final state + [ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ], + [ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ], + [ "notify", "progress", jQuery.Callbacks("memory") ] + ], + state = "pending", + promise = { + state: function() { + return state; + }, + always: function() { + deferred.done( arguments ).fail( arguments ); + return this; + }, + then: function( /* fnDone, fnFail, fnProgress */ ) { + var fns = arguments; + return jQuery.Deferred(function( newDefer ) { + jQuery.each( tuples, function( i, tuple ) { + var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ]; + // deferred[ done | fail | progress ] for forwarding actions to newDefer + deferred[ tuple[1] ](function() { + var returned = fn && fn.apply( this, arguments ); + if ( returned && jQuery.isFunction( returned.promise ) ) { + returned.promise() + .done( newDefer.resolve ) + .fail( newDefer.reject ) + .progress( newDefer.notify ); + } else { + newDefer[ tuple[ 0 ] + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments ); + } + }); + }); + fns = null; + }).promise(); + }, + // Get a promise for this deferred + // If obj is provided, the promise aspect is added to the object + promise: function( obj ) { + return obj != null ? jQuery.extend( obj, promise ) : promise; + } + }, + deferred = {}; + + // Keep pipe for back-compat + promise.pipe = promise.then; + + // Add list-specific methods + jQuery.each( tuples, function( i, tuple ) { + var list = tuple[ 2 ], + stateString = tuple[ 3 ]; + + // promise[ done | fail | progress ] = list.add + promise[ tuple[1] ] = list.add; + + // Handle state + if ( stateString ) { + list.add(function() { + // state = [ resolved | rejected ] + state = stateString; + + // [ reject_list | resolve_list ].disable; progress_list.lock + }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock ); + } + + // deferred[ resolve | reject | notify ] + deferred[ tuple[0] ] = function() { + deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments ); + return this; + }; + deferred[ tuple[0] + "With" ] = list.fireWith; + }); + + // Make the deferred a promise + promise.promise( deferred ); + + // Call given func if any + if ( func ) { + func.call( deferred, deferred ); + } + + // All done! + return deferred; + }, + + // Deferred helper + when: function( subordinate /* , ..., subordinateN */ ) { + var i = 0, + resolveValues = slice.call( arguments ), + length = resolveValues.length, + + // the count of uncompleted subordinates + remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, + + // the master Deferred. If resolveValues consist of only a single Deferred, just use that. + deferred = remaining === 1 ? subordinate : jQuery.Deferred(), + + // Update function for both resolve and progress values + updateFunc = function( i, contexts, values ) { + return function( value ) { + contexts[ i ] = this; + values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value; + if ( values === progressValues ) { + deferred.notifyWith( contexts, values ); + } else if ( !( --remaining ) ) { + deferred.resolveWith( contexts, values ); + } + }; + }, + + progressValues, progressContexts, resolveContexts; + + // Add listeners to Deferred subordinates; treat others as resolved + if ( length > 1 ) { + progressValues = new Array( length ); + progressContexts = new Array( length ); + resolveContexts = new Array( length ); + for ( ; i < length; i++ ) { + if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { + resolveValues[ i ].promise() + .done( updateFunc( i, resolveContexts, resolveValues ) ) + .fail( deferred.reject ) + .progress( updateFunc( i, progressContexts, progressValues ) ); + } else { + --remaining; + } + } + } + + // If we're not waiting on anything, resolve the master + if ( !remaining ) { + deferred.resolveWith( resolveContexts, resolveValues ); + } + + return deferred.promise(); + } +}); + + +// The deferred used on DOM ready +var readyList; + +jQuery.fn.ready = function( fn ) { + // Add the callback + jQuery.ready.promise().done( fn ); + + return this; +}; + +jQuery.extend({ + // Is the DOM ready to be used? Set to true once it occurs. + isReady: false, + + // A counter to track how many items to wait for before + // the ready event fires. See #6781 + readyWait: 1, + + // Hold (or release) the ready event + holdReady: function( hold ) { + if ( hold ) { + jQuery.readyWait++; + } else { + jQuery.ready( true ); + } + }, + + // Handle when the DOM is ready + ready: function( wait ) { + + // Abort if there are pending holds or we're already ready + if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { + return; + } + + // Remember that the DOM is ready + jQuery.isReady = true; + + // If a normal DOM Ready event fired, decrement, and wait if need be + if ( wait !== true && --jQuery.readyWait > 0 ) { + return; + } + + // If there are functions bound, to execute + readyList.resolveWith( document, [ jQuery ] ); + + // Trigger any bound ready events + if ( jQuery.fn.triggerHandler ) { + jQuery( document ).triggerHandler( "ready" ); + jQuery( document ).off( "ready" ); + } + } +}); + +/** + * The ready event handler and self cleanup method + */ +function completed() { + document.removeEventListener( "DOMContentLoaded", completed, false ); + window.removeEventListener( "load", completed, false ); + jQuery.ready(); +} + +jQuery.ready.promise = function( obj ) { + if ( !readyList ) { + + readyList = jQuery.Deferred(); + + // Catch cases where $(document).ready() is called after the browser event has already occurred. + // We once tried to use readyState "interactive" here, but it caused issues like the one + // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15 + if ( document.readyState === "complete" ) { + // Handle it asynchronously to allow scripts the opportunity to delay ready + setTimeout( jQuery.ready ); + + } else { + + // Use the handy event callback + document.addEventListener( "DOMContentLoaded", completed, false ); + + // A fallback to window.onload, that will always work + window.addEventListener( "load", completed, false ); + } + } + return readyList.promise( obj ); +}; + +// Kick off the DOM ready check even if the user does not +jQuery.ready.promise(); + + + + +// Multifunctional method to get and set values of a collection +// The value/s can optionally be executed if it's a function +var access = jQuery.access = function( elems, fn, key, value, chainable, emptyGet, raw ) { + var i = 0, + len = elems.length, + bulk = key == null; + + // Sets many values + if ( jQuery.type( key ) === "object" ) { + chainable = true; + for ( i in key ) { + jQuery.access( elems, fn, i, key[i], true, emptyGet, raw ); + } + + // Sets one value + } else if ( value !== undefined ) { + chainable = true; + + if ( !jQuery.isFunction( value ) ) { + raw = true; + } + + if ( bulk ) { + // Bulk operations run against the entire set + if ( raw ) { + fn.call( elems, value ); + fn = null; + + // ...except when executing function values + } else { + bulk = fn; + fn = function( elem, key, value ) { + return bulk.call( jQuery( elem ), value ); + }; + } + } + + if ( fn ) { + for ( ; i < len; i++ ) { + fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) ); + } + } + } + + return chainable ? + elems : + + // Gets + bulk ? + fn.call( elems ) : + len ? fn( elems[0], key ) : emptyGet; +}; + + +/** + * Determines whether an object can have data + */ +jQuery.acceptData = function( owner ) { + // Accepts only: + // - Node + // - Node.ELEMENT_NODE + // - Node.DOCUMENT_NODE + // - Object + // - Any + /* jshint -W018 */ + return owner.nodeType === 1 || owner.nodeType === 9 || !( +owner.nodeType ); +}; + + +function Data() { + // Support: Android<4, + // Old WebKit does not have Object.preventExtensions/freeze method, + // return new empty object instead with no [[set]] accessor + Object.defineProperty( this.cache = {}, 0, { + get: function() { + return {}; + } + }); + + this.expando = jQuery.expando + Data.uid++; +} + +Data.uid = 1; +Data.accepts = jQuery.acceptData; + +Data.prototype = { + key: function( owner ) { + // We can accept data for non-element nodes in modern browsers, + // but we should not, see #8335. + // Always return the key for a frozen object. + if ( !Data.accepts( owner ) ) { + return 0; + } + + var descriptor = {}, + // Check if the owner object already has a cache key + unlock = owner[ this.expando ]; + + // If not, create one + if ( !unlock ) { + unlock = Data.uid++; + + // Secure it in a non-enumerable, non-writable property + try { + descriptor[ this.expando ] = { value: unlock }; + Object.defineProperties( owner, descriptor ); + + // Support: Android<4 + // Fallback to a less secure definition + } catch ( e ) { + descriptor[ this.expando ] = unlock; + jQuery.extend( owner, descriptor ); + } + } + + // Ensure the cache object + if ( !this.cache[ unlock ] ) { + this.cache[ unlock ] = {}; + } + + return unlock; + }, + set: function( owner, data, value ) { + var prop, + // There may be an unlock assigned to this node, + // if there is no entry for this "owner", create one inline + // and set the unlock as though an owner entry had always existed + unlock = this.key( owner ), + cache = this.cache[ unlock ]; + + // Handle: [ owner, key, value ] args + if ( typeof data === "string" ) { + cache[ data ] = value; + + // Handle: [ owner, { properties } ] args + } else { + // Fresh assignments by object are shallow copied + if ( jQuery.isEmptyObject( cache ) ) { + jQuery.extend( this.cache[ unlock ], data ); + // Otherwise, copy the properties one-by-one to the cache object + } else { + for ( prop in data ) { + cache[ prop ] = data[ prop ]; + } + } + } + return cache; + }, + get: function( owner, key ) { + // Either a valid cache is found, or will be created. + // New caches will be created and the unlock returned, + // allowing direct access to the newly created + // empty data object. A valid owner object must be provided. + var cache = this.cache[ this.key( owner ) ]; + + return key === undefined ? + cache : cache[ key ]; + }, + access: function( owner, key, value ) { + var stored; + // In cases where either: + // + // 1. No key was specified + // 2. A string key was specified, but no value provided + // + // Take the "read" path and allow the get method to determine + // which value to return, respectively either: + // + // 1. The entire cache object + // 2. The data stored at the key + // + if ( key === undefined || + ((key && typeof key === "string") && value === undefined) ) { + + stored = this.get( owner, key ); + + return stored !== undefined ? + stored : this.get( owner, jQuery.camelCase(key) ); + } + + // [*]When the key is not a string, or both a key and value + // are specified, set or extend (existing objects) with either: + // + // 1. An object of properties + // 2. A key and value + // + this.set( owner, key, value ); + + // Since the "set" path can have two possible entry points + // return the expected data based on which path was taken[*] + return value !== undefined ? value : key; + }, + remove: function( owner, key ) { + var i, name, camel, + unlock = this.key( owner ), + cache = this.cache[ unlock ]; + + if ( key === undefined ) { + this.cache[ unlock ] = {}; + + } else { + // Support array or space separated string of keys + if ( jQuery.isArray( key ) ) { + // If "name" is an array of keys... + // When data is initially created, via ("key", "val") signature, + // keys will be converted to camelCase. + // Since there is no way to tell _how_ a key was added, remove + // both plain key and camelCase key. #12786 + // This will only penalize the array argument path. + name = key.concat( key.map( jQuery.camelCase ) ); + } else { + camel = jQuery.camelCase( key ); + // Try the string as a key before any manipulation + if ( key in cache ) { + name = [ key, camel ]; + } else { + // If a key with the spaces exists, use it. + // Otherwise, create an array by matching non-whitespace + name = camel; + name = name in cache ? + [ name ] : ( name.match( rnotwhite ) || [] ); + } + } + + i = name.length; + while ( i-- ) { + delete cache[ name[ i ] ]; + } + } + }, + hasData: function( owner ) { + return !jQuery.isEmptyObject( + this.cache[ owner[ this.expando ] ] || {} + ); + }, + discard: function( owner ) { + if ( owner[ this.expando ] ) { + delete this.cache[ owner[ this.expando ] ]; + } + } +}; +var data_priv = new Data(); + +var data_user = new Data(); + + + +// Implementation Summary +// +// 1. Enforce API surface and semantic compatibility with 1.9.x branch +// 2. Improve the module's maintainability by reducing the storage +// paths to a single mechanism. +// 3. Use the same single mechanism to support "private" and "user" data. +// 4. _Never_ expose "private" data to user code (TODO: Drop _data, _removeData) +// 5. Avoid exposing implementation details on user objects (eg. expando properties) +// 6. Provide a clear path for implementation upgrade to WeakMap in 2014 + +var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, + rmultiDash = /([A-Z])/g; + +function dataAttr( elem, key, data ) { + var name; + + // If nothing was found internally, try to fetch any + // data from the HTML5 data-* attribute + if ( data === undefined && elem.nodeType === 1 ) { + name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); + data = elem.getAttribute( name ); + + if ( typeof data === "string" ) { + try { + data = data === "true" ? true : + data === "false" ? false : + data === "null" ? null : + // Only convert to a number if it doesn't change the string + +data + "" === data ? +data : + rbrace.test( data ) ? jQuery.parseJSON( data ) : + data; + } catch( e ) {} + + // Make sure we set the data so it isn't changed later + data_user.set( elem, key, data ); + } else { + data = undefined; + } + } + return data; +} + +jQuery.extend({ + hasData: function( elem ) { + return data_user.hasData( elem ) || data_priv.hasData( elem ); + }, + + data: function( elem, name, data ) { + return data_user.access( elem, name, data ); + }, + + removeData: function( elem, name ) { + data_user.remove( elem, name ); + }, + + // TODO: Now that all calls to _data and _removeData have been replaced + // with direct calls to data_priv methods, these can be deprecated. + _data: function( elem, name, data ) { + return data_priv.access( elem, name, data ); + }, + + _removeData: function( elem, name ) { + data_priv.remove( elem, name ); + } +}); + +jQuery.fn.extend({ + data: function( key, value ) { + var i, name, data, + elem = this[ 0 ], + attrs = elem && elem.attributes; + + // Gets all values + if ( key === undefined ) { + if ( this.length ) { + data = data_user.get( elem ); + + if ( elem.nodeType === 1 && !data_priv.get( elem, "hasDataAttrs" ) ) { + i = attrs.length; + while ( i-- ) { + + // Support: IE11+ + // The attrs elements can be null (#14894) + if ( attrs[ i ] ) { + name = attrs[ i ].name; + if ( name.indexOf( "data-" ) === 0 ) { + name = jQuery.camelCase( name.slice(5) ); + dataAttr( elem, name, data[ name ] ); + } + } + } + data_priv.set( elem, "hasDataAttrs", true ); + } + } + + return data; + } + + // Sets multiple values + if ( typeof key === "object" ) { + return this.each(function() { + data_user.set( this, key ); + }); + } + + return access( this, function( value ) { + var data, + camelKey = jQuery.camelCase( key ); + + // The calling jQuery object (element matches) is not empty + // (and therefore has an element appears at this[ 0 ]) and the + // `value` parameter was not undefined. An empty jQuery object + // will result in `undefined` for elem = this[ 0 ] which will + // throw an exception if an attempt to read a data cache is made. + if ( elem && value === undefined ) { + // Attempt to get data from the cache + // with the key as-is + data = data_user.get( elem, key ); + if ( data !== undefined ) { + return data; + } + + // Attempt to get data from the cache + // with the key camelized + data = data_user.get( elem, camelKey ); + if ( data !== undefined ) { + return data; + } + + // Attempt to "discover" the data in + // HTML5 custom data-* attrs + data = dataAttr( elem, camelKey, undefined ); + if ( data !== undefined ) { + return data; + } + + // We tried really hard, but the data doesn't exist. + return; + } + + // Set the data... + this.each(function() { + // First, attempt to store a copy or reference of any + // data that might've been store with a camelCased key. + var data = data_user.get( this, camelKey ); + + // For HTML5 data-* attribute interop, we have to + // store property names with dashes in a camelCase form. + // This might not apply to all properties...* + data_user.set( this, camelKey, value ); + + // *... In the case of properties that might _actually_ + // have dashes, we need to also store a copy of that + // unchanged property. + if ( key.indexOf("-") !== -1 && data !== undefined ) { + data_user.set( this, key, value ); + } + }); + }, null, value, arguments.length > 1, null, true ); + }, + + removeData: function( key ) { + return this.each(function() { + data_user.remove( this, key ); + }); + } +}); + + +jQuery.extend({ + queue: function( elem, type, data ) { + var queue; + + if ( elem ) { + type = ( type || "fx" ) + "queue"; + queue = data_priv.get( elem, type ); + + // Speed up dequeue by getting out quickly if this is just a lookup + if ( data ) { + if ( !queue || jQuery.isArray( data ) ) { + queue = data_priv.access( elem, type, jQuery.makeArray(data) ); + } else { + queue.push( data ); + } + } + return queue || []; + } + }, + + dequeue: function( elem, type ) { + type = type || "fx"; + + var queue = jQuery.queue( elem, type ), + startLength = queue.length, + fn = queue.shift(), + hooks = jQuery._queueHooks( elem, type ), + next = function() { + jQuery.dequeue( elem, type ); + }; + + // If the fx queue is dequeued, always remove the progress sentinel + if ( fn === "inprogress" ) { + fn = queue.shift(); + startLength--; + } + + if ( fn ) { + + // Add a progress sentinel to prevent the fx queue from being + // automatically dequeued + if ( type === "fx" ) { + queue.unshift( "inprogress" ); + } + + // Clear up the last queue stop function + delete hooks.stop; + fn.call( elem, next, hooks ); + } + + if ( !startLength && hooks ) { + hooks.empty.fire(); + } + }, + + // Not public - generate a queueHooks object, or return the current one + _queueHooks: function( elem, type ) { + var key = type + "queueHooks"; + return data_priv.get( elem, key ) || data_priv.access( elem, key, { + empty: jQuery.Callbacks("once memory").add(function() { + data_priv.remove( elem, [ type + "queue", key ] ); + }) + }); + } +}); + +jQuery.fn.extend({ + queue: function( type, data ) { + var setter = 2; + + if ( typeof type !== "string" ) { + data = type; + type = "fx"; + setter--; + } + + if ( arguments.length < setter ) { + return jQuery.queue( this[0], type ); + } + + return data === undefined ? + this : + this.each(function() { + var queue = jQuery.queue( this, type, data ); + + // Ensure a hooks for this queue + jQuery._queueHooks( this, type ); + + if ( type === "fx" && queue[0] !== "inprogress" ) { + jQuery.dequeue( this, type ); + } + }); + }, + dequeue: function( type ) { + return this.each(function() { + jQuery.dequeue( this, type ); + }); + }, + clearQueue: function( type ) { + return this.queue( type || "fx", [] ); + }, + // Get a promise resolved when queues of a certain type + // are emptied (fx is the type by default) + promise: function( type, obj ) { + var tmp, + count = 1, + defer = jQuery.Deferred(), + elements = this, + i = this.length, + resolve = function() { + if ( !( --count ) ) { + defer.resolveWith( elements, [ elements ] ); + } + }; + + if ( typeof type !== "string" ) { + obj = type; + type = undefined; + } + type = type || "fx"; + + while ( i-- ) { + tmp = data_priv.get( elements[ i ], type + "queueHooks" ); + if ( tmp && tmp.empty ) { + count++; + tmp.empty.add( resolve ); + } + } + resolve(); + return defer.promise( obj ); + } +}); +var pnum = (/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/).source; + +var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; + +var isHidden = function( elem, el ) { + // isHidden might be called from jQuery#filter function; + // in that case, element will be second argument + elem = el || elem; + return jQuery.css( elem, "display" ) === "none" || !jQuery.contains( elem.ownerDocument, elem ); + }; + +var rcheckableType = (/^(?:checkbox|radio)$/i); + + + +(function() { + var fragment = document.createDocumentFragment(), + div = fragment.appendChild( document.createElement( "div" ) ), + input = document.createElement( "input" ); + + // Support: Safari<=5.1 + // Check state lost if the name is set (#11217) + // Support: Windows Web Apps (WWA) + // `name` and `type` must use .setAttribute for WWA (#14901) + input.setAttribute( "type", "radio" ); + input.setAttribute( "checked", "checked" ); + input.setAttribute( "name", "t" ); + + div.appendChild( input ); + + // Support: Safari<=5.1, Android<4.2 + // Older WebKit doesn't clone checked state correctly in fragments + support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked; + + // Support: IE<=11+ + // Make sure textarea (and checkbox) defaultValue is properly cloned + div.innerHTML = ""; + support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; +})(); +var strundefined = typeof undefined; + + + +support.focusinBubbles = "onfocusin" in window; + + +var + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|pointer|contextmenu)|click/, + rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)$/; + +function returnTrue() { + return true; +} + +function returnFalse() { + return false; +} + +function safeActiveElement() { + try { + return document.activeElement; + } catch ( err ) { } +} + +/* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ +jQuery.event = { + + global: {}, + + add: function( elem, types, handler, data, selector ) { + + var handleObjIn, eventHandle, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = data_priv.get( elem ); + + // Don't attach events to noData or text/comment nodes (but allow plain objects) + if ( !elemData ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + if ( !(events = elemData.events) ) { + events = elemData.events = {}; + } + if ( !(eventHandle = elemData.handle) ) { + eventHandle = elemData.handle = function( e ) { + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== strundefined && jQuery.event.triggered !== e.type ? + jQuery.event.dispatch.apply( elem, arguments ) : undefined; + }; + } + + // Handle multiple events separated by a space + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[t] ) || []; + type = origType = tmp[1]; + namespaces = ( tmp[2] || "" ).split( "." ).sort(); + + // There *must* be a type, no attaching namespace-only handlers + if ( !type ) { + continue; + } + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend({ + type: type, + origType: origType, + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join(".") + }, handleObjIn ); + + // Init the event handler queue if we're the first + if ( !(handlers = events[ type ]) ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener if the special events handler returns false + if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle, false ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + }, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + + var j, origCount, tmp, + events, t, handleObj, + special, handlers, type, namespaces, origType, + elemData = data_priv.hasData( elem ) && data_priv.get( elem ); + + if ( !elemData || !(events = elemData.events) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[t] ) || []; + type = origType = tmp[1]; + namespaces = ( tmp[2] || "" ).split( "." ).sort(); + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector ? special.delegateType : special.bindType ) || type; + handlers = events[ type ] || []; + tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ); + + // Remove matching events + origCount = j = handlers.length; + while ( j-- ) { + handleObj = handlers[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !tmp || tmp.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { + handlers.splice( j, 1 ); + + if ( handleObj.selector ) { + handlers.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( origCount && !handlers.length ) { + if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + delete elemData.handle; + data_priv.remove( elem, "events" ); + } + }, + + trigger: function( event, data, elem, onlyHandlers ) { + + var i, cur, tmp, bubbleType, ontype, handle, special, + eventPath = [ elem || document ], + type = hasOwn.call( event, "type" ) ? event.type : event, + namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : []; + + cur = tmp = elem = elem || document; + + // Don't do events on text and comment nodes + if ( elem.nodeType === 3 || elem.nodeType === 8 ) { + return; + } + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf(".") >= 0 ) { + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split("."); + type = namespaces.shift(); + namespaces.sort(); + } + ontype = type.indexOf(":") < 0 && "on" + type; + + // Caller can pass in a jQuery.Event object, Object, or just an event type string + event = event[ jQuery.expando ] ? + event : + new jQuery.Event( type, typeof event === "object" && event ); + + // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) + event.isTrigger = onlyHandlers ? 2 : 3; + event.namespace = namespaces.join("."); + event.namespace_re = event.namespace ? + new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) : + null; + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data == null ? + [ event ] : + jQuery.makeArray( data, [ event ] ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + if ( !rfocusMorph.test( bubbleType + type ) ) { + cur = cur.parentNode; + } + for ( ; cur; cur = cur.parentNode ) { + eventPath.push( cur ); + tmp = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( tmp === (elem.ownerDocument || document) ) { + eventPath.push( tmp.defaultView || tmp.parentWindow || window ); + } + } + + // Fire handlers on the event path + i = 0; + while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) { + + event.type = i > 1 ? + bubbleType : + special.bindType || type; + + // jQuery handler + handle = ( data_priv.get( cur, "events" ) || {} )[ event.type ] && data_priv.get( cur, "handle" ); + if ( handle ) { + handle.apply( cur, data ); + } + + // Native handler + handle = ontype && cur[ ontype ]; + if ( handle && handle.apply && jQuery.acceptData( cur ) ) { + event.result = handle.apply( cur, data ); + if ( event.result === false ) { + event.preventDefault(); + } + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( (!special._default || special._default.apply( eventPath.pop(), data ) === false) && + jQuery.acceptData( elem ) ) { + + // Call a native DOM method on the target with the same name name as the event. + // Don't do default actions on window, that's where global variables be (#6170) + if ( ontype && jQuery.isFunction( elem[ type ] ) && !jQuery.isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + tmp = elem[ ontype ]; + + if ( tmp ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + elem[ type ](); + jQuery.event.triggered = undefined; + + if ( tmp ) { + elem[ ontype ] = tmp; + } + } + } + } + + return event.result; + }, + + dispatch: function( event ) { + + // Make a writable jQuery.Event from the native event object + event = jQuery.event.fix( event ); + + var i, j, ret, matched, handleObj, + handlerQueue = [], + args = slice.call( arguments ), + handlers = ( data_priv.get( this, "events" ) || {} )[ event.type ] || [], + special = jQuery.event.special[ event.type ] || {}; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[0] = event; + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers + handlerQueue = jQuery.event.handlers.call( this, event, handlers ); + + // Run delegates first; they may want to stop propagation beneath us + i = 0; + while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) { + event.currentTarget = matched.elem; + + j = 0; + while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) { + + // Triggered event must either 1) have no namespace, or 2) have namespace(s) + // a subset or equal to those in the bound event (both can have no namespace). + if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) { + + event.handleObj = handleObj; + event.data = handleObj.data; + + ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) + .apply( matched.elem, args ); + + if ( ret !== undefined ) { + if ( (event.result = ret) === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + handlers: function( event, handlers ) { + var i, matches, sel, handleObj, + handlerQueue = [], + delegateCount = handlers.delegateCount, + cur = event.target; + + // Find delegate handlers + // Black-hole SVG instance trees (#13180) + // Avoid non-left-click bubbling in Firefox (#3861) + if ( delegateCount && cur.nodeType && (!event.button || event.type !== "click") ) { + + for ( ; cur !== this; cur = cur.parentNode || this ) { + + // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.disabled !== true || event.type !== "click" ) { + matches = []; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + + // Don't conflict with Object.prototype properties (#13203) + sel = handleObj.selector + " "; + + if ( matches[ sel ] === undefined ) { + matches[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) >= 0 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( matches[ sel ] ) { + matches.push( handleObj ); + } + } + if ( matches.length ) { + handlerQueue.push({ elem: cur, handlers: matches }); + } + } + } + } + + // Add the remaining (directly-bound) handlers + if ( delegateCount < handlers.length ) { + handlerQueue.push({ elem: this, handlers: handlers.slice( delegateCount ) }); + } + + return handlerQueue; + }, + + // Includes some event props shared by KeyEvent and MouseEvent + props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), + + fixHooks: {}, + + keyHooks: { + props: "char charCode key keyCode".split(" "), + filter: function( event, original ) { + + // Add which for key events + if ( event.which == null ) { + event.which = original.charCode != null ? original.charCode : original.keyCode; + } + + return event; + } + }, + + mouseHooks: { + props: "button buttons clientX clientY offsetX offsetY pageX pageY screenX screenY toElement".split(" "), + filter: function( event, original ) { + var eventDoc, doc, body, + button = original.button; + + // Calculate pageX/Y if missing and clientX/Y available + if ( event.pageX == null && original.clientX != null ) { + eventDoc = event.target.ownerDocument || document; + doc = eventDoc.documentElement; + body = eventDoc.body; + + event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); + event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + // Note: button is not normalized, so don't use it + if ( !event.which && button !== undefined ) { + event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); + } + + return event; + } + }, + + fix: function( event ) { + if ( event[ jQuery.expando ] ) { + return event; + } + + // Create a writable copy of the event object and normalize some properties + var i, prop, copy, + type = event.type, + originalEvent = event, + fixHook = this.fixHooks[ type ]; + + if ( !fixHook ) { + this.fixHooks[ type ] = fixHook = + rmouseEvent.test( type ) ? this.mouseHooks : + rkeyEvent.test( type ) ? this.keyHooks : + {}; + } + copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; + + event = new jQuery.Event( originalEvent ); + + i = copy.length; + while ( i-- ) { + prop = copy[ i ]; + event[ prop ] = originalEvent[ prop ]; + } + + // Support: Cordova 2.5 (WebKit) (#13255) + // All events should have a target; Cordova deviceready doesn't + if ( !event.target ) { + event.target = document; + } + + // Support: Safari 6.0+, Chrome<28 + // Target should not be a text node (#504, #13143) + if ( event.target.nodeType === 3 ) { + event.target = event.target.parentNode; + } + + return fixHook.filter ? fixHook.filter( event, originalEvent ) : event; + }, + + special: { + load: { + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + focus: { + // Fire native event if possible so blur/focus sequence is correct + trigger: function() { + if ( this !== safeActiveElement() && this.focus ) { + this.focus(); + return false; + } + }, + delegateType: "focusin" + }, + blur: { + trigger: function() { + if ( this === safeActiveElement() && this.blur ) { + this.blur(); + return false; + } + }, + delegateType: "focusout" + }, + click: { + // For checkbox, fire native event so checked state will be right + trigger: function() { + if ( this.type === "checkbox" && this.click && jQuery.nodeName( this, "input" ) ) { + this.click(); + return false; + } + }, + + // For cross-browser consistency, don't fire native .click() on links + _default: function( event ) { + return jQuery.nodeName( event.target, "a" ); + } + }, + + beforeunload: { + postDispatch: function( event ) { + + // Support: Firefox 20+ + // Firefox doesn't alert if the returnValue field is not set. + if ( event.result !== undefined && event.originalEvent ) { + event.originalEvent.returnValue = event.result; + } + } + } + }, + + simulate: function( type, elem, event, bubble ) { + // Piggyback on a donor event to simulate a different one. + // Fake originalEvent to avoid donor's stopPropagation, but if the + // simulated event prevents default then we do the same on the donor. + var e = jQuery.extend( + new jQuery.Event(), + event, + { + type: type, + isSimulated: true, + originalEvent: {} + } + ); + if ( bubble ) { + jQuery.event.trigger( e, null, elem ); + } else { + jQuery.event.dispatch.call( elem, e ); + } + if ( e.isDefaultPrevented() ) { + event.preventDefault(); + } + } +}; + +jQuery.removeEvent = function( elem, type, handle ) { + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle, false ); + } +}; + +jQuery.Event = function( src, props ) { + // Allow instantiation without the 'new' keyword + if ( !(this instanceof jQuery.Event) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = src.defaultPrevented || + src.defaultPrevented === undefined && + // Support: Android<4.0 + src.returnValue === false ? + returnTrue : + returnFalse; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || jQuery.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; +}; + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html +jQuery.Event.prototype = { + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse, + + preventDefault: function() { + var e = this.originalEvent; + + this.isDefaultPrevented = returnTrue; + + if ( e && e.preventDefault ) { + e.preventDefault(); + } + }, + stopPropagation: function() { + var e = this.originalEvent; + + this.isPropagationStopped = returnTrue; + + if ( e && e.stopPropagation ) { + e.stopPropagation(); + } + }, + stopImmediatePropagation: function() { + var e = this.originalEvent; + + this.isImmediatePropagationStopped = returnTrue; + + if ( e && e.stopImmediatePropagation ) { + e.stopImmediatePropagation(); + } + + this.stopPropagation(); + } +}; + +// Create mouseenter/leave events using mouseover/out and event-time checks +// Support: Chrome 15+ +jQuery.each({ + mouseenter: "mouseover", + mouseleave: "mouseout", + pointerenter: "pointerover", + pointerleave: "pointerout" +}, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj; + + // For mousenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || (related !== target && !jQuery.contains( target, related )) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; +}); + +// Support: Firefox, Chrome, Safari +// Create "bubbling" focus and blur events +if ( !support.focusinBubbles ) { + jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler on the document while someone wants focusin/focusout + var handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + var doc = this.ownerDocument || this, + attaches = data_priv.access( doc, fix ); + + if ( !attaches ) { + doc.addEventListener( orig, handler, true ); + } + data_priv.access( doc, fix, ( attaches || 0 ) + 1 ); + }, + teardown: function() { + var doc = this.ownerDocument || this, + attaches = data_priv.access( doc, fix ) - 1; + + if ( !attaches ) { + doc.removeEventListener( orig, handler, true ); + data_priv.remove( doc, fix ); + + } else { + data_priv.access( doc, fix, attaches ); + } + } + }; + }); +} + +jQuery.fn.extend({ + + on: function( types, selector, data, fn, /*INTERNAL*/ one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + this.on( type, selector, data, types[ type ], one ); + } + return this; + } + + if ( data == null && fn == null ) { + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return this; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return this.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + }); + }, + one: function( types, selector, data, fn ) { + return this.on( types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each(function() { + jQuery.event.remove( this, types, fn, selector ); + }); + }, + + trigger: function( type, data ) { + return this.each(function() { + jQuery.event.trigger( type, data, this ); + }); + }, + triggerHandler: function( type, data ) { + var elem = this[0]; + if ( elem ) { + return jQuery.event.trigger( type, data, elem, true ); + } + } +}); + + +var + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, + rtagName = /<([\w:]+)/, + rhtml = /<|&#?\w+;/, + rnoInnerhtml = /<(?:script|style|link)/i, + // checked="checked" or checked + rchecked = /checked\s*(?:[^=]|=\s*.checked.)/i, + rscriptType = /^$|\/(?:java|ecma)script/i, + rscriptTypeMasked = /^true\/(.*)/, + rcleanScript = /^\s*\s*$/g, + + // We have to close these tags to support XHTML (#13200) + wrapMap = { + + // Support: IE9 + option: [ 1, "" ], + + thead: [ 1, "", "
" ], + col: [ 2, "", "
" ], + tr: [ 2, "", "
" ], + td: [ 3, "", "
" ], + + _default: [ 0, "", "" ] + }; + +// Support: IE9 +wrapMap.optgroup = wrapMap.option; + +wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; +wrapMap.th = wrapMap.td; + +// Support: 1.x compatibility +// Manipulating tables requires a tbody +function manipulationTarget( elem, content ) { + return jQuery.nodeName( elem, "table" ) && + jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ? + + elem.getElementsByTagName("tbody")[0] || + elem.appendChild( elem.ownerDocument.createElement("tbody") ) : + elem; +} + +// Replace/restore the type attribute of script elements for safe DOM manipulation +function disableScript( elem ) { + elem.type = (elem.getAttribute("type") !== null) + "/" + elem.type; + return elem; +} +function restoreScript( elem ) { + var match = rscriptTypeMasked.exec( elem.type ); + + if ( match ) { + elem.type = match[ 1 ]; + } else { + elem.removeAttribute("type"); + } + + return elem; +} + +// Mark scripts as having already been evaluated +function setGlobalEval( elems, refElements ) { + var i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + data_priv.set( + elems[ i ], "globalEval", !refElements || data_priv.get( refElements[ i ], "globalEval" ) + ); + } +} + +function cloneCopyEvent( src, dest ) { + var i, l, type, pdataOld, pdataCur, udataOld, udataCur, events; + + if ( dest.nodeType !== 1 ) { + return; + } + + // 1. Copy private data: events, handlers, etc. + if ( data_priv.hasData( src ) ) { + pdataOld = data_priv.access( src ); + pdataCur = data_priv.set( dest, pdataOld ); + events = pdataOld.events; + + if ( events ) { + delete pdataCur.handle; + pdataCur.events = {}; + + for ( type in events ) { + for ( i = 0, l = events[ type ].length; i < l; i++ ) { + jQuery.event.add( dest, type, events[ type ][ i ] ); + } + } + } + } + + // 2. Copy user data + if ( data_user.hasData( src ) ) { + udataOld = data_user.access( src ); + udataCur = jQuery.extend( {}, udataOld ); + + data_user.set( dest, udataCur ); + } +} + +function getAll( context, tag ) { + var ret = context.getElementsByTagName ? context.getElementsByTagName( tag || "*" ) : + context.querySelectorAll ? context.querySelectorAll( tag || "*" ) : + []; + + return tag === undefined || tag && jQuery.nodeName( context, tag ) ? + jQuery.merge( [ context ], ret ) : + ret; +} + +// Fix IE bugs, see support tests +function fixInput( src, dest ) { + var nodeName = dest.nodeName.toLowerCase(); + + // Fails to persist the checked state of a cloned checkbox or radio button. + if ( nodeName === "input" && rcheckableType.test( src.type ) ) { + dest.checked = src.checked; + + // Fails to return the selected option to the default selected state when cloning options + } else if ( nodeName === "input" || nodeName === "textarea" ) { + dest.defaultValue = src.defaultValue; + } +} + +jQuery.extend({ + clone: function( elem, dataAndEvents, deepDataAndEvents ) { + var i, l, srcElements, destElements, + clone = elem.cloneNode( true ), + inPage = jQuery.contains( elem.ownerDocument, elem ); + + // Fix IE cloning issues + if ( !support.noCloneChecked && ( elem.nodeType === 1 || elem.nodeType === 11 ) && + !jQuery.isXMLDoc( elem ) ) { + + // We eschew Sizzle here for performance reasons: http://jsperf.com/getall-vs-sizzle/2 + destElements = getAll( clone ); + srcElements = getAll( elem ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + fixInput( srcElements[ i ], destElements[ i ] ); + } + } + + // Copy the events from the original to the clone + if ( dataAndEvents ) { + if ( deepDataAndEvents ) { + srcElements = srcElements || getAll( elem ); + destElements = destElements || getAll( clone ); + + for ( i = 0, l = srcElements.length; i < l; i++ ) { + cloneCopyEvent( srcElements[ i ], destElements[ i ] ); + } + } else { + cloneCopyEvent( elem, clone ); + } + } + + // Preserve script evaluation history + destElements = getAll( clone, "script" ); + if ( destElements.length > 0 ) { + setGlobalEval( destElements, !inPage && getAll( elem, "script" ) ); + } + + // Return the cloned set + return clone; + }, + + buildFragment: function( elems, context, scripts, selection ) { + var elem, tmp, tag, wrap, contains, j, + fragment = context.createDocumentFragment(), + nodes = [], + i = 0, + l = elems.length; + + for ( ; i < l; i++ ) { + elem = elems[ i ]; + + if ( elem || elem === 0 ) { + + // Add nodes directly + if ( jQuery.type( elem ) === "object" ) { + // Support: QtWebKit, PhantomJS + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); + + // Convert non-html into a text node + } else if ( !rhtml.test( elem ) ) { + nodes.push( context.createTextNode( elem ) ); + + // Convert html into DOM nodes + } else { + tmp = tmp || fragment.appendChild( context.createElement("div") ); + + // Deserialize a standard representation + tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase(); + wrap = wrapMap[ tag ] || wrapMap._default; + tmp.innerHTML = wrap[ 1 ] + elem.replace( rxhtmlTag, "<$1>" ) + wrap[ 2 ]; + + // Descend through wrappers to the right content + j = wrap[ 0 ]; + while ( j-- ) { + tmp = tmp.lastChild; + } + + // Support: QtWebKit, PhantomJS + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( nodes, tmp.childNodes ); + + // Remember the top-level container + tmp = fragment.firstChild; + + // Ensure the created nodes are orphaned (#12392) + tmp.textContent = ""; + } + } + } + + // Remove wrapper from fragment + fragment.textContent = ""; + + i = 0; + while ( (elem = nodes[ i++ ]) ) { + + // #4087 - If origin and destination elements are the same, and this is + // that element, do not do anything + if ( selection && jQuery.inArray( elem, selection ) !== -1 ) { + continue; + } + + contains = jQuery.contains( elem.ownerDocument, elem ); + + // Append to fragment + tmp = getAll( fragment.appendChild( elem ), "script" ); + + // Preserve script evaluation history + if ( contains ) { + setGlobalEval( tmp ); + } + + // Capture executables + if ( scripts ) { + j = 0; + while ( (elem = tmp[ j++ ]) ) { + if ( rscriptType.test( elem.type || "" ) ) { + scripts.push( elem ); + } + } + } + } + + return fragment; + }, + + cleanData: function( elems ) { + var data, elem, type, key, + special = jQuery.event.special, + i = 0; + + for ( ; (elem = elems[ i ]) !== undefined; i++ ) { + if ( jQuery.acceptData( elem ) ) { + key = elem[ data_priv.expando ]; + + if ( key && (data = data_priv.cache[ key ]) ) { + if ( data.events ) { + for ( type in data.events ) { + if ( special[ type ] ) { + jQuery.event.remove( elem, type ); + + // This is a shortcut to avoid jQuery.event.remove's overhead + } else { + jQuery.removeEvent( elem, type, data.handle ); + } + } + } + if ( data_priv.cache[ key ] ) { + // Discard any remaining `private` data + delete data_priv.cache[ key ]; + } + } + } + // Discard any remaining `user` data + delete data_user.cache[ elem[ data_user.expando ] ]; + } + } +}); + +jQuery.fn.extend({ + text: function( value ) { + return access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().each(function() { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + this.textContent = value; + } + }); + }, null, value, arguments.length ); + }, + + append: function() { + return this.domManip( arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.appendChild( elem ); + } + }); + }, + + prepend: function() { + return this.domManip( arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.insertBefore( elem, target.firstChild ); + } + }); + }, + + before: function() { + return this.domManip( arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this ); + } + }); + }, + + after: function() { + return this.domManip( arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + } + }); + }, + + remove: function( selector, keepData /* Internal Use Only */ ) { + var elem, + elems = selector ? jQuery.filter( selector, this ) : this, + i = 0; + + for ( ; (elem = elems[i]) != null; i++ ) { + if ( !keepData && elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem ) ); + } + + if ( elem.parentNode ) { + if ( keepData && jQuery.contains( elem.ownerDocument, elem ) ) { + setGlobalEval( getAll( elem, "script" ) ); + } + elem.parentNode.removeChild( elem ); + } + } + + return this; + }, + + empty: function() { + var elem, + i = 0; + + for ( ; (elem = this[i]) != null; i++ ) { + if ( elem.nodeType === 1 ) { + + // Prevent memory leaks + jQuery.cleanData( getAll( elem, false ) ); + + // Remove any remaining nodes + elem.textContent = ""; + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map(function() { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + }); + }, + + html: function( value ) { + return access( this, function( value ) { + var elem = this[ 0 ] || {}, + i = 0, + l = this.length; + + if ( value === undefined && elem.nodeType === 1 ) { + return elem.innerHTML; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { + + value = value.replace( rxhtmlTag, "<$1>" ); + + try { + for ( ; i < l; i++ ) { + elem = this[ i ] || {}; + + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch( e ) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function() { + var arg = arguments[ 0 ]; + + // Make the changes, replacing each context element with the new content + this.domManip( arguments, function( elem ) { + arg = this.parentNode; + + jQuery.cleanData( getAll( this ) ); + + if ( arg ) { + arg.replaceChild( elem, this ); + } + }); + + // Force removal if there was no new content (e.g., from empty arguments) + return arg && (arg.length || arg.nodeType) ? this : this.remove(); + }, + + detach: function( selector ) { + return this.remove( selector, true ); + }, + + domManip: function( args, callback ) { + + // Flatten any nested arrays + args = concat.apply( [], args ); + + var fragment, first, scripts, hasScripts, node, doc, + i = 0, + l = this.length, + set = this, + iNoClone = l - 1, + value = args[ 0 ], + isFunction = jQuery.isFunction( value ); + + // We can't cloneNode fragments that contain checked, in WebKit + if ( isFunction || + ( l > 1 && typeof value === "string" && + !support.checkClone && rchecked.test( value ) ) ) { + return this.each(function( index ) { + var self = set.eq( index ); + if ( isFunction ) { + args[ 0 ] = value.call( this, index, self.html() ); + } + self.domManip( args, callback ); + }); + } + + if ( l ) { + fragment = jQuery.buildFragment( args, this[ 0 ].ownerDocument, false, this ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + if ( first ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + // Support: QtWebKit + // jQuery.merge because push.apply(_, arraylike) throws + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( this[ i ], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !data_priv.access( node, "globalEval" ) && jQuery.contains( doc, node ) ) { + + if ( node.src ) { + // Optional AJAX dependency, but won't run scripts if not present + if ( jQuery._evalUrl ) { + jQuery._evalUrl( node.src ); + } + } else { + jQuery.globalEval( node.textContent.replace( rcleanScript, "" ) ); + } + } + } + } + } + } + + return this; + } +}); + +jQuery.each({ + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" +}, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + ret = [], + insert = jQuery( selector ), + last = insert.length - 1, + i = 0; + + for ( ; i <= last; i++ ) { + elems = i === last ? this : this.clone( true ); + jQuery( insert[ i ] )[ original ]( elems ); + + // Support: QtWebKit + // .get() because push.apply(_, arraylike) throws + push.apply( ret, elems.get() ); + } + + return this.pushStack( ret ); + }; +}); + + +var iframe, + elemdisplay = {}; + +/** + * Retrieve the actual display of a element + * @param {String} name nodeName of the element + * @param {Object} doc Document object + */ +// Called only from within defaultDisplay +function actualDisplay( name, doc ) { + var style, + elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ), + + // getDefaultComputedStyle might be reliably used only on attached element + display = window.getDefaultComputedStyle && ( style = window.getDefaultComputedStyle( elem[ 0 ] ) ) ? + + // Use of this method is a temporary fix (more like optimization) until something better comes along, + // since it was removed from specification and supported only in FF + style.display : jQuery.css( elem[ 0 ], "display" ); + + // We don't have any data stored on the element, + // so use "detach" method as fast way to get rid of the element + elem.detach(); + + return display; +} + +/** + * Try to determine the default display value of an element + * @param {String} nodeName + */ +function defaultDisplay( nodeName ) { + var doc = document, + display = elemdisplay[ nodeName ]; + + if ( !display ) { + display = actualDisplay( nodeName, doc ); + + // If the simple way fails, read from inside an iframe + if ( display === "none" || !display ) { + + // Use the already-created iframe if possible + iframe = (iframe || jQuery( "