mirror of
https://github.com/Evil0ctal/Douyin_TikTok_Download_API.git
synced 2026-10-17 04:40:38 +08:00
feat: reveal, export and restore identity cookies
Three asks, one shape: a jar could go into this instance and never come back out of it, in any form. **Reveal.** The drawer showed masked values, and a mask cannot be compared against a browser's cookies - which is what somebody debugging a jar is doing. Values are now readable through a route of its own: operator role rather than any admin reader, an audit line per read, and still masked on the view the drawer loads on every open. The disclosure is a decision somebody made and a record somebody can read, which the shell-and-hand-rolled-decrypt it replaces was not. Both copy shapes are there because both are the next step: the Cookie header goes into curl, the JSON goes into another tool. **Export.** Select identities, get a document. An operator who had built a pool that works could not move it to a second instance, keep a copy before a risky change, or hand one identity to somebody debugging - the only route out was the database. The result is a credential file and says so in a field the importer ignores, so whoever finds it later knows what they are holding. **Restore.** The same document back in, through a route that is not the paste route. Re-pasting an exported jar re-infers the browser from a user agent no longer beside it, so the restored identity signs with a different fingerprint from the one the platform first saw - which is precisely the difference risk control looks for. Entries are judged one at a time: a jar that expired since the export does not stop the rest, and a file from a newer build is refused rather than read on a guess, because dropping a field it does not know about is invisible until it is not.
This commit is contained in:
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final
|
||||
|
||||
@@ -27,7 +28,13 @@ from dtk.api.deps import Principal
|
||||
from dtk.api.routes import operations
|
||||
from dtk.api.routes.openapi import ACCEPTED_RESPONSES, CREATED_RESPONSES, I18N_KEY
|
||||
from dtk.api.routes.operations import Maintenance
|
||||
from dtk.api.routes.schemas import IdentityImport, MintRequest, RetireRequest
|
||||
from dtk.api.routes.schemas import (
|
||||
IdentityBundle,
|
||||
IdentityExport,
|
||||
IdentityImport,
|
||||
MintRequest,
|
||||
RetireRequest,
|
||||
)
|
||||
from dtk.api.routes.support import (
|
||||
DEFAULT_ADMIN_PAGE_SIZE,
|
||||
MAX_ADMIN_PAGE_SIZE,
|
||||
@@ -51,6 +58,8 @@ from dtk.identity.importing import (
|
||||
ImportReport,
|
||||
build_report,
|
||||
mask_value,
|
||||
session_expiry,
|
||||
to_cookie_header,
|
||||
)
|
||||
from dtk.identity.pool import IdentityPool
|
||||
from dtk.scheduler.health import score
|
||||
@@ -123,6 +132,35 @@ def _session_health(platform: str, cookies: Mapping[str, str]) -> dict[str, Any]
|
||||
_SIGNING_COOKIES: Final[frozenset[str]] = frozenset({*UIFID_COOKIE_NAMES, VERIFY_FP_COOKIE})
|
||||
|
||||
|
||||
#: The export document's shape. Bumped when an entry gains or loses a field,
|
||||
#: so an importer can refuse a file it would silently read wrong rather than
|
||||
#: dropping a fingerprint and producing an identity the platform can tell apart
|
||||
#: from the browser it came from.
|
||||
EXPORT_VERSION: Final = 1
|
||||
|
||||
|
||||
def _text(value: Any) -> str | None:
|
||||
"""A field from an imported document, when it is a string worth passing on."""
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _decrypt(request: Request, identity: Identity) -> dict[str, str] | None:
|
||||
"""This identity's jar, or None when it cannot be read.
|
||||
|
||||
None is a real answer rather than an empty jar: the secret key was rotated
|
||||
without re-encrypting, the identity cannot sign anything either, and an
|
||||
empty mapping would render as "holds no cookies" - a different fault with a
|
||||
different fix.
|
||||
"""
|
||||
if not identity.cookies_encrypted:
|
||||
return {}
|
||||
try:
|
||||
header = request.app.state.cipher.decrypt(identity.cookies_encrypted, aad=str(identity.id))
|
||||
except Exception:
|
||||
return None
|
||||
return _parse_cookies(header)
|
||||
|
||||
|
||||
def _cookie_role(name: str) -> str:
|
||||
"""Why this cookie matters, using the sets the rest of the build judges by.
|
||||
|
||||
@@ -534,19 +572,9 @@ async def identity_cookies(
|
||||
if identity is None:
|
||||
raise NotFound("no such identity", details={"identity_id": str(identity_id)})
|
||||
|
||||
cookies: dict[str, str] = {}
|
||||
readable = True
|
||||
if identity.cookies_encrypted:
|
||||
try:
|
||||
cookies = _parse_cookies(
|
||||
request.app.state.cipher.decrypt(identity.cookies_encrypted, aad=str(identity.id))
|
||||
)
|
||||
except Exception:
|
||||
# A jar this instance can no longer decrypt is a real state - the
|
||||
# secret key was rotated without re-encrypting - and it is worth
|
||||
# saying so rather than rendering an identity that appears to hold
|
||||
# nothing.
|
||||
readable = False
|
||||
decrypted = _decrypt(request, identity)
|
||||
readable = decrypted is not None
|
||||
cookies = decrypted or {}
|
||||
|
||||
return ok(
|
||||
request,
|
||||
@@ -576,6 +604,270 @@ async def identity_cookies(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/import/bundle",
|
||||
summary="Import an export document",
|
||||
openapi_extra={I18N_KEY: "identities_import_bundle", **CREATED_RESPONSES},
|
||||
)
|
||||
async def import_bundle(
|
||||
request: Request,
|
||||
body: IdentityBundle,
|
||||
principal: Principal = Depends(manage_pool),
|
||||
) -> Any:
|
||||
"""Restore identities from a file this build exported.
|
||||
|
||||
The other import takes a paste, which is what somebody has when they copy a
|
||||
jar out of a browser. This takes what somebody has when they exported a
|
||||
pool: many identities at once, each already carrying the fingerprint it was
|
||||
minted or imported with.
|
||||
|
||||
Carrying the fingerprint is the point. Re-pasting an exported jar through
|
||||
the paste route infers the browser from a user agent that is no longer
|
||||
beside it, so a restored identity signs with a different fingerprint from
|
||||
the one the platform first saw it with - which is exactly the difference
|
||||
risk control is looking for.
|
||||
|
||||
Every entry is judged on its own. One jar that has expired since it was
|
||||
exported does not stop the rest: the response says what was stored, what
|
||||
was skipped and why, and `dry_run` answers that question without writing.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `version` - the document's shape. A newer one is refused rather than
|
||||
read on a guess.
|
||||
- `identities` - the entries, as `POST /identities/export` wrote them.
|
||||
- `proxy_id` - bind every restored identity to this egress. Optional.
|
||||
- `dry_run` - report without storing.
|
||||
|
||||
**Returns**
|
||||
|
||||
`stored`, and one result per entry: its platform, whether it was usable,
|
||||
and the id it was given.
|
||||
"""
|
||||
if body.version > EXPORT_VERSION:
|
||||
raise InvalidParam(
|
||||
"this file was written by a newer build than this instance",
|
||||
details={"version": body.version, "supported": EXPORT_VERSION},
|
||||
)
|
||||
|
||||
proxy_id = _as_uuid(body.proxy_id, "proxy_id") if body.proxy_id else None
|
||||
lang = language(request)
|
||||
results: list[dict[str, Any]] = []
|
||||
stored = 0
|
||||
|
||||
for index, entry in enumerate(body.identities):
|
||||
outcome: dict[str, Any] = {"index": index}
|
||||
platform_value = entry.get("platform")
|
||||
cookies = entry.get("cookies")
|
||||
if not isinstance(cookies, dict) or not cookies:
|
||||
# A retired identity exports with an empty jar on purpose, so this
|
||||
# is the ordinary case rather than a malformed file.
|
||||
results.append({**outcome, "usable": False, "reason": "no_cookies"})
|
||||
continue
|
||||
try:
|
||||
platform = Platform(str(platform_value))
|
||||
except ValueError:
|
||||
results.append({**outcome, "usable": False, "reason": "unknown_platform"})
|
||||
continue
|
||||
|
||||
report = build_report(
|
||||
to_cookie_header({str(k): str(v) for k, v in cookies.items()}),
|
||||
platform,
|
||||
user_agent=_text(entry.get("user_agent")),
|
||||
language=_text(entry.get("language")),
|
||||
timezone=_text(entry.get("timezone")),
|
||||
)
|
||||
outcome |= {
|
||||
"platform": platform.value,
|
||||
"authenticated": report.authenticated,
|
||||
"usable": report.usable,
|
||||
"missing_required": list(report.missing_required),
|
||||
"warnings": _warnings(report, lang),
|
||||
}
|
||||
if not report.usable:
|
||||
results.append({**outcome, "reason": "unusable"})
|
||||
continue
|
||||
if body.dry_run:
|
||||
results.append(outcome)
|
||||
continue
|
||||
|
||||
identity_id = await _pool(request).add(
|
||||
request.state.db,
|
||||
platform=platform,
|
||||
cookies=report.cookies,
|
||||
fingerprint=report.fingerprint,
|
||||
source=IdentitySource.IMPORTED,
|
||||
proxy_id=proxy_id,
|
||||
authenticated=report.authenticated,
|
||||
)
|
||||
stored += 1
|
||||
results.append({**outcome, "identity_id": str(identity_id)})
|
||||
|
||||
if not body.dry_run and stored:
|
||||
await audit(
|
||||
request,
|
||||
principal,
|
||||
"identity.bundle_imported",
|
||||
target_type="identity",
|
||||
target_id=",".join(
|
||||
str(result["identity_id"]) for result in results if "identity_id" in result
|
||||
)[:512],
|
||||
detail={"stored": stored, "submitted": len(body.identities)},
|
||||
)
|
||||
|
||||
return ok(
|
||||
request,
|
||||
{"stored": stored, "submitted": len(body.identities), "results": results},
|
||||
status_code=201 if stored else 200,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{identity_id}/cookies/reveal",
|
||||
summary="Read one identity's cookies in full",
|
||||
openapi_extra={I18N_KEY: "identities_cookies_reveal"},
|
||||
)
|
||||
async def reveal_cookies(
|
||||
request: Request,
|
||||
identity_id: uuid.UUID = Path(description="The identity to read."),
|
||||
principal: Principal = Depends(manage_pool),
|
||||
) -> Any:
|
||||
"""The jar as it will be sent, values and all.
|
||||
|
||||
A deliberate exception to this module's rule, and the reason it is a route
|
||||
of its own rather than a flag on the inventory: the masked view is what the
|
||||
drawer loads on every open and this is what somebody asks for on purpose.
|
||||
Separating them means the audit line records the second and is not drowned
|
||||
by the first, and the wider scope - operator, not any admin reader - is
|
||||
enforced by the dependency rather than by a branch inside a handler.
|
||||
|
||||
Why it exists at all: an operator debugging a jar ends up comparing it
|
||||
against a browser's, and a mask cannot be compared. The alternative on
|
||||
offer was a shell and a hand-rolled decrypt, which is the same disclosure
|
||||
with no audit line and no scope check in front of it.
|
||||
|
||||
**Returns**
|
||||
|
||||
The cookies as a mapping, the same jar as one ``Cookie:`` header, and the
|
||||
expiry read out of the session cookie where there is one.
|
||||
"""
|
||||
identity = await request.state.db.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
raise NotFound("no such identity", details={"identity_id": str(identity_id)})
|
||||
|
||||
cookies = _decrypt(request, identity)
|
||||
if cookies is None:
|
||||
raise InvalidParam(
|
||||
"this identity's cookies cannot be decrypted with the current secret key"
|
||||
)
|
||||
|
||||
await audit(
|
||||
request,
|
||||
principal,
|
||||
"identity.cookies_revealed",
|
||||
target_type="identity",
|
||||
target_id=str(identity_id),
|
||||
detail={"platform": identity.platform, "count": len(cookies)},
|
||||
)
|
||||
return ok(
|
||||
request,
|
||||
{
|
||||
"identity_id": str(identity.id),
|
||||
"platform": identity.platform,
|
||||
"authenticated": identity.authenticated,
|
||||
"cookies": cookies,
|
||||
"header": to_cookie_header(cookies),
|
||||
"expires_at": iso(session_expiry(cookies)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/export",
|
||||
summary="Export identities as a file",
|
||||
openapi_extra={I18N_KEY: "identities_export"},
|
||||
)
|
||||
async def export_identities(
|
||||
request: Request,
|
||||
body: IdentityExport,
|
||||
principal: Principal = Depends(manage_pool),
|
||||
) -> Any:
|
||||
"""Hand back whole identities - jars, fingerprints and all - as a document.
|
||||
|
||||
The console could import a jar and never get one back out, so an operator
|
||||
who had built a working pool could not move it to a second instance, keep a
|
||||
copy before a risky change, or hand one identity to somebody debugging.
|
||||
The only route out was the database.
|
||||
|
||||
**What comes back is a credential file.** Every jar in it works until the
|
||||
platform expires it, and a logged-in one is somebody's account. It is
|
||||
audited by identity, and the document says so in a field the importer
|
||||
ignores, so a person who finds one later knows what they are holding.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `identity_ids` - which identities. Between one and 200.
|
||||
|
||||
**Returns**
|
||||
|
||||
A document with a `version`, the export time, and one entry per identity.
|
||||
Retired identities export as an entry with an empty jar rather than being
|
||||
dropped: silently returning fewer than were asked for is how an operator
|
||||
finds out mid-restore.
|
||||
"""
|
||||
wanted = [_as_uuid(value, "identity_ids") for value in body.identity_ids]
|
||||
rows = (
|
||||
(await request.state.db.execute(select(Identity).where(Identity.id.in_(wanted))))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
found = {row.id: row for row in rows}
|
||||
missing = [str(value) for value in wanted if value not in found]
|
||||
if missing:
|
||||
raise NotFound("no such identity", details={"identity_ids": missing})
|
||||
|
||||
entries = []
|
||||
for identity_id in wanted:
|
||||
identity = found[identity_id]
|
||||
cookies = _decrypt(request, identity) or {}
|
||||
fingerprint = identity.fingerprint or {}
|
||||
entries.append(
|
||||
{
|
||||
"identity_id": str(identity.id),
|
||||
"platform": identity.platform,
|
||||
"source": identity.source,
|
||||
"authenticated": identity.authenticated,
|
||||
"minted_at": iso(identity.minted_at),
|
||||
"retired_at": iso(identity.retired_at),
|
||||
"cookies": cookies,
|
||||
"user_agent": fingerprint.get("user_agent"),
|
||||
"language": fingerprint.get("language"),
|
||||
"timezone": fingerprint.get("timezone"),
|
||||
}
|
||||
)
|
||||
|
||||
await audit(
|
||||
request,
|
||||
principal,
|
||||
"identity.exported",
|
||||
target_type="identity",
|
||||
target_id=",".join(str(value) for value in wanted[:20]),
|
||||
detail={"count": len(entries)},
|
||||
)
|
||||
return ok(
|
||||
request,
|
||||
{
|
||||
"version": EXPORT_VERSION,
|
||||
"exported_at": iso(datetime.now(UTC)),
|
||||
"warning": (
|
||||
"This file contains working credentials. Anyone holding it can "
|
||||
"make requests as these identities."
|
||||
),
|
||||
"identities": entries,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{identity_id}/test",
|
||||
summary="Probe one identity",
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
@@ -112,6 +113,34 @@ class IdentityImport(Body):
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class IdentityExport(Body):
|
||||
"""Which identities to write into an export document.
|
||||
|
||||
Bounded at 200 because the response carries every jar in full and is
|
||||
assembled in memory; a pool larger than that is exported in pages, which is
|
||||
a request the caller can see the size of.
|
||||
"""
|
||||
|
||||
identity_ids: list[str] = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class IdentityBundle(Body):
|
||||
"""An export document, on its way back in.
|
||||
|
||||
`version` is checked rather than trusted: a file from a future build may
|
||||
describe fields this one would drop silently, and dropping a fingerprint
|
||||
turns a working identity into one the platform can tell apart from the
|
||||
browser it was taken from.
|
||||
"""
|
||||
|
||||
version: int
|
||||
identities: list[dict[str, Any]] = Field(min_length=1, max_length=200)
|
||||
proxy_id: str | None = None
|
||||
#: Preview only, exactly as on a paste: the console says what it found
|
||||
#: before anything is written.
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class RetireRequest(Body):
|
||||
reason: str = Field(default="retired from the console", max_length=256)
|
||||
|
||||
|
||||
@@ -374,10 +374,22 @@
|
||||
"summary": "What one identity's jar holds",
|
||||
"description": "Cookie names, roles and masked values for one identity. Never the values themselves."
|
||||
},
|
||||
"identities_cookies_reveal": {
|
||||
"summary": "Read one identity's cookies in full",
|
||||
"description": "The jar as it will be sent, values included. Audited."
|
||||
},
|
||||
"identities_export": {
|
||||
"summary": "Export identities as a file",
|
||||
"description": "Whole identities, jars included. The result is a credential file."
|
||||
},
|
||||
"identities_import": {
|
||||
"summary": "Import a logged-in cookie jar",
|
||||
"description": "Detect the paste format, report what was understood, then store it."
|
||||
},
|
||||
"identities_import_bundle": {
|
||||
"summary": "Import an export document",
|
||||
"description": "Restore identities from a file this build exported, fingerprints and all."
|
||||
},
|
||||
"identities_list": {
|
||||
"summary": "List identities with state and health",
|
||||
"description": "The identity pool, newest first."
|
||||
|
||||
@@ -374,10 +374,22 @@
|
||||
"summary": "查看某个身份的 Cookie 组成",
|
||||
"description": "返回该身份持有的 Cookie 名称、作用分类与掩码后的值,绝不返回真实值。"
|
||||
},
|
||||
"identities_cookies_reveal": {
|
||||
"summary": "读取某个身份的完整 Cookie",
|
||||
"description": "返回将要发出的完整 Cookie,含真实值。该操作会被审计记录。"
|
||||
},
|
||||
"identities_export": {
|
||||
"summary": "导出身份为文件",
|
||||
"description": "导出完整身份,包含 Cookie。产物是一份凭证文件。"
|
||||
},
|
||||
"identities_import": {
|
||||
"summary": "导入已登录的 Cookie",
|
||||
"description": "识别粘贴格式,报告解析结果,然后加密存储。"
|
||||
},
|
||||
"identities_import_bundle": {
|
||||
"summary": "导入身份文件",
|
||||
"description": "从本程序导出的文件恢复身份,连同指纹一并还原。"
|
||||
},
|
||||
"identities_list": {
|
||||
"summary": "列出身份及其状态与健康度",
|
||||
"description": "身份池,最新的在前。"
|
||||
|
||||
@@ -136,3 +136,108 @@ async def test_an_unknown_identity_is_a_404_rather_than_an_empty_jar(client: Any
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert envelope(response)["error"]["code"] == "NOT_FOUND"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Reveal, export, restore
|
||||
#
|
||||
# The masked inventory above is what the drawer loads on every open. These are
|
||||
# the routes somebody asks for on purpose, and they hand back credentials - so
|
||||
# what is pinned here is that they do it under the wider scope, that the export
|
||||
# says what it is, and that a document survives the round trip with the
|
||||
# fingerprint attached.
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_reveal_hands_back_the_jar_as_it_will_be_sent(client: Any) -> None:
|
||||
await signed_in(client)
|
||||
identity_id = await make_identity(client)
|
||||
|
||||
response = await client.get(f"/api/v1/admin/identities/{identity_id}/cookies/reveal")
|
||||
assert response.status_code == 200, response.text
|
||||
data = envelope(response)["data"]
|
||||
|
||||
assert data["cookies"]["ttwid"] == TTWID
|
||||
assert data["cookies"]["UIFID_TEMP"] == UIFID
|
||||
# The header is the same jar in the shape curl takes, not a second source
|
||||
# of truth about what it holds.
|
||||
for name, value in data["cookies"].items():
|
||||
assert f"{name}={value}" in data["header"]
|
||||
|
||||
|
||||
async def test_an_export_says_what_it_is(client: Any) -> None:
|
||||
"""The document is a credential file and has to read as one.
|
||||
|
||||
A file found on a laptop a year from now is the case this is for: without
|
||||
a line saying what it holds, an export looks like configuration.
|
||||
"""
|
||||
await signed_in(client)
|
||||
identity_id = await make_identity(client)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/identities/export", json={"identity_ids": [identity_id]}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
document = envelope(response)["data"]
|
||||
|
||||
assert document["version"] == 1
|
||||
assert "credentials" in document["warning"]
|
||||
entry = document["identities"][0]
|
||||
assert entry["cookies"]["ttwid"] == TTWID
|
||||
# The fingerprint travels with the jar. Without it a restore re-infers the
|
||||
# browser and signs as a different client than the platform first saw.
|
||||
assert entry["user_agent"] == CHROME_UA
|
||||
|
||||
|
||||
async def test_a_document_survives_the_round_trip(client: Any) -> None:
|
||||
await signed_in(client)
|
||||
exported = await client.post(
|
||||
"/api/v1/admin/identities/export",
|
||||
json={"identity_ids": [await make_identity(client)]},
|
||||
)
|
||||
document = envelope(exported)["data"]
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/identities/import/bundle",
|
||||
json={"version": document["version"], "identities": document["identities"]},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
result = envelope(response)["data"]
|
||||
assert result["stored"] == 1
|
||||
assert result["results"][0]["authenticated"] is True
|
||||
|
||||
|
||||
async def test_a_file_from_a_newer_build_is_refused_rather_than_guessed_at(client: Any) -> None:
|
||||
"""Reading it anyway would drop the fields this build does not know about.
|
||||
|
||||
Dropping a fingerprint is not a visible failure - it produces an identity
|
||||
that works and signs as somebody else, which is the failure risk control is
|
||||
built to find.
|
||||
"""
|
||||
await signed_in(client)
|
||||
response = await client.post(
|
||||
"/api/v1/admin/identities/import/bundle",
|
||||
json={"version": 99, "identities": [{"platform": "douyin", "cookies": {"ttwid": "x"}}]},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert envelope(response)["error"]["code"] == "INVALID_PARAM"
|
||||
|
||||
|
||||
async def test_an_entry_that_cannot_be_used_does_not_stop_the_others(client: Any) -> None:
|
||||
await signed_in(client)
|
||||
exported = await client.post(
|
||||
"/api/v1/admin/identities/export",
|
||||
json={"identity_ids": [await make_identity(client)]},
|
||||
)
|
||||
document = envelope(exported)["data"]
|
||||
# A retired identity exports with an empty jar; this is that entry.
|
||||
document["identities"].insert(0, {"platform": "douyin", "cookies": {}})
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/admin/identities/import/bundle",
|
||||
json={"version": document["version"], "identities": document["identities"]},
|
||||
)
|
||||
result = envelope(response)["data"]
|
||||
assert result["stored"] == 1
|
||||
assert result["submitted"] == 2
|
||||
assert result["results"][0]["reason"] == "no_cookies"
|
||||
|
||||
@@ -45,6 +45,13 @@ export const paths = {
|
||||
reset: (id: string) => `${API_V1}/admin/identities/${encodeURIComponent(id)}/reset`,
|
||||
/** Cookie names, roles and masked values. Never the values themselves. */
|
||||
cookies: (id: string) => `${API_V1}/admin/identities/${encodeURIComponent(id)}/cookies`,
|
||||
/** The same jar with its real values. Audited; operator role only. */
|
||||
reveal: (id: string) =>
|
||||
`${API_V1}/admin/identities/${encodeURIComponent(id)}/cookies/reveal`,
|
||||
/** Whole identities as a document. The result is a credential file. */
|
||||
export: `${API_V1}/admin/identities/export`,
|
||||
/** An export document on its way back in, fingerprints included. */
|
||||
importBundle: `${API_V1}/admin/identities/import/bundle`,
|
||||
},
|
||||
proxies: {
|
||||
list: `${API_V1}/admin/proxies`,
|
||||
|
||||
@@ -412,7 +412,15 @@
|
||||
"empty": "This identity holds no cookies at all, which means it can only ever be refused.",
|
||||
"wiped": "Retired identities have their jar wiped, so there is nothing left to show.",
|
||||
"missing": "Missing a cookie this build treats as required: {names}. Requests on this identity will be refused whatever else is in the jar.",
|
||||
"maskNote": "Values are shown as their first and last four characters. The full values are never sent to this page - the jar is the credential, and a logged-in one is somebody's account."
|
||||
"maskNote": "Values are shown as their first and last four characters. The full values are never sent to this page - the jar is the credential, and a logged-in one is somebody's account.",
|
||||
"reveal": "Show the real values",
|
||||
"revealFailed": "Could not read this jar",
|
||||
"revealed": "These are working credentials. Anyone who sees this screen can make requests as this identity. Reading them was recorded in the audit log.",
|
||||
"copyHeader": "Copy as a Cookie header",
|
||||
"copyJson": "Copy as JSON",
|
||||
"copiedHeader": "Cookie header copied",
|
||||
"copiedJson": "Cookies copied as JSON",
|
||||
"hide": "Hide them again"
|
||||
},
|
||||
"cookieRole": {
|
||||
"required": "required",
|
||||
@@ -465,7 +473,22 @@
|
||||
"UIFIDTEMP": "The visitor id a Douyin mint produces, under another of the spellings the SDK accepts.",
|
||||
"uifidtemp": "The visitor id a Douyin mint produces, under another of the spellings the SDK accepts."
|
||||
},
|
||||
"drawerTitle": "Identity"
|
||||
"drawerTitle": "Identity",
|
||||
"export": {
|
||||
"action": "{count, plural, one {Export 1} other {Export #}}",
|
||||
"done": "{count, plural, one {1 identity exported} other {# identities exported}}",
|
||||
"failed": "Could not export these identities"
|
||||
},
|
||||
"restore": {
|
||||
"label": "Restore from a file",
|
||||
"hint": "A document written by Export. Cookies and fingerprints come back together, which re-pasting them cannot do.",
|
||||
"loaded": "{file} · {count, plural, one {1 identity} other {# identities}}. The paste form below is off while a file is loaded.",
|
||||
"submit": "{count, plural, one {Restore 1 identity} other {Restore # identities}}",
|
||||
"done": "{count, plural, =0 {Nothing was restored} one {1 identity restored} other {# identities restored}}",
|
||||
"skipped": "{count, plural, one {1 was skipped - its jar had expired or was empty.} other {# were skipped - their jars had expired or were empty.}}",
|
||||
"failed": "Could not restore from this file",
|
||||
"unreadable": "This is not a file Export wrote."
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"column": {
|
||||
|
||||
@@ -412,7 +412,15 @@
|
||||
"empty": "这个身份一条 Cookie 都没有,发出去只会被拒。",
|
||||
"wiped": "已退休的身份会清空 Cookie,所以这里没有内容可看。",
|
||||
"missing": "缺少本程序视为必需的 Cookie:{names}。不管其余部分如何,用这个身份的请求都会被拒。",
|
||||
"maskNote": "只显示每个值的首尾各四位。完整值从不会发到这个页面——Cookie 本身就是凭证,带登录态的那份等于别人的账号。"
|
||||
"maskNote": "只显示每个值的首尾各四位。完整值从不会发到这个页面——Cookie 本身就是凭证,带登录态的那份等于别人的账号。",
|
||||
"reveal": "显示真实内容",
|
||||
"revealFailed": "读不出这份 Cookie",
|
||||
"revealed": "这些是可用的凭证。看到这个屏幕的人都能以这个身份发请求。本次读取已写入审计日志。",
|
||||
"copyHeader": "复制为 Cookie 请求头",
|
||||
"copyJson": "复制为 JSON",
|
||||
"copiedHeader": "已复制 Cookie 请求头",
|
||||
"copiedJson": "已按 JSON 复制 Cookie",
|
||||
"hide": "重新隐藏"
|
||||
},
|
||||
"cookieRole": {
|
||||
"required": "必需",
|
||||
@@ -465,7 +473,22 @@
|
||||
"UIFIDTEMP": "抖音铸造出来的访客 id,SDK 接受的另一种拼写。",
|
||||
"uifidtemp": "抖音铸造出来的访客 id,SDK 接受的另一种拼写。"
|
||||
},
|
||||
"drawerTitle": "身份详情"
|
||||
"drawerTitle": "身份详情",
|
||||
"export": {
|
||||
"action": "导出 {count} 个",
|
||||
"done": "已导出 {count} 个身份",
|
||||
"failed": "导出失败"
|
||||
},
|
||||
"restore": {
|
||||
"label": "从文件恢复",
|
||||
"hint": "「导出」写出的文件。Cookie 和指纹会一起还原,重新粘贴做不到这一点。",
|
||||
"loaded": "{file} · {count} 个身份。载入文件后,下面的粘贴表单会关闭。",
|
||||
"submit": "恢复 {count} 个身份",
|
||||
"done": "{count, plural, =0 {没有恢复任何身份} other {已恢复 # 个身份}}",
|
||||
"skipped": "{count} 个被跳过——它们的 Cookie 已过期或为空。",
|
||||
"failed": "无法从这个文件恢复",
|
||||
"unreadable": "这不是「导出」写出的文件。"
|
||||
}
|
||||
},
|
||||
"proxy": {
|
||||
"column": {
|
||||
|
||||
+329
-15
@@ -16,6 +16,7 @@ import {
|
||||
LockIcon,
|
||||
Banner,
|
||||
Drawer,
|
||||
InfoIcon,
|
||||
MaskedSecret,
|
||||
Modal,
|
||||
PageHeader,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
useToast,
|
||||
type Column,
|
||||
} from '@/components'
|
||||
import { apiDelete, apiPost, apiPut, waitForTask, type ApiError } from '@/lib/api'
|
||||
import { apiDelete, apiGet, apiPost, apiPut, waitForTask, type ApiError } from '@/lib/api'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { paths } from '@/lib/endpoints'
|
||||
import { MISSING } from '@/lib/format'
|
||||
@@ -41,7 +42,14 @@ import {
|
||||
type Outcome,
|
||||
type Platform,
|
||||
} from '@/lib/types'
|
||||
import { useApiMutation, useApiQuery, useFormatters, useInvalidate, useSession } from '@/hooks'
|
||||
import {
|
||||
useApiMutation,
|
||||
useApiQuery,
|
||||
useCopy,
|
||||
useFormatters,
|
||||
useInvalidate,
|
||||
useSession,
|
||||
} from '@/hooks'
|
||||
|
||||
import styles from './identities.module.css'
|
||||
|
||||
@@ -115,6 +123,59 @@ interface CookieInventory {
|
||||
cookies: CookieEntry[]
|
||||
}
|
||||
|
||||
/** What `POST /admin/identities/export` writes, and what the file import reads. */
|
||||
interface IdentityBundleFile {
|
||||
version: number
|
||||
exported_at: string
|
||||
warning: string
|
||||
identities: Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
interface BundleImportResult {
|
||||
stored: number
|
||||
submitted: number
|
||||
results: Array<{
|
||||
index: number
|
||||
platform?: string
|
||||
usable?: boolean
|
||||
reason?: string
|
||||
identity_id?: string
|
||||
warnings?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the browser a JSON file.
|
||||
*
|
||||
* The same shape the archive export and the comment save use. Revoked a tick
|
||||
* later rather than immediately: Safari cancels an in-flight save otherwise.
|
||||
*/
|
||||
function saveJson(data: unknown, filename: string): void {
|
||||
const href = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }),
|
||||
)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = href
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
window.setTimeout(() => {
|
||||
URL.revokeObjectURL(href)
|
||||
}, 1_000)
|
||||
}
|
||||
|
||||
/** `GET /admin/identities/{id}/cookies/reveal` - the jar, values and all. */
|
||||
interface RevealedJar {
|
||||
identity_id: string
|
||||
platform: Platform
|
||||
authenticated: boolean
|
||||
cookies: Record<string, string>
|
||||
/** The same jar as one `Cookie:` header, ready to paste into curl. */
|
||||
header: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
interface ProxyOption {
|
||||
id: string
|
||||
label?: string | null
|
||||
@@ -914,6 +975,32 @@ export default function Identities() {
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Write the selected identities to a file.
|
||||
*
|
||||
* The pool could be filled and never emptied: an operator who had built one
|
||||
* that works could not move it to a second instance, keep a copy before a
|
||||
* risky change, or hand one identity to somebody debugging. The only way out
|
||||
* was the database.
|
||||
*
|
||||
* What lands in the downloads folder is a credential file. The name says the
|
||||
* date so two exports do not overwrite each other, and the document carries
|
||||
* a warning field for whoever finds it later.
|
||||
*/
|
||||
const exporting = useApiMutation<IdentityBundleFile, string[]>(
|
||||
(ids) => apiPost<IdentityBundleFile>(paths.identities.export, { identity_ids: ids }),
|
||||
{
|
||||
onSuccess: (bundle) => {
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')
|
||||
saveJson(bundle, `dtk-identities-${stamp}.json`)
|
||||
toast.success(t('console:identity.export.done', { count: bundle.identities.length }))
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.apiError(error, t('console:identity.export.failed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const selectedRows = rows.filter((row) => selected.has(row.id))
|
||||
// The pool is not empty, this view is. Offering "mint your first identity"
|
||||
// here would answer a question nobody asked and hide the one that matters:
|
||||
@@ -1105,6 +1192,18 @@ export default function Identities() {
|
||||
})}
|
||||
</Button>
|
||||
) : null}
|
||||
{selected.size > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={exporting.isPending}
|
||||
onClick={() => {
|
||||
exporting.mutate([...selected])
|
||||
}}
|
||||
>
|
||||
{t('console:identity.export.action', { count: selected.size })}
|
||||
</Button>
|
||||
) : null}
|
||||
{selected.size > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1472,6 +1571,11 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
|
||||
const [userAgent, setUserAgent] = useState('')
|
||||
const [proxyId, setProxyId] = useState('')
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
//: A file this build exported, once it has been read and understood. The
|
||||
//: paste path and this one cannot both be armed: they mean different things
|
||||
//: - one jar typed in, or a pool restored - and a dialog that guessed which
|
||||
//: was intended would guess wrong on the day it mattered.
|
||||
const [bundle, setBundle] = useState<{ file: string; doc: IdentityBundleFile } | null>(null)
|
||||
const [preview, setPreview] = useState<{
|
||||
loading: boolean
|
||||
report?: CookieReport
|
||||
@@ -1513,6 +1617,55 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
|
||||
}
|
||||
}, [open, cookies, platform, userAgent])
|
||||
|
||||
/**
|
||||
* Restore a whole document, fingerprints and all.
|
||||
*
|
||||
* Not the paste route with the jars re-serialised through it: that infers
|
||||
* the browser from a user agent no longer beside the cookies, so a restored
|
||||
* identity would sign with a different fingerprint from the one the platform
|
||||
* first saw it with - which is the difference risk control looks for.
|
||||
*/
|
||||
const restore = useApiMutation<BundleImportResult, void>(
|
||||
() =>
|
||||
apiPost<BundleImportResult>(paths.identities.importBundle, {
|
||||
version: bundle?.doc.version ?? 0,
|
||||
identities: bundle?.doc.identities ?? [],
|
||||
proxy_id: proxyId || null,
|
||||
dry_run: false,
|
||||
}),
|
||||
{
|
||||
onSuccess: async (result) => {
|
||||
const failed = result.submitted - result.stored
|
||||
toast.success(t('console:identity.restore.done', { count: result.stored }), {
|
||||
description: failed
|
||||
? t('console:identity.restore.skipped', { count: failed })
|
||||
: undefined,
|
||||
})
|
||||
close()
|
||||
await onDone()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.apiError(error, t('console:identity.restore.failed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
/** Read a chosen file and check it is one of ours before arming the button. */
|
||||
const readBundle = async (file: File): Promise<void> => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await file.text())
|
||||
const doc = parsed as IdentityBundleFile
|
||||
if (typeof doc?.version !== 'number' || !Array.isArray(doc.identities)) {
|
||||
throw new Error('shape')
|
||||
}
|
||||
setBundle({ file: file.name, doc })
|
||||
setCookies('')
|
||||
} catch {
|
||||
setBundle(null)
|
||||
toast.error(t('console:identity.restore.unreadable'))
|
||||
}
|
||||
}
|
||||
|
||||
const store = useApiMutation<ImportResponse, void>(
|
||||
() =>
|
||||
apiPost<ImportResponse>(paths.identities.import, {
|
||||
@@ -1558,16 +1711,29 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
|
||||
<Button variant="ghost" onClick={close}>
|
||||
{t('common:action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={store.isPending}
|
||||
disabled={!canStore}
|
||||
onClick={() => {
|
||||
store.mutate()
|
||||
}}
|
||||
>
|
||||
{t('console:identity.import.submit')}
|
||||
</Button>
|
||||
{bundle ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={restore.isPending}
|
||||
disabled={!acknowledged}
|
||||
onClick={() => {
|
||||
restore.mutate()
|
||||
}}
|
||||
>
|
||||
{t('console:identity.restore.submit', { count: bundle.doc.identities.length })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
loading={store.isPending}
|
||||
disabled={!canStore}
|
||||
onClick={() => {
|
||||
store.mutate()
|
||||
}}
|
||||
>
|
||||
{t('console:identity.import.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -1591,6 +1757,49 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
|
||||
<p className="u-xs u-secondary">{t('console:identity.import.risk.dedicated')}</p>
|
||||
</div>
|
||||
|
||||
{/* A file first, because restoring a pool is one action and pasting a
|
||||
jar is a dozen. Everything below is for the paste, and a loaded
|
||||
file hides it rather than leaving two half-filled forms. */}
|
||||
<div className="u-stack-sm">
|
||||
<label className="u-xs u-secondary">{t('console:identity.restore.label')}</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
className="u-xs"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) void readBundle(file)
|
||||
// Cleared so choosing the same file twice fires again, which is
|
||||
// what somebody who just fixed the file expects.
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="u-xs u-muted">{t('console:identity.restore.hint')}</span>
|
||||
</div>
|
||||
|
||||
{bundle ? (
|
||||
<Banner tone="accent" icon={<InfoIcon size={14} />}>
|
||||
<div className="u-stack-sm">
|
||||
<span>
|
||||
{t('console:identity.restore.loaded', {
|
||||
file: bundle.file,
|
||||
count: bundle.doc.identities.length,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setBundle(null)
|
||||
}}
|
||||
>
|
||||
{t('common:action.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</Banner>
|
||||
) : null}
|
||||
|
||||
{bundle ? null : (
|
||||
<Select
|
||||
label={t('console:field.platform')}
|
||||
value={platform}
|
||||
@@ -1599,6 +1808,7 @@ function ImportDialog({ open, proxies, onClose, onDone }: ImportDialogProps) {
|
||||
}}
|
||||
options={PLATFORMS.map((value) => ({ value, label: value }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
label={t('console:identity.import.cookies')}
|
||||
@@ -1828,6 +2038,112 @@ const HISTORY_LIMIT = 200
|
||||
* and the first and last four characters, which is enough to say whether two
|
||||
* identities hold the same jar.
|
||||
*/
|
||||
/**
|
||||
* The masked jar, and a way past the mask.
|
||||
*
|
||||
* Masked by default because the drawer opens on every click and a screenshot
|
||||
* of an admin panel should not be a credential. Revealed on purpose, through a
|
||||
* route of its own that writes an audit line and demands the operator role -
|
||||
* so the disclosure is a decision somebody made and a record somebody can
|
||||
* read, rather than the shell-and-hand-rolled-decrypt it replaces.
|
||||
*
|
||||
* Both copy shapes are here because both are what people actually do next: the
|
||||
* header goes into curl, the JSON goes into another tool.
|
||||
*/
|
||||
function RevealRow({ identity }: { identity: Identity }) {
|
||||
const { t } = useTranslation(['console', 'common'])
|
||||
const toast = useToast()
|
||||
const { copy } = useCopy()
|
||||
const [jar, setJar] = useState<RevealedJar | null>(null)
|
||||
|
||||
const reveal = useApiMutation<RevealedJar, void>(
|
||||
() => apiGet<RevealedJar>(paths.identities.reveal(identity.id)),
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setJar(result)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.apiError(error, t('console:identity.contents.revealFailed'))
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Cleared when the drawer moves to a different identity, or one jar would be
|
||||
// shown under another's name.
|
||||
useEffect(() => {
|
||||
setJar(null)
|
||||
}, [identity.id])
|
||||
|
||||
if (!jar) {
|
||||
return (
|
||||
<div className="u-stack-sm">
|
||||
<p className="u-xs u-muted" style={{ margin: 0 }}>
|
||||
{t('console:identity.contents.maskNote')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={reveal.isPending}
|
||||
onClick={() => {
|
||||
reveal.mutate()
|
||||
}}
|
||||
>
|
||||
{t('console:identity.contents.reveal')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="u-stack-sm">
|
||||
<Banner tone="caution" icon={<AlertIcon size={14} />}>
|
||||
{t('console:identity.contents.revealed')}
|
||||
</Banner>
|
||||
<ul className={styles.cookieList}>
|
||||
{Object.entries(jar.cookies).map(([name, value]) => (
|
||||
<li key={name} className={styles.cookieRow}>
|
||||
<span className={styles.cookieHead}>
|
||||
<span className="u-mono">{name}</span>
|
||||
</span>
|
||||
<CopyableId value={value} wrap />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="u-row u-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void copy(jar.header)
|
||||
toast.success(t('console:identity.contents.copiedHeader'))
|
||||
}}
|
||||
>
|
||||
{t('console:identity.contents.copyHeader')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void copy(JSON.stringify(jar.cookies, null, 2))
|
||||
toast.success(t('console:identity.contents.copiedJson'))
|
||||
}}
|
||||
>
|
||||
{t('console:identity.contents.copyJson')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setJar(null)
|
||||
}}
|
||||
>
|
||||
{t('console:identity.contents.hide')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentityContents({
|
||||
identity,
|
||||
jar,
|
||||
@@ -1929,9 +2245,7 @@ function IdentityContents({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="u-xs u-muted" style={{ margin: 0 }}>
|
||||
{t('console:identity.contents.maskNote')}
|
||||
</p>
|
||||
<RevealRow identity={identity} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user