Cache full images encrypted on persistent storage
Browse files- backend/app.py +64 -15
backend/app.py
CHANGED
|
@@ -78,6 +78,9 @@ CACHE_EXPLORER_PAGE_1 = "explorer:page:1"
|
|
| 78 |
CACHE_EXPLORER_TOTAL = "explorer:total"
|
| 79 |
POST_SEARCH_LIMIT = 1000
|
| 80 |
RECLASS_LOG_KEY = "TF#g9FA@UgKdN'usVtm3dTz*yoqbria`"
|
|
|
|
|
|
|
|
|
|
| 81 |
THUMB_MAX_AGE = 1800
|
| 82 |
THUMB_CLEAN_INTERVAL = 300
|
| 83 |
THUMB_DIR = Path(tempfile.gettempdir()) / "pixif2-thumbs"
|
|
@@ -101,6 +104,7 @@ TASK_SCAN_TASKS = {}
|
|
| 101 |
TASK_EVENT_QUEUES = set()
|
| 102 |
PAGE_URL_CACHE = {}
|
| 103 |
PAGE_URL_INFLIGHT = {}
|
|
|
|
| 104 |
TURSO_CLIENT = None
|
| 105 |
PIXIV_CLIENT = None
|
| 106 |
LAST_THUMB_CLEANUP = 0
|
|
@@ -920,11 +924,12 @@ async def create_download_zip(targets):
|
|
| 920 |
batch = targets[start : start + DOWNLOAD_BATCH_SIZE]
|
| 921 |
rows = await asyncio.gather(
|
| 922 |
*(
|
| 923 |
-
|
|
|
|
|
|
|
| 924 |
item["url"]
|
| 925 |
if item["url"].startswith("http")
|
| 926 |
else IMG_BASE + item["url"].lstrip("/"),
|
| 927 |
-
PHPSESSID,
|
| 928 |
)
|
| 929 |
for item in batch
|
| 930 |
)
|
|
@@ -1518,12 +1523,12 @@ async def scan_post(post_id, session, post_sem, exif_sem, img_sem):
|
|
| 1518 |
try:
|
| 1519 |
pages = await get_post_pages(post_id, session)
|
| 1520 |
image_urls = [
|
| 1521 |
-
p["original"]
|
| 1522 |
-
for p in pages
|
| 1523 |
if image_ext(p["original"]) in METADATA_EXTS
|
| 1524 |
]
|
| 1525 |
fallback = None
|
| 1526 |
-
for url in image_urls:
|
| 1527 |
metadata = await get_exif_range(url, session, exif_sem)
|
| 1528 |
exif_type = determine_exif_type(metadata)
|
| 1529 |
if exif_type in FALLBACK_EXIF_TYPES:
|
|
@@ -1533,8 +1538,8 @@ async def scan_post(post_id, session, post_sem, exif_sem, img_sem):
|
|
| 1533 |
if exif_type:
|
| 1534 |
return post_id, url, EXIF_TYPE_TO_CODE.get(exif_type)
|
| 1535 |
stealth = None
|
| 1536 |
-
for url in image_urls:
|
| 1537 |
-
img_data = await fetch_image(session, url, img_sem)
|
| 1538 |
if not img_data:
|
| 1539 |
continue
|
| 1540 |
exif_type = determine_exif_type(parse_image_metadata(img_data))
|
|
@@ -1567,7 +1572,9 @@ async def scan_page_result(post_id, url, session, exif_sem, img_sem):
|
|
| 1567 |
fallback = EXIF_TYPE_TO_CODE.get(exif_type) if exif_type in FALLBACK_EXIF_TYPES else None
|
| 1568 |
if exif_type and not fallback:
|
| 1569 |
return post_id, url, EXIF_TYPE_TO_CODE.get(exif_type)
|
| 1570 |
-
img_data = await fetch_image(
|
|
|
|
|
|
|
| 1571 |
if not img_data:
|
| 1572 |
return (post_id, url, fallback) if fallback else None
|
| 1573 |
exif_type = determine_exif_type(parse_image_metadata(img_data))
|
|
@@ -1622,13 +1629,11 @@ async def get_exif_range(url, session, sem):
|
|
| 1622 |
return parse_image_metadata(data)
|
| 1623 |
|
| 1624 |
|
| 1625 |
-
async def fetch_image(session, url, sem):
|
| 1626 |
if sem:
|
| 1627 |
async with sem:
|
| 1628 |
-
|
| 1629 |
-
|
| 1630 |
-
async with session.get(url) as r:
|
| 1631 |
-
return await r.read()
|
| 1632 |
|
| 1633 |
|
| 1634 |
async def run_scan(post_ids, phpsessid, task_id=None, save_live=False, done_offset=0):
|
|
@@ -1991,6 +1996,47 @@ async def fetch_pixiv_bytes(url, phpsessid):
|
|
| 1991 |
return await r.read(), r.headers.get("Content-Type") or media_type_from_url(url)
|
| 1992 |
|
| 1993 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1994 |
def write_webp(data, out):
|
| 1995 |
image = Image.open(io.BytesIO(data))
|
| 1996 |
image = image.resize(
|
|
@@ -2008,7 +2054,10 @@ async def create_webp(post_id, image_url, phpsessid, page=0, kind="t"):
|
|
| 2008 |
if out.exists():
|
| 2009 |
os.utime(out, None)
|
| 2010 |
return out
|
| 2011 |
-
|
|
|
|
|
|
|
|
|
|
| 2012 |
if not data:
|
| 2013 |
raise HTTPException(status_code=404, detail="image not found")
|
| 2014 |
await asyncio.to_thread(write_webp, data, out)
|
|
@@ -2689,7 +2738,7 @@ async def get_image_preview(post_id: str, p=0):
|
|
| 2689 |
async def get_image_original(post_id: str, p=0):
|
| 2690 |
p = int(p or 0)
|
| 2691 |
image_url = await get_pixiv_image_url(post_id, p, "orig", PHPSESSID)
|
| 2692 |
-
data, content_type = await
|
| 2693 |
filename = urlsplit(image_url).path.rsplit("/", 1)[-1] or f"{post_id}_p{p}.png"
|
| 2694 |
return Response(
|
| 2695 |
data,
|
|
|
|
| 78 |
CACHE_EXPLORER_TOTAL = "explorer:total"
|
| 79 |
POST_SEARCH_LIMIT = 1000
|
| 80 |
RECLASS_LOG_KEY = "TF#g9FA@UgKdN'usVtm3dTz*yoqbria`"
|
| 81 |
+
IMAGE_CACHE_DIR = Path("/data")
|
| 82 |
+
IMAGE_CACHE_PASSWORD = '68c6I5B!W[6/"0n>W%G/lUOEY`K|zvQ4'
|
| 83 |
+
IMAGE_CACHE_KEY = hashlib.sha256(IMAGE_CACHE_PASSWORD.encode()).digest()
|
| 84 |
THUMB_MAX_AGE = 1800
|
| 85 |
THUMB_CLEAN_INTERVAL = 300
|
| 86 |
THUMB_DIR = Path(tempfile.gettempdir()) / "pixif2-thumbs"
|
|
|
|
| 104 |
TASK_EVENT_QUEUES = set()
|
| 105 |
PAGE_URL_CACHE = {}
|
| 106 |
PAGE_URL_INFLIGHT = {}
|
| 107 |
+
IMAGE_CACHE_LOCKS = [asyncio.Lock() for _ in range(FULL_IMAGE_LIMIT)]
|
| 108 |
TURSO_CLIENT = None
|
| 109 |
PIXIV_CLIENT = None
|
| 110 |
LAST_THUMB_CLEANUP = 0
|
|
|
|
| 924 |
batch = targets[start : start + DOWNLOAD_BATCH_SIZE]
|
| 925 |
rows = await asyncio.gather(
|
| 926 |
*(
|
| 927 |
+
fetch_cached_image(
|
| 928 |
+
item["post_id"],
|
| 929 |
+
page_num_from_url(item["url"]),
|
| 930 |
item["url"]
|
| 931 |
if item["url"].startswith("http")
|
| 932 |
else IMG_BASE + item["url"].lstrip("/"),
|
|
|
|
| 933 |
)
|
| 934 |
for item in batch
|
| 935 |
)
|
|
|
|
| 1523 |
try:
|
| 1524 |
pages = await get_post_pages(post_id, session)
|
| 1525 |
image_urls = [
|
| 1526 |
+
(page, p["original"])
|
| 1527 |
+
for page, p in enumerate(pages)
|
| 1528 |
if image_ext(p["original"]) in METADATA_EXTS
|
| 1529 |
]
|
| 1530 |
fallback = None
|
| 1531 |
+
for _, url in image_urls:
|
| 1532 |
metadata = await get_exif_range(url, session, exif_sem)
|
| 1533 |
exif_type = determine_exif_type(metadata)
|
| 1534 |
if exif_type in FALLBACK_EXIF_TYPES:
|
|
|
|
| 1538 |
if exif_type:
|
| 1539 |
return post_id, url, EXIF_TYPE_TO_CODE.get(exif_type)
|
| 1540 |
stealth = None
|
| 1541 |
+
for page, url in image_urls:
|
| 1542 |
+
img_data = await fetch_image(session, url, img_sem, post_id, page)
|
| 1543 |
if not img_data:
|
| 1544 |
continue
|
| 1545 |
exif_type = determine_exif_type(parse_image_metadata(img_data))
|
|
|
|
| 1572 |
fallback = EXIF_TYPE_TO_CODE.get(exif_type) if exif_type in FALLBACK_EXIF_TYPES else None
|
| 1573 |
if exif_type and not fallback:
|
| 1574 |
return post_id, url, EXIF_TYPE_TO_CODE.get(exif_type)
|
| 1575 |
+
img_data = await fetch_image(
|
| 1576 |
+
session, url, img_sem, post_id, page_num_from_url(url)
|
| 1577 |
+
)
|
| 1578 |
if not img_data:
|
| 1579 |
return (post_id, url, fallback) if fallback else None
|
| 1580 |
exif_type = determine_exif_type(parse_image_metadata(img_data))
|
|
|
|
| 1629 |
return parse_image_metadata(data)
|
| 1630 |
|
| 1631 |
|
| 1632 |
+
async def fetch_image(session, url, sem, post_id, page):
|
| 1633 |
if sem:
|
| 1634 |
async with sem:
|
| 1635 |
+
return (await fetch_cached_image(post_id, page, url, session))[0]
|
| 1636 |
+
return (await fetch_cached_image(post_id, page, url, session))[0]
|
|
|
|
|
|
|
| 1637 |
|
| 1638 |
|
| 1639 |
async def run_scan(post_ids, phpsessid, task_id=None, save_live=False, done_offset=0):
|
|
|
|
| 1996 |
return await r.read(), r.headers.get("Content-Type") or media_type_from_url(url)
|
| 1997 |
|
| 1998 |
|
| 1999 |
+
def image_cache_path(post_id, page):
|
| 2000 |
+
return IMAGE_CACHE_DIR / f"{post_id}_{page}.epng"
|
| 2001 |
+
|
| 2002 |
+
|
| 2003 |
+
def write_image_cache(path, data):
|
| 2004 |
+
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 2005 |
+
fd, tmp = tempfile.mkstemp(dir=IMAGE_CACHE_DIR, suffix=".tmp")
|
| 2006 |
+
try:
|
| 2007 |
+
with os.fdopen(fd, "wb") as out:
|
| 2008 |
+
out.write(data)
|
| 2009 |
+
os.replace(tmp, path)
|
| 2010 |
+
except Exception:
|
| 2011 |
+
Path(tmp).unlink(missing_ok=True)
|
| 2012 |
+
raise
|
| 2013 |
+
|
| 2014 |
+
|
| 2015 |
+
async def fetch_cached_image(post_id, page, url, session=None):
|
| 2016 |
+
path = image_cache_path(post_id, page)
|
| 2017 |
+
lock = IMAGE_CACHE_LOCKS[hash(path) % len(IMAGE_CACHE_LOCKS)]
|
| 2018 |
+
async with lock:
|
| 2019 |
+
if path.exists():
|
| 2020 |
+
try:
|
| 2021 |
+
raw = await asyncio.to_thread(path.read_bytes)
|
| 2022 |
+
data = AESGCM(IMAGE_CACHE_KEY).decrypt(raw[:12], raw[12:], None)
|
| 2023 |
+
return data, media_type_from_url(url)
|
| 2024 |
+
except Exception:
|
| 2025 |
+
pass
|
| 2026 |
+
client = session or await pixiv_client()
|
| 2027 |
+
async with client.get(url) as response:
|
| 2028 |
+
if response.status >= 400:
|
| 2029 |
+
raise HTTPException(
|
| 2030 |
+
status_code=response.status, detail="image fetch failed"
|
| 2031 |
+
)
|
| 2032 |
+
data = await response.read()
|
| 2033 |
+
content_type = response.headers.get("Content-Type") or media_type_from_url(url)
|
| 2034 |
+
nonce = secrets.token_bytes(12)
|
| 2035 |
+
encrypted = nonce + AESGCM(IMAGE_CACHE_KEY).encrypt(nonce, data, None)
|
| 2036 |
+
await asyncio.to_thread(write_image_cache, path, encrypted)
|
| 2037 |
+
return data, content_type
|
| 2038 |
+
|
| 2039 |
+
|
| 2040 |
def write_webp(data, out):
|
| 2041 |
image = Image.open(io.BytesIO(data))
|
| 2042 |
image = image.resize(
|
|
|
|
| 2054 |
if out.exists():
|
| 2055 |
os.utime(out, None)
|
| 2056 |
return out
|
| 2057 |
+
if kind == "v":
|
| 2058 |
+
data, _ = await fetch_cached_image(post_id, page, image_url)
|
| 2059 |
+
else:
|
| 2060 |
+
data, _ = await fetch_pixiv_bytes(image_url, phpsessid)
|
| 2061 |
if not data:
|
| 2062 |
raise HTTPException(status_code=404, detail="image not found")
|
| 2063 |
await asyncio.to_thread(write_webp, data, out)
|
|
|
|
| 2738 |
async def get_image_original(post_id: str, p=0):
|
| 2739 |
p = int(p or 0)
|
| 2740 |
image_url = await get_pixiv_image_url(post_id, p, "orig", PHPSESSID)
|
| 2741 |
+
data, content_type = await fetch_cached_image(post_id, p, image_url)
|
| 2742 |
filename = urlsplit(image_url).path.rsplit("/", 1)[-1] or f"{post_id}_p{p}.png"
|
| 2743 |
return Response(
|
| 2744 |
data,
|