- version 0.2.0 (versionCode 2) - The app reads version.json from the download page once at launch and offers the page when a newer build is out; settings shows the version and a check button. - tools/publish-download.py encrypts an APK under the download password and writes the page that decrypts it in the browser. Agent: main (opus) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
7.3 KiB
Python
180 lines
7.3 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"
|
|
|
|
|
|
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)
|
|
|
|
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,
|
|
"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 response = await fetch(META.file, { cache: "no-store" });
|
|
if (!response.ok) throw new Error("fetch");
|
|
const sealed = await response.arrayBuffer();
|
|
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"]);
|
|
let apk;
|
|
try {
|
|
apk = await crypto.subtle.decrypt({ name: "AES-GCM", iv: b64(META.iv) }, key, sealed);
|
|
} catch (e) {
|
|
say("비밀번호가 맞지 않습니다.", true);
|
|
return;
|
|
}
|
|
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()
|