Ubisam-MobileGit/tools/publish-download.py
choiminyeong a02a987946 TASK-066: the download page checks the password before fetching
A wrong password fetched the encrypted APK before being refused, and a
phone browser can keep that fetch as a download. A small sealed check is
opened first; nothing is fetched when it fails.

Agent: main (opus)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:57:06 +09:00

188 lines
7.7 KiB
Python

"""Builds the password-protected download page for one APK (TASK-066).
The APK is encrypted with AES-256-GCM under a key derived from the password with PBKDF2-SHA256,
so the files can sit on a public static host: the page asks for the password and decrypts in the
browser with WebCrypto. version.json beside it is plain; the app reads it to know a newer build
is out.
python tools/publish-download.py <apk> <out-dir> --notes "what changed"
The password is read from UBGITEA_DOWNLOAD_PASSWORD, or asked for. It is never written anywhere.
versionCode and versionName are read from app/build.gradle.kts.
"""
import argparse
import base64
import getpass
import hashlib
import html
import json
import os
import re
import sys
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
ITERATIONS = 310_000
ENCRYPTED_NAME = "ubgitea.apk.enc"
CHECK_TEXT = b"UBGitea"
def gradle_version(root: Path):
text = (root / "app" / "build.gradle.kts").read_text(encoding="utf-8")
code = re.search(r"versionCode\s*=\s*(\d+)", text)
name = re.search(r'versionName\s*=\s*"([^"]+)"', text)
if not code or not name:
sys.exit("versionCode / versionName not found in app/build.gradle.kts")
return int(code.group(1)), name.group(1)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("apk", type=Path)
parser.add_argument("out", type=Path)
parser.add_argument("--notes", default="")
args = parser.parse_args()
password = os.environ.get("UBGITEA_DOWNLOAD_PASSWORD") or getpass.getpass("Download password: ")
if not password:
sys.exit("A password is required.")
code, name = gradle_version(Path(__file__).resolve().parent.parent)
data = args.apk.read_bytes()
salt = os.urandom(16)
iv = os.urandom(12)
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, ITERATIONS, 32)
sealed = AESGCM(key).encrypt(iv, data, None)
# A few sealed bytes the page opens first: a wrong password is refused before the APK is
# fetched at all, so nothing is downloaded with it.
check_iv = os.urandom(12)
check = AESGCM(key).encrypt(check_iv, CHECK_TEXT, None)
args.out.mkdir(parents=True, exist_ok=True)
(args.out / ENCRYPTED_NAME).write_bytes(sealed)
release = {"versionCode": code, "versionName": name, "notes": args.notes}
(args.out / "version.json").write_text(json.dumps(release, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
meta = {
"salt": base64.b64encode(salt).decode(),
"iv": base64.b64encode(iv).decode(),
"iterations": ITERATIONS,
"check": base64.b64encode(check).decode(),
"checkIv": base64.b64encode(check_iv).decode(),
"file": ENCRYPTED_NAME,
"fileName": f"UBGitea-{name}.apk",
"size": len(data),
}
page = PAGE.replace("{{META}}", json.dumps(meta)) \
.replace("{{VERSION}}", html.escape(name)) \
.replace("{{NOTES}}", html.escape(args.notes)) \
.replace("{{SIZE}}", f"{len(data) / 1024 / 1024:.1f} MB")
(args.out / "index.html").write_text(page, encoding="utf-8")
print(f"UBGitea {name} (versionCode {code}) -> {args.out}")
PAGE = """<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>UBGitea 다운로드</title>
<style>
:root { --bg: #f4f7fc; --card: #ffffff; --ink: #14213d; --muted: #5b6b86; --line: #d8e1f0;
--blue: #1f5fd6; --blue-ink: #ffffff; --bad: #c62828; }
@media (prefers-color-scheme: dark) {
:root { --bg: #0d1524; --card: #16213a; --ink: #e8eefb; --muted: #9fb0cf; --line: #2a3a5c;
--blue: #6d9cff; --blue-ink: #0d1524; --bad: #ff8a80; }
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
font-family: system-ui, -apple-system, "Segoe UI", "Malgun Gothic", sans-serif;
display: flex; align-items: center; justify-content: center; padding: 16px; }
main { width: 100%; max-width: 420px; background: var(--card); border: 1px solid var(--line);
border-radius: 20px; padding: 28px 24px; }
h1 { margin: 0 0 4px; font-size: 24px; }
.version { color: var(--blue); font-weight: 600; }
p { color: var(--muted); line-height: 1.5; margin: 8px 0; }
.notes { white-space: pre-line; color: var(--ink); background: var(--bg); border-radius: 12px;
padding: 12px 14px; margin: 16px 0; }
.notes:empty { display: none; }
label { display: block; font-weight: 600; margin: 20px 0 8px; }
input { width: 100%; font-size: 17px; padding: 14px; border-radius: 12px; border: 1px solid var(--line);
background: var(--card); color: var(--ink); }
button { width: 100%; margin-top: 12px; font-size: 17px; font-weight: 600; padding: 14px; border: 0;
border-radius: 12px; background: var(--blue); color: var(--blue-ink); cursor: pointer; }
button:disabled { opacity: .6; cursor: default; }
#status { min-height: 1.5em; margin-top: 12px; }
#status.bad { color: var(--bad); }
ol { color: var(--muted); padding-left: 20px; line-height: 1.6; margin: 20px 0 0; }
</style>
</head>
<body>
<main>
<h1>UBGitea</h1>
<div class="version">버전 {{VERSION}} · {{SIZE}}</div>
<div class="notes">{{NOTES}}</div>
<form id="form">
<label for="password">다운로드 비밀번호</label>
<input id="password" type="password" autocomplete="off" required>
<button id="go" type="submit">APK 다운로드</button>
</form>
<p id="status" role="status" aria-live="polite"></p>
<ol>
<li>받은 APK 파일을 엽니다.</li>
<li>"출처를 알 수 없는 앱" 설치를 이 브라우저에 허용합니다.</li>
<li>설치(업데이트)를 누릅니다. 로그인과 설정은 그대로 남습니다.</li>
</ol>
</main>
<script>
const META = {{META}};
const status = document.getElementById("status");
const go = document.getElementById("go");
const b64 = s => Uint8Array.from(atob(s), c => c.charCodeAt(0));
function say(text, bad) { status.textContent = text; status.className = bad ? "bad" : ""; }
document.getElementById("form").addEventListener("submit", async event => {
event.preventDefault();
go.disabled = true;
try {
say("비밀번호 확인 중…");
const password = new TextEncoder().encode(document.getElementById("password").value);
const base = await crypto.subtle.importKey("raw", password, "PBKDF2", false, ["deriveKey"]);
const key = await crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: b64(META.salt), iterations: META.iterations, hash: "SHA-256" },
base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]);
try {
await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.checkIv) }, key, b64(META.check));
} catch (e) {
say("비밀번호가 맞지 않습니다.", true);
return;
}
say("파일을 받는 중…");
const response = await fetch(META.file, { cache: "no-store" });
if (!response.ok) throw new Error("fetch");
const sealed = await response.arrayBuffer();
say("여는 중…");
const apk = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.iv) }, key, sealed);
const url = URL.createObjectURL(new Blob([apk], { type: "application/vnd.android.package-archive" }));
const link = document.createElement("a");
link.href = url;
link.download = META.fileName;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
say("다운로드를 시작했습니다. 받은 파일을 열어 설치하세요.");
} catch (e) {
say("파일을 받지 못했습니다. 잠시 뒤 다시 시도하세요.", true);
} finally {
go.disabled = false;
}
});
</script>
</body>
</html>
"""
if __name__ == "__main__":
main()