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.
@@ -0,0 +1,3 @@
1
+ """Toolost uploader: manifest validation, browser setup, readiness check, and Playwright draft automation."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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__}