-
Notifications
You must be signed in to change notification settings - Fork 57
Expand file tree
/
Copy pathserver.py
More file actions
2168 lines (1801 loc) · 83.8 KB
/
Copy pathserver.py
File metadata and controls
2168 lines (1801 loc) · 83.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
GPlay Downloader - Local Python Server
Downloads APKs from Google Play Store with direct browser downloads
Uses gpapi for proper protobuf parsing
"""
import os
# Fix protobuf compatibility issue with gpapi
os.environ['PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION'] = 'python'
import json
import base64
import re
from datetime import date
import logging
import threading
import time as time_module
import random
from flask import Flask, request, jsonify, send_file, Response
# Use gevent for parallel downloads (compatible with gunicorn gevent workers)
try:
from gevent.pool import Pool as GeventPool
HAS_GEVENT = True
except ImportError:
HAS_GEVENT = False
from flask_cors import CORS
import requests
import cloudscraper
# Thread-local scraper instances (thread-safe for concurrent requests)
_scraper_local = threading.local()
def get_scraper():
"""Get a thread-local cloudscraper instance."""
if not hasattr(_scraper_local, 'scraper'):
_scraper_local.scraper = cloudscraper.create_scraper()
return _scraper_local.scraper
# Configure logging (INFO in production, DEBUG via env)
_log_level = os.environ.get('LOG_LEVEL', 'INFO').upper()
logging.basicConfig(level=getattr(logging, _log_level, logging.INFO), format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
app = Flask(__name__, static_folder='public', static_url_path='')
app.config['MAX_CONTENT_LENGTH'] = 1 * 1024 * 1024 # 1MB max request body
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
_cors_origins = os.environ.get('CORS_ORIGINS', '')
if _cors_origins:
CORS(app, origins=_cors_origins.split(','))
@app.after_request
def set_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = 'camera=(), microphone=(), geolocation=()'
csp = '; '.join([
"default-src 'self'",
f"script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net{_ANALYTICS_ORIGIN}",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' https://fonts.gstatic.com",
"img-src 'self' data: https://play-lh.googleusercontent.com",
f"connect-src 'self' https://*.google.com https://*.googleapis.com https://*.googleusercontent.com https://*.ggpht.com https://api.github.com{_ANALYTICS_ORIGIN}",
"frame-src 'self'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"worker-src 'self' blob:",
])
response.headers['Content-Security-Policy'] = csp
return response
# Import gpapi protobuf
try:
from gpapi import googleplay_pb2
HAS_GPAPI = True
except (ImportError, TypeError) as e:
HAS_GPAPI = False
print(f"Warning: gpapi not available ({e}). Using fallback parser.")
DISPENSER_URL = os.environ.get('DISPENSER_URL', '').strip()
if not DISPENSER_URL:
print("WARNING: DISPENSER_URL is not set. Downloads will fail until you configure a self-hosted dispenser. Do not use auroraoss.com (see issue #22).")
FDFE_URL = 'https://android.clients.google.com/fdfe'
PURCHASE_URL = f'{FDFE_URL}/purchase'
DELIVERY_URL = f'{FDFE_URL}/delivery'
DETAILS_URL = f'{FDFE_URL}/details'
# Server-side auth cache files (per architecture)
from pathlib import Path
from contextlib import contextmanager
import fcntl
AUTH_CACHE_DIR = Path.home()
AUTH_CACHE_FILES = {
'arm64-v8a': AUTH_CACHE_DIR / '.gplay-auth.json', # Default for backward compat
'armeabi-v7a': AUTH_CACHE_DIR / '.gplay-auth-armv7.json',
}
@contextmanager
def file_lock(file_path, exclusive=True):
"""Context manager for file locking (prevents race conditions)."""
lock_path = Path(str(file_path) + '.lock')
lock_fd = None
try:
lock_fd = open(lock_path, 'w')
fcntl.flock(lock_fd, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
yield
finally:
if lock_fd:
fcntl.flock(lock_fd, fcntl.LOCK_UN)
lock_fd.close()
# Import device profiles from centralized module
from device_profiles import (
ARM64_PROFILES, ARMV7_PROFILES,
DEFAULT_ARM64_PROFILE, DEFAULT_ARMV7_PROFILE,
get_profile, get_all_profiles, get_priority_profiles,
)
# Legacy aliases for backward compatibility
DEVICE_ARM64 = DEFAULT_ARM64_PROFILE
DEVICE_ARMV7 = DEFAULT_ARMV7_PROFILE
DEFAULT_DEVICE = DEVICE_ARM64
SUPPORTED_ARCHS = ['arm64-v8a', 'armeabi-v7a']
# Blacklist: packages that are fully blocked from download, search, and app pages
BLACKLIST_FILE = Path(__file__).parent / 'public' / 'blacklist.json'
def _load_blacklist():
try:
data = json.loads(BLACKLIST_FILE.read_text())
return set(data.get('packages', [])), data.get('message', 'This app is not available.')
except (FileNotFoundError, json.JSONDecodeError):
return set(), 'This app is not available.'
BLACKLISTED_PACKAGES, BLACKLIST_MESSAGE = _load_blacklist()
def is_blacklisted(pkg):
"""Return True if the package is blacklisted."""
return pkg in BLACKLISTED_PACKAGES
def get_device_config(arch='arm64-v8a', profile_index=0):
"""Get device config for a specific architecture.
Args:
arch: 'arm64-v8a' or 'armeabi-v7a'
profile_index: Index into the profile list (for fallback rotation)
Returns:
Device profile dict
"""
if arch == 'armeabi-v7a':
profiles = ARMV7_PROFILES
else:
profiles = ARM64_PROFILES
# Use modulo to wrap around if index exceeds list length
idx = profile_index % len(profiles)
return profiles[idx][1].copy()
def get_priority_device_configs(arch='arm64-v8a'):
"""Get priority-ordered list of device profiles for an architecture.
Returns profiles sorted by reliability (best first), with remaining
profiles appended after.
Args:
arch: 'arm64-v8a' or 'armeabi-v7a'
Returns:
List of (profile_key, profile_dict) tuples
"""
internal_arch = 'armv7' if arch == 'armeabi-v7a' else 'arm64'
return get_priority_profiles(internal_arch)
def merge_apks(base_apk_bytes, split_apks_bytes_list):
"""Merge base APK with split APKs into a single installable APK.
Uses APKEditor (REAndroid) for proper resource merging.
Args:
base_apk_bytes: Bytes of the base APK
split_apks_bytes_list: List of (name, bytes) tuples for split APKs
Returns:
Bytes of the merged APK (unsigned)
"""
import zipfile
import io
import subprocess
import tempfile
import shutil
logger.info(f"merge_apks called with base ({len(base_apk_bytes)} bytes) and {len(split_apks_bytes_list)} splits")
# Try APKEditor first (best results)
apkeditor_jar = os.path.join(os.path.dirname(__file__), 'APKEditor.jar')
if os.path.exists(apkeditor_jar):
try:
return merge_apks_with_apkeditor(base_apk_bytes, split_apks_bytes_list, apkeditor_jar)
except Exception as e:
logger.error(f"APKEditor merge failed: {e}, falling back to simple merge")
else:
logger.warning("APKEditor.jar not found, using simple merge")
return merge_apks_simple(base_apk_bytes, split_apks_bytes_list)
def merge_apks_with_apkeditor(base_apk_bytes, split_apks_bytes_list, apkeditor_jar):
"""Use APKEditor to merge split APKs properly."""
import subprocess
import tempfile
import shutil
work_dir = tempfile.mkdtemp(prefix='apk_merge_')
try:
# Write base APK
base_path = os.path.join(work_dir, 'base.apk')
with open(base_path, 'wb') as f:
f.write(base_apk_bytes)
# Write split APKs
for i, (name, data) in enumerate(split_apks_bytes_list):
split_path = os.path.join(work_dir, f'split{i}.apk')
with open(split_path, 'wb') as f:
f.write(data)
# Run APKEditor merge
output_path = os.path.join(work_dir, 'merged.apk')
result = subprocess.run(
['java', '-jar', apkeditor_jar, 'm', '-i', work_dir, '-o', output_path],
capture_output=True, text=True, timeout=300
)
if result.returncode != 0:
logger.error(f"APKEditor failed: {result.stderr}")
raise Exception(f"APKEditor failed: {result.stderr}")
if not os.path.exists(output_path):
raise Exception("APKEditor did not produce output file")
# Patch fused modules for asset pack splits (e.g. obbassets)
from axml_patcher import get_asset_pack_split_names, patch_apk_fused_modules
split_names = [name for name, _ in split_apks_bytes_list]
asset_packs = get_asset_pack_split_names(split_names)
if asset_packs:
fused_value = ','.join(asset_packs)
logger.info(f"Patching fused modules: {fused_value}")
try:
patch_apk_fused_modules(output_path, fused_value)
except Exception as e:
logger.warning(f"Fused modules patch failed: {e}")
with open(output_path, 'rb') as f:
merged_bytes = f.read()
logger.info(f"APKEditor merge successful: {len(merged_bytes)} bytes")
return merged_bytes
finally:
shutil.rmtree(work_dir, ignore_errors=True)
def should_skip_meta_inf(name):
"""Skip signature files but keep META-INF/services and other important content."""
if not name.startswith('META-INF/'):
return False
# Skip signature files
if name.endswith(('.SF', '.RSA', '.DSA', '.EC', '.MF')):
return True
if name == 'META-INF/MANIFEST.MF':
return True
# Keep everything else (services, kotlin_module, version files, etc.)
return False
def merge_apks_simple(base_apk_bytes, split_apks_bytes_list):
"""Simple merge without manifest patching."""
import zipfile
import io
merged_files = {}
with zipfile.ZipFile(io.BytesIO(base_apk_bytes), 'r') as base_zip:
for name in base_zip.namelist():
if should_skip_meta_inf(name):
continue
merged_files[name] = base_zip.read(name)
for split_name, split_bytes in split_apks_bytes_list:
with zipfile.ZipFile(io.BytesIO(split_bytes), 'r') as split_zip:
for name in split_zip.namelist():
if should_skip_meta_inf(name):
continue
if name == 'AndroidManifest.xml':
continue
if name.startswith('lib/') or name not in merged_files:
merged_files[name] = split_zip.read(name)
output = io.BytesIO()
with zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as merged_zip:
for name, data in sorted(merged_files.items()):
merged_zip.writestr(name, data)
return output.getvalue()
def sign_apk(apk_bytes):
"""Sign an APK using apksigner with debug keystore.
Returns signed APK bytes, or original bytes if signing fails.
"""
import subprocess
import tempfile
import shutil
keystore = Path.home() / '.android' / 'debug.keystore'
if not keystore.exists():
logger.warning("Debug keystore not found, returning unsigned APK")
return apk_bytes
# Check if apksigner is available
if not shutil.which('apksigner'):
logger.warning("apksigner not found, returning unsigned APK")
return apk_bytes
tmp_in_path = None
tmp_out_path = None
try:
with tempfile.NamedTemporaryFile(suffix='.apk', delete=False) as tmp_in:
tmp_in.write(apk_bytes)
tmp_in_path = tmp_in.name
tmp_out_path = tmp_in_path + '.signed'
# Sign with apksigner using debug keystore
cmd = [
'apksigner', 'sign',
'--ks', str(keystore),
'--ks-pass', 'pass:android',
'--key-pass', 'pass:android',
'--out', tmp_out_path,
tmp_in_path
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0 and os.path.exists(tmp_out_path):
with open(tmp_out_path, 'rb') as f:
signed_bytes = f.read()
logger.info("APK signed successfully")
return signed_bytes
else:
logger.warning(f"apksigner failed: {result.stderr}")
return apk_bytes
except Exception as e:
logger.error(f"APK signing failed: {e}")
return apk_bytes
finally:
# Cleanup temp files
for path in [tmp_in_path, tmp_out_path]:
try:
os.unlink(path)
except Exception:
pass
def format_size(bytes_size):
if not bytes_size:
return 'Unknown'
units = ['B', 'KB', 'MB', 'GB']
i = 0
size = float(bytes_size)
while size >= 1024 and i < len(units) - 1:
size /= 1024
i += 1
return f'{size:.2f} {units[i]}'
def sanitize_filename(name):
"""Sanitize a filename for use in Content-Disposition headers."""
name = name.replace('/', '_').replace('\\', '_').replace('\0', '')
name = re.sub(r'[\r\n"]', '', name)
name = os.path.basename(name)
return name or 'download.apk'
# Valid Android package name: segments of [a-zA-Z][a-zA-Z0-9_]* separated by dots, max 255 chars
_PKG_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$')
def validate_package_name(pkg):
"""Return True if pkg is a valid Android package name."""
return bool(pkg and len(pkg) <= 255 and _PKG_RE.match(pkg))
def _require_valid_pkg(pkg):
"""Return a 400 JSON response if pkg is invalid or blacklisted, else None."""
if not validate_package_name(pkg):
return jsonify({'error': 'Invalid package name'}), 400
if is_blacklisted(pkg):
return jsonify({'error': BLACKLIST_MESSAGE}), 403
return None
def get_cached_auth(arch='arm64-v8a'):
"""Load cached auth from server-side auth file for specific architecture (thread-safe)."""
cache_file = AUTH_CACHE_FILES.get(arch, AUTH_CACHE_FILES['arm64-v8a'])
if not cache_file.exists():
return None
try:
with file_lock(cache_file, exclusive=False): # Shared lock for reads
with open(cache_file) as f:
auth = json.load(f)
if auth.get('authToken') and auth.get('gsfId'):
logger.info(f"Using cached auth token for {arch}")
return auth
except Exception as e:
logger.warning(f"Failed to load cached auth for {arch}: {e}")
return None
def save_cached_auth(auth_data, arch='arm64-v8a'):
"""Save auth data to server-side cache file for specific architecture (thread-safe, atomic)."""
cache_file = AUTH_CACHE_FILES.get(arch, AUTH_CACHE_FILES['arm64-v8a'])
tmp_file = cache_file.with_suffix('.tmp')
try:
with file_lock(cache_file, exclusive=True): # Exclusive lock for writes
# Write to temp file first (atomic write pattern)
tmp_file.write_text(json.dumps(auth_data, indent=2))
os.chmod(str(tmp_file), 0o600) # Restrict permissions before rename
# Atomic rename
tmp_file.replace(cache_file)
logger.info(f"Auth saved to: {cache_file}")
return True
except Exception as e:
logger.error(f"Failed to save auth: {e}")
# Clean up temp file if it exists
if tmp_file.exists():
try:
tmp_file.unlink()
except Exception:
pass
return False
def test_auth_token(auth, strict=False):
"""Test if an auth token works by making a simple API request.
Args:
auth: Auth data dict
strict: If True, test against a stricter app (Chase) that requires better tokens
"""
try:
headers = get_auth_headers(auth)
headers['Accept'] = 'application/x-protobuf'
# Use a stricter test app - banking apps like Chase require better tokens
# than simple apps like YouTube. If strict=True or default, use Chase.
test_app = 'com.chase.sig.android' if strict else 'com.google.android.youtube'
resp = requests.get(f'{DETAILS_URL}?doc={test_app}', headers=headers, timeout=10)
if resp.status_code == 200:
wrapper = googleplay_pb2.ResponseWrapper()
wrapper.ParseFromString(resp.content)
# Check if we got valid version info (not 0)
vc = wrapper.payload.detailsResponse.docV2.details.appDetails.versionCode
if vc > 0:
logger.info(f"Auth token validated ({test_app} versionCode={vc})")
return True
else:
logger.warning(f"Auth test returned versionCode=0 for {test_app}")
else:
logger.warning(f"Auth token test failed: status={resp.status_code}")
return False
except Exception as e:
logger.warning(f"Auth token test error: {e}")
return False
def get_auth_from_request(arch='arm64-v8a'):
# Always prefer cached CLI auth since AuroraOSS dispenser tokens have limited permissions
cached = get_cached_auth(arch)
if cached:
return cached
# Fall back to request auth if no cached auth available
auth_header = request.headers.get('Authorization', '')
if auth_header:
try:
token = auth_header.replace('Bearer ', '')
auth_data = json.loads(base64.b64decode(token).decode('utf-8'))
if auth_data.get('authToken'):
return auth_data
except Exception:
pass
return None
def get_auth_headers(auth, accept_language='en-US'):
"""
Build headers for Google Play API requests.
Enhanced with additional headers from Aurora Store for better compatibility.
"""
device_info = auth.get('deviceInfoProvider', {})
locale = accept_language.replace('-', '_')
headers = {
'Authorization': f"Bearer {auth.get('authToken', '')}",
'User-Agent': device_info.get('userAgentString', 'Android-Finsky/41.2.29-23 [0] [PR] 639844241 (api=3,versionCode=84122900,sdk=34,device=lynx,hardware=lynx,product=lynx,platformVersionRelease=14,model=Pixel%207a,buildId=UQ1A.231205.015,isWideScreen=0,supportedAbis=arm64-v8a;armeabi-v7a;armeabi)'),
'X-DFE-Device-Id': auth.get('gsfId', ''),
'Accept-Language': accept_language,
'X-DFE-Encoded-Targets': 'CAESN/qigQYC2AMBFfUbyA7SM5Ij/CvfBoIDgxXrBPsDlQUdMfOLAfoFrwEHgAcBrQYhoA0cGt4MKK0Y2gI',
'X-DFE-Phenotype': 'H4sIAAAAAAAAAB3OO3KjMAAA0KRNuWXukBkBQkAJ2MhgAZb5u2GCwQZbCH_EJ77QHmgvtDtbv-Z9_H63zXXU0NVPB1odlyGy7751Q3CitlPDvFd8lxhz3tpNmz7P92CFw73zdHU2Ie0Ad2kmR8lxhiErTFLt3RPGfJQHSDy7Clw10bg8kqf2owLokN4SecJTLoSwBnzQSd652_MOf2d1vKBNVedzg4ciPoLz2mQ8efGAgYeLou-l-PXn_7Sna1MfhHuySxt-4esulEDp8Sbq54CPPKjpANW-lkU2IZ0F92LBI-ukCKSptqeq1eXU96LD9nZfhKHdtjSWwJqUm_2r6pMHOxk01saVanmNopjX3YxQafC4iC6T55aRbC8nTI98AF_kItIQAJb5EQxnKTO7TZDWnr01HVPxelb9A2OWX6poidMWl16K54kcu_jhXw-JSBQkVcD_fPsLSZu6joIBAAA',
'X-DFE-Client-Id': 'am-android-google',
'X-DFE-Network-Type': '4',
'X-DFE-Content-Filters': '',
'X-Limit-Ad-Tracking-Enabled': 'false',
'X-Ad-Id': '',
'X-DFE-UserLanguages': locale,
'X-DFE-Request-Params': 'timeoutMs=4000',
'X-DFE-Cookie': auth.get('dfeCookie', ''),
'X-DFE-No-Prefetch': 'true',
}
# Add optional tokens if available in auth data
if auth.get('deviceCheckInConsistencyToken'):
headers['X-DFE-Device-Checkin-Consistency-Token'] = auth['deviceCheckInConsistencyToken']
if auth.get('deviceConfigToken'):
headers['X-DFE-Device-Config-Token'] = auth['deviceConfigToken']
if device_info.get('mccMnc'):
headers['X-DFE-MCCMNC'] = device_info['mccMnc']
return headers
def get_download_info(pkg, auth):
"""Get download info using proper protobuf parsing."""
if not HAS_GPAPI:
return {'error': 'gpapi library not installed'}
headers = {
**get_auth_headers(auth),
'Content-Type': 'application/x-protobuf',
'Accept': 'application/x-protobuf',
}
# Step 1: Get app details
details_resp = requests.get(f'{DETAILS_URL}?doc={pkg}', headers=headers, timeout=(5, 15))
if details_resp.status_code != 200:
return {'error': f'Failed to get app details: {details_resp.status_code}'}
# Parse details response with protobuf
try:
details_wrapper = googleplay_pb2.ResponseWrapper()
details_wrapper.ParseFromString(details_resp.content)
if not details_wrapper.payload.detailsResponse.docV2.docid:
return {'error': 'App not found or not available'}
app = details_wrapper.payload.detailsResponse.docV2
version_code = app.details.appDetails.versionCode
version_string = app.details.appDetails.versionString
title = app.title
logger.info(f"Details for {pkg}: title={title}, versionCode={version_code}, versionString={version_string}")
# Detect paid apps before attempting purchase/delivery
for offer in app.offer:
if offer.offerType == 1:
logger.info(f"Offer for {pkg}: micros={offer.micros}, formatted=\"{offer.formattedAmount}\"")
if offer.micros > 0:
logger.warning(f"Paid app detected: {pkg} costs {offer.formattedAmount} ({offer.micros} micros)")
return {'error': 'paid_app', 'formattedAmount': offer.formattedAmount or 'paid'}
# If version_code is 0, try to get it from offer
if version_code == 0 and app.offer:
for offer in app.offer:
if offer.offerType == 1:
logger.debug(f"Offer version fallback for {pkg}: micros={offer.micros}")
except Exception as e:
return {'error': f'Failed to parse app details: {str(e)}'}
# Step 2: Purchase (acquire free app)
purchase_headers = {**headers, 'Content-Type': 'application/x-www-form-urlencoded'}
purchase_data = f'doc={pkg}&ot=1&vc={version_code}'
try:
logger.info(f"Attempting purchase for {pkg} (vc={version_code})")
purchase_resp = requests.post(PURCHASE_URL, headers=purchase_headers, data=purchase_data, timeout=(5, 15))
logger.info(f"Purchase response status: {purchase_resp.status_code}")
if purchase_resp.status_code not in [200, 204]:
logger.warning(f"Purchase returned non-success status: {purchase_resp.status_code}")
logger.debug(f"Purchase response content: {purchase_resp.content[:500]}")
except Exception as e:
logger.error(f"Purchase request failed: {type(e).__name__}: {e}")
# Continue anyway - app might already be "purchased" or free
# Step 3: Get delivery URL
logger.info(f"Requesting delivery URL for {pkg}")
delivery_resp = requests.get(
f'{DELIVERY_URL}?doc={pkg}&ot=1&vc={version_code}',
headers=headers,
timeout=(5, 15)
)
logger.info(f"Delivery response status: {delivery_resp.status_code}")
if delivery_resp.status_code != 200:
logger.error(f"Delivery failed with status {delivery_resp.status_code}")
logger.debug(f"Delivery response: {delivery_resp.content[:500]}")
return {'error': f'Failed to get download URL: {delivery_resp.status_code}'}
# Parse delivery response with protobuf
try:
delivery_wrapper = googleplay_pb2.ResponseWrapper()
delivery_wrapper.ParseFromString(delivery_resp.content)
delivery_data = delivery_wrapper.payload.deliveryResponse.appDeliveryData
if not delivery_data.downloadUrl:
logger.error(f"No downloadUrl in delivery response for {pkg}")
logger.debug(f"Delivery data fields: downloadSize={delivery_data.downloadSize}, splits={len(delivery_data.split)}")
return {'error': 'No download URL available. App may require purchase or is region-restricted.'}
download_url = delivery_data.downloadUrl
download_size = delivery_data.downloadSize
# Get cookies
cookies = []
for cookie in delivery_data.downloadAuthCookie:
cookies.append({'name': cookie.name, 'value': cookie.value})
# Get split APKs
splits = []
for i, split in enumerate(delivery_data.split):
if split.downloadUrl:
splits.append({
'name': split.name or f'split{i}',
'downloadUrl': split.downloadUrl,
'size': split.size,
})
return {
'docid': pkg,
'title': title,
'versionCode': version_code,
'versionString': version_string,
'downloadUrl': download_url,
'downloadSize': download_size,
'cookies': cookies,
'splits': splits,
'filename': f'{pkg}-{version_code}.apk'
}
except Exception as e:
return {'error': f'Failed to parse delivery data: {str(e)}'}
SITE_URL = os.environ.get('SITE_URL', '').rstrip('/')
UMAMI_SCRIPT = os.environ.get('UMAMI_SCRIPT', '')
UMAMI_REPLAY_SCRIPT = os.environ.get('UMAMI_REPLAY_SCRIPT', '')
_ANALYTICS_ORIGIN = ''
if UMAMI_SCRIPT:
_m = re.search(r'src="(https?://[^"/]+)', UMAMI_SCRIPT)
if _m:
_ANALYTICS_ORIGIN = ' ' + _m.group(1)
# Routes
@app.route('/')
def index():
with open(os.path.join(app.static_folder, 'index.html'), 'r') as f:
html = f.read()
if SITE_URL:
html = html.replace('__SITE_URL__', SITE_URL)
else:
# Strip SEO tags that need a domain
import re
html = re.sub(r'<link rel="canonical"[^>]*>\n?', '', html)
html = re.sub(r'<meta property="og:url"[^>]*>\n?', '', html)
html = re.sub(r'<meta property="og:image"[^>]*>\n?', '', html)
html = re.sub(r'<meta name="twitter:image"[^>]*>\n?', '', html)
html = re.sub(r'<script type="application/ld\+json">[^<]*__SITE_URL__[^<]*</script>\n?', '', html)
if UMAMI_SCRIPT:
html = html.replace('</head>', f' {UMAMI_SCRIPT}\n</head>')
if UMAMI_REPLAY_SCRIPT:
html = html.replace('</head>', f' {UMAMI_REPLAY_SCRIPT}\n</head>')
return Response(html, content_type='text/html')
_DISABLE_APP_PAGES = os.environ.get('DISABLE_APP_PAGES', '') == '1'
if not _DISABLE_APP_PAGES:
@app.route('/apps')
@app.route('/apps/')
def apps_browse():
from app_pages import render_browse_page
html = render_browse_page()
if SITE_URL:
html = html.replace('__SITE_URL__', SITE_URL)
else:
html = re.sub(r'<link rel="canonical"[^>]*>\n?', '', html)
html = re.sub(r'<meta property="og:url"[^>]*>\n?', '', html)
html = re.sub(r'<script type="application/ld\+json">[^<]*__SITE_URL__[^<]*</script>\n?', '', html)
if UMAMI_SCRIPT:
html = html.replace('</head>', f' {UMAMI_SCRIPT}\n</head>')
if UMAMI_REPLAY_SCRIPT:
html = html.replace('</head>', f' {UMAMI_REPLAY_SCRIPT}\n</head>')
return Response(html, content_type='text/html')
@app.route('/app/<path:pkg>')
def app_page(pkg):
if not re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', pkg):
return Response('Invalid package name', status=400, content_type='text/plain')
if is_blacklisted(pkg):
return Response('App not found. <a href="/">Try searching for it</a>.', status=404, content_type='text/html')
from app_pages import render_app_page
html = render_app_page(pkg)
if not html:
return Response('App not found. <a href="/">Try searching for it</a>.', status=404, content_type='text/html')
if UMAMI_SCRIPT:
html = html.replace('</head>', f' {UMAMI_SCRIPT}\n</head>')
if UMAMI_REPLAY_SCRIPT:
html = html.replace('</head>', f' {UMAMI_REPLAY_SCRIPT}\n</head>')
return Response(html, content_type='text/html')
@app.route('/robots.txt')
def robots():
with open(os.path.join(app.static_folder, 'robots.txt'), 'r') as f:
txt = f.read()
if SITE_URL:
txt = txt.replace('__SITE_URL__', SITE_URL)
else:
txt = txt.replace('Sitemap: __SITE_URL__/sitemap.xml\n', '')
return Response(txt, content_type='text/plain')
@app.route('/sitemap.xml')
def sitemap():
if not SITE_URL:
return Response('', status=404)
with open(os.path.join(app.static_folder, 'sitemap.xml'), 'r') as f:
xml = f.read().replace('__SITE_URL__', SITE_URL)
# Inject cached app pages into sitemap
if not _DISABLE_APP_PAGES:
try:
from app_pages import _load_meta
meta = _load_meta()
app_urls = ''.join(
f' <url><loc>{SITE_URL}/app/{pkg}</loc><lastmod>{date.today().isoformat()}</lastmod></url>\n'
for pkg in meta
if pkg not in BLACKLISTED_PACKAGES
and re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', pkg)
)
if app_urls:
xml = xml.replace('</urlset>', app_urls + '</urlset>')
except Exception:
pass
return Response(xml, content_type='application/xml')
@app.route('/health')
def health_check():
"""Health check endpoint for monitoring and load balancers."""
import psutil
try:
# Check disk space for temp storage
disk = psutil.disk_usage(str(TEMP_APK_DIR))
disk_ok = disk.percent < 90
# Check memory
mem = psutil.virtual_memory()
mem_ok = mem.percent < 90
# Count active temp files
with TEMP_APK_LOCK:
temp_count = len(TEMP_APK_REGISTRY)
temp_size = sum(m.get('size', 0) for m in TEMP_APK_REGISTRY.values())
# Get semaphore availability
download_slots = download_semaphore._value
merge_slots = merge_semaphore._value
status = {
'status': 'healthy' if (disk_ok and mem_ok) else 'degraded',
'gpapi_available': HAS_GPAPI,
'temp_files': temp_count,
'temp_size_mb': round(temp_size / 1024 / 1024, 2),
'disk_percent': disk.percent,
'memory_percent': mem.percent,
'download_slots_available': download_slots,
'download_slots_max': MAX_CONCURRENT_DOWNLOADS,
'merge_slots_available': merge_slots,
'merge_slots_max': MAX_CONCURRENT_MERGES,
}
code = 200 if status['status'] == 'healthy' else 503
return jsonify(status), code
except Exception as e:
logger.error(f"Health check failed: {e}")
return jsonify({
'status': 'error',
'error': 'Health check failed'
}), 500
@app.route('/api/stats')
def stats():
"""Return download count for the UI."""
return jsonify({'downloads': get_download_count()})
# Per-worker rate limit cache; with ProxyFix, request.remote_addr is now the
# real client IP (via Cloudflare's X-Forwarded-For). Each gunicorn worker
# tracks independently; worst case is N workers allow N increments in 10s.
_last_increment = {}
@app.route('/api/stats/increment', methods=['POST'])
def stats_increment():
"""Increment download counter (for client-side installs like ADB)."""
ip = request.remote_addr
now = time_module.time()
if ip in _last_increment and now - _last_increment[ip] < 10:
return jsonify({'downloads': get_download_count()}), 429
_last_increment[ip] = now
# Periodic cleanup: remove stale entries to prevent unbounded growth
if len(_last_increment) > 1000:
stale = [k for k, v in _last_increment.items() if now - v > 60]
for k in stale:
del _last_increment[k]
count = increment_download_count()
return jsonify({'downloads': count})
@app.route('/api/auth', methods=['POST'])
def auth():
# First check if we have a valid cached token - use strict validation (Chase test)
cached = get_cached_auth()
if cached and test_auth_token(cached, strict=True):
logger.info("Using existing valid cached token (passed Chase test)")
return jsonify({'success': True, 'authenticated': True, 'cached': True})
# If we have a cached token that at least works for simple apps, use it
# but warn that some apps may not work
if cached and test_auth_token(cached, strict=False):
logger.warning("Cached token works for simple apps (may have limited functionality)")
return jsonify({'success': True, 'authenticated': True, 'cached': True, 'warning': 'Token may not work for all apps'})
return jsonify({'error': 'No valid cached token. Use the streaming auth endpoint.'}), 400
@app.route('/api/auth/stream', methods=['GET'])
def auth_stream():
"""SSE endpoint that tries tokens with timeout protection."""
def generate():
start_time = time_module.time()
# First check if we have a valid cached token
cached = get_cached_auth()
if cached and test_auth_token(cached, strict=True):
logger.info("Using existing valid cached token (passed Chase test)")
yield f"data: {json.dumps({'type': 'success', 'authenticated': True, 'cached': True, 'attempt': 0})}\n\n"
return
attempt = 0
# Get priority-ordered profiles for rotation
profiles = get_priority_device_configs('arm64-v8a')
profile_count = len(profiles)
max_attempts = profile_count * MAX_PROFILE_CYCLES
while True:
# Check timeout
if time_module.time() - start_time > SSE_MAX_DURATION:
yield f"data: {json.dumps({'type': 'error', 'message': 'Timeout - please try again'})}\n\n"
return
attempt += 1
if attempt > max_attempts:
yield f"data: {json.dumps({'type': 'error', 'message': f'Failed after trying all {profile_count} profiles {MAX_PROFILE_CYCLES} times'})}\n\n"
return
# Rotate through profiles
profile_key, profile = profiles[(attempt - 1) % profile_count]
profile_name = profile.get('UserReadableName', profile_key)
# Send progress update
yield f"data: {json.dumps({'type': 'progress', 'attempt': attempt, 'message': f'Trying token #{attempt} ({profile_name})...'})}\n\n"
scraper = None
try:
scraper = cloudscraper.create_scraper() # Fresh scraper each attempt
response = scraper.post(
DISPENSER_URL,
headers={
'User-Agent': 'com.aurora.store-4.6.1-70',
'Content-Type': 'application/json',
},
json=profile,
timeout=(5, 30)
)
if not response.ok:
logger.warning(f"Dispenser returned {response.status_code}, attempt {attempt} ({profile_name})")
yield f"data: {json.dumps({'type': 'progress', 'attempt': attempt, 'message': f'Token #{attempt} ({profile_name}) - dispenser error ({response.status_code})'})}\n\n"
time_module.sleep(1)
continue
auth_data = response.json()
# Send validation progress
yield f"data: {json.dumps({'type': 'progress', 'attempt': attempt, 'message': f'Token #{attempt} ({profile_name}) - validating...'})}\n\n"
# Test with strict validation (Chase) - this ensures token works for all apps
if test_auth_token(auth_data, strict=True):
# Save the working token
save_cached_auth(auth_data)
logger.info(f"Token #{attempt} ({profile_name}) validated with Chase and saved")
yield f"data: {json.dumps({'type': 'success', 'authenticated': True, 'cached': False, 'attempt': attempt})}\n\n"
return
else:
logger.warning(f"Token #{attempt} ({profile_name}) failed Chase validation")
yield f"data: {json.dumps({'type': 'progress', 'attempt': attempt, 'message': f'Token #{attempt} ({profile_name}) - failed validation, retrying...'})}\n\n"
except requests.exceptions.ConnectionError as e:
logger.warning(f"Connection error on auth attempt {attempt}: {e}")
yield f"data: {json.dumps({'type': 'progress', 'attempt': attempt, 'message': f'Token #{attempt} - retrying connection...'})}\n\n"
time_module.sleep(get_backoff_delay(attempt, base=2.0))
except requests.exceptions.Timeout as e:
logger.warning(f"Timeout on auth attempt {attempt}: {e}")
yield f"data: {json.dumps({'type': 'progress', 'attempt': attempt, 'message': f'Token #{attempt} - request timeout, retrying...'})}\n\n"
time_module.sleep(get_backoff_delay(attempt))
except Exception as e:
logger.warning(f"Auth attempt {attempt} failed: {e}")
yield f"data: {json.dumps({'type': 'progress', 'attempt': attempt, 'message': f'Token #{attempt} - retrying...'})}\n\n"
time_module.sleep(get_backoff_delay(attempt, base=0.5))
finally:
if scraper:
scraper.close()
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', # Disable nginx buffering
}
)
@app.route('/api/auth/status')
def auth_status():
auth = get_auth_from_request()
return jsonify({'authenticated': bool(auth and auth.get('authToken'))})
_search_rate = {} # {ip: [timestamps]}
SEARCH_RATE_LIMIT = 10 # max searches per minute per IP
SEARCH_RATE_WINDOW = 60 # seconds
@app.route('/api/search')
def search():
query = request.args.get('q', '')
if not query:
return jsonify({'error': 'Query required'}), 400
if len(query) > 200:
return jsonify({'error': 'Query too long (max 200 characters)'}), 400
# Per-IP search rate limit
ip = request.remote_addr
now = time_module.time()
timestamps = [t for t in _search_rate.get(ip, []) if now - t < SEARCH_RATE_WINDOW]
if len(timestamps) >= SEARCH_RATE_LIMIT:
return jsonify({'error': 'Too many searches, please wait'}), 429
timestamps.append(now)