"""GitHub 域的输入校验 —— 一处定义,提交入口与后台入口共用。 放在单独模块而不是散在 service 里,是因为同一条规则有三个调用方: 自助申请的提交、管理员手工登记成员、以及对账时从 GitHub 拉回来的 handle 归一化。三处各写一遍,迟早会有一处忘了 casefold。 """ from __future__ import annotations import re from app.core.exceptions import ValidationError from . import constants as const _LOGIN_RE = re.compile(const.GITHUB_LOGIN_PATTERN) #: 组织 slug 与用户名的字符集一致,但允许的长度口径分开写,免得以后 #: GitHub 改了其中一个,另一个被顺手改坏。 _SLUG_RE = re.compile(const.GITHUB_LOGIN_PATTERN) def normalize_github_login(raw: str | None, *, field: str = "github_login") -> str: """把用户输入的 handle 归一成可存储的形式,不合法直接拒。 允许用户粘贴整条个人主页链接(``https://github.com/octocat`` 或 ``github.com/octocat/``)—— 这是提交表单时最常见的输入形态,与其 让一条走完整个审批链的申请在调 API 时才失败,不如在入口就接受它。 """ if raw is None: raise ValidationError(f"{field} is required") value = raw.strip() if not value: raise ValidationError(f"{field} is required") # 剥掉可能被粘进来的 URL 外壳。 # # ⚠️ 「只取第一段」这条**只对识别出 URL 前缀的输入生效**。裸的 # ``a/../b`` 无条件取第一段会被静默归一成 ``a`` —— 一个合法但**错误** # 的 handle,而提交的人以为自己填对了。安全扫描抓到的:不是穿越漏洞 # (穿越确实被削掉了),而是一条静默产生错误结果的路径。 looked_like_url = bool( re.match(r"^(https?://|(www\.)?github\.com/)", value, re.IGNORECASE) ) if looked_like_url: value = re.sub(r"^https?://", "", value, flags=re.IGNORECASE) value = re.sub( r"^(www\.)?github\.com/", "", value, flags=re.IGNORECASE, ) value = value.strip("/").split("/", 1)[0] value = value.split("?", 1)[0].strip() # @octocat 也是常见写法。 value = value.lstrip("@") if len(value) > const.GITHUB_LOGIN_MAX_LEN: raise ValidationError( f"{field} must be at most {const.GITHUB_LOGIN_MAX_LEN} characters", ) if not _LOGIN_RE.match(value): raise ValidationError( f"{field} is not a valid GitHub username " "(letters, digits and single hyphens; cannot start or end with '-')", ) return value def normalize_org_slug(raw: str | None) -> str: """组织 slug 的归一。与用户名同一字符集,但**保留大小写**。 GitHub 的组织 login 大小写不敏感,但显示时保留原样(``PingCAP-QE`` 就是这么写的)。所以这里只做去空白与合法性校验,唯一性由 ``slug_lower`` 列上的约束负责。 """ if raw is None: raise ValidationError("slug is required") value = raw.strip() value = re.sub(r"^https?://", "", value, flags=re.IGNORECASE) value = re.sub(r"^(www\.)?github\.com/", "", value, flags=re.IGNORECASE) value = value.strip("/").split("/", 1)[0].strip() if not value: raise ValidationError("slug is required") if len(value) > const.GITHUB_ORG_SLUG_MAX_LEN: raise ValidationError( f"slug must be at most {const.GITHUB_ORG_SLUG_MAX_LEN} characters", ) if not _SLUG_RE.match(value): raise ValidationError( "slug is not a valid GitHub organization name " "(letters, digits and single hyphens)", ) return value def validate_enum(value: str, allowed: tuple[str, ...], field: str) -> str: if value not in allowed: raise ValidationError( f"{field} must be one of {', '.join(allowed)}", ) return value def bounded_text(value: str | None, limit: int, field: str) -> str | None: """长度上限,超了就拒 —— **不**静默截断。 静默截断会让一条 500 字的申请理由在审批人眼里变成半句话,而提交的人 以为写全了。 """ if value is None: return None trimmed = value.strip() if not trimmed: return None if len(trimmed) > limit: raise ValidationError(f"{field} must be at most {limit} characters") return trimmed