Add optional photo providers, layout studio and scoped integrations
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
"""Read-only OAuth-token drive readers. Credentials never enter catalog metadata."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from pigway_frame_flow.errors import ErrorCode as E
|
||||
from pigway_frame_flow.errors import FlowError
|
||||
from pigway_frame_flow.settings import secret
|
||||
|
||||
|
||||
class CloudDrives:
|
||||
def __init__(self, photos):
|
||||
self.photos = photos
|
||||
self.settings = photos.settings
|
||||
|
||||
def credentials(self, source_id, source):
|
||||
raw = secret(source.credential_env) if source.credential_env else ""
|
||||
path = self.settings.config_dir / ("source-" + source_id + ".secret")
|
||||
if not raw and path.exists():
|
||||
raw = path.read_text()
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
if (
|
||||
not isinstance(value.get("access_token"), str)
|
||||
or not value["access_token"]
|
||||
):
|
||||
raise ValueError()
|
||||
for key in ["access_token", "refresh_token", "client_secret"]:
|
||||
if value.get(key):
|
||||
self.settings.additional_secrets.append(value[key])
|
||||
return value
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"Authorize this cloud drive with a valid OAuth access token",
|
||||
409,
|
||||
) from None
|
||||
|
||||
def fetch(self, url, **kwargs):
|
||||
# Only fixed provider API hosts and explicit download domains reach the network.
|
||||
host = urlsplit(url).hostname
|
||||
trusted = {"www.googleapis.com", "openapi.aliyundrive.com", "pan.baidu.com"}
|
||||
if host in trusted and host not in self.settings.image_url_hosts:
|
||||
self.settings.image_url_hosts.append(host)
|
||||
return self.photos.assets.fetch_url(url, **kwargs)
|
||||
|
||||
def api(self, url, token, *, body=None, params=None):
|
||||
headers = {"Authorization": "Bearer " + token}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
try:
|
||||
raw = self.fetch(
|
||||
url + ("?" + urlencode(params) if params else ""),
|
||||
method="POST" if body is not None else "GET",
|
||||
headers=headers,
|
||||
body=json.dumps(body).encode() if body is not None else None,
|
||||
limit=2 * 1024 * 1024,
|
||||
)
|
||||
data = json.loads(raw)
|
||||
if data.get("error") or data.get("code") or data.get("errno", 0):
|
||||
raise ValueError()
|
||||
return data
|
||||
except Exception:
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE,
|
||||
"Cloud drive query failed; check authorization and permissions",
|
||||
502,
|
||||
) from None
|
||||
|
||||
def catalog(self, source_id, source):
|
||||
credentials = self.credentials(source_id, source)
|
||||
token = credentials["access_token"]
|
||||
items: list[dict] = []
|
||||
queue = [source.location]
|
||||
visited = set()
|
||||
page_count = 0
|
||||
while queue and len(items) < source.catalog_limit:
|
||||
folder = queue.pop(0)
|
||||
if folder in visited:
|
||||
continue
|
||||
visited.add(folder)
|
||||
cursor = None
|
||||
offset = 0
|
||||
while len(items) < source.catalog_limit:
|
||||
page_count += 1
|
||||
if page_count > 200:
|
||||
raise FlowError(
|
||||
E.CONFLICT,
|
||||
"Drive query exceeds 200 pages; select a smaller folder",
|
||||
409,
|
||||
)
|
||||
if source.kind == "google-drive":
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,200}", folder):
|
||||
raise FlowError(
|
||||
E.INVALID_REQUEST, "Invalid Google Drive folder ID"
|
||||
)
|
||||
page = self.api(
|
||||
"https://www.googleapis.com/drive/v3/files",
|
||||
token,
|
||||
params={
|
||||
"q": f"'{folder}' in parents and trashed = false",
|
||||
"pageSize": 1000,
|
||||
"fields": "nextPageToken,files(id,name,mimeType)",
|
||||
**({"pageToken": cursor} if cursor else {}),
|
||||
},
|
||||
)
|
||||
rows = page.get("files", [])
|
||||
next_page = page.get("nextPageToken")
|
||||
elif source.kind == "alipan":
|
||||
drive = credentials.get("drive_id")
|
||||
if not drive:
|
||||
raise FlowError(
|
||||
E.CONFIG_REQUIRED,
|
||||
"AliPan credentials require drive_id",
|
||||
409,
|
||||
)
|
||||
page = self.api(
|
||||
"https://openapi.aliyundrive.com/adrive/v1.0/openFile/list",
|
||||
token,
|
||||
body={
|
||||
"drive_id": drive,
|
||||
"parent_file_id": folder,
|
||||
"limit": 100,
|
||||
**({"marker": cursor} if cursor else {}),
|
||||
},
|
||||
)
|
||||
rows = page.get("items", [])
|
||||
next_page = page.get("next_marker")
|
||||
else:
|
||||
page = self.api(
|
||||
"https://pan.baidu.com/rest/2.0/xpan/file",
|
||||
token,
|
||||
params={
|
||||
"method": "list",
|
||||
"dir": folder,
|
||||
"start": offset,
|
||||
"limit": 1000,
|
||||
"access_token": token,
|
||||
},
|
||||
)
|
||||
rows = page.get("list", [])
|
||||
next_page = str(offset + 1000) if len(rows) == 1000 else None
|
||||
for row in rows:
|
||||
name = row.get("name", row.get("server_filename", ""))
|
||||
id = str(row.get("id", row.get("file_id", row.get("fs_id", ""))))
|
||||
directory = (
|
||||
row.get("mimeType") == "application/vnd.google-apps.folder"
|
||||
or row.get("type") == "folder"
|
||||
or row.get("isdir") == 1
|
||||
)
|
||||
if directory:
|
||||
if source.recursive:
|
||||
queue.append(row.get("path", id))
|
||||
continue
|
||||
if (
|
||||
not id
|
||||
or Path(name).suffix.lower() not in self.photos.extensions
|
||||
):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"id": id,
|
||||
"name": name,
|
||||
"caption": None,
|
||||
"provider_preview": False,
|
||||
}
|
||||
)
|
||||
if len(items) >= source.catalog_limit:
|
||||
break
|
||||
if not next_page:
|
||||
break
|
||||
if next_page == cursor:
|
||||
raise FlowError(
|
||||
E.CLOUD_UNAVAILABLE, "Drive pagination did not advance", 502
|
||||
)
|
||||
cursor = next_page
|
||||
offset += 1000
|
||||
return items
|
||||
|
||||
def read(self, source_id, source, item):
|
||||
credentials = self.credentials(source_id, source)
|
||||
token = credentials["access_token"]
|
||||
id = item["id"]
|
||||
if source.kind == "google-drive":
|
||||
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,200}", id):
|
||||
raise FlowError(E.INVALID_REQUEST, "Invalid file ID")
|
||||
return self.fetch(
|
||||
"https://www.googleapis.com/drive/v3/files/" + id + "?alt=media",
|
||||
headers={"Authorization": "Bearer " + token},
|
||||
)
|
||||
suffixes: tuple[str, ...]
|
||||
if source.kind == "alipan":
|
||||
result = self.api(
|
||||
"https://openapi.aliyundrive.com/adrive/v1.0/openFile/getDownloadUrl",
|
||||
token,
|
||||
body={"drive_id": credentials["drive_id"], "file_id": id},
|
||||
)
|
||||
url = result.get("url", "")
|
||||
suffixes = ("aliyuncs.com", "alipan.com", "aliyundrive.com")
|
||||
else:
|
||||
result = self.api(
|
||||
"https://pan.baidu.com/rest/2.0/xpan/multimedia",
|
||||
token,
|
||||
params={
|
||||
"method": "filemetas",
|
||||
"fsids": json.dumps([int(id)]),
|
||||
"dlink": 1,
|
||||
"access_token": token,
|
||||
},
|
||||
)
|
||||
url = result.get("list", [{}])[0].get("dlink", "")
|
||||
suffixes = ("baidupcs.com", "baidu.com")
|
||||
host = urlsplit(url).hostname or ""
|
||||
if not any(
|
||||
host == suffix or host.endswith("." + suffix) for suffix in suffixes
|
||||
):
|
||||
raise FlowError(E.FORBIDDEN, "Unexpected drive download host", 403)
|
||||
if host not in self.settings.image_url_hosts:
|
||||
self.settings.image_url_hosts.append(host)
|
||||
if source.kind == "baidu":
|
||||
url += ("&" if "?" in url else "?") + urlencode({"access_token": token})
|
||||
return self.fetch(
|
||||
url,
|
||||
headers={"User-Agent": "pan.baidu.com"} if source.kind == "baidu" else {},
|
||||
)
|
||||
Reference in New Issue
Block a user