toolost-uploader 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- toolost_uploader/__init__.py +3 -0
- toolost_uploader/__main__.py +3 -0
- toolost_uploader/approval.py +116 -0
- toolost_uploader/browser.py +253 -0
- toolost_uploader/cli.py +333 -0
- toolost_uploader/fake_flow.py +63 -0
- toolost_uploader/fake_ui.py +166 -0
- toolost_uploader/fake_upload.py +146 -0
- toolost_uploader/fake_workflow.py +160 -0
- toolost_uploader/journal.py +171 -0
- toolost_uploader/live_draft.py +468 -0
- toolost_uploader/live_journal.py +129 -0
- toolost_uploader/read_only.py +126 -0
- toolost_uploader/storage.py +282 -0
- toolost_uploader/validation.py +241 -0
- toolost_uploader-0.1.0.dist-info/METADATA +248 -0
- toolost_uploader-0.1.0.dist-info/RECORD +21 -0
- toolost_uploader-0.1.0.dist-info/WHEEL +5 -0
- toolost_uploader-0.1.0.dist-info/entry_points.txt +2 -0
- toolost_uploader-0.1.0.dist-info/licenses/LICENSE +21 -0
- toolost_uploader-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Gate sintetis offline; bukan pembuktian identitas akun atau izin executor nyata."""
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from .journal import _checkpoint, _save
|
|
6
|
+
from .storage import StorageError, _digest, _read, _require
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
BINDING_KEYS = {"version", "profile", "account", "authority", "record_sha256"}
|
|
10
|
+
APPROVAL_KEYS = {"version", "profile", "account", "draft", "run_id", "checkpoint_sha256",
|
|
11
|
+
"plan_sha256", "approval_id", "state", "authority", "record_sha256"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _identity(account, draft):
|
|
15
|
+
_require(account == "synthetic-account" and draft == "synthetic-draft", "identity_review_required")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read_record(session, release_id, suffix, keys):
|
|
19
|
+
session._check(release_id)
|
|
20
|
+
try:
|
|
21
|
+
record = _read(session.directory, release_id + suffix)
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
raise StorageError("approval_record_missing") from None
|
|
24
|
+
_require(isinstance(record, dict) and set(record) == keys, "invalid_approval_record")
|
|
25
|
+
_require(type(record["version"]) is int and record["version"] == 1
|
|
26
|
+
and record["profile"] == session.alias and record["authority"] == "synthetic-offline",
|
|
27
|
+
"invalid_approval_record")
|
|
28
|
+
_require(record["record_sha256"] == _digest({k: v for k, v in record.items() if k != "record_sha256"}),
|
|
29
|
+
"approval_hash_mismatch")
|
|
30
|
+
return record
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _absent(session, name):
|
|
34
|
+
try:
|
|
35
|
+
_read(session.directory, name)
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
return
|
|
38
|
+
raise StorageError("approval_record_exists")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _plan(plan):
|
|
42
|
+
_require(isinstance(plan, dict) and set(plan) == {"operations", "desired_sha256"}, "invalid_draft_plan")
|
|
43
|
+
operations = plan["operations"]
|
|
44
|
+
_require(isinstance(operations, list) and bool(operations)
|
|
45
|
+
and all(op in ("fake.upload", "fake.edit", "fake.save") for op in operations)
|
|
46
|
+
and len(operations) == len(set(operations)), "invalid_draft_plan")
|
|
47
|
+
digest = plan["desired_sha256"]
|
|
48
|
+
_require(isinstance(digest, str) and len(digest) == 64
|
|
49
|
+
and all(c in "0123456789abcdef" for c in digest), "invalid_draft_plan")
|
|
50
|
+
return _digest(plan)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def bind(session, *, account, reviewed):
|
|
54
|
+
session._check("binding")
|
|
55
|
+
_require(reviewed is True, "binding_review_required")
|
|
56
|
+
_identity(account, "synthetic-draft")
|
|
57
|
+
name = "_offline-binding.json"
|
|
58
|
+
_absent(session, name)
|
|
59
|
+
return _save(session, name, dict(version=1, profile=session.alias, account=account,
|
|
60
|
+
authority="synthetic-offline"))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _binding(session, release_id, account):
|
|
64
|
+
# Binding per profile memakai inode privat dalam directory yang sudah terkunci.
|
|
65
|
+
try:
|
|
66
|
+
record = _read(session.directory, "_offline-binding.json")
|
|
67
|
+
except FileNotFoundError:
|
|
68
|
+
raise StorageError("binding_review_required") from None
|
|
69
|
+
_require(isinstance(record, dict) and set(record) == BINDING_KEYS, "invalid_approval_record")
|
|
70
|
+
_require(type(record["version"]) is int and record["version"] == 1
|
|
71
|
+
and record["profile"] == session.alias and record["authority"] == "synthetic-offline"
|
|
72
|
+
and record["account"] == account == "synthetic-account", "binding_mismatch")
|
|
73
|
+
_require(record["record_sha256"] == _digest({k: v for k, v in record.items() if k != "record_sha256"}),
|
|
74
|
+
"approval_hash_mismatch")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def approve(session, release_id, *, run_id, expected_revision, account, draft, plan, approved):
|
|
78
|
+
checkpoint = _checkpoint(session, release_id, run_id)
|
|
79
|
+
_identity(account, draft)
|
|
80
|
+
_binding(session, release_id, account)
|
|
81
|
+
_require(approved is True, "confirmation_required")
|
|
82
|
+
_require(type(expected_revision) is int and checkpoint["revision"] == expected_revision, "revision_conflict")
|
|
83
|
+
plan_hash = _plan(plan)
|
|
84
|
+
name = release_id + ".approval.json"
|
|
85
|
+
_absent(session, name)
|
|
86
|
+
return _save(session, name, dict(version=1, profile=session.alias, account=account, draft=draft,
|
|
87
|
+
run_id=run_id, checkpoint_sha256=checkpoint["record_sha256"],
|
|
88
|
+
plan_sha256=plan_hash, approval_id=uuid.uuid4().hex,
|
|
89
|
+
state="approved", authority="synthetic-offline"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def verify(session, release_id, *, run_id, approval_id, account, draft, plan, consumed=False):
|
|
93
|
+
checkpoint = _checkpoint(session, release_id, run_id)
|
|
94
|
+
_identity(account, draft)
|
|
95
|
+
_binding(session, release_id, account)
|
|
96
|
+
record = _read_record(session, release_id, ".approval.json", APPROVAL_KEYS)
|
|
97
|
+
token = record["approval_id"]
|
|
98
|
+
_require(isinstance(token, str) and len(token) == 32
|
|
99
|
+
and all(c in "0123456789abcdef" for c in token)
|
|
100
|
+
and record["state"] in ("approved", "consumed"), "invalid_approval_record")
|
|
101
|
+
_require(type(consumed) is bool, "invalid_approval_record")
|
|
102
|
+
_require(record["state"] == ("consumed" if consumed else "approved"),
|
|
103
|
+
"approval_not_consumed" if consumed else "approval_consumed")
|
|
104
|
+
_require(record["approval_id"] == approval_id and record["run_id"] == run_id,
|
|
105
|
+
"approval_mismatch")
|
|
106
|
+
_require(record["account"] == account and record["draft"] == draft
|
|
107
|
+
and record["checkpoint_sha256"] == checkpoint["record_sha256"]
|
|
108
|
+
and record["plan_sha256"] == _plan(plan), "approval_stale")
|
|
109
|
+
return record
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def consume(session, release_id, *, run_id, approval_id, account, draft, plan):
|
|
113
|
+
record = verify(session, release_id, run_id=run_id, approval_id=approval_id,
|
|
114
|
+
account=account, draft=draft, plan=plan)
|
|
115
|
+
record["state"] = "consumed"
|
|
116
|
+
return _save(session, release_id + ".approval.json", record)
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Pengelolaan proses Google Chrome, profil lokal, dan koneksi CDP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import shutil
|
|
9
|
+
import socket
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
from urllib.error import URLError
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
DEFAULT_CDP_PORT = 9437
|
|
18
|
+
DEFAULT_CDP_ENDPOINT = f"http://127.0.0.1:{DEFAULT_CDP_PORT}"
|
|
19
|
+
DEFAULT_PORTAL_URL = "https://toolost.com/user-portal/releases/manage/1"
|
|
20
|
+
DEFAULT_LOGIN_URL = "https://toolost.com/login"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_chrome_binary(custom_path: str | Path | None = None) -> Path | None:
|
|
24
|
+
"""Temukan lokasi executable Google Chrome."""
|
|
25
|
+
if custom_path:
|
|
26
|
+
p = Path(custom_path).expanduser().resolve()
|
|
27
|
+
if p.is_file() and os.access(p, os.X_OK):
|
|
28
|
+
return p
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
env_path = os.environ.get("CHROME_BIN")
|
|
32
|
+
if env_path:
|
|
33
|
+
p = Path(env_path).expanduser().resolve()
|
|
34
|
+
if p.is_file() and os.access(p, os.X_OK):
|
|
35
|
+
return p
|
|
36
|
+
|
|
37
|
+
if sys.platform == "darwin":
|
|
38
|
+
mac_default = Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
|
39
|
+
if mac_default.is_file() and os.access(mac_default, os.X_OK):
|
|
40
|
+
return mac_default
|
|
41
|
+
|
|
42
|
+
for name in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"):
|
|
43
|
+
found = shutil.which(name)
|
|
44
|
+
if found:
|
|
45
|
+
p = Path(found).resolve()
|
|
46
|
+
if p.is_file() and os.access(p, os.X_OK):
|
|
47
|
+
return p
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_profile_dir(alias: str | None = None, root: Path | None = None) -> Path:
|
|
53
|
+
"""Tentukan direktori profil browser terisolasi."""
|
|
54
|
+
if root is not None:
|
|
55
|
+
base = root
|
|
56
|
+
else:
|
|
57
|
+
# Cek jika berjalan di repository workspace lokal dengan direktori .local
|
|
58
|
+
repo_local = Path(__file__).resolve().parents[1] / ".local"
|
|
59
|
+
if repo_local.is_dir():
|
|
60
|
+
if not alias or alias == "default":
|
|
61
|
+
return repo_local / "chrome-profile"
|
|
62
|
+
return repo_local / "profiles" / alias
|
|
63
|
+
|
|
64
|
+
# Fallback ke direktori konfigurasi user standar
|
|
65
|
+
data_home = os.environ.get("XDG_DATA_HOME")
|
|
66
|
+
if data_home:
|
|
67
|
+
base = Path(data_home) / "toolost-uploader" / "chrome-profiles"
|
|
68
|
+
elif sys.platform == "darwin":
|
|
69
|
+
base = Path.home() / "Library" / "Application Support" / "toolost-uploader" / "chrome-profiles"
|
|
70
|
+
else:
|
|
71
|
+
base = Path.home() / ".local" / "share" / "toolost-uploader" / "chrome-profiles"
|
|
72
|
+
|
|
73
|
+
target = base / (alias or "default")
|
|
74
|
+
target.mkdir(parents=True, mode=0o700, exist_ok=True)
|
|
75
|
+
return target
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_and_clean_stale_lock(profile_dir: Path) -> tuple[bool, str]:
|
|
79
|
+
"""Periksa apakah profil sedang digunakan; bersihkan stale lock bila PID sudah mati."""
|
|
80
|
+
lock = profile_dir / "SingletonLock"
|
|
81
|
+
if not lock.exists() and not lock.is_symlink():
|
|
82
|
+
return False, "Profil bebas (tidak ada lock)."
|
|
83
|
+
|
|
84
|
+
if not lock.is_symlink():
|
|
85
|
+
return True, "Profil memiliki non-symlink lock."
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
owner = os.readlink(lock)
|
|
89
|
+
except OSError:
|
|
90
|
+
return True, "Tidak dapat membaca symlink lock."
|
|
91
|
+
|
|
92
|
+
host, separator, pid_str = owner.rpartition("-")
|
|
93
|
+
if not separator or not pid_str.isdigit():
|
|
94
|
+
return True, f"Format lock tidak dikenal: {owner}"
|
|
95
|
+
|
|
96
|
+
# Hanya bersihkan jika hostname cocok dengan host saat ini
|
|
97
|
+
if host != socket.gethostname():
|
|
98
|
+
return True, f"Lock dimiliki oleh host lain: {host}"
|
|
99
|
+
|
|
100
|
+
pid = int(pid_str)
|
|
101
|
+
try:
|
|
102
|
+
os.kill(pid, 0)
|
|
103
|
+
return True, f"Profil sedang aktif digunakan oleh proses PID {pid}."
|
|
104
|
+
except ProcessLookupError:
|
|
105
|
+
# Proses sudah mati; aman untuk membersihkan stale locks
|
|
106
|
+
try:
|
|
107
|
+
lock.unlink(missing_ok=True)
|
|
108
|
+
(profile_dir / "SingletonCookie").unlink(missing_ok=True)
|
|
109
|
+
(profile_dir / "SingletonSocket").unlink(missing_ok=True)
|
|
110
|
+
return False, f"Stale lock dari PID {pid} yang sudah mati berhasil dibersihkan."
|
|
111
|
+
except OSError as e:
|
|
112
|
+
return True, f"Gagal menghapus stale lock: {e}"
|
|
113
|
+
except PermissionError:
|
|
114
|
+
return True, f"Proses PID {pid} masih berjalan (izin terbatas)."
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_cdp_available(endpoint: str = DEFAULT_CDP_ENDPOINT, timeout: float = 1.0) -> bool:
|
|
118
|
+
"""Periksa apakah endpoint CDP browser loopback aktif dan merespons."""
|
|
119
|
+
url = f"{endpoint.rstrip('/')}/json/version"
|
|
120
|
+
try:
|
|
121
|
+
req = urllib.request.Request(url, headers={"User-Agent": "toolost-readiness-check"})
|
|
122
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
123
|
+
if resp.status == 200:
|
|
124
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
125
|
+
return isinstance(data, dict) and "Browser" in data
|
|
126
|
+
except (URLError, OSError, ValueError, TimeoutError):
|
|
127
|
+
pass
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def launch_chrome(
|
|
132
|
+
profile_dir: Path,
|
|
133
|
+
*,
|
|
134
|
+
mode: str = "cdp",
|
|
135
|
+
port: int = DEFAULT_CDP_PORT,
|
|
136
|
+
chrome_path: str | Path | None = None,
|
|
137
|
+
url: str | None = None,
|
|
138
|
+
) -> subprocess.Popen:
|
|
139
|
+
"""Jalankan proses Google Chrome dalam mode setup manual atau background CDP."""
|
|
140
|
+
chrome_bin = find_chrome_binary(chrome_path)
|
|
141
|
+
if not chrome_bin:
|
|
142
|
+
raise FileNotFoundError("Google Chrome executable tidak ditemukan di sistem.")
|
|
143
|
+
|
|
144
|
+
check_and_clean_stale_lock(profile_dir)
|
|
145
|
+
|
|
146
|
+
target_url = url or (DEFAULT_LOGIN_URL if mode == "setup" else DEFAULT_PORTAL_URL)
|
|
147
|
+
args = [
|
|
148
|
+
str(chrome_bin),
|
|
149
|
+
f"--user-data-dir={profile_dir}",
|
|
150
|
+
"--no-first-run",
|
|
151
|
+
"--no-default-browser-check",
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
if mode == "cdp":
|
|
155
|
+
args.extend([
|
|
156
|
+
"--remote-debugging-address=127.0.0.1",
|
|
157
|
+
f"--remote-debugging-port={port}",
|
|
158
|
+
])
|
|
159
|
+
# Background process mandiri
|
|
160
|
+
proc = subprocess.Popen(
|
|
161
|
+
[*args, target_url],
|
|
162
|
+
stdout=subprocess.DEVNULL,
|
|
163
|
+
stderr=subprocess.DEVNULL,
|
|
164
|
+
start_new_session=True,
|
|
165
|
+
)
|
|
166
|
+
return proc
|
|
167
|
+
|
|
168
|
+
# Setup mode: interaktif di depan pengguna
|
|
169
|
+
return subprocess.Popen([*args, target_url])
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def ensure_cdp_ready(
|
|
173
|
+
endpoint: str = DEFAULT_CDP_ENDPOINT,
|
|
174
|
+
profile_dir: Path | None = None,
|
|
175
|
+
timeout: float = 12.0,
|
|
176
|
+
log=None,
|
|
177
|
+
chrome_path: str | Path | None = None,
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""Pastikan browser CDP aktif; luncurkan otomatis jika belum menyala."""
|
|
180
|
+
if is_cdp_available(endpoint):
|
|
181
|
+
return True
|
|
182
|
+
|
|
183
|
+
u = urlsplit(endpoint)
|
|
184
|
+
port = u.port or DEFAULT_CDP_PORT
|
|
185
|
+
pdir = profile_dir or resolve_profile_dir()
|
|
186
|
+
|
|
187
|
+
if log:
|
|
188
|
+
log(f"Browser CDP di port {port} belum aktif; melakukan auto-bootup Chrome...")
|
|
189
|
+
|
|
190
|
+
launch_chrome(pdir, mode="cdp", port=port, chrome_path=chrome_path)
|
|
191
|
+
|
|
192
|
+
start_time = time.monotonic()
|
|
193
|
+
while time.monotonic() - start_time < timeout:
|
|
194
|
+
if is_cdp_available(endpoint):
|
|
195
|
+
if log:
|
|
196
|
+
log(f"Browser CDP di port {port} berhasil aktif dan siap.")
|
|
197
|
+
return True
|
|
198
|
+
time.sleep(0.3)
|
|
199
|
+
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def check_auth_identity(endpoint: str = DEFAULT_CDP_ENDPOINT, timeout: float = 15.0) -> dict:
|
|
204
|
+
"""Periksa status autentikasi aktif pada browser CDP Too Lost dengan mengamati navigasi portal."""
|
|
205
|
+
try:
|
|
206
|
+
from playwright.sync_api import sync_playwright
|
|
207
|
+
with sync_playwright() as p:
|
|
208
|
+
browser = p.chromium.connect_over_cdp(endpoint)
|
|
209
|
+
try:
|
|
210
|
+
context = browser.contexts[0] if browser.contexts else browser.new_context()
|
|
211
|
+
pages = [pg for pg in context.pages if not pg.is_closed() and "toolost.com" in pg.url]
|
|
212
|
+
page = pages[0] if pages else context.new_page()
|
|
213
|
+
|
|
214
|
+
user_ids = []
|
|
215
|
+
http_statuses = []
|
|
216
|
+
|
|
217
|
+
def on_response(r):
|
|
218
|
+
u = urlsplit(r.url)
|
|
219
|
+
if u.scheme == "https" and u.netloc in ("toolost.com", "www.toolost.com") and u.path == "/api/auth/me":
|
|
220
|
+
http_statuses.append(r.status)
|
|
221
|
+
if r.status == 200:
|
|
222
|
+
try:
|
|
223
|
+
d = r.json()
|
|
224
|
+
uid = d.get("user", {}).get("id") or d.get("id")
|
|
225
|
+
if uid is not None:
|
|
226
|
+
user_ids.append(str(uid))
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
page.on("response", on_response)
|
|
231
|
+
try:
|
|
232
|
+
page.goto(DEFAULT_PORTAL_URL, wait_until="domcontentloaded", timeout=timeout * 1000)
|
|
233
|
+
start_wait = time.monotonic()
|
|
234
|
+
while time.monotonic() - start_wait < 6.0:
|
|
235
|
+
if user_ids or any(s in (401, 403) for s in http_statuses):
|
|
236
|
+
break
|
|
237
|
+
page.wait_for_timeout(250)
|
|
238
|
+
finally:
|
|
239
|
+
page.remove_listener("response", on_response)
|
|
240
|
+
|
|
241
|
+
u = urlsplit(page.url)
|
|
242
|
+
if any(x in u.path.lower() for x in ("login", "signin", "auth")):
|
|
243
|
+
return {"status": "unauthorized", "http_code": 401}
|
|
244
|
+
|
|
245
|
+
if user_ids:
|
|
246
|
+
return {"status": "authenticated", "account_id": user_ids[0]}
|
|
247
|
+
if 401 in http_statuses:
|
|
248
|
+
return {"status": "unauthorized", "http_code": 401}
|
|
249
|
+
return {"status": "unknown", "http_statuses": http_statuses}
|
|
250
|
+
finally:
|
|
251
|
+
browser.close()
|
|
252
|
+
except Exception as e:
|
|
253
|
+
return {"status": "error", "message": type(e).__name__}
|