mirror of
https://github.com/Haidra-Org/AI-Horde.git
synced 2026-11-06 05:17:31 +00:00
A worker fetches one self-contained JSON body, and large payloads stay off the pop response. - With `HORDE_CHAT_MESSAGES_OFFLOAD_ENABLED`, a request whose messages exceed `HORDE_CHAT_MESSAGES_OFFLOAD_BYTES` has the whole array, images inline as JPEG data URLs, stored as one object at submit; the row records `chat_messages_offloaded`, so assignment does not need a store call - The `messages_url` transport: a worker that declares it receives a 120 s signed URL in place of `messages`; a vision worker still declares `url` or `base64` to receive images, and a worker that does not declare `messages_url` receives an offloaded request inline - Compression is a transport encoding of the whole object and the JSON fields are plain; `HORDE_CHAT_MESSAGES_OFFLOAD_GZIP` is off by default, and every object carries `Cache-Control: no-transform` - `R2_CHAT_MESSAGES_BUCKET` holds the objects, defaulting to `R2_SOURCE_IMAGE_BUCKET`; the key suffix `_chat_messages.json` (`CHAT_MESSAGES_KEY_SUFFIX`, `is_chat_messages_key`) routes upload, presigned download and bulk delete to the right bucket, since delete-retry keys are bare - The object is deleted with the request's images, on expiry and on a failed activation - Tests cover the threshold, both encodings, the transport choice, the bucket routing and deletion; the reference, ADR 0032 and the worker how-to describe the fetch cap and the `Content-Encoding` contract - `docs/explanation/chat_requests.md` explains the offload and shows how the pop chooses between `messages_url`, signed image URLs and inline bytes
568 lines
22 KiB
Python
568 lines
22 KiB
Python
# SPDX-FileCopyrightText: 2022 Konstantinos Thoukydidis <mail@dbzer0.com>
|
|
#
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import json
|
|
import os
|
|
from collections.abc import Collection, Mapping
|
|
from contextlib import closing
|
|
from io import BytesIO
|
|
from uuid import uuid4
|
|
|
|
import boto3
|
|
import logfire
|
|
from botocore.config import Config
|
|
from botocore.exceptions import BotoCoreError, ClientError
|
|
from PIL import Image
|
|
|
|
from horde.logger import logger
|
|
|
|
r2_transient_account = os.getenv(
|
|
"R2_TRANSIENT_ACCOUNT",
|
|
"https://a223539ccf6caa2d76459c9727d276e6.r2.cloudflarestorage.com",
|
|
)
|
|
r2_permanent_account = os.getenv(
|
|
"R2_PERMANENT_ACCOUNT",
|
|
"https://a223539ccf6caa2d76459c9727d276e6.r2.cloudflarestorage.com",
|
|
)
|
|
r2_transient_bucket = os.getenv("R2_TRANSIENT_BUCKET", "stable-horde")
|
|
r2_permanent_bucket = os.getenv("R2_PERMANENT_BUCKET", "stable-horde")
|
|
r2_source_image_bucket = os.getenv("R2_SOURCE_IMAGE_BUCKET", "stable-horde-source-images")
|
|
r2_chat_messages_bucket = os.getenv("R2_CHAT_MESSAGES_BUCKET", r2_source_image_bucket)
|
|
"""The bucket of chat requests' offloaded messages objects. It defaults to the source image bucket, beside the
|
|
requests' images, so an existing layout is unchanged unless a deployment keeps text payloads in a bucket of their
|
|
own."""
|
|
|
|
s3_client = boto3.client("s3", endpoint_url=r2_transient_account)
|
|
s3_client_shared = boto3.client(
|
|
"s3",
|
|
endpoint_url=r2_permanent_account,
|
|
aws_access_key_id=os.getenv("SHARED_AWS_ACCESS_ID"),
|
|
aws_secret_access_key=os.getenv("SHARED_AWS_ACCESS_KEY"),
|
|
)
|
|
old_r2 = boto3.client(
|
|
"s3",
|
|
endpoint_url="https://eu2.contabostorage.com",
|
|
aws_access_key_id=os.getenv("OLD_AWS_ACCESS_KEY_ID"),
|
|
aws_secret_access_key=os.getenv("OLD_AWS_SECRET_ACCESS_KEY"),
|
|
)
|
|
|
|
# Lists shared bucket contents
|
|
# for key in s3_client_shared.list_objects(Bucket=r2_transient_bucket)['Contents']:
|
|
# logger.debug(key['Key'])
|
|
|
|
|
|
@logger.catch(reraise=True)
|
|
def generate_presigned_url(client, client_method, method_parameters, expires_in=1800):
|
|
"""
|
|
Generate a presigned Amazon S3 URL that can be used to perform an action.
|
|
|
|
:param s3_client: A Boto3 Amazon S3 client.
|
|
:param client_method: The name of the client method that the URL performs.
|
|
:param method_parameters: The parameters of the specified client method.
|
|
:param expires_in: The number of seconds the presigned URL is valid for.
|
|
:return: The presigned URL.
|
|
"""
|
|
try:
|
|
url = client.generate_presigned_url(ClientMethod=client_method, Params=method_parameters, ExpiresIn=expires_in)
|
|
except ClientError:
|
|
logger.exception(
|
|
f"Couldn't get a presigned URL for client method {client_method}",
|
|
)
|
|
raise
|
|
# logger.debug(url)
|
|
return url
|
|
|
|
|
|
def generate_procgen_upload_url(procgen_id, shared=False):
|
|
client = s3_client
|
|
if shared:
|
|
client = s3_client_shared
|
|
return generate_presigned_url(
|
|
client=client,
|
|
client_method="put_object",
|
|
method_parameters={"Bucket": r2_transient_bucket, "Key": f"{procgen_id}.webp"},
|
|
expires_in=1800,
|
|
)
|
|
|
|
|
|
def generate_procgen_download_url(procgen_id, shared=False):
|
|
client = s3_client
|
|
if shared:
|
|
client = s3_client_shared
|
|
# if not file_exists(client, f"{procgen_id}.webp"):
|
|
# client = old_r2
|
|
return generate_presigned_url(
|
|
client=client,
|
|
client_method="get_object",
|
|
method_parameters={"Bucket": r2_transient_bucket, "Key": f"{procgen_id}.webp"},
|
|
expires_in=1800,
|
|
)
|
|
|
|
|
|
def delete_procgen_image(procgen_id):
|
|
s3_client.delete_object(Bucket=r2_transient_bucket, Key=f"{procgen_id}.webp")
|
|
|
|
|
|
def delete_source_image(source_image_uuid):
|
|
s3_client.delete_object(Bucket=r2_source_image_bucket, Key=f"{source_image_uuid}.webp")
|
|
|
|
|
|
def upload_image(client, bucket, image, filename, quality=100):
|
|
with logfire.span("horde.r2.upload_image", bucket=bucket, filename=filename):
|
|
return _upload_image(client, bucket, image, filename, quality)
|
|
|
|
|
|
def _upload_image(client, bucket, image, filename, quality=100):
|
|
image_io = BytesIO()
|
|
image.save(image_io, format="WebP", quality=quality, exact=True)
|
|
image_io.seek(0)
|
|
try:
|
|
client.upload_fileobj(image_io, bucket, filename)
|
|
except ClientError as err:
|
|
logger.error(f"Error encountered while uploading {filename}: {err}")
|
|
return False
|
|
return generate_img_download_url(filename, r2_source_image_bucket)
|
|
|
|
|
|
def download_image(client, bucket, key):
|
|
with logfire.span("horde.r2.download_image", bucket=bucket, key=key):
|
|
return _download_image(client, bucket, key)
|
|
|
|
|
|
def _download_image(client, bucket, key):
|
|
try:
|
|
response = client.get_object(Bucket=bucket, Key=key)
|
|
img = response["Body"].read()
|
|
return Image.open(BytesIO(img))
|
|
except ClientError as e:
|
|
logger.error(f"Error encountered while downloading {key}: {e}")
|
|
return None
|
|
|
|
|
|
def download_procgen_image(procgen_id, shared=False):
|
|
if shared:
|
|
return download_image(s3_client_shared, r2_permanent_bucket, f"{procgen_id}.webp")
|
|
return download_image(s3_client, r2_transient_bucket, f"{procgen_id}.webp")
|
|
|
|
|
|
def download_source_image(wp_id, shared=False):
|
|
return download_image(s3_client, r2_source_image_bucket, f"{wp_id}_src.webp")
|
|
|
|
|
|
def download_source_mask(wp_id, shared=False):
|
|
return download_image(s3_client, r2_source_image_bucket, f"{wp_id}_msk.webp")
|
|
|
|
|
|
def upload_source_image(image, filename):
|
|
return upload_image(s3_client, r2_source_image_bucket, image, filename, quality=50)
|
|
|
|
|
|
CHAT_IMAGE_DELETE_BATCH_SIZE = 1000
|
|
"""The most keys one bulk delete of chat images names, which is the S3 ``DeleteObjects`` limit."""
|
|
|
|
CHAT_MESSAGES_CACHE_CONTROL = "no-transform"
|
|
"""The ``Cache-Control`` an offloaded messages object is stored with, gzipped or not. Cloudflare R2 may decompress an
|
|
object stored with ``Content-Encoding: gzip`` on the fly and drop the header unless the object carries
|
|
``no-transform``; a worker honours whichever encoding it is served, and this keeps the stored one."""
|
|
|
|
CHAT_MESSAGES_KEY_SUFFIX = "_chat_messages.json"
|
|
"""The suffix of every offloaded messages object key, which
|
|
[`build_chat_messages_key`][horde.classes.kobold.chat_request.build_chat_messages_key] appends to the request id."""
|
|
|
|
|
|
def is_chat_messages_key(key: str) -> bool:
|
|
"""Return whether an object key identifies a chat request's offloaded messages rather than one of its images.
|
|
|
|
The suffix is the object's declared type: every messages key ends with
|
|
[`CHAT_MESSAGES_KEY_SUFFIX`][horde.r2.CHAT_MESSAGES_KEY_SUFFIX], and every image key
|
|
[`build_chat_image_key`][horde.classes.kobold.chat_request.build_chat_image_key] builds ends with ``.jpg``. The
|
|
keys queued for a delete retry are bare keys, so the bucket an object is in is read from its key.
|
|
|
|
Args:
|
|
key: The object key.
|
|
|
|
Returns:
|
|
Whether the key is an offloaded messages key.
|
|
"""
|
|
return key.endswith(CHAT_MESSAGES_KEY_SUFFIX)
|
|
|
|
|
|
def _chat_object_bucket(key: str) -> str:
|
|
"""Return the bucket a chat request's stored object is in: the messages bucket or the source image bucket.
|
|
|
|
Args:
|
|
key: The object key.
|
|
|
|
Returns:
|
|
The bucket name.
|
|
"""
|
|
if is_chat_messages_key(key):
|
|
return r2_chat_messages_bucket
|
|
return r2_source_image_bucket
|
|
|
|
|
|
def upload_chat_image(jpeg_bytes: bytes, key: str) -> None:
|
|
"""Store one chat request's image in the source image bucket.
|
|
|
|
Args:
|
|
jpeg_bytes: The normalised image, encoded as JPEG.
|
|
key: The object key.
|
|
|
|
Raises:
|
|
botocore.exceptions.ClientError: If the store refuses the write.
|
|
botocore.exceptions.BotoCoreError: If the store cannot be reached.
|
|
"""
|
|
with logfire.span("horde.r2.upload_chat_image", key=key, size=len(jpeg_bytes)):
|
|
s3_client.put_object(Bucket=r2_source_image_bucket, Key=key, Body=jpeg_bytes, ContentType="image/jpeg")
|
|
|
|
|
|
def upload_chat_messages(body: bytes, key: str, *, gzipped: bool) -> None:
|
|
"""Store a chat request's offloaded ``messages`` array in the ``R2_CHAT_MESSAGES_BUCKET`` bucket.
|
|
|
|
The bucket is the source image bucket, beside the request's images, unless a deployment sets it.
|
|
|
|
The object is the plain JSON array, stored with ``Content-Encoding: gzip`` when ``gzipped`` is set and with
|
|
[`CHAT_MESSAGES_CACHE_CONTROL`][horde.r2.CHAT_MESSAGES_CACHE_CONTROL] either way, so an edge that compresses
|
|
downloads does not change what is stored.
|
|
|
|
Args:
|
|
body: The UTF-8 JSON of the messages, gzipped when ``gzipped`` is set.
|
|
key: The object key.
|
|
gzipped: Whether ``body`` is gzip compressed.
|
|
|
|
Raises:
|
|
botocore.exceptions.ClientError: If the store refuses the write.
|
|
botocore.exceptions.BotoCoreError: If the store cannot be reached.
|
|
"""
|
|
object_arguments = {
|
|
"Bucket": r2_chat_messages_bucket,
|
|
"Key": key,
|
|
"Body": body,
|
|
"ContentType": "application/json",
|
|
"CacheControl": CHAT_MESSAGES_CACHE_CONTROL,
|
|
}
|
|
if gzipped:
|
|
object_arguments["ContentEncoding"] = "gzip"
|
|
with logfire.span("horde.r2.upload_chat_messages", key=key, size=len(body), gzipped=gzipped):
|
|
s3_client.put_object(**object_arguments)
|
|
|
|
|
|
def download_chat_image(filename: str) -> bytes:
|
|
"""Return the stored bytes of a chat request's image, without decoding them.
|
|
|
|
Args:
|
|
filename: The object key.
|
|
|
|
Returns:
|
|
The JPEG bytes.
|
|
|
|
Raises:
|
|
botocore.exceptions.ClientError: If the object is missing or the store refuses the read.
|
|
botocore.exceptions.BotoCoreError: If the store cannot be reached.
|
|
"""
|
|
with logfire.span("horde.r2.download_chat_image", key=filename):
|
|
response = s3_client.get_object(Bucket=r2_source_image_bucket, Key=filename)
|
|
with closing(response["Body"]) as body:
|
|
return body.read()
|
|
|
|
|
|
def generate_chat_image_download_url(filename: str, *, expires_in: int) -> str:
|
|
"""Return a presigned download URL for a chat request's stored image or offloaded messages.
|
|
|
|
The URL references the bucket [`is_chat_messages_key`][horde.r2.is_chat_messages_key] places the key in. Signing is
|
|
local, so this makes no request to the store.
|
|
|
|
Args:
|
|
filename: The object key.
|
|
expires_in: How many seconds the URL is valid for.
|
|
|
|
Returns:
|
|
The URL.
|
|
"""
|
|
return generate_presigned_url(
|
|
s3_client,
|
|
"get_object",
|
|
{"Bucket": _chat_object_bucket(filename), "Key": filename},
|
|
expires_in=expires_in,
|
|
)
|
|
|
|
|
|
def delete_chat_images(keys: Collection[str]) -> set[str]:
|
|
"""Delete chat images and offloaded chat messages, in bulk.
|
|
|
|
Each key is deleted from the bucket [`is_chat_messages_key`][horde.r2.is_chat_messages_key] places it in, one
|
|
batch of requests per bucket. A key that does not exist counts as deleted.
|
|
|
|
Args:
|
|
keys: The object keys.
|
|
|
|
Returns:
|
|
The keys the store reported it could not delete.
|
|
|
|
Raises:
|
|
botocore.exceptions.ClientError: If the store refuses a whole request.
|
|
botocore.exceptions.BotoCoreError: If the store cannot be reached.
|
|
"""
|
|
keys_by_bucket: dict[str, set[str]] = {}
|
|
for key in keys:
|
|
keys_by_bucket.setdefault(_chat_object_bucket(key), set()).add(key)
|
|
failed_keys: set[str] = set()
|
|
for bucket, bucket_keys in sorted(keys_by_bucket.items()):
|
|
sorted_keys = sorted(bucket_keys)
|
|
for start in range(0, len(sorted_keys), CHAT_IMAGE_DELETE_BATCH_SIZE):
|
|
batch_keys = sorted_keys[start : start + CHAT_IMAGE_DELETE_BATCH_SIZE]
|
|
with logfire.span("horde.r2.delete_chat_images", bucket=bucket, count=len(batch_keys)):
|
|
response = s3_client.delete_objects(
|
|
Bucket=bucket,
|
|
Delete={"Objects": [{"Key": key} for key in batch_keys], "Quiet": True},
|
|
)
|
|
for error in response.get("Errors", []):
|
|
if error.get("Code") != "NoSuchKey" and error.get("Key") is not None:
|
|
failed_keys.add(error["Key"])
|
|
return failed_keys
|
|
|
|
|
|
def upload_generated_image(image, filename):
|
|
return upload_image(
|
|
s3_client,
|
|
r2_transient_bucket,
|
|
image,
|
|
filename,
|
|
quality=95,
|
|
)
|
|
|
|
|
|
def upload_shared_generated_image(image, filename):
|
|
return upload_image(
|
|
s3_client_shared,
|
|
r2_permanent_bucket,
|
|
image,
|
|
filename,
|
|
quality=95,
|
|
)
|
|
|
|
|
|
def upload_shared_metadata(filename):
|
|
try:
|
|
s3_client_shared.upload_file(filename, r2_permanent_bucket, filename)
|
|
except ClientError as e:
|
|
logger.error(f"Error encountered while uploading metadata {filename}: {e}")
|
|
return False
|
|
|
|
|
|
def upload_prompt(prompt_dict):
|
|
with logfire.span("horde.r2.upload_prompt"):
|
|
return _upload_prompt(prompt_dict)
|
|
|
|
|
|
def _upload_prompt(prompt_dict):
|
|
filename = f"{uuid4()}.json"
|
|
json_object = json.dumps(prompt_dict, indent=4)
|
|
# Writing to sample.json
|
|
with open(filename, "w") as f:
|
|
f.write(json_object)
|
|
try:
|
|
s3_client.upload_file(filename, "prompts", filename)
|
|
os.remove(filename)
|
|
except Exception as err:
|
|
logger.error(f"Error encountered while uploading prompt {filename}: {err}")
|
|
os.remove(filename)
|
|
return False
|
|
|
|
|
|
def generate_img_download_url(filename, bucket=r2_transient_bucket):
|
|
return generate_presigned_url(s3_client, "get_object", {"Bucket": bucket, "Key": filename}, 1800)
|
|
|
|
|
|
def generate_img_upload_url(filename, bucket=r2_transient_bucket):
|
|
return generate_presigned_url(s3_client, "put_object", {"Bucket": bucket, "Key": filename}, 1800)
|
|
|
|
|
|
def generate_uuid_img_upload_url(img_uuid, imgtype):
|
|
return generate_img_upload_url(f"{img_uuid}.{imgtype}")
|
|
|
|
|
|
def generate_uuid_img_download_url(img_uuid, imgtype):
|
|
return generate_img_download_url(f"{img_uuid}.{imgtype}")
|
|
|
|
|
|
def check_file(client, bucket, filename):
|
|
try:
|
|
return client.head_object(Bucket=bucket, Key=filename)
|
|
except ClientError as e:
|
|
return int(e.response["Error"]["Code"]) != 404
|
|
|
|
|
|
def check_shared_image(filename):
|
|
return isinstance(check_file(s3_client_shared, r2_transient_bucket, filename), dict)
|
|
|
|
|
|
def file_exists(client, bucket, filename):
|
|
# If the return of check_file is an int, it means it encountered an error
|
|
return not isinstance(check_file(client, bucket, filename), int)
|
|
|
|
|
|
# Moderation evidence text. These objects live in their own bucket under EVIDENCE_OBJECT_PREFIX and share no client,
|
|
# bucket or key scheme with the image and prompt objects above.
|
|
|
|
EVIDENCE_ACCOUNT_ENV: str = "R2_EVIDENCE_ACCOUNT"
|
|
"""The environment variable holding the evidence store's S3 endpoint URL."""
|
|
EVIDENCE_BUCKET_ENV: str = "R2_EVIDENCE_BUCKET"
|
|
"""The environment variable holding the evidence bucket name."""
|
|
EVIDENCE_ACCESS_KEY_ID_ENV: str = "EVIDENCE_AWS_ACCESS_KEY_ID"
|
|
"""The environment variable holding the access key id of the token scoped to the evidence bucket."""
|
|
EVIDENCE_SECRET_ACCESS_KEY_ENV: str = "EVIDENCE_AWS_SECRET_ACCESS_KEY"
|
|
"""The environment variable holding the secret of the token scoped to the evidence bucket."""
|
|
EVIDENCE_REGION_ENV: str = "R2_EVIDENCE_REGION"
|
|
"""The environment variable holding the signing region of the evidence store.
|
|
|
|
R2 signs with the region ``auto``, the default. Another S3-compatible store rejects a signature for a region it does not
|
|
serve, so a deployment on one sets its region here.
|
|
"""
|
|
EVIDENCE_OBJECT_PREFIX: str = "evidence/"
|
|
"""The key prefix of every evidence object; ``evidence_object_key`` builds keys only under it."""
|
|
EVIDENCE_CONNECT_TIMEOUT_SECONDS: int = 3
|
|
"""How long an evidence request waits to connect.
|
|
|
|
Only the background upload and retention jobs call the store, and a short timeout lets a tick give up on an
|
|
unreachable store and retry on the next one.
|
|
"""
|
|
EVIDENCE_READ_TIMEOUT_SECONDS: int = 10
|
|
"""How long an evidence request waits for a response; an object is at most a few tens of kilobytes."""
|
|
EVIDENCE_MAX_ATTEMPTS: int = 2
|
|
"""The attempts per evidence request, including the first; the jobs retry anything left on their next tick."""
|
|
EVIDENCE_MAX_POOL_CONNECTIONS: int = 8
|
|
"""The connections the evidence client keeps; one job uploads at a time, so a small pool suffices."""
|
|
EVIDENCE_DELETE_BATCH_SIZE: int = 1000
|
|
"""The most keys one ``DeleteObjects`` request takes, the S3 limit."""
|
|
EVIDENCE_TEXT_URL_SECONDS: int = 600
|
|
"""How long a moderator's signed evidence text URL stays valid.
|
|
|
|
Ten minutes covers opening a listing page and reading its events, while a leaked URL stops working soon after.
|
|
"""
|
|
|
|
|
|
def build_evidence_client(environ: Mapping[str, str] = os.environ):
|
|
"""Return the evidence store client, or None unless the endpoint, bucket, access key id and secret are all set.
|
|
|
|
Without credentials boto3 falls back to the default credential chain, which the image clients use, so a partial
|
|
configuration builds no client rather than signing evidence requests with another token. Logs one warning naming
|
|
the unset variables.
|
|
|
|
Args:
|
|
environ: Variables to read; the process environment by default.
|
|
"""
|
|
required = (
|
|
EVIDENCE_ACCOUNT_ENV,
|
|
EVIDENCE_BUCKET_ENV,
|
|
EVIDENCE_ACCESS_KEY_ID_ENV,
|
|
EVIDENCE_SECRET_ACCESS_KEY_ENV,
|
|
)
|
|
missing = [variable for variable in required if not environ.get(variable)]
|
|
if missing:
|
|
logger.warning(f"{', '.join(missing)} unset; moderation evidence text stays in the database")
|
|
return None
|
|
return boto3.client(
|
|
"s3",
|
|
endpoint_url=environ[EVIDENCE_ACCOUNT_ENV],
|
|
aws_access_key_id=environ[EVIDENCE_ACCESS_KEY_ID_ENV],
|
|
aws_secret_access_key=environ[EVIDENCE_SECRET_ACCESS_KEY_ENV],
|
|
region_name=environ.get(EVIDENCE_REGION_ENV) or "auto",
|
|
config=Config(
|
|
signature_version="s3v4",
|
|
connect_timeout=EVIDENCE_CONNECT_TIMEOUT_SECONDS,
|
|
read_timeout=EVIDENCE_READ_TIMEOUT_SECONDS,
|
|
retries={"max_attempts": EVIDENCE_MAX_ATTEMPTS},
|
|
max_pool_connections=EVIDENCE_MAX_POOL_CONNECTIONS,
|
|
),
|
|
)
|
|
|
|
|
|
r2_evidence_bucket: str | None = os.getenv(EVIDENCE_BUCKET_ENV) or None
|
|
"""The evidence bucket, or None when unset."""
|
|
evidence_client = build_evidence_client()
|
|
"""The evidence store client, or None when any of its endpoint, bucket and credential variables is unset.
|
|
|
|
Without it, prompt text stays in the database (``pending``), the upload job idles and the listing returns no text URL.
|
|
"""
|
|
|
|
|
|
def evidence_object_key(event_id: int) -> str:
|
|
"""Return the object key of one event's evidence text; the only way an evidence key is built."""
|
|
return f"{EVIDENCE_OBJECT_PREFIX}{int(event_id)}.json"
|
|
|
|
|
|
def put_evidence_text(event_id: int, body: bytes) -> bool:
|
|
"""Store one event's canonical evidence text object.
|
|
|
|
Returns:
|
|
Whether the store accepted the object. False when no client is configured or the request failed; the failure
|
|
is logged by type only, never with the body.
|
|
"""
|
|
if evidence_client is None:
|
|
return False
|
|
try:
|
|
evidence_client.put_object(
|
|
Bucket=r2_evidence_bucket,
|
|
Key=evidence_object_key(event_id),
|
|
Body=body,
|
|
ContentType="application/json",
|
|
)
|
|
except (BotoCoreError, ClientError) as error:
|
|
logger.warning(f"Evidence text upload failed ({type(error).__name__})")
|
|
return False
|
|
return True
|
|
|
|
|
|
def delete_evidence_texts(event_ids: Collection[int]) -> set[int]:
|
|
"""Delete the evidence text objects of the given events.
|
|
|
|
A key that does not exist counts as deleted, so deleting an event that never had an object succeeds.
|
|
|
|
Returns:
|
|
The IDs whose object could not be deleted; every ID when no client is configured.
|
|
|
|
Raises:
|
|
BotoCoreError: The store could not be reached.
|
|
ClientError: The store refused a whole request.
|
|
"""
|
|
ids = sorted({int(event_id) for event_id in event_ids})
|
|
if evidence_client is None:
|
|
return set(ids)
|
|
failed: set[int] = set()
|
|
for start in range(0, len(ids), EVIDENCE_DELETE_BATCH_SIZE):
|
|
chunk = {evidence_object_key(event_id): event_id for event_id in ids[start : start + EVIDENCE_DELETE_BATCH_SIZE]}
|
|
response = evidence_client.delete_objects(
|
|
Bucket=r2_evidence_bucket,
|
|
Delete={"Objects": [{"Key": key} for key in chunk], "Quiet": True},
|
|
)
|
|
for error in response.get("Errors", []):
|
|
if error.get("Code") == "NoSuchKey":
|
|
continue
|
|
event_id = chunk.get(error.get("Key", ""))
|
|
if event_id is not None:
|
|
failed.add(event_id)
|
|
return failed
|
|
|
|
|
|
def evidence_text_url(event_id: int) -> str | None:
|
|
"""Return a short-lived signed URL to one event's evidence text, or None when no client is configured.
|
|
|
|
Signing is local, so this makes no request. The response is marked private and uncacheable, and served as UTF-8
|
|
JSON.
|
|
"""
|
|
if evidence_client is None:
|
|
return None
|
|
return generate_presigned_url(
|
|
evidence_client,
|
|
"get_object",
|
|
{
|
|
"Bucket": r2_evidence_bucket,
|
|
"Key": evidence_object_key(event_id),
|
|
"ResponseCacheControl": "private, no-store",
|
|
"ResponseContentType": "application/json; charset=utf-8",
|
|
},
|
|
expires_in=EVIDENCE_TEXT_URL_SECONDS,
|
|
)
|