From d7d4e8da0368adac7b85bde6f5ee992b35fb4b29 Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 3 Sep 2026 22:04:00 +0200 Subject: [PATCH] refactor(media-proxy): split into modules and harden streaming (#2437) --- .devcontainer/Dockerfile | 28 + .github/workflows/tests.yaml | 44 +- Cargo.lock | 1 + fluxer_media_proxy/Cargo.toml | 1 + fluxer_media_proxy/Dockerfile | 42 +- fluxer_media_proxy/benches/core.rs | 77 +- fluxer_media_proxy/build.rs | 118 +- fluxer_media_proxy/examples/bench_gif.rs | 23 +- fluxer_media_proxy/examples/test_anim.rs | 23 +- fluxer_media_proxy/fuzz/Cargo.lock | 41 +- fluxer_media_proxy/fuzz/build.rs | 30 + .../fuzz/fuzz_targets/parsers.rs | 11 +- .../fuzz_targets/signing_external_path.rs | 6 + fluxer_media_proxy/src/aggregate_error.rs | 147 + fluxer_media_proxy/src/asset_hash.rs | 42 + fluxer_media_proxy/src/asset_size.rs | 62 + fluxer_media_proxy/src/aws_sigv4.rs | 189 + fluxer_media_proxy/src/bunny_ip_gate.rs | 109 + fluxer_media_proxy/src/byte_budget.rs | 308 + fluxer_media_proxy/src/byte_cache.rs | 382 +- fluxer_media_proxy/src/cli.rs | 8 +- fluxer_media_proxy/src/coalescer.rs | 338 - fluxer_media_proxy/src/coalescer/mod.rs | 257 + fluxer_media_proxy/src/coalescer/tests.rs | 411 ++ fluxer_media_proxy/src/codec.rs | 6 +- fluxer_media_proxy/src/component_tests.rs | 470 ++ fluxer_media_proxy/src/config.rs | 862 --- fluxer_media_proxy/src/config/mod.rs | 389 ++ fluxer_media_proxy/src/config/parse.rs | 239 + fluxer_media_proxy/src/config/tests/mod.rs | 447 ++ .../src/config/tests/s3_read.rs | 145 + fluxer_media_proxy/src/constants.rs | 47 +- fluxer_media_proxy/src/disposition.rs | 207 +- fluxer_media_proxy/src/external_path.rs | 128 +- fluxer_media_proxy/src/http_client.rs | 234 - fluxer_media_proxy/src/http_client/mod.rs | 277 + fluxer_media_proxy/src/http_client/retry.rs | 259 + fluxer_media_proxy/src/http_headers.rs | 216 + fluxer_media_proxy/src/image_quality.rs | 159 + fluxer_media_proxy/src/image_transform.rs | 206 + fluxer_media_proxy/src/lib.rs | 25 +- fluxer_media_proxy/src/media_limits.rs | 187 + fluxer_media_proxy/src/media_process.rs | 3136 ---------- .../src/media_process/animated_transform.rs | 262 + fluxer_media_proxy/src/media_process/apng.rs | 549 ++ .../src/media_process/av_metadata.rs | 229 + .../src/media_process/encoding.rs | 364 ++ .../src/media_process/image_probe.rs | 169 + .../src/media_process/loaded_image.rs | 422 ++ .../src/media_process/metadata.rs | 302 + fluxer_media_proxy/src/media_process/mod.rs | 211 + .../src/media_process/native_runtime.rs | 90 + .../src/media_process/nsfw_processing/mod.rs | 424 ++ .../media_process/nsfw_processing/tests.rs | 228 + .../src/media_process/placeholder.rs | 75 + .../src/media_process/streaming_encoder.rs | 134 + .../src/media_process/tests/animated_apng.rs | 327 + .../src/media_process/tests/animated_gif.rs | 400 ++ .../src/media_process/tests/animated_webp.rs | 305 + .../src/media_process/tests/encoding.rs | 128 + .../src/media_process/tests/fixtures.rs | 148 + .../src/media_process/tests/heif.rs | 246 + .../src/media_process/tests/metadata.rs | 239 + .../src/media_process/tests/mod.rs | 12 + .../src/media_process/tests/nsfw.rs | 76 + .../src/media_process/tests/transform.rs | 105 + .../src/media_process/tests/video.rs | 374 ++ .../src/media_process/transform.rs | 443 ++ .../src/media_process/transform_plan.rs | 144 + .../src/media_process/video_thumbnail.rs | 125 + fluxer_media_proxy/src/media_type.rs | 254 + fluxer_media_proxy/src/metrics.rs | 556 -- fluxer_media_proxy/src/metrics/cache.rs | 70 + fluxer_media_proxy/src/metrics/external.rs | 40 + fluxer_media_proxy/src/metrics/histogram.rs | 78 + fluxer_media_proxy/src/metrics/http_client.rs | 38 + fluxer_media_proxy/src/metrics/mod.rs | 133 + fluxer_media_proxy/src/metrics/nsfw.rs | 32 + fluxer_media_proxy/src/metrics/relay.rs | 40 + fluxer_media_proxy/src/metrics/rendering.rs | 334 + fluxer_media_proxy/src/metrics/request.rs | 101 + fluxer_media_proxy/src/metrics/storage.rs | 32 + fluxer_media_proxy/src/metrics/tests.rs | 206 + fluxer_media_proxy/src/metrics/transform.rs | 128 + fluxer_media_proxy/src/mime.rs | 859 --- .../src/mime/image_containers.rs | 367 ++ fluxer_media_proxy/src/mime/iso_bmff.rs | 192 + fluxer_media_proxy/src/mime/mod.rs | 330 + fluxer_media_proxy/src/mime/registry.rs | 44 + .../src/mime/stream_containers.rs | 66 + fluxer_media_proxy/src/mime/tests.rs | 382 ++ fluxer_media_proxy/src/native.rs | 312 - fluxer_media_proxy/src/native/buffer.rs | 123 + fluxer_media_proxy/src/native/delay_array.rs | 56 + fluxer_media_proxy/src/native/mod.rs | 470 ++ .../src/native/nsfw_frame_output.rs | 72 + .../src/native_shim/animated_webp.c | 519 ++ .../src/native_shim/animation_decode.c | 1012 +++ .../src/native_shim/apng_validation.c | 523 ++ .../src/native_shim/av_frame_rgba.c | 461 ++ fluxer_media_proxy/src/native_shim/av_input.c | 316 + .../src/native_shim/gif_filter.c | 184 + .../src/native_shim/gif_timing.c | 288 + .../src/native_shim/gif_transform.c | 990 +++ .../src/native_shim/gif_validation.c | 176 + .../src/native_shim/heif_decode.c | 731 +++ .../src/native_shim/heif_pixels.c | 1050 ++++ .../src/native_shim/native_shim_internal.h | 194 + .../src/native_shim/nsfw_frame.c | 958 +++ .../src/native_shim/nsfw_webp.c | 272 + .../src/native_shim/video_frame.c | 855 +++ .../src/native_shim/vips_image.c | 1044 ++++ fluxer_media_proxy/src/nsfw.rs | 562 +- fluxer_media_proxy/src/output_format.rs | 157 +- fluxer_media_proxy/src/percent_decode.rs | 120 + .../src/policy_tests/adversarial.rs | 103 + .../src/policy_tests/headers.rs | 78 + fluxer_media_proxy/src/policy_tests/mod.rs | 5 + .../src/policy_tests/parsers.rs | 83 + fluxer_media_proxy/src/public_net_policy.rs | 450 -- .../src/public_net_policy/ip_tables.rs | 140 + .../src/public_net_policy/mod.rs | 258 + .../src/public_net_policy/resolver.rs | 68 + .../src/public_net_policy/tests.rs | 281 + fluxer_media_proxy/src/query.rs | 116 + fluxer_media_proxy/src/range.rs | 355 +- fluxer_media_proxy/src/request_log/failure.rs | 29 + .../{request_log.rs => request_log/mod.rs} | 351 +- fluxer_media_proxy/src/request_log/stage.rs | 122 + fluxer_media_proxy/src/response_body_limit.rs | 30 + fluxer_media_proxy/src/secret.rs | 110 + fluxer_media_proxy/src/server.rs | 5541 ----------------- fluxer_media_proxy/src/server/asset_path.rs | 382 ++ .../src/server/download_stream.rs | 318 + .../src/server/external/byte_range.rs | 147 + .../src/server/external/fetch.rs | 462 ++ fluxer_media_proxy/src/server/external/mod.rs | 529 ++ .../src/server/external/response.rs | 180 + .../src/server/external/tests/cache_policy.rs | 136 + .../src/server/external/tests/mod.rs | 931 +++ .../src/server/format_policy.rs | 599 ++ .../src/server/media_operations/failure.rs | 115 + .../src/server/media_operations/input.rs | 134 + .../src/server/media_operations/mod.rs | 197 + fluxer_media_proxy/src/server/middleware.rs | 232 + fluxer_media_proxy/src/server/mod.rs | 19 + .../src/server/native_task_executor.rs | 405 ++ fluxer_media_proxy/src/server/params.rs | 188 + fluxer_media_proxy/src/server/relay/body.rs | 242 + fluxer_media_proxy/src/server/relay/mod.rs | 5 + fluxer_media_proxy/src/server/relay/tests.rs | 529 ++ .../src/server/response/error.rs | 246 + fluxer_media_proxy/src/server/response/mod.rs | 183 + .../src/server/routes/dispatch.rs | 306 + .../src/server/routes/internal.rs | 408 ++ fluxer_media_proxy/src/server/routes/mod.rs | 6 + fluxer_media_proxy/src/server/routes/ops.rs | 69 + fluxer_media_proxy/src/server/routes/relay.rs | 266 + fluxer_media_proxy/src/server/runtime.rs | 230 + fluxer_media_proxy/src/server/state.rs | 130 + fluxer_media_proxy/src/server/stored/mod.rs | 361 ++ .../src/server/stored/passthrough.rs | 365 ++ .../src/server/stored/response.rs | 176 + fluxer_media_proxy/src/server/stored/tests.rs | 575 ++ .../src/server/transform/cache.rs | 85 + .../src/server/transform/cache_key.rs | 183 + .../src/server/transform/execution.rs | 202 + .../src/server/transform/mod.rs | 710 +++ .../src/server/transform/parameters.rs | 172 + .../src/server/transform/tests.rs | 201 + fluxer_media_proxy/src/signing.rs | 64 + fluxer_media_proxy/src/spool.rs | 124 + fluxer_media_proxy/src/srgb_profile.h | 9 - fluxer_media_proxy/src/storage.rs | 1765 ------ fluxer_media_proxy/src/storage/identity.rs | 102 + fluxer_media_proxy/src/storage/keys.rs | 42 + fluxer_media_proxy/src/storage/local.rs | 281 + fluxer_media_proxy/src/storage/mod.rs | 283 + fluxer_media_proxy/src/storage/object_read.rs | 142 + .../src/storage/object_stream.rs | 74 + fluxer_media_proxy/src/storage/relay_body.rs | 80 + .../src/storage/response_body.rs | 614 ++ fluxer_media_proxy/src/storage/s3.rs | 499 ++ fluxer_media_proxy/src/storage/s3_endpoint.rs | 150 + fluxer_media_proxy/src/storage/source_read.rs | 212 + fluxer_media_proxy/src/storage/tests/keys.rs | 57 + fluxer_media_proxy/src/storage/tests/local.rs | 140 + fluxer_media_proxy/src/storage/tests/mod.rs | 292 + fluxer_media_proxy/src/storage/tests/relay.rs | 82 + .../src/storage/tests/response_body.rs | 284 + fluxer_media_proxy/src/storage/tests/s3.rs | 352 ++ .../src/storage/tests/s3_read_endpoint.rs | 419 ++ .../src/test_fixtures/adversarial.rs | 111 + .../src/test_fixtures/ffmpeg_cli.rs | 185 + .../src/test_fixtures/images.rs | 318 + fluxer_media_proxy/src/test_fixtures/media.rs | 83 + fluxer_media_proxy/src/test_fixtures/mod.rs | 21 + fluxer_media_proxy/src/tests/mod.rs | 5 + fluxer_media_proxy/src/tests/native_stack.rs | 273 + fluxer_media_proxy/src/tests/provisioning.rs | 514 ++ .../src/tests/source_hygiene.rs | 194 + fluxer_media_proxy/src/thumbhash.rs | 37 + fluxer_media_proxy/src/timed_semaphore.rs | 163 +- fluxer_media_proxy/src/transform_cache.rs | 240 + fluxer_media_proxy/src/upload_relay.rs | 388 -- fluxer_media_proxy/src/upload_relay/mod.rs | 91 + fluxer_media_proxy/src/upload_relay/target.rs | 219 + fluxer_media_proxy/src/upload_relay/tests.rs | 227 + fluxer_media_proxy/src/upload_relay/token.rs | 149 + fluxer_media_proxy/src/vips_shim.c | 2984 --------- fluxer_media_proxy/src/vips_shim.h | 257 +- fluxer_media_proxy/src/webp_animation.c | 1097 ++++ fluxer_media_proxy/src/webp_animation.h | 71 + .../tools/install-native-deps.sh | 272 + 214 files changed, 47213 insertions(+), 18092 deletions(-) create mode 100644 fluxer_media_proxy/fuzz/build.rs create mode 100644 fluxer_media_proxy/src/aggregate_error.rs create mode 100644 fluxer_media_proxy/src/asset_size.rs create mode 100644 fluxer_media_proxy/src/byte_budget.rs delete mode 100644 fluxer_media_proxy/src/coalescer.rs create mode 100644 fluxer_media_proxy/src/coalescer/mod.rs create mode 100644 fluxer_media_proxy/src/coalescer/tests.rs create mode 100644 fluxer_media_proxy/src/component_tests.rs delete mode 100644 fluxer_media_proxy/src/config.rs create mode 100644 fluxer_media_proxy/src/config/mod.rs create mode 100644 fluxer_media_proxy/src/config/parse.rs create mode 100644 fluxer_media_proxy/src/config/tests/mod.rs create mode 100644 fluxer_media_proxy/src/config/tests/s3_read.rs delete mode 100644 fluxer_media_proxy/src/http_client.rs create mode 100644 fluxer_media_proxy/src/http_client/mod.rs create mode 100644 fluxer_media_proxy/src/http_client/retry.rs create mode 100644 fluxer_media_proxy/src/image_quality.rs create mode 100644 fluxer_media_proxy/src/image_transform.rs create mode 100644 fluxer_media_proxy/src/media_limits.rs delete mode 100644 fluxer_media_proxy/src/media_process.rs create mode 100644 fluxer_media_proxy/src/media_process/animated_transform.rs create mode 100644 fluxer_media_proxy/src/media_process/apng.rs create mode 100644 fluxer_media_proxy/src/media_process/av_metadata.rs create mode 100644 fluxer_media_proxy/src/media_process/encoding.rs create mode 100644 fluxer_media_proxy/src/media_process/image_probe.rs create mode 100644 fluxer_media_proxy/src/media_process/loaded_image.rs create mode 100644 fluxer_media_proxy/src/media_process/metadata.rs create mode 100644 fluxer_media_proxy/src/media_process/mod.rs create mode 100644 fluxer_media_proxy/src/media_process/native_runtime.rs create mode 100644 fluxer_media_proxy/src/media_process/nsfw_processing/mod.rs create mode 100644 fluxer_media_proxy/src/media_process/nsfw_processing/tests.rs create mode 100644 fluxer_media_proxy/src/media_process/placeholder.rs create mode 100644 fluxer_media_proxy/src/media_process/streaming_encoder.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/animated_apng.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/animated_gif.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/animated_webp.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/encoding.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/fixtures.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/heif.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/metadata.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/mod.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/nsfw.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/transform.rs create mode 100644 fluxer_media_proxy/src/media_process/tests/video.rs create mode 100644 fluxer_media_proxy/src/media_process/transform.rs create mode 100644 fluxer_media_proxy/src/media_process/transform_plan.rs create mode 100644 fluxer_media_proxy/src/media_process/video_thumbnail.rs create mode 100644 fluxer_media_proxy/src/media_type.rs delete mode 100644 fluxer_media_proxy/src/metrics.rs create mode 100644 fluxer_media_proxy/src/metrics/cache.rs create mode 100644 fluxer_media_proxy/src/metrics/external.rs create mode 100644 fluxer_media_proxy/src/metrics/histogram.rs create mode 100644 fluxer_media_proxy/src/metrics/http_client.rs create mode 100644 fluxer_media_proxy/src/metrics/mod.rs create mode 100644 fluxer_media_proxy/src/metrics/nsfw.rs create mode 100644 fluxer_media_proxy/src/metrics/relay.rs create mode 100644 fluxer_media_proxy/src/metrics/rendering.rs create mode 100644 fluxer_media_proxy/src/metrics/request.rs create mode 100644 fluxer_media_proxy/src/metrics/storage.rs create mode 100644 fluxer_media_proxy/src/metrics/tests.rs create mode 100644 fluxer_media_proxy/src/metrics/transform.rs delete mode 100644 fluxer_media_proxy/src/mime.rs create mode 100644 fluxer_media_proxy/src/mime/image_containers.rs create mode 100644 fluxer_media_proxy/src/mime/iso_bmff.rs create mode 100644 fluxer_media_proxy/src/mime/mod.rs create mode 100644 fluxer_media_proxy/src/mime/registry.rs create mode 100644 fluxer_media_proxy/src/mime/stream_containers.rs create mode 100644 fluxer_media_proxy/src/mime/tests.rs delete mode 100644 fluxer_media_proxy/src/native.rs create mode 100644 fluxer_media_proxy/src/native/buffer.rs create mode 100644 fluxer_media_proxy/src/native/delay_array.rs create mode 100644 fluxer_media_proxy/src/native/mod.rs create mode 100644 fluxer_media_proxy/src/native/nsfw_frame_output.rs create mode 100644 fluxer_media_proxy/src/native_shim/animated_webp.c create mode 100644 fluxer_media_proxy/src/native_shim/animation_decode.c create mode 100644 fluxer_media_proxy/src/native_shim/apng_validation.c create mode 100644 fluxer_media_proxy/src/native_shim/av_frame_rgba.c create mode 100644 fluxer_media_proxy/src/native_shim/av_input.c create mode 100644 fluxer_media_proxy/src/native_shim/gif_filter.c create mode 100644 fluxer_media_proxy/src/native_shim/gif_timing.c create mode 100644 fluxer_media_proxy/src/native_shim/gif_transform.c create mode 100644 fluxer_media_proxy/src/native_shim/gif_validation.c create mode 100644 fluxer_media_proxy/src/native_shim/heif_decode.c create mode 100644 fluxer_media_proxy/src/native_shim/heif_pixels.c create mode 100644 fluxer_media_proxy/src/native_shim/native_shim_internal.h create mode 100644 fluxer_media_proxy/src/native_shim/nsfw_frame.c create mode 100644 fluxer_media_proxy/src/native_shim/nsfw_webp.c create mode 100644 fluxer_media_proxy/src/native_shim/video_frame.c create mode 100644 fluxer_media_proxy/src/native_shim/vips_image.c create mode 100644 fluxer_media_proxy/src/percent_decode.rs create mode 100644 fluxer_media_proxy/src/policy_tests/adversarial.rs create mode 100644 fluxer_media_proxy/src/policy_tests/headers.rs create mode 100644 fluxer_media_proxy/src/policy_tests/mod.rs create mode 100644 fluxer_media_proxy/src/policy_tests/parsers.rs delete mode 100644 fluxer_media_proxy/src/public_net_policy.rs create mode 100644 fluxer_media_proxy/src/public_net_policy/ip_tables.rs create mode 100644 fluxer_media_proxy/src/public_net_policy/mod.rs create mode 100644 fluxer_media_proxy/src/public_net_policy/resolver.rs create mode 100644 fluxer_media_proxy/src/public_net_policy/tests.rs create mode 100644 fluxer_media_proxy/src/request_log/failure.rs rename fluxer_media_proxy/src/{request_log.rs => request_log/mod.rs} (50%) create mode 100644 fluxer_media_proxy/src/request_log/stage.rs create mode 100644 fluxer_media_proxy/src/response_body_limit.rs create mode 100644 fluxer_media_proxy/src/secret.rs delete mode 100644 fluxer_media_proxy/src/server.rs create mode 100644 fluxer_media_proxy/src/server/asset_path.rs create mode 100644 fluxer_media_proxy/src/server/download_stream.rs create mode 100644 fluxer_media_proxy/src/server/external/byte_range.rs create mode 100644 fluxer_media_proxy/src/server/external/fetch.rs create mode 100644 fluxer_media_proxy/src/server/external/mod.rs create mode 100644 fluxer_media_proxy/src/server/external/response.rs create mode 100644 fluxer_media_proxy/src/server/external/tests/cache_policy.rs create mode 100644 fluxer_media_proxy/src/server/external/tests/mod.rs create mode 100644 fluxer_media_proxy/src/server/format_policy.rs create mode 100644 fluxer_media_proxy/src/server/media_operations/failure.rs create mode 100644 fluxer_media_proxy/src/server/media_operations/input.rs create mode 100644 fluxer_media_proxy/src/server/media_operations/mod.rs create mode 100644 fluxer_media_proxy/src/server/middleware.rs create mode 100644 fluxer_media_proxy/src/server/mod.rs create mode 100644 fluxer_media_proxy/src/server/native_task_executor.rs create mode 100644 fluxer_media_proxy/src/server/params.rs create mode 100644 fluxer_media_proxy/src/server/relay/body.rs create mode 100644 fluxer_media_proxy/src/server/relay/mod.rs create mode 100644 fluxer_media_proxy/src/server/relay/tests.rs create mode 100644 fluxer_media_proxy/src/server/response/error.rs create mode 100644 fluxer_media_proxy/src/server/response/mod.rs create mode 100644 fluxer_media_proxy/src/server/routes/dispatch.rs create mode 100644 fluxer_media_proxy/src/server/routes/internal.rs create mode 100644 fluxer_media_proxy/src/server/routes/mod.rs create mode 100644 fluxer_media_proxy/src/server/routes/ops.rs create mode 100644 fluxer_media_proxy/src/server/routes/relay.rs create mode 100644 fluxer_media_proxy/src/server/runtime.rs create mode 100644 fluxer_media_proxy/src/server/state.rs create mode 100644 fluxer_media_proxy/src/server/stored/mod.rs create mode 100644 fluxer_media_proxy/src/server/stored/passthrough.rs create mode 100644 fluxer_media_proxy/src/server/stored/response.rs create mode 100644 fluxer_media_proxy/src/server/stored/tests.rs create mode 100644 fluxer_media_proxy/src/server/transform/cache.rs create mode 100644 fluxer_media_proxy/src/server/transform/cache_key.rs create mode 100644 fluxer_media_proxy/src/server/transform/execution.rs create mode 100644 fluxer_media_proxy/src/server/transform/mod.rs create mode 100644 fluxer_media_proxy/src/server/transform/parameters.rs create mode 100644 fluxer_media_proxy/src/server/transform/tests.rs delete mode 100644 fluxer_media_proxy/src/srgb_profile.h delete mode 100644 fluxer_media_proxy/src/storage.rs create mode 100644 fluxer_media_proxy/src/storage/identity.rs create mode 100644 fluxer_media_proxy/src/storage/keys.rs create mode 100644 fluxer_media_proxy/src/storage/local.rs create mode 100644 fluxer_media_proxy/src/storage/mod.rs create mode 100644 fluxer_media_proxy/src/storage/object_read.rs create mode 100644 fluxer_media_proxy/src/storage/object_stream.rs create mode 100644 fluxer_media_proxy/src/storage/relay_body.rs create mode 100644 fluxer_media_proxy/src/storage/response_body.rs create mode 100644 fluxer_media_proxy/src/storage/s3.rs create mode 100644 fluxer_media_proxy/src/storage/s3_endpoint.rs create mode 100644 fluxer_media_proxy/src/storage/source_read.rs create mode 100644 fluxer_media_proxy/src/storage/tests/keys.rs create mode 100644 fluxer_media_proxy/src/storage/tests/local.rs create mode 100644 fluxer_media_proxy/src/storage/tests/mod.rs create mode 100644 fluxer_media_proxy/src/storage/tests/relay.rs create mode 100644 fluxer_media_proxy/src/storage/tests/response_body.rs create mode 100644 fluxer_media_proxy/src/storage/tests/s3.rs create mode 100644 fluxer_media_proxy/src/storage/tests/s3_read_endpoint.rs create mode 100644 fluxer_media_proxy/src/test_fixtures/adversarial.rs create mode 100644 fluxer_media_proxy/src/test_fixtures/ffmpeg_cli.rs create mode 100644 fluxer_media_proxy/src/test_fixtures/images.rs create mode 100644 fluxer_media_proxy/src/test_fixtures/media.rs create mode 100644 fluxer_media_proxy/src/test_fixtures/mod.rs create mode 100644 fluxer_media_proxy/src/tests/mod.rs create mode 100644 fluxer_media_proxy/src/tests/native_stack.rs create mode 100644 fluxer_media_proxy/src/tests/provisioning.rs create mode 100644 fluxer_media_proxy/src/tests/source_hygiene.rs create mode 100644 fluxer_media_proxy/src/transform_cache.rs delete mode 100644 fluxer_media_proxy/src/upload_relay.rs create mode 100644 fluxer_media_proxy/src/upload_relay/mod.rs create mode 100644 fluxer_media_proxy/src/upload_relay/target.rs create mode 100644 fluxer_media_proxy/src/upload_relay/tests.rs create mode 100644 fluxer_media_proxy/src/upload_relay/token.rs delete mode 100644 fluxer_media_proxy/src/vips_shim.c create mode 100644 fluxer_media_proxy/src/webp_animation.c create mode 100644 fluxer_media_proxy/src/webp_animation.h create mode 100755 fluxer_media_proxy/tools/install-native-deps.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index a65c81d51..8f7d54d08 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -32,6 +32,7 @@ RUN apt-get update \ jq \ libasound2 \ libatk-bridge2.0-0 \ + libaom-dev \ libavcodec-dev \ libavfilter-dev \ libavformat-dev \ @@ -51,9 +52,15 @@ RUN apt-get update \ libcurl4-openssl-dev \ libswresample-dev \ libswscale-dev \ + libdav1d-dev \ + libde265-dev \ + liblcms2-dev \ libvips-dev \ + libyuv-dev \ libwayland-dev \ libwebp-dev \ + nasm \ + yasm \ libssl-dev \ libx11-xcb1 \ libxcb-dri3-0 \ @@ -78,6 +85,7 @@ RUN apt-get update \ unzip \ xz-utils \ xdg-utils \ + zlib1g-dev \ zstd \ && rm -rf /var/lib/apt/lists/* @@ -127,6 +135,26 @@ RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesour RUN python3 -m pip install --break-system-packages --no-cache-dir awscli +COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/fluxer-install-native-deps.sh +RUN /tmp/fluxer-install-native-deps.sh /usr/local \ + && rm /tmp/fluxer-install-native-deps.sh + +ENV PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/local/lib64/pkgconfig +ENV LD_LIBRARY_PATH=/usr/local/lib + +RUN printf '%s\n' \ + '#include ' \ + '#include ' \ + '#if !LIBHEIF_HAVE_VERSION(1, 23, 0)' \ + '#error the source-built libheif headers must win the include search' \ + '#endif' \ + 'int main(void) { return strcmp(heif_get_version(), LIBHEIF_VERSION) != 0; }' \ + >/tmp/fluxer-heif-probe.c \ + && cc /tmp/fluxer-heif-probe.c $(pkg-config --cflags --libs libheif) -o /tmp/fluxer-heif-probe \ + && /tmp/fluxer-heif-probe \ + && [ "$(pkg-config --variable=prefix libheif)" = /usr/local ] \ + && rm /tmp/fluxer-heif-probe.c /tmp/fluxer-heif-probe + COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \ && rm /tmp/fluxer-fonts-requirements.txt \ diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8b8883b60..27325f657 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -152,8 +152,8 @@ jobs: 'packages/markdown_parser/rust/src/**') }} rust: - runs-on: ubuntu-24.04 - timeout-minutes: 60 + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 45 steps: - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 @@ -174,11 +174,15 @@ jobs: cache: 'pnpm' - name: Cache cargo - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: - workspaces: | - . -> target - save-if: ${{ github.ref == 'refs/heads/main' }} + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + rust-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }}- - name: Install cargo-deny run: cargo install cargo-deny --version 0.19.6 --locked @@ -189,6 +193,12 @@ jobs: - name: Check desktop native dependencies run: tools/ci/check-desktop-native-workspaces.sh dependencies + - name: Cache native media dependencies + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: /opt/fluxer-native + key: media-native-${{ runner.os }}-${{ hashFiles('fluxer_media_proxy/tools/install-native-deps.sh') }} + - name: Install native dependencies run: | sudo apt-get update @@ -197,21 +207,34 @@ jobs: binutils \ clang \ cmake \ + curl \ + libaom-dev \ libavfilter-dev \ libclang-dev \ libcurl4-openssl-dev \ + libdav1d-dev \ + libde265-dev \ libfido2-dev \ libheif-dev \ + liblcms2-dev \ libpipewire-0.3-dev \ libspa-0.2-dev \ libssl-dev \ libudev-dev \ libvips-dev \ libwebp-dev \ + libyuv-dev \ meson \ + nasm \ ninja-build \ pkg-config \ + xz-utils \ + yasm \ zlib1g-dev + sudo fluxer_media_proxy/tools/install-native-deps.sh /opt/fluxer-native + echo "PKG_CONFIG_PATH=/opt/fluxer-native/lib/pkgconfig:/opt/fluxer-native/lib64/pkgconfig" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=/opt/fluxer-native/lib:/opt/fluxer-native/lib64" >> "$GITHUB_ENV" + echo "/opt/fluxer-native/bin" >> "$GITHUB_PATH" - name: Install Node.js dependencies run: pnpm --filter fluxer_admin install @@ -228,7 +251,16 @@ jobs: - name: Clippy (desktop native workspaces on Linux, warnings as errors) run: tools/ci/check-desktop-native-workspaces.sh clippy + - name: Verify the source-built ffmpeg CLI is on PATH + run: | + set -euo pipefail + command -v ffmpeg + test "$(command -v ffmpeg)" = /opt/fluxer-native/bin/ffmpeg + ffmpeg -hide_banner -version + - name: Run tests + env: + FLUXER_REQUIRE_MEDIA_FIXTURES: "1" run: cargo test --workspace --all-features --locked - name: Run desktop native workspace tests on Linux diff --git a/Cargo.lock b/Cargo.lock index 556f66b49..3ed3d6e7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1809,6 +1809,7 @@ dependencies = [ "clap", "criterion", "fluxer_common", + "futures-util", "hex", "hmac 0.13.0", "http 1.4.2", diff --git a/fluxer_media_proxy/Cargo.toml b/fluxer_media_proxy/Cargo.toml index c2a724e58..c7f273b88 100644 --- a/fluxer_media_proxy/Cargo.toml +++ b/fluxer_media_proxy/Cargo.toml @@ -25,6 +25,7 @@ axum = {version = "0.8.9", default-features = false, features = ["http1", "json" base64 = "0.22.1" bytes = "1.11.1" clap = {version = "4.6.1", features = ["derive"]} +futures-util = {version = "0.3.32", default-features = false, features = ["std"]} hex = "0.4.3" hmac = "0.13.0" http = "1.4.2" diff --git a/fluxer_media_proxy/Dockerfile b/fluxer_media_proxy/Dockerfile index f6c87e188..e8e0be203 100644 --- a/fluxer_media_proxy/Dockerfile +++ b/fluxer_media_proxy/Dockerfile @@ -3,17 +3,36 @@ ARG DEBIAN_BASE=debian:trixie-slim ARG RUST_IMAGE=rust:1-trixie +FROM ${DEBIAN_BASE} AS native + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl xz-utils build-essential pkg-config cmake nasm yasm git \ + zlib1g-dev libde265-dev libdav1d-dev libaom-dev \ + libvips-dev libwebp-dev liblcms2-dev libyuv-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY fluxer_media_proxy/tools/install-native-deps.sh /tmp/install-native-deps.sh +RUN /tmp/install-native-deps.sh /usr/local + +RUN mkdir -p /native/lib \ + && cp -a /usr/local/lib/*.so.* /native/lib/ + FROM ${RUST_IMAGE} AS builder WORKDIR /build +COPY --from=native /usr/local /usr/local +RUN ldconfig + RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates pkg-config build-essential \ - libcurl4-openssl-dev libvips-dev \ - libavformat-dev libavcodec-dev libavfilter-dev libavutil-dev libswscale-dev libswresample-dev \ - libheif-dev libwebp-dev \ + libcurl4-openssl-dev libvips-dev libwebp-dev \ + liblcms2-dev libyuv-dev libde265-dev libdav1d-dev libaom-dev \ && rm -rf /var/lib/apt/lists/* COPY . . @@ -42,18 +61,21 @@ LABEL org.opencontainers.image.revision="${SOURCE_SHA}" LABEL org.opencontainers.image.created="${SOURCE_DATE}" LABEL app.fluxer.build-version="${BUILD_VERSION}" +COPY --from=native /native/lib /usr/local/lib + RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates tini \ libcurl4t64 libvips42t64 \ - libavformat61 libavcodec61 libavfilter10 libavutil59 libswscale8 libswresample5 \ - libheif1 libheif-plugin-libde265 libheif-plugin-dav1d \ - libwebp7 libwebpmux3 \ + libwebp7 libwebpmux3 libwebpdemux2 \ + liblcms2-2 libyuv0 libde265-0 libdav1d7 libaom3 \ libjemalloc2 \ && apt-get clean \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* \ + && rm -f /usr/lib/*/libheif.so.1* \ + && ldconfig RUN groupadd --system --gid 10001 fluxer \ && useradd --system --uid 10001 --gid 10001 --home /var/lib/fluxer --create-home fluxer @@ -62,6 +84,12 @@ COPY --from=builder /usr/local/bin/fluxer-media-proxy /usr/local/bin/fluxer-medi RUN install -d -o fluxer -g fluxer /var/lib/fluxer/media_proxy_storage +RUN ldd /usr/local/bin/fluxer-media-proxy \ + && ! ldd /usr/local/bin/fluxer-media-proxy | grep -q 'not found' \ + && [ "$(ldconfig -p | grep -c 'libheif\.so\.1 ')" -eq 1 ] \ + && ldconfig -p | grep -q 'libheif\.so\.1 .* => /usr/local/lib/libheif\.so\.1$' \ + && ldd /usr/local/bin/fluxer-media-proxy | grep -q 'libheif\.so\.1 => /usr/local/lib/libheif\.so\.1' + ENV LD_PRELOAD=libjemalloc.so.2 ENV MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:5000,muzzy_decay_ms:5000" diff --git a/fluxer_media_proxy/benches/core.rs b/fluxer_media_proxy/benches/core.rs index b592b2453..2c03bd3cc 100644 --- a/fluxer_media_proxy/benches/core.rs +++ b/fluxer_media_proxy/benches/core.rs @@ -1,12 +1,56 @@ // SPDX-License-Identifier: AGPL-3.0-or-later use criterion::{Criterion, criterion_group, criterion_main}; -use fluxer_media_proxy::{aws_sigv4, mime, range, signing, thumbhash}; +use fluxer_media_proxy::{ + asset_hash::AssetHash, + aws_sigv4, + image_quality::ImageQuality, + image_transform::{EncodeEffort, ResizeMode}, + mime, + output_format::OutputFormat, + percent_decode, range, + server::transform::{ + cache_key::{TransformCacheKeyInput, transform_cache_key}, + parameters::TransformRoute, + }, + signing, thumbhash, +}; +use std::hint::black_box; + +fn vp8x_webp_header() -> [u8; 30] { + let mut webp = [0u8; 30]; + webp[0..4].copy_from_slice(b"RIFF"); + webp[8..12].copy_from_slice(b"WEBP"); + webp[12..16].copy_from_slice(b"VP8X"); + webp[16..20].copy_from_slice(&10u32.to_le_bytes()); + webp[20] = 0x12; + webp[24..27].copy_from_slice(&[0xff, 0x03, 0]); + webp[27..30].copy_from_slice(&[0xff, 0x03, 0]); + webp +} + +fn external_cache_key_input(identity: &str) -> TransformCacheKeyInput<'_> { + TransformCacheKeyInput { + route: TransformRoute::External, + asset_kind: None, + cache_identity: identity, + width: Some(1024), + height: Some(1024), + format: OutputFormat::WebP, + quality: Some(ImageQuality::High), + animated: false, + effort: EncodeEffort::parse_lenient("4"), + resize_mode: Some(ResizeMode::Fit), + } +} fn bench_range(c: &mut Criterion) { c.bench_function("range_parse_explicit", |b| { b.iter(|| range::parse_range(Some("bytes=1024-65535"), 10 * 1024 * 1024)) }); + c.bench_function("content_range_parse", |b| { + b.iter(|| range::parse_content_range(black_box(Some("bytes 1048576-2097151/8388608")))) + }); } fn bench_signing(c: &mut Criterion) { @@ -33,6 +77,34 @@ fn bench_signing(c: &mut Criterion) { fn bench_mime(c: &mut Criterion) { let png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x20\x00\x00\x00\x20\x08\x06"; c.bench_function("mime_sniff_png", |b| b.iter(|| mime::sniff(png))); + let webp = vp8x_webp_header(); + c.bench_function("mime_sniff_webp_vp8x", |b| { + b.iter(|| mime::sniff(black_box(&webp))) + }); +} + +fn bench_percent_decode(c: &mut Criterion) { + let encoded = "users/not-decoded/photo%20name-%E2%82%AC.webp"; + c.bench_function("percent_decode_utf8", |b| { + b.iter(|| percent_decode::decode_utf8(black_box(encoded)).unwrap()) + }); +} + +fn bench_asset_hash(c: &mut Criterion) { + c.bench_function("asset_hash_parse", |b| { + b.iter(|| AssetHash::parse(black_box("deadbeef"))) + }); + c.bench_function("asset_hash_parse_animated", |b| { + b.iter(|| AssetHash::parse(black_box("a_deadbeef"))) + }); +} + +fn bench_transform_cache_key(c: &mut Criterion) { + let identity = + "https://example.invalid/media/asset.png?width=1024&height=1024&token=0123456789abcdef"; + c.bench_function("external_transform_cache_key", |b| { + b.iter(|| transform_cache_key(external_cache_key_input(black_box(identity)))) + }); } fn bench_thumbhash(c: &mut Criterion) { @@ -53,6 +125,9 @@ criterion_group!( bench_range, bench_signing, bench_mime, + bench_percent_decode, + bench_asset_hash, + bench_transform_cache_key, bench_thumbhash ); criterion_main!(benches); diff --git a/fluxer_media_proxy/build.rs b/fluxer_media_proxy/build.rs index ef2dde822..8c9fb6f4d 100644 --- a/fluxer_media_proxy/build.rs +++ b/fluxer_media_proxy/build.rs @@ -1,17 +1,76 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::path::PathBuf; +use std::{collections::HashSet, path::PathBuf, process::Command}; + +const NATIVE_SHIM_SOURCES: [&str; 16] = [ + "src/webp_animation.c", + "src/native_shim/av_input.c", + "src/native_shim/av_frame_rgba.c", + "src/native_shim/vips_image.c", + "src/native_shim/gif_validation.c", + "src/native_shim/gif_timing.c", + "src/native_shim/gif_filter.c", + "src/native_shim/gif_transform.c", + "src/native_shim/apng_validation.c", + "src/native_shim/animation_decode.c", + "src/native_shim/heif_pixels.c", + "src/native_shim/heif_decode.c", + "src/native_shim/animated_webp.c", + "src/native_shim/video_frame.c", + "src/native_shim/nsfw_frame.c", + "src/native_shim/nsfw_webp.c", +]; + +const NATIVE_SHIM_HEADERS: [&str; 3] = [ + "src/vips_shim.h", + "src/webp_animation.h", + "src/native_shim/native_shim_internal.h", +]; + +const NATIVE_BUILD_ENVIRONMENT: [&str; 6] = [ + "CARGO_CFG_TARGET_OS", + "HOMEBREW_PREFIX", + "PKG_CONFIG", + "PKG_CONFIG_LIBDIR", + "PKG_CONFIG_PATH", + "PKG_CONFIG_SYSROOT_DIR", +]; + +fn homebrew_prefix() -> PathBuf { + if let Ok(prefix) = std::env::var("HOMEBREW_PREFIX") { + return PathBuf::from(prefix); + } + let output = Command::new("brew") + .arg("--prefix") + .output() + .expect("libyuv needs a Homebrew prefix: set HOMEBREW_PREFIX or install Homebrew"); + assert!(output.status.success(), "`brew --prefix` failed"); + PathBuf::from( + String::from_utf8(output.stdout) + .expect("`brew --prefix` printed invalid UTF-8") + .trim(), + ) +} fn main() { - println!("cargo:rerun-if-changed=src/vips_shim.c"); - println!("cargo:rerun-if-changed=src/vips_shim.h"); - println!("cargo:rerun-if-changed=src/srgb_profile.h"); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + for variable in NATIVE_BUILD_ENVIRONMENT { + println!("cargo:rerun-if-env-changed={variable}"); + } + for header in NATIVE_SHIM_HEADERS { + println!("cargo:rerun-if-changed={header}"); + } + for source in NATIVE_SHIM_SOURCES { + println!("cargo:rerun-if-changed={source}"); + } - let mut build = cc::Build::new(); - build - .file("src/vips_shim.c") - .include("src") - .flag_if_supported("-std=gnu11"); + let mut shim = cc::Build::new(); + shim.include("src") + .flag("-std=c11") + .warnings_into_errors(true); + for source in NATIVE_SHIM_SOURCES { + shim.file(source); + } let mut link_paths: Vec = Vec::new(); let mut link_files: Vec = Vec::new(); @@ -21,7 +80,6 @@ fn main() { let mut ld_args: Vec> = Vec::new(); for lib in [ - "libcurl", "vips", "libheif", "libavformat", @@ -29,7 +87,8 @@ fn main() { "libavfilter", "libavutil", "libswscale", - "libswresample", + "lcms2", + "libwebpdemux", "libwebpmux", "libwebp", ] { @@ -38,7 +97,7 @@ fn main() { .probe(lib) .unwrap_or_else(|err| panic!("pkg-config could not find {lib}: {err}")); for include in probed.include_paths { - build.include(include); + shim.include(include); } link_paths.extend(probed.link_paths); link_files.extend(probed.link_files); @@ -48,10 +107,39 @@ fn main() { ld_args.extend(probed.ld_args); } - build.compile("fluxer_vips_shim"); + libs.push("yuv".into()); + if target_os == "macos" { + let prefix = homebrew_prefix(); + let header = prefix.join("include/libyuv.h"); + assert!( + header.is_file(), + "libyuv header missing at {}: run `brew install libyuv`", + header.display() + ); + let has_library = ["lib/libyuv.dylib", "lib/libyuv.a"] + .iter() + .any(|candidate| prefix.join(candidate).is_file()); + assert!( + has_library, + "libyuv library missing under {}: run `brew install libyuv`", + prefix.join("lib").display() + ); + shim.include(prefix.join("include")); + link_paths.push(prefix.join("lib")); + } + + shim.compile("fluxer_vips_shim"); + + let mut emitted_link_paths: HashSet = HashSet::new(); for path in link_paths { + if !emitted_link_paths.insert(path.clone()) { + continue; + } println!("cargo:rustc-link-search=native={}", path.display()); + if target_os == "macos" { + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", path.display()); + } } for path in framework_paths { println!("cargo:rustc-link-search=framework={}", path.display()); @@ -67,7 +155,11 @@ fn main() { for framework in frameworks { println!("cargo:rustc-link-lib=framework={framework}"); } + let mut emitted_libs: HashSet = HashSet::new(); for lib in libs { + if !emitted_libs.insert(lib.clone()) { + continue; + } println!("cargo:rustc-link-lib={lib}"); } } diff --git a/fluxer_media_proxy/examples/bench_gif.rs b/fluxer_media_proxy/examples/bench_gif.rs index ef43038bf..2e31f0407 100644 --- a/fluxer_media_proxy/examples/bench_gif.rs +++ b/fluxer_media_proxy/examples/bench_gif.rs @@ -1,7 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use fluxer_media_proxy::constants::AssetExtension; -use fluxer_media_proxy::media_process::{ImageOptions, transform_image}; +use fluxer_media_proxy::media_process::{ + AnimationLimits, AnimationMode, ImageOptions, ImageQuality, MediaLimits, ResizeMode, + transform_image, +}; +use fluxer_media_proxy::metrics::Metrics; +use fluxer_media_proxy::output_format::OutputFormat; use std::path::PathBuf; use std::time::Instant; @@ -71,6 +75,11 @@ fn parse_gif(bytes: &[u8]) -> Option<(u16, u16, usize, usize)> { } fn main() { + let metrics = Metrics::new(); + let media_limits = MediaLimits::default_from_config(); + let animation = AnimationMode::Animated( + AnimationLimits::new(20_000, 30_000).expect("valid animation limits"), + ); let runs_per_file = std::env::var("RUNS") .ok() .and_then(|s| s.parse().ok()) @@ -106,12 +115,14 @@ fn main() { &ImageOptions { width: Some(target), height: Some(target), - format: AssetExtension::Gif, - quality: "high".to_owned(), - animated: true, - cover_crop: false, + format: OutputFormat::GIF, + quality: ImageQuality::High, + animation, + resize_mode: ResizeMode::Fit, ..Default::default() }, + &media_limits, + &metrics.transform(), ) .expect("transform ok"); runs_ms.push(t0.elapsed().as_millis()); diff --git a/fluxer_media_proxy/examples/test_anim.rs b/fluxer_media_proxy/examples/test_anim.rs index 3561590c4..bf4d79ee6 100644 --- a/fluxer_media_proxy/examples/test_anim.rs +++ b/fluxer_media_proxy/examples/test_anim.rs @@ -1,21 +1,32 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use fluxer_media_proxy::constants::AssetExtension; -use fluxer_media_proxy::media_process::{ImageOptions, transform_image}; +use fluxer_media_proxy::media_process::{ + AnimationLimits, AnimationMode, ImageOptions, ImageQuality, MediaLimits, ResizeMode, + transform_image, +}; +use fluxer_media_proxy::metrics::Metrics; +use fluxer_media_proxy::output_format::OutputFormat; fn main() { + let metrics = Metrics::new(); + let media_limits = MediaLimits::default_from_config(); + let animation = AnimationMode::Animated( + AnimationLimits::new(20_000, 30_000).expect("valid animation limits"), + ); let input = std::fs::read("/tmp/source.bin").expect("read source"); let out = transform_image( &input, &ImageOptions { width: Some(240), height: Some(240), - format: AssetExtension::Gif, - quality: "high".to_owned(), - animated: true, - cover_crop: false, + format: OutputFormat::GIF, + quality: ImageQuality::High, + animation, + resize_mode: ResizeMode::Fit, ..Default::default() }, + &media_limits, + &metrics.transform(), ) .expect("transform"); std::fs::write("/tmp/out_current.gif", &out.bytes).expect("write"); diff --git a/fluxer_media_proxy/fuzz/Cargo.lock b/fluxer_media_proxy/fuzz/Cargo.lock index 4ef61e279..40b556070 100644 --- a/fluxer_media_proxy/fuzz/Cargo.lock +++ b/fluxer_media_proxy/fuzz/Cargo.lock @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.104" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arbitrary" @@ -98,9 +98,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-credential-types" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -132,9 +132,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.4.5" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -154,9 +154,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.14" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -165,9 +165,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.63.6" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -186,9 +186,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.12.3" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -204,9 +204,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" dependencies = [ "proc-macro2", "quote", @@ -215,9 +215,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.5.0" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" +checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647" dependencies = [ "base64-simd", "bytes", @@ -365,9 +365,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures", @@ -487,9 +487,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] @@ -598,6 +598,7 @@ dependencies = [ "cc", "clap", "fluxer_common", + "futures-util", "hex", "hmac", "http 1.4.2", diff --git a/fluxer_media_proxy/fuzz/build.rs b/fluxer_media_proxy/fuzz/build.rs new file mode 100644 index 000000000..ce35ec8db --- /dev/null +++ b/fluxer_media_proxy/fuzz/build.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::{path::PathBuf, process::Command}; + +fn homebrew_prefix() -> PathBuf { + if let Ok(prefix) = std::env::var("HOMEBREW_PREFIX") { + return PathBuf::from(prefix); + } + let output = Command::new("brew") + .arg("--prefix") + .output() + .expect("libyuv needs a Homebrew prefix: set HOMEBREW_PREFIX or install Homebrew"); + assert!(output.status.success(), "`brew --prefix` failed"); + PathBuf::from( + String::from_utf8(output.stdout) + .expect("`brew --prefix` printed invalid UTF-8") + .trim(), + ) +} + +fn main() { + println!("cargo:rerun-if-env-changed=HOMEBREW_PREFIX"); + if std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default() != "macos" { + return; + } + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + homebrew_prefix().join("lib").display() + ); +} diff --git a/fluxer_media_proxy/fuzz/fuzz_targets/parsers.rs b/fluxer_media_proxy/fuzz/fuzz_targets/parsers.rs index d7fa9fdf4..c41c08dd8 100644 --- a/fluxer_media_proxy/fuzz/fuzz_targets/parsers.rs +++ b/fluxer_media_proxy/fuzz/fuzz_targets/parsers.rs @@ -2,11 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use fluxer_media_proxy::{mime, public_net_policy, query, range}; +use fluxer_media_proxy::{disposition, mime, percent_decode, public_net_policy, query, range}; use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { let _ = mime::sniff(data); + let _ = mime::sniff_prefix(data, data.len()); + let _ = mime::sniff_prefix(data, data.len().saturating_mul(2).saturating_add(1)); if let Ok(text) = std::str::from_utf8(data) { let _ = range::parse_range(Some(text), data.len().saturating_mul(3).saturating_add(1)); let _ = range::parse_bounded_request_range(Some(text), 1024 * 1024); @@ -15,5 +17,12 @@ fuzz_target!(|data: &[u8]| { let _ = query::Query::parse(text); let _ = public_net_policy::parse_url(text); let _ = public_net_policy::resolve_redirect("https://example.com/a/b/c?x=1", text); + let _ = percent_decode::decode_utf8(text); + let _ = disposition::is_inline_viewable(text); + let inline = disposition::decide(text, false); + let attachment = disposition::decide(text, true); + let _ = disposition::header(inline, Some(text)); + let _ = disposition::header(attachment, Some(text)); + let _ = disposition::header(attachment, None); } }); diff --git a/fluxer_media_proxy/fuzz/fuzz_targets/signing_external_path.rs b/fluxer_media_proxy/fuzz/fuzz_targets/signing_external_path.rs index 3d4ee1676..9e22dbd54 100644 --- a/fluxer_media_proxy/fuzz/fuzz_targets/signing_external_path.rs +++ b/fluxer_media_proxy/fuzz/fuzz_targets/signing_external_path.rs @@ -13,8 +13,14 @@ fuzz_target!(|data: &[u8]| { let _ = signing::verify_signature(&path, &sig, b"fuzz-secret"); let _ = signing::verify_signature(text, &sig, b"other-secret"); } + let opaque = external_path::build_opaque_external_media_proxy_path(text); + let _ = external_path::reconstruct_original_url(&opaque); + let opaque_signature = signing::create_signature(&opaque, b"fuzz-secret"); + let _ = signing::verify_signature(&opaque, &opaque_signature, b"fuzz-secret"); + let _ = signing::verify_signature(&opaque, "", b"fuzz-secret"); let _ = external_path::reconstruct_original_url(text); let _ = external_path::percent_decode(text, true); let _ = external_path::percent_decode(text, false); + let _ = external_path::percent_decode_string(text, true); } }); diff --git a/fluxer_media_proxy/src/aggregate_error.rs b/fluxer_media_proxy/src/aggregate_error.rs new file mode 100644 index 000000000..c469a7273 --- /dev/null +++ b/fluxer_media_proxy/src/aggregate_error.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt::{self, Display, Formatter}; + +#[derive(Debug)] +pub struct AggregateError { + operation: &'static str, + errors: Vec, +} + +impl AggregateError { + pub const fn new(operation: &'static str) -> Self { + Self { + operation, + errors: Vec::new(), + } + } + + pub fn push(&mut self, error: anyhow::Error) { + self.errors.push(error); + } + + pub fn push_result(&mut self, result: anyhow::Result) { + if let Err(error) = result { + self.push(error); + } + } + + pub fn is_empty(&self) -> bool { + self.errors.is_empty() + } + + pub fn len(&self) -> usize { + self.errors.len() + } + + pub fn finish(self) -> anyhow::Result<()> { + if self.is_empty() { + Ok(()) + } else { + Err(anyhow::Error::new(self)) + } + } +} + +impl Display for AggregateError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + write!( + formatter, + "{} produced {} independent failures", + self.operation, + self.len() + )?; + for (index, error) in self.errors.iter().enumerate() { + write!(formatter, "\n{}. {error:#}", index + 1)?; + } + Ok(()) + } +} + +impl std::error::Error for AggregateError {} + +pub fn aggregate_results( + operation: &'static str, + results: impl IntoIterator>, +) -> anyhow::Result<()> { + let mut failures = AggregateError::new(operation); + for result in results { + failures.push_result(result); + } + failures.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_starts_empty() { + let error = AggregateError::new("op"); + assert!(error.is_empty()); + assert_eq!(error.len(), 0); + } + + #[test] + fn push_result_ok_leaves_empty_and_err_records_error() { + let mut error = AggregateError::new("op"); + error.push_result::(Ok(1)); + assert!(error.is_empty()); + error.push_result::(Err(anyhow::anyhow!("boom"))); + assert_eq!(error.len(), 1); + } + + #[test] + fn finish_ok_when_no_errors_were_recorded() { + let error = AggregateError::new("op"); + assert!(error.finish().is_ok()); + } + + #[test] + fn finish_err_returns_self_as_downcastable_error_when_non_empty() { + let mut error = AggregateError::new("op"); + error.push(anyhow::anyhow!("boom")); + let aggregate = error + .finish() + .expect_err("a recorded failure must surface") + .downcast::() + .expect("finish wraps itself as the error"); + assert_eq!(aggregate.len(), 1); + } + + #[test] + fn display_numbers_every_failure_from_one() { + let mut error = AggregateError::new("op"); + error.push(anyhow::anyhow!("first")); + error.push(anyhow::anyhow!("second")); + assert_eq!( + format!("{error}"), + "op produced 2 independent failures\n1. first\n2. second" + ); + } + + #[test] + fn aggregate_results_ok_when_all_succeed() { + let results: Vec> = vec![Ok(1), Ok(2), Ok(3)]; + assert!(aggregate_results("agg", results).is_ok()); + } + + #[test] + fn aggregate_results_collects_every_failure_without_short_circuiting() { + let results: Vec> = vec![ + Ok(1), + Err(anyhow::anyhow!("first")), + Ok(2), + Err(anyhow::anyhow!("second")), + ]; + let aggregate = aggregate_results("agg", results) + .expect_err("recorded failures must surface") + .downcast::() + .expect("aggregate error"); + assert_eq!(aggregate.len(), 2); + assert_eq!( + format!("{aggregate}"), + "agg produced 2 independent failures\n1. first\n2. second" + ); + } +} diff --git a/fluxer_media_proxy/src/asset_hash.rs b/fluxer_media_proxy/src/asset_hash.rs index 7fe864c23..297a3c5da 100644 --- a/fluxer_media_proxy/src/asset_hash.rs +++ b/fluxer_media_proxy/src/asset_hash.rs @@ -1,5 +1,28 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AssetHash<'a> { + digest: &'a str, + animated: bool, +} + +impl<'a> AssetHash<'a> { + pub fn parse(raw: &'a str) -> Self { + Self { + digest: strip_animation_prefix(raw), + animated: has_animation_prefix(raw), + } + } + + pub fn digest(self) -> &'a str { + self.digest + } + + pub fn is_animated(self) -> bool { + self.animated + } +} + pub fn strip_animation_prefix(hash: &str) -> &str { hash.strip_prefix("a_").unwrap_or(hash) } @@ -17,4 +40,23 @@ mod tests { assert_eq!("abc123", strip_animation_prefix("a_abc123")); assert_eq!("abc123", strip_animation_prefix("abc123")); } + + #[test] + fn parsed_hashes_split_the_animation_prefix_from_the_digest() { + let plain = AssetHash::parse("abc123"); + assert_eq!("abc123", plain.digest()); + assert!(!plain.is_animated()); + + let animated = AssetHash::parse("a_abc123"); + assert_eq!("abc123", animated.digest()); + assert!(animated.is_animated()); + + let empty = AssetHash::parse(""); + assert_eq!("", empty.digest()); + assert!(!empty.is_animated()); + + let doubled = AssetHash::parse("a_a_abc123"); + assert_eq!("a_abc123", doubled.digest()); + assert!(doubled.is_animated()); + } } diff --git a/fluxer_media_proxy/src/asset_size.rs b/fluxer_media_proxy/src/asset_size.rs new file mode 100644 index 000000000..166bb3c8e --- /dev/null +++ b/fluxer_media_proxy/src/asset_size.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub use crate::constants::{clamp_size, parse_image_size}; + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::{AssetKind, DEFAULT_IMAGE_SIZE, IMAGE_SIZES}; + + #[test] + fn unparsable_sizes_fall_back_to_the_default() { + assert_eq!(DEFAULT_IMAGE_SIZE, parse_image_size(None)); + assert_eq!(DEFAULT_IMAGE_SIZE, parse_image_size(Some(""))); + assert_eq!(DEFAULT_IMAGE_SIZE, parse_image_size(Some("-1"))); + assert_eq!(DEFAULT_IMAGE_SIZE, parse_image_size(Some("99999999999"))); + assert_eq!(DEFAULT_IMAGE_SIZE, parse_image_size(Some("not-a-number"))); + } + + #[test] + fn parsable_sizes_snap_up_to_the_next_rung() { + assert_eq!(16, parse_image_size(Some("0"))); + assert_eq!(640, parse_image_size(Some("640"))); + assert_eq!(1024, parse_image_size(Some("641"))); + assert_eq!(1024, parse_image_size(Some("777"))); + assert_eq!(1024, parse_image_size(Some("1000"))); + assert_eq!(16384, parse_image_size(Some("99999"))); + for size in IMAGE_SIZES { + assert_eq!(*size, parse_image_size(Some(&size.to_string()))); + } + assert_eq!(16384, parse_image_size(Some("16384"))); + } + + #[test] + fn an_off_ladder_size_never_serves_fewer_pixels_than_requested() { + for requested in 1..=4096u32 { + let served = parse_image_size(Some(&requested.to_string())); + assert!( + served >= requested, + "size={requested} served {served}, a silent downscale" + ); + } + } + + #[test] + fn snapping_is_idempotent() { + for raw in [0u32, 1, 17, 641, 777, 1000, 4097, 99999] { + let once = parse_image_size(Some(&raw.to_string())); + let twice = parse_image_size(Some(&once.to_string())); + assert_eq!(once, twice, "size={raw} did not settle"); + } + } + + #[test] + fn clamping_respects_kind_bounds_and_leaves_attachments_alone() { + assert_eq!(128, clamp_size(1, AssetKind::Avatar)); + assert_eq!(1024, clamp_size(4096, AssetKind::Avatar)); + assert_eq!(480, clamp_size(16, AssetKind::Banner)); + assert_eq!(512, clamp_size(4096, AssetKind::Emoji)); + assert_eq!(4096, clamp_size(4096, AssetKind::Attachment)); + assert_eq!(1, clamp_size(0, AssetKind::Attachment)); + } +} diff --git a/fluxer_media_proxy/src/aws_sigv4.rs b/fluxer_media_proxy/src/aws_sigv4.rs index 7ccde953f..b558307d6 100644 --- a/fluxer_media_proxy/src/aws_sigv4.rs +++ b/fluxer_media_proxy/src/aws_sigv4.rs @@ -363,4 +363,193 @@ mod tests { format_timestamp(y as u32, m, d, 0, 0, 0) }); } + + const ACCESS_KEY_ID: &str = "AKIAIOSFODNN7EXAMPLE"; + const SECRET_ACCESS_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + const OBJECT_URL: &str = "https://examplebucket.s3.amazonaws.com/test.txt"; + + fn sign_get(session_token: &str, extra_signed_headers: &[Header<'_>]) -> SignedRequest { + let mut opts = Options::new( + "GET", + OBJECT_URL, + "us-east-1", + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + ); + opts.session_token = session_token; + opts.extra_signed_headers = extra_signed_headers; + opts.timestamp = Some(format_timestamp(2013, 5, 24, 0, 0, 0)); + sign(opts).unwrap() + } + + #[test] + fn aws_s3_sigv4_put_object_example() { + let mut opts = Options::new( + "PUT", + "https://examplebucket.s3.amazonaws.com/test%24file.text", + "us-east-1", + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + ); + opts.payload = b"Welcome to Amazon S3."; + opts.extra_signed_headers = &[ + Header { + name: "Date", + value: "Fri, 24 May 2013 00:00:00 GMT", + }, + Header { + name: "x-amz-storage-class", + value: "REDUCED_REDUNDANCY", + }, + ]; + opts.timestamp = Some(format_timestamp(2013, 5, 24, 0, 0, 0)); + let signed = sign(opts).unwrap(); + assert_eq!( + "44ce7dd67c959e0d3524ffac1771dfbba87d2b6b4b4e99e42034a8b803f8b072", + signed.payload_hash + ); + assert_eq!( + "date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class", + signed.signed_headers + ); + assert_eq!("examplebucket.s3.amazonaws.com", signed.host); + assert_eq!("20130524T000000Z", signed.amz_date); + assert_eq!( + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request,SignedHeaders=date;host;x-amz-content-sha256;x-amz-date;x-amz-storage-class,Signature=98ad721746da40c64f1a55b78f14c238d841ea1380cd77a1b5971af0ece108bd", + signed.authorization + ); + } + + #[test] + fn extra_signed_headers_are_canonical_whatever_order_they_arrive_in() { + let ascending = sign_get( + "", + &[ + Header { + name: "Accept-Encoding", + value: "identity", + }, + Header { + name: "Range", + value: "bytes=0-9", + }, + Header { + name: "X-Amz-Meta-Album", + value: "covers", + }, + ], + ); + let descending = sign_get( + "", + &[ + Header { + name: "\tX-AMZ-META-ALBUM ", + value: "covers", + }, + Header { + name: " range", + value: "bytes=0-9", + }, + Header { + name: "accept-encoding", + value: "identity", + }, + ], + ); + assert_eq!( + "accept-encoding;host;range;x-amz-content-sha256;x-amz-date;x-amz-meta-album", + ascending.signed_headers + ); + assert_eq!(ascending.signed_headers, descending.signed_headers); + assert_eq!(ascending.authorization, descending.authorization); + } + + #[test] + fn signed_header_values_are_trimmed_and_their_inner_runs_collapsed() { + let exact = sign_get( + "", + &[Header { + name: "X-Amz-Meta-Note", + value: "one two", + }], + ); + for padded in [" one two ", "\tone two\r\n", "one \t two"] { + assert_eq!( + exact.authorization, + sign_get( + "", + &[Header { + name: "X-Amz-Meta-Note", + value: padded, + }], + ) + .authorization, + "{padded:?}" + ); + } + assert_ne!( + exact.authorization, + sign_get( + "", + &[Header { + name: "X-Amz-Meta-Note", + value: "onetwo", + }], + ) + .authorization + ); + } + + #[test] + fn a_configured_session_token_is_signed_as_x_amz_security_token() { + let anonymous = sign_get("", &[]); + assert_eq!( + "host;x-amz-content-sha256;x-amz-date", + anonymous.signed_headers + ); + let session = sign_get("FQoGZXIvYXdzEXAMPLESESSIONTOKEN", &[]); + assert_eq!( + "host;x-amz-content-sha256;x-amz-date;x-amz-security-token", + session.signed_headers + ); + assert!( + session.authorization.contains( + "SignedHeaders=host;x-amz-content-sha256;x-amz-date;x-amz-security-token" + ) + ); + assert_ne!(anonymous.authorization, session.authorization); + assert_ne!( + session.authorization, + sign_get("FQoGZXIvYXdzANOTHERSESSIONTOKEN", &[]).authorization + ); + } + + #[test] + fn empty_credentials_and_broken_urls_are_refused_before_signing() { + let mut opts = Options::new("GET", OBJECT_URL, "", ACCESS_KEY_ID, SECRET_ACCESS_KEY); + opts.timestamp = Some(format_timestamp(2013, 5, 24, 0, 0, 0)); + assert_eq!(Err(Error::MissingAwsCredentials), sign(opts)); + let mut opts = Options::new( + "GET", + "https://", + "us-east-1", + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + ); + opts.timestamp = Some(format_timestamp(2013, 5, 24, 0, 0, 0)); + assert_eq!(Err(Error::InvalidUrl), sign(opts)); + let mut opts = Options::new( + "GET", + OBJECT_URL, + "us-east-1", + ACCESS_KEY_ID, + SECRET_ACCESS_KEY, + ); + opts.extra_signed_headers = &[Header { + name: " ", + value: "x", + }]; + opts.timestamp = Some(format_timestamp(2013, 5, 24, 0, 0, 0)); + assert_eq!(Err(Error::InvalidHeader), sign(opts)); + } } diff --git a/fluxer_media_proxy/src/bunny_ip_gate.rs b/fluxer_media_proxy/src/bunny_ip_gate.rs index 4bb6d8ce6..becfec425 100644 --- a/fluxer_media_proxy/src/bunny_ip_gate.rs +++ b/fluxer_media_proxy/src/bunny_ip_gate.rs @@ -224,6 +224,7 @@ pub fn build_refresh_client() -> reqwest::Result { #[cfg(test)] mod tests { use super::*; + use crate::test_fixtures::ADVERSARIAL_TEXT_INPUTS; use std::net::{Ipv4Addr, Ipv6Addr}; fn ip4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { @@ -304,4 +305,112 @@ mod tests { assert!(!snap.contains(&ip4(1, 1, 1, 1))); assert_eq!(snap.len(), 1); } + + fn ip6(text: &str) -> IpAddr { + IpAddr::V6(text.parse::().unwrap()) + } + + fn forwarded_for(gate: &BunnyIpGate, peer: IpAddr, xff: &str) -> IpAddr { + let mut headers = axum::http::HeaderMap::new(); + headers.insert("x-forwarded-for", xff.parse().unwrap()); + resolve_client_ip(gate, &sock(peer), &headers) + } + + #[test] + fn a_long_forwarded_chain_still_resolves_the_rightmost_untrusted_hop() { + let trusted: Vec = (1..=24).map(|last| ip4(10, 0, 0, last)).collect(); + let gate = BunnyIpGate::new(reqwest::Client::new(), trusted.clone()); + let mut chain = vec!["89.187.188.227".to_owned()]; + chain.extend(trusted.iter().map(ToString::to_string)); + assert_eq!(25, chain.len()); + assert_eq!( + ip4(89, 187, 188, 227), + forwarded_for(&gate, ip4(10, 0, 0, 24), &chain.join(", ")) + ); + assert_eq!( + ip4(10, 0, 0, 24), + forwarded_for(&gate, ip4(10, 0, 0, 24), &chain[1..].join(", ")) + ); + } + + #[test] + fn unparseable_hops_are_skipped_instead_of_ending_the_scan() { + let gate = BunnyIpGate::new( + reqwest::Client::new(), + vec![ip4(10, 0, 0, 1), ip4(10, 0, 0, 2)], + ); + assert_eq!( + ip4(89, 187, 188, 227), + forwarded_for( + &gate, + ip4(10, 0, 0, 1), + "89.187.188.227, unknown, 10.0.0.2, 10.0.0.1" + ) + ); + assert_eq!( + ip4(89, 187, 188, 227), + forwarded_for( + &gate, + ip4(10, 0, 0, 1), + "89.187.188.227, 10.0.0.1, , not-an-ip" + ) + ); + assert_eq!( + ip4(10, 0, 0, 1), + forwarded_for(&gate, ip4(10, 0, 0, 1), "unknown, also-unknown") + ); + } + + #[test] + fn forwarded_ipv6_hops_are_only_accepted_in_their_bare_form() { + let gate = BunnyIpGate::new(reqwest::Client::new(), vec![ip4(10, 0, 0, 1)]); + assert_eq!( + ip6("2a01:4f8::1"), + forwarded_for(&gate, ip4(10, 0, 0, 1), "2a01:4f8::1, 10.0.0.1") + ); + assert_eq!( + ip4(10, 0, 0, 1), + forwarded_for(&gate, ip4(10, 0, 0, 1), "[2a01:4f8::1]:443, 10.0.0.1") + ); + assert_eq!( + ip6("2a01:4f8::2"), + forwarded_for( + &gate, + ip4(10, 0, 0, 1), + "2a01:4f8::2, [2a01:4f8::1]:443, 10.0.0.1" + ) + ); + } + + #[test] + fn a_trusted_ipv6_proxy_is_skipped_like_any_other_hop() { + let gate = BunnyIpGate::new(reqwest::Client::new(), vec![ip6("2a01:4f8::1")]); + assert_eq!( + ip4(89, 187, 188, 227), + forwarded_for(&gate, ip6("2a01:4f8::1"), "89.187.188.227, 2a01:4f8::1") + ); + } + + #[test] + fn adversarial_paths_are_never_exempt_from_the_bunny_gate() { + for text in ADVERSARIAL_TEXT_INPUTS { + for path in [ + (*text).to_owned(), + format!("/{text}"), + format!("/avatars/1/{text}.png"), + format!("/external/{text}"), + format!("/_health{text}"), + format!("/v1/relay{text}"), + format!("/v1/relay/{text}"), + ] { + if !is_exempt_path(&path) { + continue; + } + assert!( + path == "/_health" || path.starts_with("/v1/relay/"), + "{path:?} is exempt from the bunny gate" + ); + } + } + } } diff --git a/fluxer_media_proxy/src/byte_budget.rs b/fluxer_media_proxy/src/byte_budget.rs new file mode 100644 index 000000000..d612529ac --- /dev/null +++ b/fluxer_media_proxy/src/byte_budget.rs @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use bytes::Bytes; +use std::ops::Deref; +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +#[derive(Clone, Debug)] +pub struct ByteBudget { + inner: Arc, +} + +#[derive(Debug)] +struct ByteBudgetInner { + limit: u64, + used: AtomicU64, +} + +#[derive(Debug)] +pub struct ByteReservation { + inner: Arc, +} + +#[derive(Debug)] +pub struct BudgetedBytes { + data: Bytes, + reservation: Option, +} + +#[derive(Debug)] +struct ByteReservationInner { + budget: ByteBudget, + amount: AtomicU64, +} + +impl ByteBudgetInner { + fn release(&self, amount: u64) { + if amount == 0 { + return; + } + assert!(amount <= self.limit); + let released = self + .used + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_sub(amount) + }); + assert!(released.is_ok()); + } +} + +impl ByteBudget { + pub fn new(limit: usize) -> Self { + Self { + inner: Arc::new(ByteBudgetInner { + limit: limit as u64, + used: AtomicU64::new(0), + }), + } + } + + pub fn try_reserve(&self, amount: usize) -> Option { + let amount = amount as u64; + let mut used = self.inner.used.load(Ordering::Acquire); + loop { + let next = used + .checked_add(amount) + .filter(|next| *next <= self.inner.limit)?; + match self.inner.used.compare_exchange_weak( + used, + next, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + return Some(ByteReservation { + inner: Arc::new(ByteReservationInner { + budget: self.clone(), + amount: AtomicU64::new(amount), + }), + }); + } + Err(actual) => used = actual, + } + } + } +} + +impl ByteReservation { + pub fn amount(&self) -> usize { + self.inner.amount.load(Ordering::Acquire) as usize + } + + pub fn try_grow(&mut self, additional: usize) -> bool { + assert_eq!(Arc::strong_count(&self.inner), 1); + if additional == 0 { + return true; + } + let additional = additional as u64; + let mut used = self.inner.budget.inner.used.load(Ordering::Acquire); + loop { + let Some(next) = used + .checked_add(additional) + .filter(|next| *next <= self.inner.budget.inner.limit) + else { + return false; + }; + match self.inner.budget.inner.used.compare_exchange_weak( + used, + next, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => { + let previous = self.inner.amount.fetch_add(additional, Ordering::AcqRel); + assert!(previous.checked_add(additional).is_some()); + return true; + } + Err(actual) => used = actual, + } + } + } + + pub fn shrink_to(&mut self, amount: usize) { + assert_eq!(Arc::strong_count(&self.inner), 1); + let amount = amount as u64; + let previous = self.inner.amount.load(Ordering::Acquire); + assert!(amount <= previous); + self.inner.amount.store(amount, Ordering::Release); + self.inner.budget.inner.release(previous - amount); + } +} + +impl Clone for BudgetedBytes { + fn clone(&self) -> Self { + let reservation = self + .reservation + .as_ref() + .map(|reservation| ByteReservation { + inner: Arc::clone(&reservation.inner), + }); + Self { + data: self.data.clone(), + reservation, + } + } +} + +impl BudgetedBytes { + pub fn unbudgeted(data: Bytes) -> Self { + Self { + data, + reservation: None, + } + } + + pub fn budgeted(data: Bytes, reservation: ByteReservation) -> Self { + assert!(data.len() <= reservation.amount()); + Self { + data, + reservation: Some(reservation), + } + } + pub fn len(&self) -> usize { + self.data.len() + } + + pub fn is_empty(&self) -> bool { + self.data.is_empty() + } + + pub fn resident_bytes(&self) -> usize { + self.reservation + .as_ref() + .map(ByteReservation::amount) + .unwrap_or_else(|| self.data.len()) + } + + pub fn as_bytes(&self) -> &Bytes { + &self.data + } +} + +impl From for BudgetedBytes { + fn from(data: Bytes) -> Self { + Self::unbudgeted(data) + } +} + +impl AsRef<[u8]> for BudgetedBytes { + fn as_ref(&self) -> &[u8] { + &self.data + } +} + +impl Deref for BudgetedBytes { + type Target = Bytes; + + fn deref(&self) -> &Self::Target { + &self.data + } +} + +impl Drop for ByteReservationInner { + fn drop(&mut self) { + let amount = self.amount.load(Ordering::Acquire); + self.budget.inner.release(amount); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn try_reserve_up_to_limit_succeeds() { + let budget = ByteBudget::new(100); + let reservation = budget.try_reserve(60).expect("reserve under limit"); + assert_eq!(reservation.amount(), 60); + } + + #[test] + fn try_reserve_exactly_at_limit_succeeds() { + let budget = ByteBudget::new(100); + let reservation = budget.try_reserve(100).expect("reserve at limit"); + assert_eq!(reservation.amount(), 100); + } + + #[test] + fn try_reserve_over_limit_fails() { + let budget = ByteBudget::new(100); + let _reservation = budget.try_reserve(100).expect("reserve at limit"); + assert!(budget.try_reserve(1).is_none()); + } + + #[test] + fn try_grow_within_remaining_budget_succeeds() { + let budget = ByteBudget::new(100); + let mut reservation = budget.try_reserve(60).expect("reserve under limit"); + assert!(reservation.try_grow(30)); + assert_eq!(reservation.amount(), 90); + } + + #[test] + fn try_grow_past_remaining_budget_fails_and_leaves_reservation_unchanged() { + let budget = ByteBudget::new(100); + let mut reservation = budget.try_reserve(60).expect("reserve under limit"); + assert!(!reservation.try_grow(41)); + assert_eq!(reservation.amount(), 60); + let remaining = budget.try_reserve(40).expect("untouched remaining budget"); + assert_eq!(remaining.amount(), 40); + } + + #[test] + fn try_grow_zero_is_a_no_op() { + let budget = ByteBudget::new(100); + let mut reservation = budget.try_reserve(60).expect("reserve under limit"); + assert!(reservation.try_grow(0)); + assert_eq!(reservation.amount(), 60); + } + + #[test] + fn shrink_to_releases_delta_back_to_budget() { + let budget = ByteBudget::new(100); + let mut reservation = budget.try_reserve(100).expect("reserve at limit"); + reservation.shrink_to(70); + assert_eq!(reservation.amount(), 70); + let released = budget.try_reserve(30).expect("released delta available"); + assert_eq!(released.amount(), 30); + } + + #[test] + fn drop_releases_full_reservation() { + let budget = ByteBudget::new(100); + let reservation = budget.try_reserve(100).expect("reserve at limit"); + drop(reservation); + let reservation = budget + .try_reserve(100) + .expect("full budget available again"); + assert_eq!(reservation.amount(), 100); + } + + #[test] + fn resident_bytes_uses_reservation_amount_when_budgeted() { + let budget = ByteBudget::new(100); + let reservation = budget.try_reserve(50).expect("reserve under limit"); + let budgeted = BudgetedBytes::budgeted(Bytes::from_static(b"hello"), reservation); + assert_eq!(budgeted.len(), 5); + assert_eq!(budgeted.resident_bytes(), 50); + } + + #[test] + fn resident_bytes_uses_data_len_when_unbudgeted() { + let unbudgeted = BudgetedBytes::unbudgeted(Bytes::from_static(b"hello")); + assert_eq!(unbudgeted.resident_bytes(), 5); + } + + #[test] + fn empty_data_reports_empty_and_exposes_the_underlying_bytes() { + let empty = BudgetedBytes::unbudgeted(Bytes::new()); + assert!(empty.is_empty()); + assert_eq!(empty.as_bytes(), &Bytes::new()); + let hello = BudgetedBytes::unbudgeted(Bytes::from_static(b"hello")); + assert!(!hello.is_empty()); + assert_eq!(hello.as_bytes(), &Bytes::from_static(b"hello")); + } +} diff --git a/fluxer_media_proxy/src/byte_cache.rs b/fluxer_media_proxy/src/byte_cache.rs index 808eb1c2d..6520f3100 100644 --- a/fluxer_media_proxy/src/byte_cache.rs +++ b/fluxer_media_proxy/src/byte_cache.rs @@ -1,55 +1,231 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use bytes::Bytes; -use moka::sync::Cache as MokaCache; -use std::time::Duration; +use crate::byte_budget::BudgetedBytes; +use moka::{policy::EvictionPolicy, sync::Cache as MokaCache}; +use std::{mem::size_of, num::NonZeroUsize, time::Duration}; +use thiserror::Error; -#[derive(Debug)] -pub struct Cache { - enabled: bool, - max_entry_bytes: usize, - inner: MokaCache, +const BYTE_CACHE_ENTRY_OVERHEAD_BYTES: usize = 512; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ByteCacheSettings { + limits: Option, } -impl Cache { - pub fn new(capacity_bytes: usize, max_entry_bytes: usize, ttl_ms: u64) -> Self { - let enabled = capacity_bytes > 0 && max_entry_bytes > 0 && ttl_ms > 0; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ByteCacheLimits { + capacity_bytes: u64, + max_entry_bytes: NonZeroUsize, + ttl: Duration, +} + +#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)] +pub enum ByteCacheSettingsError { + #[error("byte cache capacity must be positive")] + CapacityIsZero, + #[error("byte cache capacity does not fit in u64")] + CapacityExceedsU64, + #[error("byte cache maximum entry size must be positive")] + MaxEntryBytesIsZero, + #[error("byte cache maximum entry size does not fit in u64")] + MaxEntryBytesExceedsU64, + #[error("byte cache maximum entry size exceeds its capacity")] + MaxEntryBytesExceedsCapacity, + #[error("byte cache TTL must be positive")] + TtlIsZero, +} + +impl ByteCacheSettings { + pub const fn disabled() -> Self { + Self { limits: None } + } + + pub fn try_new( + capacity_bytes: usize, + max_entry_bytes: usize, + ttl_ms: u64, + ) -> Result { + let capacity_bytes = u64::try_from(capacity_bytes) + .map_err(|_| ByteCacheSettingsError::CapacityExceedsU64)?; + if capacity_bytes == 0 { + return Err(ByteCacheSettingsError::CapacityIsZero); + } + let max_entry_bytes = NonZeroUsize::new(max_entry_bytes) + .ok_or(ByteCacheSettingsError::MaxEntryBytesIsZero)?; + if u64::try_from(max_entry_bytes.get()) + .map_err(|_| ByteCacheSettingsError::MaxEntryBytesExceedsU64)? + > capacity_bytes + { + return Err(ByteCacheSettingsError::MaxEntryBytesExceedsCapacity); + } + if ttl_ms == 0 { + return Err(ByteCacheSettingsError::TtlIsZero); + } + Ok(Self { + limits: Some(ByteCacheLimits { + capacity_bytes, + max_entry_bytes, + ttl: Duration::from_millis(ttl_ms), + }), + }) + } + + pub fn clamped(capacity_bytes: usize, max_entry_bytes: usize, ttl_ms: u64) -> Self { + Self::try_new(capacity_bytes, max_entry_bytes.min(capacity_bytes), ttl_ms) + .unwrap_or_else(|_| Self::disabled()) + } +} + +pub trait ByteCacheValue: Clone + Send + Sync + 'static { + fn bytes(&self) -> &BudgetedBytes; +} + +impl ByteCacheValue for BudgetedBytes { + fn bytes(&self) -> &BudgetedBytes { + self + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[must_use] +pub enum ByteCacheInsertOutcome { + Inserted, + Rejected(ByteCacheInsertRejection), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ByteCacheInsertRejection { + CacheDisabled, + EmptyValue, + MaxEntryBytesExceeded, + WeightExceedsCapacity, + WeightExceedsU32, +} + +#[derive(Debug)] +pub struct ByteCache { + storage: Option>, +} + +#[derive(Debug)] +struct ByteCacheStorage { + capacity_bytes: u64, + max_entry_bytes: NonZeroUsize, + inner: MokaCache, +} + +impl ByteCache +where + T: ByteCacheValue, +{ + pub fn new( + settings: ByteCacheSettings, + on_eviction: impl Fn() + Send + Sync + 'static, + ) -> Self { + let Some(limits) = settings.limits else { + return Self { storage: None }; + }; + let inner = MokaCache::builder() + .max_capacity(limits.capacity_bytes) + .eviction_policy(EvictionPolicy::tiny_lfu()) + .weigher(|key: &String, value: &T| { + entry_weight(key.capacity(), value) + .expect("byte cache insertion validates that entry weight fits u32") + }) + .eviction_listener(move |_key, _value, cause| { + if cause.was_evicted() { + on_eviction(); + } + }) + .time_to_live(limits.ttl) + .build(); Self { - enabled, - max_entry_bytes: max_entry_bytes.min(capacity_bytes), - inner: MokaCache::builder() - .max_capacity(capacity_bytes as u64) - .weigher(|_key: &String, value: &Bytes| -> u32 { - value.len().min(u32::MAX as usize) as u32 - }) - .time_to_live(Duration::from_millis(ttl_ms)) - .build(), + storage: Some(ByteCacheStorage { + capacity_bytes: limits.capacity_bytes, + max_entry_bytes: limits.max_entry_bytes, + inner, + }), } } - pub fn get(&self, key: &str) -> Option { - if !self.enabled { - return None; - } - self.inner.get(key) + pub fn get(&self, key: &str) -> Option { + self.storage.as_ref()?.inner.get(key) } - pub fn put(&self, key: impl Into, data: Bytes) { - if !self.enabled || data.is_empty() || data.len() > self.max_entry_bytes { - return; + pub fn put(&self, key: impl Into, value: impl Into) -> ByteCacheInsertOutcome { + let Some(storage) = self.storage.as_ref() else { + return ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::CacheDisabled); + }; + let value = value.into(); + if value.bytes().is_empty() { + return ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::EmptyValue); } - self.inner.insert(key.into(), data); + if value.bytes().resident_bytes() > storage.max_entry_bytes.get() { + return ByteCacheInsertOutcome::Rejected( + ByteCacheInsertRejection::MaxEntryBytesExceeded, + ); + } + let key = key.into(); + let Some(weight) = entry_weight(key.capacity(), &value) else { + return ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::WeightExceedsU32); + }; + if u64::from(weight) > storage.capacity_bytes { + return ByteCacheInsertOutcome::Rejected( + ByteCacheInsertRejection::WeightExceedsCapacity, + ); + } + storage.inner.insert(key, value); + ByteCacheInsertOutcome::Inserted } + + #[cfg(test)] + pub(crate) fn settle(&self) { + if let Some(storage) = self.storage.as_ref() { + storage.inner.run_pending_tasks(); + } + } + + #[cfg(test)] + fn weighted_size(&self) -> u64 { + self.storage + .as_ref() + .map(|storage| storage.inner.weighted_size()) + .unwrap_or(0) + } +} + +fn entry_weight(key_bytes: usize, value: &T) -> Option { + BYTE_CACHE_ENTRY_OVERHEAD_BYTES + .checked_add(size_of::()) + .and_then(|weight| weight.checked_add(size_of::())) + .and_then(|weight| weight.checked_add(key_bytes)) + .and_then(|weight| weight.checked_add(value.bytes().resident_bytes())) + .and_then(|weight| u32::try_from(weight).ok()) } #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; + use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }; + + fn byte_cache(capacity_bytes: usize, max_entry_bytes: usize, ttl_ms: u64) -> ByteCache { + ByteCache::new( + ByteCacheSettings::clamped(capacity_bytes, max_entry_bytes, ttl_ms), + || {}, + ) + } #[test] fn byte_cache_roundtrips_returning_shared_handles() { - let cache = Cache::new(64, 64, 60_000); - cache.put("a", Bytes::from_static(b"abc")); + let cache = byte_cache(4096, 4096, 60_000); + assert_eq!( + ByteCacheInsertOutcome::Inserted, + cache.put("a", Bytes::from_static(b"abc")) + ); let a1 = cache.get("a").unwrap(); assert_eq!(b"abc", a1.as_ref()); let a2 = cache.get("a").unwrap(); @@ -58,29 +234,147 @@ mod tests { #[test] fn byte_cache_bounds_total_weighted_size() { - let cache = Cache::new(8, 8, 60_000); + let cache = byte_cache(4096, 4096, 60_000); for i in 0..50 { - cache.put(format!("k{i}"), Bytes::from(vec![0u8; 4])); + let _ = cache.put(format!("k{i}"), Bytes::from(vec![0u8; 4])); } - cache.inner.run_pending_tasks(); - assert!(cache.inner.weighted_size() <= 8); + cache.settle(); + assert!(cache.weighted_size() <= 4096); } #[test] fn byte_cache_skips_entries_over_max_entry_size() { - let cache = Cache::new(32, 4, 60_000); - cache.put("large", Bytes::from_static(b"12345")); - assert_eq!(None, cache.get("large")); + let cache = byte_cache(4096, 4, 60_000); + assert_eq!( + ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::MaxEntryBytesExceeded), + cache.put("large", Bytes::from_static(b"12345")) + ); + assert_eq!( + None, + cache.get("large").map(|value| value.as_bytes().clone()) + ); + } + + #[test] + fn byte_cache_clamps_the_entry_ceiling_to_the_capacity() { + let cache = byte_cache(4096, 8192, 60_000); + assert_eq!( + ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::MaxEntryBytesExceeded), + cache.put("over", Bytes::from(vec![7u8; 5000])) + ); + assert_eq!( + None, + cache.get("over").map(|value| value.as_bytes().clone()) + ); + assert_eq!( + ByteCacheInsertOutcome::Inserted, + cache.put("fits", Bytes::from(vec![7u8; 3000])) + ); + assert_eq!( + Some(Bytes::from(vec![7u8; 3000])), + cache.get("fits").map(|value| value.as_bytes().clone()) + ); } #[test] fn byte_cache_disabled_when_capacity_or_ttl_is_zero() { - let no_capacity = Cache::new(0, 4, 60_000); - no_capacity.put("a", Bytes::from_static(b"abc")); - assert_eq!(None, no_capacity.get("a")); + let no_capacity = byte_cache(0, 4, 60_000); + assert_eq!( + ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::CacheDisabled), + no_capacity.put("a", Bytes::from_static(b"abc")) + ); + assert_eq!( + None, + no_capacity.get("a").map(|value| value.as_bytes().clone()) + ); - let no_ttl = Cache::new(32, 4, 0); - no_ttl.put("a", Bytes::from_static(b"abc")); - assert_eq!(None, no_ttl.get("a")); + let no_ttl = byte_cache(32, 4, 0); + assert_eq!( + ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::CacheDisabled), + no_ttl.put("a", Bytes::from_static(b"abc")) + ); + assert_eq!(None, no_ttl.get("a").map(|value| value.as_bytes().clone())); + + let no_max_entry = byte_cache(4096, 0, 60_000); + assert_eq!( + ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::CacheDisabled), + no_max_entry.put("a", Bytes::from_static(b"abc")) + ); + } + + #[test] + fn byte_cache_settings_reject_every_invalid_shape() { + assert_eq!( + Err(ByteCacheSettingsError::CapacityIsZero), + ByteCacheSettings::try_new(0, 1, 1) + ); + assert_eq!( + Err(ByteCacheSettingsError::MaxEntryBytesIsZero), + ByteCacheSettings::try_new(16, 0, 1) + ); + assert_eq!( + Err(ByteCacheSettingsError::MaxEntryBytesExceedsCapacity), + ByteCacheSettings::try_new(16, 17, 1) + ); + assert_eq!( + Err(ByteCacheSettingsError::TtlIsZero), + ByteCacheSettings::try_new(16, 16, 0) + ); + assert_eq!( + ByteCacheSettings::disabled(), + ByteCacheSettings::clamped(16, 17, 0) + ); + assert_eq!( + ByteCacheSettings::try_new(16, 16, 1), + Ok(ByteCacheSettings::clamped(16, 17, 1)) + ); + } + + #[test] + fn byte_cache_rejects_an_empty_value_and_a_weight_over_the_capacity() { + let cache = byte_cache(2_048, 1_024, 60_000); + assert_eq!( + ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::EmptyValue), + cache.put("empty", Bytes::new()) + ); + assert_eq!( + ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::MaxEntryBytesExceeded), + cache.put("large", Bytes::from(vec![0; 1_025])) + ); + assert_eq!( + ByteCacheInsertOutcome::Inserted, + cache.put("0123456789abcdef", Bytes::from_static(b"0123456789abcdef")) + ); + assert_eq!( + b"0123456789abcdef", + cache + .get("0123456789abcdef") + .expect("cached bytes") + .as_ref() + ); + + let tight = byte_cache(1_024, 1_024, 60_000); + assert_eq!( + ByteCacheInsertOutcome::Rejected(ByteCacheInsertRejection::WeightExceedsCapacity), + tight.put("key", Bytes::from(vec![0; 1_024])) + ); + } + + #[test] + fn byte_cache_reports_evictions_through_the_hook() { + let evictions = Arc::new(AtomicU64::new(0)); + let recorded = Arc::clone(&evictions); + let cache: ByteCache = ByteCache::new( + ByteCacheSettings::clamped(8_192, 8_192, 60_000), + move || { + recorded.fetch_add(1, Ordering::Relaxed); + }, + ); + for index in 0..64 { + let _ = cache.put(format!("entry-{index}"), Bytes::from(vec![0u8; 512])); + } + cache.settle(); + assert!(cache.weighted_size() <= 8_192); + assert!(evictions.load(Ordering::Relaxed) > 0); } } diff --git a/fluxer_media_proxy/src/cli.rs b/fluxer_media_proxy/src/cli.rs index 54750b50e..e99a9e313 100644 --- a/fluxer_media_proxy/src/cli.rs +++ b/fluxer_media_proxy/src/cli.rs @@ -73,14 +73,14 @@ fn apply_overrides(args: &Args, cfg: &mut Config) -> anyhow::Result<()> { cfg.mode = mode.into(); } if let Some(storage_backend) = args.storage_backend { - cfg.storage_backend = storage_backend.into(); + cfg.storage.backend = storage_backend.into(); } if let Some(storage_root) = args.storage_root.as_deref() { anyhow::ensure!( !storage_root.trim().is_empty(), "--storage-root cannot be empty" ); - cfg.storage_root = storage_root.to_owned(); + cfg.storage.root = storage_root.to_owned(); } if args.read_only { cfg.read_only = true; @@ -140,8 +140,8 @@ mod tests { assert_eq!("127.0.0.1", cfg.bind_host); assert_eq!(18080, cfg.port); assert_eq!(DeploymentMode::Static, cfg.mode); - assert_eq!(StorageBackend::S3, cfg.storage_backend); - assert_eq!("/srv/media", cfg.storage_root); + assert_eq!(StorageBackend::S3, cfg.storage.backend); + assert_eq!("/srv/media", cfg.storage.root); assert!(cfg.read_only); } diff --git a/fluxer_media_proxy/src/coalescer.rs b/fluxer_media_proxy/src/coalescer.rs deleted file mode 100644 index a5b4a06de..000000000 --- a/fluxer_media_proxy/src/coalescer.rs +++ /dev/null @@ -1,338 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use bytes::Bytes; -use parking_lot::Mutex; -use std::{collections::HashMap, future::Future, sync::Arc, time::Instant}; -use thiserror::Error; -use tokio::sync::Notify; - -#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] -pub enum CoalescerError { - #[error("request timed out")] - RequestTimeout, - #[error("coalesced work failed")] - WorkFailed, -} - -#[derive(Debug)] -struct Slot { - state: Mutex>>, - notify: Notify, -} - -#[derive(Debug, Default)] -pub struct ByteCoalescer { - in_flight: Mutex>>, -} - -struct CleanupGuard<'a> { - in_flight: &'a Mutex>>, - key: &'a str, - slot: &'a Arc, - completed: bool, -} - -impl Drop for CleanupGuard<'_> { - fn drop(&mut self) { - if !self.completed { - self.in_flight.lock().remove(self.key); - let mut state = self.slot.state.lock(); - *state = Some(Err(CoalescerError::WorkFailed)); - self.slot.notify.notify_waiters(); - } - } -} - -impl ByteCoalescer { - pub fn new() -> Self { - Self::default() - } - - pub async fn run_once( - &self, - key: impl Into, - work: F, - ) -> Result - where - F: FnOnce() -> Fut, - Fut: Future>>, - { - self.run_once_until(key, None, work).await - } - - pub async fn run_once_until( - &self, - key: impl Into, - deadline: Option, - work: F, - ) -> Result - where - F: FnOnce() -> Fut, - Fut: Future>>, - { - let key = key.into(); - let (slot, leader) = { - let mut in_flight = self.in_flight.lock(); - if let Some(existing) = in_flight.get(&key) { - (existing.clone(), false) - } else { - let slot = Arc::new(Slot { - state: Mutex::new(None), - notify: Notify::new(), - }); - in_flight.insert(key.clone(), slot.clone()); - (slot, true) - } - }; - - if leader { - crate::metrics::GLOBAL - .coalescer_leader - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - - let mut guard = CleanupGuard { - in_flight: &self.in_flight, - key: key.as_str(), - slot: &slot, - completed: false, - }; - - let result = work().await.map(Bytes::from).map_err(coalesced_work_error); - *slot.state.lock() = Some(result.clone()); - guard.completed = true; - drop(guard); - slot.notify.notify_waiters(); - self.in_flight.lock().remove(&key); - - result - } else { - drop(work); - crate::metrics::GLOBAL - .coalescer_waiter - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - loop { - let notified = slot.notify.notified(); - tokio::pin!(notified); - notified.as_mut().enable(); - if let Some(result) = slot.state.lock().as_ref().cloned() { - return result; - } - if let Some(deadline) = deadline { - let now = Instant::now(); - if now >= deadline { - return Err(CoalescerError::RequestTimeout); - } - if tokio::time::timeout_at(deadline.into(), notified) - .await - .is_err() - { - return Err(CoalescerError::RequestTimeout); - } - } else { - notified.await; - } - } - } - } -} - -fn coalesced_work_error(error: anyhow::Error) -> CoalescerError { - if let Some(known) = error.downcast_ref::().copied() { - return known; - } - tracing::error!(error = ?error, "coalesced work failed"); - CoalescerError::WorkFailed -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicU32, Ordering}; - use tokio::time::{Duration, sleep}; - - #[tokio::test] - async fn single_thread_run_once_returns_work_output() { - let coalescer = ByteCoalescer::new(); - let counter = AtomicU32::new(0); - let result = coalescer - .run_once("k", || async { - counter.fetch_add(1, Ordering::SeqCst); - Ok(b"hello".to_vec()) - }) - .await - .unwrap(); - assert_eq!(b"hello", result.as_ref()); - assert_eq!(1, counter.load(Ordering::SeqCst)); - } - - #[tokio::test] - async fn failure_propagates() { - let coalescer = ByteCoalescer::new(); - let err = coalescer - .run_once("k", || async { - Err::, _>(anyhow::anyhow!("intentional")) - }) - .await - .unwrap_err(); - assert_eq!(CoalescerError::WorkFailed, err); - } - - #[tokio::test] - async fn waiter_can_time_out_behind_a_slow_leader() { - let coalescer = Arc::new(ByteCoalescer::new()); - let leader = coalescer.clone(); - let task = tokio::spawn(async move { - let _ = leader - .run_once("slow-key", || async { - sleep(Duration::from_millis(50)).await; - Ok(b"slow".to_vec()) - }) - .await; - }); - sleep(Duration::from_millis(5)).await; - let err = coalescer - .run_once_until( - "slow-key", - Some(Instant::now() + Duration::from_millis(1)), - || async { Ok(b"should-not-run".to_vec()) }, - ) - .await - .unwrap_err(); - assert_eq!(CoalescerError::RequestTimeout, err); - task.await.unwrap(); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_dedup_counter_not_more_than_total_calls() { - let coalescer = Arc::new(ByteCoalescer::new()); - let counter = Arc::new(AtomicU32::new(0)); - let mut tasks = Vec::new(); - for _ in 0..4 { - let c = coalescer.clone(); - let counter = counter.clone(); - tasks.push(tokio::spawn(async move { - for _ in 0..50 { - let counter = counter.clone(); - let result = c - .run_once("hot-key", || async move { - counter.fetch_add(1, Ordering::SeqCst); - sleep(Duration::from_millis(1)).await; - Ok(b"OK".to_vec()) - }) - .await - .unwrap(); - assert_eq!(b"OK", result.as_ref()); - } - })); - } - for task in tasks { - task.await.unwrap(); - } - let total = 4 * 50; - let observed = counter.load(Ordering::SeqCst); - assert!(observed > 0); - assert!(observed <= total); - } - - #[tokio::test] - async fn cancelled_leader_does_not_poison_the_key() { - let coalescer = Arc::new(ByteCoalescer::new()); - let leader = coalescer.clone(); - let task = tokio::spawn(async move { - let _ = leader - .run_once("poison-key", || async { - sleep(Duration::from_secs(60)).await; - Ok(b"never".to_vec()) - }) - .await; - }); - sleep(Duration::from_millis(20)).await; - task.abort(); - let _ = task.await; - - let result = coalescer - .run_once_until( - "poison-key", - Some(Instant::now() + Duration::from_secs(2)), - || async { Ok(b"recovered".to_vec()) }, - ) - .await; - assert_eq!( - b"recovered".as_ref(), - result - .expect("cancelled leader left the key poisoned in in_flight") - .as_ref() - ); - } - - #[tokio::test] - async fn waiters_are_released_when_the_leader_is_cancelled() { - let coalescer = Arc::new(ByteCoalescer::new()); - let leader = coalescer.clone(); - let task = tokio::spawn(async move { - let _ = leader - .run_once("released-key", || async { - sleep(Duration::from_secs(60)).await; - Ok(b"never".to_vec()) - }) - .await; - }); - sleep(Duration::from_millis(20)).await; - - let waiter_coalescer = coalescer.clone(); - let waiter = tokio::spawn(async move { - waiter_coalescer - .run_once_until( - "released-key", - Some(Instant::now() + Duration::from_secs(60)), - || async { Ok(b"should-not-run".to_vec()) }, - ) - .await - }); - sleep(Duration::from_millis(20)).await; - task.abort(); - let _ = task.await; - - let released = tokio::time::timeout(Duration::from_secs(2), waiter).await; - assert!( - released.is_ok(), - "waiter was not released when the leader was cancelled" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 8)] - async fn waiters_never_miss_a_completion_notification() { - for round in 0..1000u32 { - let coalescer = Arc::new(ByteCoalescer::new()); - let key = format!("race-{round}"); - let leader_key = key.clone(); - let leader = coalescer.clone(); - let lead = tokio::spawn(async move { - let _ = leader - .run_once(leader_key, || async { Ok(b"done".to_vec()) }) - .await; - }); - let mut waiters = Vec::new(); - for _ in 0..4 { - let waiter_coalescer = coalescer.clone(); - let waiter_key = key.clone(); - waiters.push(tokio::spawn(async move { - waiter_coalescer - .run_once_until( - waiter_key, - Some(Instant::now() + Duration::from_secs(10)), - || async { Ok(b"waiter-ran-work".to_vec()) }, - ) - .await - })); - } - let _ = lead.await; - for waiter in waiters { - assert!( - !matches!(waiter.await, Ok(Err(CoalescerError::RequestTimeout))), - "waiter missed the completion notification in round {round}" - ); - } - } - } -} diff --git a/fluxer_media_proxy/src/coalescer/mod.rs b/fluxer_media_proxy/src/coalescer/mod.rs new file mode 100644 index 000000000..2c23f4d23 --- /dev/null +++ b/fluxer_media_proxy/src/coalescer/mod.rs @@ -0,0 +1,257 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::{BudgetedBytes, ByteBudget}, + media_process::{MediaBytes, MediaError}, + metrics::cache::CoalescerMetrics, +}; +use parking_lot::Mutex; +use std::{collections::HashMap, future::Future, sync::Arc, time::Instant}; +use thiserror::Error; +use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore, TryAcquireError}; + +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum CoalescerError { + #[error("native transform queue is full")] + Overloaded, + #[error("coalesced work is unavailable")] + Unavailable, + #[error("buffered output byte budget exhausted")] + BufferBudgetExhausted, + #[error("native transform allocation failed")] + AllocationFailed, + #[error("request timed out")] + RequestTimeout, + #[error("coalesced work failed")] + WorkFailed, + #[error("coalesced work was cancelled")] + WorkCancelled, +} + +type SlotClaim = ( + Arc, + Option, + Option, +); + +#[derive(Debug)] +struct Slot { + state: Mutex>>, + notify: Notify, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CoordinatorState { + Active, + Published, +} + +struct CoordinatorGuard<'a> { + coalescer: &'a ByteCoalescer, + key: &'a str, + slot: &'a Arc, + _permit: OwnedSemaphorePermit, + state: CoordinatorState, +} + +impl CoordinatorGuard<'_> { + fn publish(&mut self, result: Result, complete: C) + where + C: FnOnce(&BudgetedBytes), + { + if let Ok(bytes) = &result { + complete(bytes); + } + { + let mut state = self.slot.state.lock(); + assert!(state.is_none()); + *state = Some(result); + } + self.coalescer.release(self.key); + self.state = CoordinatorState::Published; + self.slot.notify.notify_waiters(); + } +} + +impl Drop for CoordinatorGuard<'_> { + fn drop(&mut self) { + if self.state == CoordinatorState::Published { + return; + } + { + let mut state = self.slot.state.lock(); + if state.is_none() { + *state = Some(Err(CoalescerError::WorkCancelled)); + } + } + self.coalescer.release(self.key); + self.slot.notify.notify_waiters(); + } +} + +#[derive(Debug)] +pub struct ByteCoalescer { + in_flight: Mutex>>, + budget: ByteBudget, + coordinator_capacity: Arc, + waiter_capacity: Arc, + idle: Notify, + metrics: Arc, +} + +impl ByteCoalescer { + pub const UNBOUNDED_CAPACITY: usize = Semaphore::MAX_PERMITS; + + pub fn with_budget( + budget: ByteBudget, + max_in_flight: usize, + max_waiters: usize, + metrics: Arc, + ) -> Self { + assert!(max_in_flight > 0); + assert!(max_waiters > 0); + Self { + in_flight: Mutex::new(HashMap::new()), + budget, + coordinator_capacity: Arc::new(Semaphore::new(max_in_flight)), + waiter_capacity: Arc::new(Semaphore::new(max_waiters)), + idle: Notify::new(), + metrics, + } + } + + pub fn begin_shutdown(&self) { + self.coordinator_capacity.close(); + self.waiter_capacity.close(); + } + + pub async fn wait_for_shutdown(&self) { + assert!(self.coordinator_capacity.is_closed()); + loop { + let idle = self.idle.notified(); + tokio::pin!(idle); + idle.as_mut().enable(); + if self.in_flight.lock().is_empty() { + return; + } + idle.await; + } + } + + pub async fn run_once_until( + &self, + key: impl Into, + deadline: Option, + work: F, + complete: C, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + C: FnOnce(&BudgetedBytes), + { + let key = key.into(); + let (slot, coordinator_permit, waiter_permit) = self.claim(&key)?; + let _waiter_permit = waiter_permit; + + if let Some(permit) = coordinator_permit { + self.metrics.record_leader(); + let mut guard = CoordinatorGuard { + coalescer: self, + key: key.as_str(), + slot: &slot, + _permit: permit, + state: CoordinatorState::Active, + }; + let result = work().await.map_err(coalesced_work_error).and_then(|data| { + data.try_into_budgeted(&self.budget) + .ok_or(CoalescerError::BufferBudgetExhausted) + }); + guard.publish(result.clone(), complete); + return result; + } + + drop(work); + drop(complete); + self.metrics.record_waiter(); + loop { + let notified = slot.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if let Some(result) = slot.state.lock().as_ref().cloned() { + return result; + } + if let Some(deadline) = deadline { + if Instant::now() >= deadline { + return Err(CoalescerError::RequestTimeout); + } + if tokio::time::timeout_at(deadline.into(), notified) + .await + .is_err() + { + return Err(CoalescerError::RequestTimeout); + } + } else { + notified.await; + } + } + } + + fn claim(&self, key: &str) -> Result { + let mut in_flight = self.in_flight.lock(); + if let Some(existing) = in_flight.get(key) { + let permit = self + .waiter_capacity + .clone() + .try_acquire_owned() + .map_err(|error| match error { + TryAcquireError::NoPermits => { + self.metrics.record_waiter_rejected(); + CoalescerError::Overloaded + } + TryAcquireError::Closed => CoalescerError::Unavailable, + })?; + return Ok((existing.clone(), None, Some(permit))); + } + let permit = self + .coordinator_capacity + .clone() + .try_acquire_owned() + .map_err(|error| match error { + TryAcquireError::NoPermits => CoalescerError::Overloaded, + TryAcquireError::Closed => CoalescerError::Unavailable, + })?; + let slot = Arc::new(Slot { + state: Mutex::new(None), + notify: Notify::new(), + }); + in_flight.insert(key.to_owned(), slot.clone()); + Ok((slot, Some(permit), None)) + } + + fn release(&self, key: &str) { + let idle = { + let mut in_flight = self.in_flight.lock(); + let removed = in_flight.remove(key); + assert!(removed.is_some()); + in_flight.is_empty() + }; + if idle { + self.idle.notify_waiters(); + } + } +} + +fn coalesced_work_error(error: anyhow::Error) -> CoalescerError { + if let Some(known) = error.downcast_ref::().copied() { + return known; + } + if error.downcast_ref::() == Some(&MediaError::AllocationFailed) { + return CoalescerError::AllocationFailed; + } + tracing::error!(error = ?error, "coalesced work failed"); + CoalescerError::WorkFailed +} diff --git a/fluxer_media_proxy/src/coalescer/tests.rs b/fluxer_media_proxy/src/coalescer/tests.rs new file mode 100644 index 000000000..45a9053a7 --- /dev/null +++ b/fluxer_media_proxy/src/coalescer/tests.rs @@ -0,0 +1,411 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::*; +use crate::metrics::Metrics; +use std::sync::atomic::{AtomicU32, Ordering}; +use tokio::time::{Duration, sleep}; + +fn coalescer() -> Arc { + Arc::new(ByteCoalescer::with_budget( + ByteBudget::new(1 << 20), + 8, + 64, + Arc::new(CoalescerMetrics::new()), + )) +} + +#[tokio::test] +async fn single_thread_run_once_returns_work_output() { + let coalescer = coalescer(); + let counter = AtomicU32::new(0); + let result = coalescer + .run_once_until( + "k", + None, + || async { + counter.fetch_add(1, Ordering::SeqCst); + Ok(MediaBytes::from(b"hello".to_vec())) + }, + |_| {}, + ) + .await + .unwrap(); + assert_eq!(b"hello", result.as_ref()); + assert_eq!(1, counter.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn failure_propagates() { + let coalescer = coalescer(); + let err = coalescer + .run_once_until( + "k", + None, + || async { Err::(anyhow::anyhow!("intentional")) }, + |_| {}, + ) + .await + .unwrap_err(); + assert_eq!(CoalescerError::WorkFailed, err); +} + +#[tokio::test] +async fn waiter_can_time_out_behind_a_slow_leader() { + let coalescer = coalescer(); + let leader = coalescer.clone(); + let task = tokio::spawn(async move { + let _ = leader + .run_once_until( + "slow-key", + None, + || async { + sleep(Duration::from_millis(50)).await; + Ok(MediaBytes::from(b"slow".to_vec())) + }, + |_| {}, + ) + .await; + }); + sleep(Duration::from_millis(5)).await; + let err = coalescer + .run_once_until( + "slow-key", + Some(Instant::now() + Duration::from_millis(1)), + || async { Ok(MediaBytes::from(b"should-not-run".to_vec())) }, + |_| {}, + ) + .await + .unwrap_err(); + assert_eq!(CoalescerError::RequestTimeout, err); + task.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_dedup_counter_not_more_than_total_calls() { + let coalescer = coalescer(); + let counter = Arc::new(AtomicU32::new(0)); + let mut tasks = Vec::new(); + for _ in 0..4 { + let c = coalescer.clone(); + let counter = counter.clone(); + tasks.push(tokio::spawn(async move { + for _ in 0..50 { + let counter = counter.clone(); + let result = c + .run_once_until( + "hot-key", + None, + || async move { + counter.fetch_add(1, Ordering::SeqCst); + sleep(Duration::from_millis(1)).await; + Ok(MediaBytes::from(b"OK".to_vec())) + }, + |_| {}, + ) + .await + .unwrap(); + assert_eq!(b"OK", result.as_ref()); + } + })); + } + for task in tasks { + task.await.unwrap(); + } + let total = 4 * 50; + let observed = counter.load(Ordering::SeqCst); + assert!(observed > 0); + assert!(observed <= total); +} + +#[tokio::test] +async fn cancelled_leader_does_not_poison_the_key() { + let coalescer = coalescer(); + let leader = coalescer.clone(); + let task = tokio::spawn(async move { + let _ = leader + .run_once_until( + "poison-key", + None, + || async { + sleep(Duration::from_secs(60)).await; + Ok(MediaBytes::from(b"never".to_vec())) + }, + |_| {}, + ) + .await; + }); + sleep(Duration::from_millis(20)).await; + task.abort(); + let _ = task.await; + + let result = coalescer + .run_once_until( + "poison-key", + Some(Instant::now() + Duration::from_secs(2)), + || async { Ok(MediaBytes::from(b"recovered".to_vec())) }, + |_| {}, + ) + .await; + assert_eq!( + b"recovered".as_ref(), + result + .expect("cancelled leader left the key poisoned in in_flight") + .as_ref() + ); +} + +#[tokio::test] +async fn waiters_are_released_when_the_leader_is_cancelled() { + let coalescer = coalescer(); + let leader = coalescer.clone(); + let task = tokio::spawn(async move { + let _ = leader + .run_once_until( + "released-key", + None, + || async { + sleep(Duration::from_secs(60)).await; + Ok(MediaBytes::from(b"never".to_vec())) + }, + |_| {}, + ) + .await; + }); + sleep(Duration::from_millis(20)).await; + + let waiter_coalescer = coalescer.clone(); + let waiter = tokio::spawn(async move { + waiter_coalescer + .run_once_until( + "released-key", + Some(Instant::now() + Duration::from_secs(60)), + || async { Ok(MediaBytes::from(b"should-not-run".to_vec())) }, + |_| {}, + ) + .await + }); + sleep(Duration::from_millis(20)).await; + task.abort(); + let _ = task.await; + + let released = tokio::time::timeout(Duration::from_secs(2), waiter).await; + assert!( + released.is_ok(), + "waiter was not released when the leader was cancelled" + ); + assert_eq!( + Some(CoalescerError::WorkCancelled), + released.unwrap().unwrap().err() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 8)] +async fn waiters_never_miss_a_completion_notification() { + for round in 0..1000u32 { + let coalescer = coalescer(); + let key = format!("race-{round}"); + let leader_key = key.clone(); + let leader = coalescer.clone(); + let lead = tokio::spawn(async move { + let _ = leader + .run_once_until( + leader_key, + None, + || async { Ok(MediaBytes::from(b"done".to_vec())) }, + |_| {}, + ) + .await; + }); + let mut waiters = Vec::new(); + for _ in 0..4 { + let waiter_coalescer = coalescer.clone(); + let waiter_key = key.clone(); + waiters.push(tokio::spawn(async move { + waiter_coalescer + .run_once_until( + waiter_key, + Some(Instant::now() + Duration::from_secs(10)), + || async { Ok(MediaBytes::from(b"waiter-ran-work".to_vec())) }, + |_| {}, + ) + .await + })); + } + let _ = lead.await; + for waiter in waiters { + assert!( + !matches!(waiter.await, Ok(Err(CoalescerError::RequestTimeout))), + "waiter missed the completion notification in round {round}" + ); + } + } +} + +#[tokio::test] +async fn byte_coalescer_shares_one_result_with_waiters() { + let metrics = Metrics::new(); + let coalescer = Arc::new(ByteCoalescer::with_budget( + ByteBudget::new(1024), + 1, + 8, + metrics.coalescer(), + )); + let published = Arc::new(Mutex::new(Vec::::new())); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + + let leader = { + let coalescer = Arc::clone(&coalescer); + let published = Arc::clone(&published); + tokio::spawn(async move { + coalescer + .run_once_until( + "same", + None, + move || async move { + started_tx.send(()).expect("leader start receiver"); + release_rx.await.expect("leader release sender"); + Ok(MediaBytes::from(vec![1, 2, 3])) + }, + move |bytes| { + *published.lock() = bytes.as_ref().to_vec(); + }, + ) + .await + }) + }; + started_rx.await.expect("leader started"); + let waiter = { + let coalescer = Arc::clone(&coalescer); + let published = Arc::clone(&published); + tokio::spawn(async move { + coalescer + .run_once_until( + "same", + None, + || async { Ok(MediaBytes::from(vec![7, 7, 7])) }, + move |bytes| { + *published.lock() = bytes.as_ref().to_vec(); + }, + ) + .await + }) + }; + tokio::task::yield_now().await; + release_tx.send(()).expect("leader release receiver"); + let leader_bytes = leader.await.expect("leader task").expect("leader result"); + let waiter_bytes = waiter.await.expect("waiter task").expect("waiter result"); + assert_eq!(leader_bytes.as_ref(), &[1, 2, 3]); + assert_eq!(waiter_bytes.as_ref(), &[1, 2, 3]); + assert_eq!(published.lock().as_slice(), &[1, 2, 3]); + coalescer.begin_shutdown(); + coalescer.wait_for_shutdown().await; +} + +#[tokio::test] +async fn byte_coalescer_enforces_capacity_budget_and_shutdown() { + let metrics = Metrics::new(); + let coalescer = Arc::new(ByteCoalescer::with_budget( + ByteBudget::new(0), + 1, + 8, + metrics.coalescer(), + )); + assert_eq!( + Err(CoalescerError::BufferBudgetExhausted), + coalescer + .run_once_until( + "budget", + None, + || async { Ok(MediaBytes::from(vec![1])) }, + |_| {}, + ) + .await + .map(|bytes| bytes.as_bytes().clone()) + ); + coalescer.begin_shutdown(); + assert_eq!( + Err(CoalescerError::Unavailable), + coalescer + .run_once_until( + "closed", + None, + || async { Ok(MediaBytes::from(vec![1])) }, + |_| {}, + ) + .await + .map(|bytes| bytes.as_bytes().clone()) + ); + coalescer.wait_for_shutdown().await; +} + +#[tokio::test] +async fn a_waiter_past_the_bound_is_rejected_as_overloaded() { + let metrics = Metrics::new(); + let coalescer = Arc::new(ByteCoalescer::with_budget( + ByteBudget::new(1024), + 1, + 1, + metrics.coalescer(), + )); + let leader = coalescer.clone(); + let task = tokio::spawn(async move { + leader + .run_once_until( + "bounded", + None, + || async { + sleep(Duration::from_millis(80)).await; + Ok(MediaBytes::from(vec![9])) + }, + |_| {}, + ) + .await + }); + sleep(Duration::from_millis(5)).await; + + let admitted = coalescer.clone(); + let waiter = tokio::spawn(async move { + admitted + .run_once_until( + "bounded", + None, + || async { Ok(MediaBytes::from(vec![0])) }, + |_| {}, + ) + .await + }); + sleep(Duration::from_millis(5)).await; + + assert_eq!( + Err(CoalescerError::Overloaded), + coalescer + .run_once_until( + "bounded", + None, + || async { Ok(MediaBytes::from(vec![0])) }, + |_| {}, + ) + .await + .map(|bytes| bytes.as_bytes().clone()) + ); + assert_eq!( + Err(CoalescerError::Overloaded), + coalescer + .run_once_until( + "another-key", + None, + || async { Ok(MediaBytes::from(vec![0])) }, + |_| {}, + ) + .await + .map(|bytes| bytes.as_bytes().clone()) + ); + assert_eq!(&[9], task.await.unwrap().unwrap().as_ref()); + assert_eq!(&[9], waiter.await.unwrap().unwrap().as_ref()); + assert!( + metrics + .render() + .contains("fluxer_media_proxy_coalescer_waiter_rejected_total 1\n") + ); +} diff --git a/fluxer_media_proxy/src/codec.rs b/fluxer_media_proxy/src/codec.rs index 5e8e16321..bc65abd7c 100644 --- a/fluxer_media_proxy/src/codec.rs +++ b/fluxer_media_proxy/src/codec.rs @@ -57,7 +57,8 @@ pub fn validate(buffer: &[u8], sniffed_mime: &str) -> std::result::Result std::result::Result { - media_process::ensure_vips_init().map_err(|_| ValidateError::UnsupportedCodec)?; + media_process::native_runtime::ensure_vips_init() + .map_err(|_| ValidateError::UnsupportedCodec)?; let opts = CString::new("").expect("static string has no NUL"); let image = unsafe { native::fluxer_vips_image_new_from_buffer( @@ -66,7 +67,8 @@ fn validate_image(buffer: &[u8], sniffed_mime: &str) -> std::result::Result (NativeStatus, *mut native::VipsImage) { + ensure_vips_init().expect("libvips initialises"); + let mut out = ptr::null_mut(); + let status = NativeStatus::from_code(unsafe { + native::fluxer_vips_thumbnail_buffer_ex( + input.as_ptr().cast(), + input.len(), + deadline_monotonic_ms, + &mut out, + 16, + 16, + 1, + native::THUMB_CROP_NONE, + 1, + 1_048_576, + ) + }); + (status, out) +} + +#[test] +fn negative_thumbnail_deadline_is_rejected_at_the_native_boundary() { + let (status, out) = thumbnail_status(&minimal_gif(), -1); + assert_eq!(status, NativeStatus::CodecFailure); + assert!(out.is_null()); +} + +#[test] +fn expired_thumbnail_deadline_is_reported_as_deadline_exceeded() { + let (status, out) = thumbnail_status(&minimal_gif(), 1); + assert_eq!(status, NativeStatus::DeadlineExceeded); + assert!(out.is_null()); +} + +#[test] +fn empty_thumbnail_input_is_rejected_before_libvips_runs() { + let (status, out) = thumbnail_status(&[], 0); + assert_eq!(status, NativeStatus::CodecFailure); + assert!(out.is_null()); +} + +#[test] +fn blocked_libvips_loaders_cannot_decode_untrusted_input() { + ensure_vips_init().expect("libvips initialises"); + let ppm = b"P6\n2 2\n255\n\x00\x00\x00\xff\xff\xff\x00\x00\x00\xff\xff\xff".to_vec(); + let options = CString::new("access=sequential").expect("static string has no NUL"); + let raw = unsafe { + native::fluxer_vips_image_new_from_buffer(ppm.as_ptr().cast(), ppm.len(), options.as_ptr()) + }; + assert!(raw.is_null()); + unsafe { native::fluxer_vips_error_clear() }; +} + +fn gif_resize_status( + input: &[u8], + deadline_monotonic_ms: i64, +) -> (NativeStatus, *mut c_void, size_t, size_t) { + let mut output = ptr::null_mut::(); + let mut output_size = usize::MAX; + let mut output_capacity = usize::MAX; + let status = NativeStatus::from_code(unsafe { + native::fluxer_ffmpeg_resize_gif( + input.as_ptr().cast(), + input.len(), + 1, + 1, + 1, + deadline_monotonic_ms, + 16, + 16, + 1_000, + 1_048_576, + 1_048_576, + &mut output, + &mut output_size, + &mut output_capacity, + ) + }); + (status, output, output_size, output_capacity) +} + +#[test] +fn negative_gif_deadline_is_rejected_at_the_native_boundary() { + let (status, output, output_size, output_capacity) = gif_resize_status(&minimal_gif(), -1); + assert_eq!(NativeStatus::CodecFailure, status); + assert!(output.is_null()); + assert_eq!(0, output_size); + assert_eq!(0, output_capacity); +} + +fn webp_direct_transform_status(input: &[u8]) -> (NativeStatus, *mut c_void, size_t) { + let limits = native::WebpAnimLimits { + max_frames: 16, + max_duration_ms: 1_000, + deadline_monotonic_ms: 0, + }; + let mut output = ptr::null_mut::(); + let mut output_size = usize::MAX; + let status = NativeStatus::from_code(unsafe { + native::fluxer_webp_transform_animated( + input.as_ptr().cast(), + input.len(), + 16, + 16, + 85, + 0, + 4, + 90, + 1, + 0, + 16, + 1_048_576, + &limits, + 1_048_576, + &mut output, + &mut output_size, + ) + }); + (status, output, output_size) +} + +fn apng_probe_status(input: &[u8]) -> (NativeStatus, c_int, c_int, c_int) { + let mut width = c_int::MAX; + let mut height = c_int::MAX; + let mut frames = c_int::MAX; + let status = NativeStatus::from_code(unsafe { + native::fluxer_apng_probe( + input.as_ptr().cast(), + input.len(), + 16, + 1_048_576, + &mut width, + &mut height, + &mut frames, + ) + }); + (status, width, height, frames) +} + +fn heif_validate_status(input: &[u8]) -> NativeStatus { + NativeStatus::from_code(unsafe { + native::fluxer_heif_validate(input.as_ptr().cast(), input.len(), 0) + }) +} + +fn gif_animation_status(input: &[u8]) -> NativeStatus { + NativeStatus::from_code(unsafe { + native::fluxer_validate_gif_animation( + input.as_ptr().cast(), + input.len(), + 16, + 1_000, + 1_048_576, + ) + }) +} + +fn probe_animated_status(input: &[u8]) -> (NativeStatus, c_int, c_int, c_int) { + ensure_vips_init().expect("libvips initialises"); + let mut width = c_int::MAX; + let mut height = c_int::MAX; + let mut pages = c_int::MAX; + let status = NativeStatus::from_code(unsafe { + native::fluxer_vips_probe_animated( + input.as_ptr().cast(), + input.len(), + &mut width, + &mut height, + &mut pages, + ) + }); + unsafe { native::fluxer_vips_error_clear() }; + (status, width, height, pages) +} + +fn apng_decode_status(input: &[u8]) -> (NativeStatus, *mut native::VipsImage, u32) { + let mut out = ptr::null_mut(); + let mut num_plays = u32::MAX; + let status = NativeStatus::from_code(unsafe { + native::fluxer_ffmpeg_decode_apng( + input.as_ptr().cast(), + input.len(), + 1, + 0, + &mut out, + 16, + 1_048_576, + 1, + &mut num_plays, + ) + }); + (status, out, num_plays) +} + +fn bmp_decode_status(input: &[u8]) -> (NativeStatus, *mut native::VipsImage) { + let mut out = ptr::null_mut(); + let status = NativeStatus::from_code(unsafe { + native::fluxer_ffmpeg_decode_bmp( + input.as_ptr().cast(), + input.len(), + 1, + 0, + &mut out, + 1_048_576, + ) + }); + (status, out) +} + +fn heif_still_decode_status( + input: &[u8], +) -> ( + NativeStatus, + *mut native::VipsImage, + native::FluxerHEIFPrimaryStillDecodeFacts, +) { + let mut out = ptr::null_mut(); + let mut facts = native::FluxerHEIFPrimaryStillDecodeFacts { + hdr_tone_mapped: c_int::MAX, + hdr_gain_map_detected: c_int::MAX, + }; + let status = NativeStatus::from_code(unsafe { + native::fluxer_heif_decode_primary_still( + input.as_ptr().cast(), + input.len(), + 0, + &mut out, + 1_048_576, + 4_096, + &mut facts, + ) + }); + (status, out, facts) +} + +fn webp_nsfw_extract_status(input: &[u8]) -> NativeStatus { + let indices: [c_int; 1] = [0]; + let mut frames = NSFWFrameOutput::new(1); + let status = NativeStatus::from_code(unsafe { + native::fluxer_webp_extract_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + 1, + 0, + indices.as_ptr(), + indices.len(), + 16, + 1_048_576, + 1_048_576, + frames.as_mut_ptr(), + ) + }); + assert_eq!( + Err(NSFWFrameCopyError::InvalidOutput), + frames.copy_frames(), + "input {input:?}" + ); + status +} + +#[test] +fn every_remaining_native_validator_zeroes_its_out_params_on_failure() { + let valid_gif = minimal_gif(); + for bytes in adversarial_media_bytes() { + let decodable = bytes == valid_gif; + + assert_eq!( + decodable, + gif_animation_status(&bytes) == NativeStatus::Ok, + "input {bytes:?}" + ); + + let (status, width, height, pages) = probe_animated_status(&bytes); + assert_eq!(decodable, status == NativeStatus::Ok, "input {bytes:?}"); + if decodable { + assert_eq!(1, width, "input {bytes:?}"); + assert_eq!(1, height, "input {bytes:?}"); + assert_eq!(1, pages, "input {bytes:?}"); + } else { + assert_eq!(0, width, "input {bytes:?}"); + assert_eq!(0, height, "input {bytes:?}"); + assert_eq!(0, pages, "input {bytes:?}"); + } + + let (status, out, num_plays) = apng_decode_status(&bytes); + assert_ne!(NativeStatus::Ok, status, "input {bytes:?}"); + assert!(out.is_null(), "input {bytes:?}"); + assert_eq!(0, num_plays, "input {bytes:?}"); + + let (status, out) = bmp_decode_status(&bytes); + assert_ne!(NativeStatus::Ok, status, "input {bytes:?}"); + assert!(out.is_null(), "input {bytes:?}"); + + let (status, out, facts) = heif_still_decode_status(&bytes); + assert_ne!(NativeStatus::Ok, status, "input {bytes:?}"); + assert!(out.is_null(), "input {bytes:?}"); + assert_eq!(0, facts.hdr_tone_mapped, "input {bytes:?}"); + assert_eq!(0, facts.hdr_gain_map_detected, "input {bytes:?}"); + + assert_ne!( + NativeStatus::Ok, + webp_nsfw_extract_status(&bytes), + "input {bytes:?}" + ); + } +} + +fn nsfw_extract_statuses(input: &[u8], deadline_monotonic_ms: i64) -> [NativeStatus; 3] { + let indices: [c_int; 1] = [0]; + let timestamps: [c_double; 1] = [0.0]; + let mut apng_frames = NSFWFrameOutput::new(1); + let apng = NativeStatus::from_code(unsafe { + native::fluxer_ffmpeg_extract_apng_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + 1, + deadline_monotonic_ms, + indices.as_ptr(), + indices.len(), + 16, + 1_048_576, + 1_048_576, + apng_frames.as_mut_ptr(), + ) + }); + assert_eq!( + Err(NSFWFrameCopyError::InvalidOutput), + apng_frames.copy_frames() + ); + let mut gif_frames = NSFWFrameOutput::new(1); + let gif = NativeStatus::from_code(unsafe { + native::fluxer_ffmpeg_extract_gif_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + 1, + deadline_monotonic_ms, + indices.as_ptr(), + indices.len(), + 16, + 1_048_576, + 1_048_576, + gif_frames.as_mut_ptr(), + ) + }); + assert_eq!( + Err(NSFWFrameCopyError::InvalidOutput), + gif_frames.copy_frames() + ); + let mut av_frames = NSFWFrameOutput::new(1); + let av = NativeStatus::from_code(unsafe { + native::fluxer_av_extract_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + 1, + deadline_monotonic_ms, + timestamps.as_ptr(), + timestamps.len(), + 1_048_576, + av_frames.as_mut_ptr(), + ) + }); + assert_eq!( + Err(NSFWFrameCopyError::InvalidOutput), + av_frames.copy_frames() + ); + [apng, gif, av] +} + +#[test] +fn native_validation_rejects_adversarial_containers_without_allocating() { + for bytes in adversarial_media_bytes() { + let (status, output, output_size, output_capacity) = gif_resize_status(&bytes, -1); + assert_eq!(NativeStatus::CodecFailure, status, "input {bytes:?}"); + assert!(output.is_null(), "input {bytes:?}"); + assert_eq!(0, output_size, "input {bytes:?}"); + assert_eq!(0, output_capacity, "input {bytes:?}"); + + let (status, width, height, frames) = apng_probe_status(&bytes); + assert_ne!(NativeStatus::Ok, status, "input {bytes:?}"); + assert_eq!(0, width, "input {bytes:?}"); + assert_eq!(0, height, "input {bytes:?}"); + assert_eq!(0, frames, "input {bytes:?}"); + + let (status, output, output_size) = webp_direct_transform_status(&bytes); + assert_ne!(NativeStatus::Ok, status, "input {bytes:?}"); + assert!(output.is_null(), "input {bytes:?}"); + assert_eq!(0, output_size, "input {bytes:?}"); + + assert_eq!( + NativeStatus::CodecFailure, + heif_validate_status(&bytes), + "input {bytes:?}" + ); + + for status in nsfw_extract_statuses(&bytes, -1) { + assert_eq!(NativeStatus::CodecFailure, status, "input {bytes:?}"); + } + } +} + +fn matroska_video(color_args: &[&str]) -> Option> { + let mut args = vec![ + "-f", + "lavfi", + "-i", + "testsrc=size=64x64:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-c:v", + "mpeg4", + ]; + args.extend_from_slice(color_args); + args.extend_from_slice(&["-f", "matroska"]); + ffmpeg_gen_media("fixture.mkv", &args) +} + +fn av_nsfw_extract( + input: &[u8], + timestamps: &[c_double], +) -> (NativeStatus, Result>, NSFWFrameCopyError>) { + ensure_vips_init().expect("libvips initialises"); + let mut frames = NSFWFrameOutput::new(timestamps.len()); + let status = NativeStatus::from_code(unsafe { + native::fluxer_av_extract_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + 1, + 0, + timestamps.as_ptr(), + timestamps.len(), + 1_048_576, + frames.as_mut_ptr(), + ) + }); + let copied = frames.copy_frames(); + (status, copied) +} + +#[test] +fn nsfw_frame_emit_failure_is_reported_instead_of_decoder_eof() { + let Some(sdr) = matroska_video(&[]) else { + eprintln!("skipping: ffmpeg is unavailable"); + return; + }; + let timestamps: [c_double; 3] = [0.0, 0.4, 0.8]; + let (sdr_status, sdr_frames) = av_nsfw_extract(&sdr, ×tamps); + assert_eq!(NativeStatus::Ok, sdr_status); + assert_eq!(3, sdr_frames.expect("sdr frames").len()); + + let rejected = matroska_video(&["-colorspace", "bt2020nc"]).expect("ffmpeg tags the matrix"); + let (status, frames) = av_nsfw_extract(&rejected, ×tamps); + assert_eq!(NativeStatus::Unsupported, status); + assert_eq!(Err(NSFWFrameCopyError::InvalidOutput), frames); +} diff --git a/fluxer_media_proxy/src/config.rs b/fluxer_media_proxy/src/config.rs deleted file mode 100644 index 86b01cc33..000000000 --- a/fluxer_media_proxy/src/config.rs +++ /dev/null @@ -1,862 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::constants; -use base64::{Engine as _, engine::general_purpose}; -use std::{env, net::IpAddr}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum StorageBackend { - Local, - S3, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum DeploymentMode { - Mp, - Static, - Upload, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum BucketStyle { - Path, - VirtualHosted, - Rooted, -} - -#[derive(Clone, Debug)] -pub struct Config { - pub node_env: String, - pub bind_host: String, - pub port: u16, - pub secret_key: String, - pub mode: DeploymentMode, - pub read_only: bool, - pub storage_backend: StorageBackend, - pub storage_root: String, - pub s3_endpoint: String, - pub s3_region: String, - pub s3_access_key_id: String, - pub s3_secret_access_key: String, - pub s3_session_token: String, - pub s3_force_path_style: bool, - pub s3_read_endpoint: Option, - pub s3_read_bucket: String, - pub s3_read_bucket_style: BucketStyle, - pub s3_read_signed: bool, - pub bucket_cdn: String, - pub bucket_uploads: String, - pub bucket_static: String, - pub upload_relay_secret: Vec, - pub upload_relay_max_body_bytes: u64, - pub upload_relay_token_ttl_secs: u64, - pub upload_relay_s3_timeout_ms: u64, - pub upload_relay_buffered_retry_max_bytes: u64, - pub upload_relay_buffered_retry_total_bytes: u64, - pub upload_relay_spool_dir: std::path::PathBuf, - pub upload_relay_spool_chunk_bytes: usize, - pub upload_relay_spool_max_total_bytes: u64, - pub max_native_transforms: usize, - pub worker_queue_capacity: usize, - pub nsfw_service_endpoint: String, - pub nsfw_threshold: f32, - pub transform_cache_capacity_bytes: usize, - pub transform_cache_max_entry_bytes: usize, - pub transform_cache_ttl_ms: u64, - pub shutdown_grace_ms: u64, - pub socket_io_timeout_ms: u64, - pub transform_timeout_ms: u64, - pub max_encode_frames: u32, - pub max_encode_duration_ms: u32, - pub bunny_ip_gate_enabled: bool, - pub bunny_ip_gate_trusted_proxies: Vec, - pub bunny_ip_gate_refresh_secs: u64, -} - -impl Config { - pub fn load_from_env() -> anyhow::Result { - Self::load_from_iter(env::vars()) - } - - pub fn load_from_iter(vars: I) -> anyhow::Result - where - I: IntoIterator, - K: Into, - V: Into, - { - let env = EnvMap::from_iter(vars); - - let mode = - parse_mode_env(env.get("FLUXER_MEDIA_PROXY_MODE"))?.unwrap_or(DeploymentMode::Mp); - let secret_key = env - .get("FLUXER_MEDIA_PROXY_SECRET_KEY") - .map(ToOwned::to_owned) - .unwrap_or_default(); - anyhow::ensure!( - !secret_key.is_empty(), - "FLUXER_MEDIA_PROXY_SECRET_KEY is required" - ); - - let max_native_transforms = parse_usize( - "FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS", - env.get("FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS"), - default_native_transform_concurrency(), - 1, - 128, - )?; - let upload_relay_secret = decode_upload_relay_secret( - env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64"), - mode, - )?; - - let s3_force_path_style = parse_bool( - "FLUXER_S3_FORCE_PATH_STYLE", - env.get("FLUXER_S3_FORCE_PATH_STYLE"), - )? - .unwrap_or(true); - let bucket_cdn = env - .get("FLUXER_S3_BUCKET_CDN") - .map(ToOwned::to_owned) - .unwrap_or_else(|| "cdn".to_owned()); - let s3_read_endpoint = non_empty(env.get("FLUXER_S3_READ_ENDPOINT")); - if let Some(endpoint) = s3_read_endpoint.as_deref() { - validate_read_endpoint(endpoint)?; - } - let s3_read_bucket = - non_empty(env.get("FLUXER_S3_READ_BUCKET")).unwrap_or_else(|| bucket_cdn.clone()); - let s3_read_bucket_style = parse_bucket_style(env.get("FLUXER_S3_READ_BUCKET_STYLE"))? - .unwrap_or(if s3_force_path_style { - BucketStyle::Path - } else { - BucketStyle::VirtualHosted - }); - let s3_read_signed = parse_bool( - "FLUXER_S3_READ_SIGNED", - non_empty(env.get("FLUXER_S3_READ_SIGNED")).as_deref(), - )? - .unwrap_or(false); - - Ok(Self { - node_env: env.get("NODE_ENV").unwrap_or("development").to_owned(), - bind_host: env - .get("FLUXER_MEDIA_PROXY_HOST") - .unwrap_or("0.0.0.0") - .to_owned(), - port: parse_u16( - "FLUXER_MEDIA_PROXY_PORT", - env.get("FLUXER_MEDIA_PROXY_PORT"), - 8080, - )?, - secret_key, - mode, - read_only: parse_bool( - "FLUXER_MEDIA_PROXY_READ_ONLY", - env.get("FLUXER_MEDIA_PROXY_READ_ONLY"), - )? - .unwrap_or(false), - storage_backend: parse_storage_backend(env.get("FLUXER_MEDIA_PROXY_STORAGE_BACKEND"))? - .unwrap_or(StorageBackend::Local), - storage_root: env - .get("FLUXER_MEDIA_PROXY_STORAGE_ROOT") - .unwrap_or("./media_proxy_storage") - .to_owned(), - s3_endpoint: env - .get("FLUXER_S3_ENDPOINT") - .map(ToOwned::to_owned) - .unwrap_or_default(), - s3_region: env - .get("FLUXER_S3_REGION") - .map(ToOwned::to_owned) - .unwrap_or_else(|| "us-east-1".to_owned()), - s3_access_key_id: env - .get("FLUXER_S3_ACCESS_KEY_ID") - .map(ToOwned::to_owned) - .unwrap_or_default(), - s3_secret_access_key: env - .get("FLUXER_S3_SECRET_ACCESS_KEY") - .map(ToOwned::to_owned) - .unwrap_or_default(), - s3_session_token: env.get("FLUXER_S3_SESSION_TOKEN").unwrap_or("").to_owned(), - s3_force_path_style, - s3_read_endpoint, - s3_read_bucket, - s3_read_bucket_style, - s3_read_signed, - bucket_cdn, - bucket_uploads: env - .get("FLUXER_S3_BUCKET_UPLOADS") - .map(ToOwned::to_owned) - .unwrap_or_else(|| "uploads".to_owned()), - bucket_static: env - .get("FLUXER_S3_BUCKET_STATIC") - .map(ToOwned::to_owned) - .unwrap_or_else(|| "static".to_owned()), - upload_relay_secret, - upload_relay_max_body_bytes: parse_u64( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES", - env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES"), - 500 * 1024 * 1024, - 1, - 5 * 1024 * 1024 * 1024, - )?, - upload_relay_token_ttl_secs: parse_u64( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS", - env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS"), - 3_600, - 1, - 7 * 24 * 60 * 60, - )?, - upload_relay_s3_timeout_ms: parse_u64( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS", - env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS"), - 900_000, - 1_000, - 60 * 60 * 1000, - )?, - upload_relay_buffered_retry_max_bytes: parse_u64( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES", - env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES"), - 32 * 1024 * 1024, - 0, - 256 * 1024 * 1024, - )?, - upload_relay_buffered_retry_total_bytes: parse_u64( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES", - env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES"), - 512 * 1024 * 1024, - 0, - 8 * 1024 * 1024 * 1024, - )?, - upload_relay_spool_dir: env - .get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(std::env::temp_dir), - upload_relay_spool_chunk_bytes: parse_usize( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES", - env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES"), - 1024 * 1024, - 64 * 1024, - 64 * 1024 * 1024, - )?, - upload_relay_spool_max_total_bytes: parse_u64( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES", - env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES"), - 8 * 1024 * 1024 * 1024, - 0, - 256 * 1024 * 1024 * 1024, - )?, - max_native_transforms, - worker_queue_capacity: parse_usize( - "FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY", - env.get("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY"), - max_native_transforms * 8, - 1, - 8192, - )?, - nsfw_service_endpoint: env - .get("FLUXER_NSFW_SERVICE_ENDPOINT") - .unwrap_or("") - .to_owned(), - nsfw_threshold: parse_f32( - "FLUXER_MEDIA_PROXY_NSFW_THRESHOLD", - env.get("FLUXER_MEDIA_PROXY_NSFW_THRESHOLD"), - 0.85, - 0.0, - 1.0, - )?, - transform_cache_capacity_bytes: parse_usize( - "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES", - env.get("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES"), - 256 * 1024 * 1024, - 0, - 4 * 1024 * 1024 * 1024, - )?, - transform_cache_max_entry_bytes: parse_usize( - "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES", - env.get("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES"), - 64 * 1024 * 1024, - 0, - 512 * 1024 * 1024, - )?, - transform_cache_ttl_ms: parse_u64( - "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS", - env.get("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS"), - 120_000, - 0, - 60 * 60 * 1000, - )?, - shutdown_grace_ms: parse_u64( - "FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS", - env.get("FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS"), - 30_000, - 0, - 5 * 60 * 1000, - )?, - socket_io_timeout_ms: parse_u64( - "FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS", - env.get("FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS"), - 30_000, - 0, - 5 * 60 * 1000, - )?, - transform_timeout_ms: parse_u64( - "FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS", - env.get("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS"), - 15_000, - 1_000, - 120_000, - )?, - max_encode_frames: parse_usize( - "FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES", - env.get("FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES"), - constants::MAX_ANIMATED_FRAMES_DEFAULT as usize, - 1, - 100_000, - )? as u32, - max_encode_duration_ms: parse_usize( - "FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS", - env.get("FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS"), - 30_000, - 100, - 10 * 60 * 1000, - )? as u32, - bunny_ip_gate_enabled: parse_bool( - "FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_ENABLED", - env.get("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_ENABLED"), - )? - .unwrap_or(false), - bunny_ip_gate_trusted_proxies: parse_ip_list_env( - "FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_TRUSTED_PROXIES", - env.get("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_TRUSTED_PROXIES"), - )?, - bunny_ip_gate_refresh_secs: parse_u64( - "FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_REFRESH_SECS", - env.get("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_REFRESH_SECS"), - 3_600, - 60, - 24 * 60 * 60, - )?, - }) - } -} - -#[derive(Debug, Default)] -struct EnvMap(Vec<(String, String)>); - -impl EnvMap { - fn from_iter(vars: I) -> Self - where - I: IntoIterator, - K: Into, - V: Into, - { - Self( - vars.into_iter() - .map(|(k, v)| (k.into(), v.into())) - .collect(), - ) - } - - fn get(&self, key: &str) -> Option<&str> { - self.0 - .iter() - .find_map(|(k, v)| (k == key).then_some(v.as_str())) - } -} - -fn parse_mode(raw: &str) -> Option { - match raw.to_ascii_lowercase().as_str() { - "mp" => Some(DeploymentMode::Mp), - "static" => Some(DeploymentMode::Static), - "upload" => Some(DeploymentMode::Upload), - _ => None, - } -} - -fn parse_mode_env(raw: Option<&str>) -> anyhow::Result> { - let Some(raw) = raw else { - return Ok(None); - }; - let raw = raw.trim(); - parse_mode(raw).map(Some).ok_or_else(|| { - anyhow::anyhow!("FLUXER_MEDIA_PROXY_MODE must be one of: mp, static, upload") - }) -} - -fn non_empty(raw: Option<&str>) -> Option { - raw.map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn parse_bucket_style(raw: Option<&str>) -> anyhow::Result> { - let Some(raw) = non_empty(raw) else { - return Ok(None); - }; - match raw.to_ascii_lowercase().as_str() { - "path" => Ok(Some(BucketStyle::Path)), - "virtual" => Ok(Some(BucketStyle::VirtualHosted)), - "root" => Ok(Some(BucketStyle::Rooted)), - _ => Err(anyhow::anyhow!( - "FLUXER_S3_READ_BUCKET_STYLE must be one of: path, virtual, root" - )), - } -} - -fn validate_read_endpoint(endpoint: &str) -> anyhow::Result<()> { - let parsed = url::Url::parse(endpoint) - .map_err(|err| anyhow::anyhow!("FLUXER_S3_READ_ENDPOINT is not a valid URL: {err}"))?; - anyhow::ensure!( - matches!(parsed.scheme(), "http" | "https"), - "FLUXER_S3_READ_ENDPOINT must be an http or https URL" - ); - anyhow::ensure!( - parsed.host_str().is_some_and(|host| !host.is_empty()), - "FLUXER_S3_READ_ENDPOINT must include a host" - ); - anyhow::ensure!( - parsed.username().is_empty() && parsed.password().is_none(), - "FLUXER_S3_READ_ENDPOINT must not contain credentials" - ); - anyhow::ensure!( - parsed.query().is_none() && parsed.fragment().is_none(), - "FLUXER_S3_READ_ENDPOINT must not contain a query string or fragment" - ); - Ok(()) -} - -fn parse_storage_backend(raw: Option<&str>) -> anyhow::Result> { - let Some(raw) = raw else { - return Ok(None); - }; - let raw = raw.trim(); - match raw.to_ascii_lowercase().as_str() { - "local" => Ok(Some(StorageBackend::Local)), - "s3" => Ok(Some(StorageBackend::S3)), - _ => Err(anyhow::anyhow!( - "FLUXER_MEDIA_PROXY_STORAGE_BACKEND must be one of: local, s3" - )), - } -} - -fn decode_upload_relay_secret(raw: Option<&str>, mode: DeploymentMode) -> anyhow::Result> { - let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else { - anyhow::ensure!( - mode != DeploymentMode::Upload, - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required in upload mode" - ); - return Ok(Vec::new()); - }; - let decoded = general_purpose::STANDARD.decode(raw).map_err(|_| { - anyhow::anyhow!("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must be base64") - })?; - anyhow::ensure!( - decoded.len() >= 32, - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes" - ); - Ok(decoded) -} - -fn parse_bool(var_name: &str, raw: Option<&str>) -> anyhow::Result> { - let Some(raw) = raw else { - return Ok(None); - }; - let raw = raw.trim(); - match raw.to_ascii_lowercase().as_str() { - "true" | "1" | "yes" => Ok(Some(true)), - "false" | "0" | "no" => Ok(Some(false)), - _ => Err(anyhow::anyhow!( - "{var_name} must be a boolean: true, false, 1, 0, yes, or no" - )), - } -} - -fn parse_u16(var_name: &str, raw: Option<&str>, default_value: u16) -> anyhow::Result { - parse_number(var_name, raw, default_value, u16::MIN, u16::MAX) -} - -fn parse_u64( - var_name: &str, - raw: Option<&str>, - default_value: u64, - min_value: u64, - max_value: u64, -) -> anyhow::Result { - parse_number(var_name, raw, default_value, min_value, max_value) -} - -fn parse_usize( - var_name: &str, - raw: Option<&str>, - default_value: usize, - min_value: usize, - max_value: usize, -) -> anyhow::Result { - parse_number(var_name, raw, default_value, min_value, max_value) -} - -fn parse_f32( - var_name: &str, - raw: Option<&str>, - default_value: f32, - min_value: f32, - max_value: f32, -) -> anyhow::Result { - let Some(raw) = raw else { - return Ok(default_value); - }; - let parsed = raw - .trim() - .parse::() - .map_err(|_| anyhow::anyhow!("{var_name} must be a number"))?; - anyhow::ensure!(parsed.is_finite(), "{var_name} must be a finite number"); - anyhow::ensure!( - (min_value..=max_value).contains(&parsed), - "{var_name} must be between {min_value} and {max_value}" - ); - Ok(parsed) -} - -fn parse_number( - var_name: &str, - raw: Option<&str>, - default_value: T, - min_value: T, - max_value: T, -) -> anyhow::Result -where - T: std::str::FromStr + PartialOrd + std::fmt::Display + Copy, -{ - let Some(raw) = raw else { - return Ok(default_value); - }; - let parsed = raw - .trim() - .parse::() - .map_err(|_| anyhow::anyhow!("{var_name} must be a number"))?; - anyhow::ensure!( - parsed >= min_value && parsed <= max_value, - "{var_name} must be between {min_value} and {max_value}" - ); - Ok(parsed) -} - -fn parse_ip_list_env(var_name: &str, raw: Option<&str>) -> anyhow::Result> { - let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else { - return Ok(Vec::new()); - }; - let mut out = Vec::new(); - for entry in raw.split(',') { - let entry = entry.trim(); - if entry.is_empty() { - continue; - } - let ip = entry - .parse::() - .map_err(|_| anyhow::anyhow!("{var_name} contains invalid IP: {entry}"))?; - out.push(ip); - } - Ok(out) -} - -fn default_native_transform_concurrency() -> usize { - std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(4) - .clamp(2, 8) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn base_env() -> Vec<(&'static str, &'static str)> { - vec![("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret")] - } - - #[test] - fn requires_secret_key() { - let err = Config::load_from_iter(std::iter::empty::<(&str, &str)>()).unwrap_err(); - assert!(err.to_string().contains("FLUXER_MEDIA_PROXY_SECRET_KEY")); - } - - #[test] - fn default_config_matches_media_service() { - let cfg = Config::load_from_iter(base_env()).unwrap(); - assert_eq!("0.0.0.0", cfg.bind_host); - assert_eq!(8080, cfg.port); - assert_eq!(StorageBackend::Local, cfg.storage_backend); - assert_eq!(DeploymentMode::Mp, cfg.mode); - assert_eq!("cdn", cfg.bucket_cdn); - assert_eq!("uploads", cfg.bucket_uploads); - assert_eq!("static", cfg.bucket_static); - assert!(cfg.max_native_transforms >= 2); - assert_eq!(cfg.max_native_transforms * 8, cfg.worker_queue_capacity); - } - - #[test] - fn canonical_media_proxy_env_overrides_apply() { - let cfg = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_STORAGE_BACKEND", "s3"), - ("FLUXER_MEDIA_PROXY_STORAGE_ROOT", "/srv/fluxer-media"), - ("FLUXER_MEDIA_PROXY_READ_ONLY", "true"), - ("FLUXER_S3_FORCE_PATH_STYLE", "false"), - ("FLUXER_S3_SESSION_TOKEN", "token"), - ("FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS", "3"), - ("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY", "24"), - ("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS", "2000"), - ("FLUXER_NSFW_SERVICE_ENDPOINT", "http://nsfw:8000"), - ("FLUXER_MEDIA_PROXY_NSFW_THRESHOLD", "0.7"), - ]) - .unwrap(); - - assert_eq!(StorageBackend::S3, cfg.storage_backend); - assert_eq!("/srv/fluxer-media", cfg.storage_root); - assert!(cfg.read_only); - assert!(!cfg.s3_force_path_style); - assert_eq!("token", cfg.s3_session_token); - assert_eq!(3, cfg.max_native_transforms); - assert_eq!(24, cfg.worker_queue_capacity); - assert_eq!(2_000, cfg.transform_timeout_ms); - assert_eq!("http://nsfw:8000", cfg.nsfw_service_endpoint); - assert!((cfg.nsfw_threshold - 0.7).abs() < f32::EPSILON); - } - - #[test] - fn upload_mode_requires_relay_secret() { - let err = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_MODE", "upload"), - ]) - .unwrap_err(); - assert!( - err.to_string() - .contains("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64") - ); - } - - #[test] - fn parses_upload_relay_secret() { - let secret = general_purpose::STANDARD.encode([7u8; 32]); - let cfg = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_MODE", "upload"), - ( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64", - secret.as_str(), - ), - ]) - .unwrap(); - assert_eq!(vec![7u8; 32], cfg.upload_relay_secret); - } - - #[test] - fn rejects_invalid_mode_env() { - let err = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_MODE", "worker"), - ]) - .unwrap_err(); - assert!(err.to_string().contains("FLUXER_MEDIA_PROXY_MODE")); - } - - #[test] - fn rejects_invalid_storage_backend_env() { - let err = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_STORAGE_BACKEND", "filesystem"), - ]) - .unwrap_err(); - assert!( - err.to_string() - .contains("FLUXER_MEDIA_PROXY_STORAGE_BACKEND") - ); - } - - #[test] - fn rejects_invalid_bool_env() { - let err = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_READ_ONLY", "maybe"), - ]) - .unwrap_err(); - assert!(err.to_string().contains("FLUXER_MEDIA_PROXY_READ_ONLY")); - } - - #[test] - fn rejects_invalid_number_env() { - let err = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY", "many"), - ]) - .unwrap_err(); - assert!( - err.to_string() - .contains("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY") - ); - } - - #[test] - fn rejects_out_of_range_number_env() { - let err = Config::load_from_iter([ - ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), - ("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS", "999999"), - ]) - .unwrap_err(); - assert!( - err.to_string() - .contains("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS") - ); - } - - fn env_with(extra: &[(&'static str, &'static str)]) -> Vec<(&'static str, &'static str)> { - let mut env = base_env(); - env.extend_from_slice(extra); - env - } - - #[test] - fn read_endpoint_defaults_to_disabled() { - let cfg = Config::load_from_iter(base_env()).unwrap(); - assert_eq!(None, cfg.s3_read_endpoint); - assert_eq!("cdn", cfg.s3_read_bucket); - assert_eq!(BucketStyle::Path, cfg.s3_read_bucket_style); - assert!(!cfg.s3_read_signed); - } - - #[test] - fn empty_read_vars_are_treated_as_unset() { - let cfg = Config::load_from_iter(env_with(&[ - ("FLUXER_S3_READ_ENDPOINT", " "), - ("FLUXER_S3_READ_BUCKET", ""), - ("FLUXER_S3_READ_BUCKET_STYLE", ""), - ("FLUXER_S3_READ_SIGNED", ""), - ])) - .unwrap(); - assert_eq!(None, cfg.s3_read_endpoint); - assert_eq!("cdn", cfg.s3_read_bucket); - assert_eq!(BucketStyle::Path, cfg.s3_read_bucket_style); - assert!(!cfg.s3_read_signed); - } - - #[test] - fn every_new_var_tolerates_a_blank_value_individually() { - for var in [ - "FLUXER_S3_READ_ENDPOINT", - "FLUXER_S3_READ_BUCKET", - "FLUXER_S3_READ_BUCKET_STYLE", - "FLUXER_S3_READ_SIGNED", - ] { - for blank in ["", " "] { - let cfg = Config::load_from_iter(env_with(&[(var, blank)])).unwrap_or_else(|err| { - panic!("{var}={blank:?} must be treated as unset: {err}") - }); - assert_eq!(None, cfg.s3_read_endpoint); - assert_eq!("cdn", cfg.s3_read_bucket); - assert_eq!(BucketStyle::Path, cfg.s3_read_bucket_style); - assert!(!cfg.s3_read_signed); - } - } - } - - #[test] - fn read_bucket_defaults_to_cdn_bucket_and_can_be_overridden() { - let cfg = Config::load_from_iter(env_with(&[("FLUXER_S3_BUCKET_CDN", "fluxer")])).unwrap(); - assert_eq!("fluxer", cfg.s3_read_bucket); - - let cfg = Config::load_from_iter(env_with(&[ - ("FLUXER_S3_BUCKET_CDN", "fluxer"), - ("FLUXER_S3_READ_BUCKET", "fluxer-static"), - ])) - .unwrap(); - assert_eq!("fluxer-static", cfg.s3_read_bucket); - } - - #[test] - fn read_bucket_style_inherits_force_path_style() { - let cfg = - Config::load_from_iter(env_with(&[("FLUXER_S3_FORCE_PATH_STYLE", "false")])).unwrap(); - assert_eq!(BucketStyle::VirtualHosted, cfg.s3_read_bucket_style); - - let cfg = Config::load_from_iter(env_with(&[ - ("FLUXER_S3_FORCE_PATH_STYLE", "false"), - ("FLUXER_S3_READ_BUCKET_STYLE", "root"), - ])) - .unwrap(); - assert_eq!(BucketStyle::Rooted, cfg.s3_read_bucket_style); - assert!(!cfg.s3_force_path_style); - } - - #[test] - fn read_bucket_style_parses_all_values_case_insensitively() { - for (raw, expected) in [ - ("path", BucketStyle::Path), - ("PATH", BucketStyle::Path), - ("virtual", BucketStyle::VirtualHosted), - (" Virtual ", BucketStyle::VirtualHosted), - ("root", BucketStyle::Rooted), - ("ROOT", BucketStyle::Rooted), - ] { - let cfg = - Config::load_from_iter(env_with(&[("FLUXER_S3_READ_BUCKET_STYLE", raw)])).unwrap(); - assert_eq!(expected, cfg.s3_read_bucket_style, "raw={raw}"); - } - } - - #[test] - fn read_bucket_style_rejects_unknown_value() { - let err = Config::load_from_iter(env_with(&[("FLUXER_S3_READ_BUCKET_STYLE", "cdn")])) - .unwrap_err(); - assert!(err.to_string().contains("FLUXER_S3_READ_BUCKET_STYLE")); - } - - #[test] - fn read_signed_parses_boolean() { - let cfg = Config::load_from_iter(env_with(&[("FLUXER_S3_READ_SIGNED", "true")])).unwrap(); - assert!(cfg.s3_read_signed); - - let err = - Config::load_from_iter(env_with(&[("FLUXER_S3_READ_SIGNED", "maybe")])).unwrap_err(); - assert!(err.to_string().contains("FLUXER_S3_READ_SIGNED")); - } - - #[test] - fn read_endpoint_is_validated_at_startup() { - let cfg = Config::load_from_iter(env_with(&[( - "FLUXER_S3_READ_ENDPOINT", - "https://cdn.example.net", - )])) - .unwrap(); - assert_eq!( - Some("https://cdn.example.net".to_owned()), - cfg.s3_read_endpoint - ); - - for (bad, expected) in [ - ("cdn.example.net", "not a valid URL"), - ("ftp://cdn.example.net", "must be an http or https URL"), - ("https://", "not a valid URL"), - ( - "https://user:pw@cdn.example.net", - "must not contain credentials", - ), - ( - "https://cdn.example.net/?token=abc", - "must not contain a query string or fragment", - ), - ( - "https://cdn.example.net/#frag", - "must not contain a query string or fragment", - ), - ] { - let err = - Config::load_from_iter(env_with(&[("FLUXER_S3_READ_ENDPOINT", bad)])).unwrap_err(); - let message = err.to_string(); - assert!( - message.contains("FLUXER_S3_READ_ENDPOINT") && message.contains(expected), - "bad={bad} err={message}" - ); - } - } -} diff --git a/fluxer_media_proxy/src/config/mod.rs b/fluxer_media_proxy/src/config/mod.rs new file mode 100644 index 000000000..18e6740af --- /dev/null +++ b/fluxer_media_proxy/src/config/mod.rs @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod parse; +#[cfg(test)] +mod tests; + +use crate::constants; +use crate::secret::{SecretBytes, SecretString}; +use parse::{ + EnvMap, decode_upload_relay_secret, default_native_transform_concurrency, non_empty, + parse_bool, parse_bucket_style, parse_f32, parse_ip_list_env, parse_mode_env, + parse_storage_backend, parse_u16, parse_u64, parse_usize, validate_read_endpoint, +}; +use std::{env, net::IpAddr, path::PathBuf}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StorageBackend { + Local, + S3, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BucketStyle { + Path, + VirtualHosted, + Rooted, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DeploymentMode { + Mp, + Static, + Upload, +} + +#[derive(Clone, Debug)] +pub struct StorageConfig { + pub backend: StorageBackend, + pub root: String, + pub s3_endpoint: String, + pub s3_region: String, + pub s3_access_key_id: String, + pub s3_secret_access_key: String, + pub s3_session_token: String, + pub s3_force_path_style: bool, + pub s3_read_endpoint: Option, + pub s3_read_bucket: String, + pub s3_read_bucket_style: BucketStyle, + pub s3_read_signed: bool, + pub bucket_cdn: String, + pub bucket_uploads: String, + pub bucket_static: String, +} + +#[derive(Clone, Debug)] +pub struct MediaServingConfig { + pub max_native_transforms: usize, + pub worker_queue_capacity: usize, + pub nsfw_service_endpoint: String, + pub nsfw_threshold: f32, + pub transform_cache_capacity_bytes: usize, + pub transform_cache_max_entry_bytes: usize, + pub transform_cache_ttl_ms: u64, + pub transform_timeout_ms: u64, + pub max_encode_frames: u32, + pub max_encode_duration_ms: u32, +} + +#[derive(Clone, Debug)] +pub struct UploadRelayConfig { + pub(crate) secret: SecretBytes, + pub max_body_bytes: u64, + pub token_ttl_secs: u64, + pub s3_timeout_ms: u64, + pub buffered_retry_max_bytes: u64, + pub buffered_retry_total_bytes: u64, + pub spool_dir: PathBuf, + pub spool_chunk_bytes: usize, + pub spool_max_total_bytes: u64, +} + +#[derive(Clone, Debug)] +pub struct Config { + pub node_env: String, + pub bind_host: String, + pub port: u16, + pub(crate) secret_key: SecretString, + pub mode: DeploymentMode, + pub read_only: bool, + pub shutdown_grace_ms: u64, + pub socket_io_timeout_ms: u64, + pub storage: StorageConfig, + pub media: MediaServingConfig, + pub upload_relay: UploadRelayConfig, + pub bunny_ip_gate_enabled: bool, + pub bunny_ip_gate_trusted_proxies: Vec, + pub bunny_ip_gate_refresh_secs: u64, +} + +impl Config { + pub fn load_from_env() -> anyhow::Result { + Self::load_from_iter(env::vars()) + } + + pub fn load_from_iter(vars: I) -> anyhow::Result + where + I: IntoIterator, + K: Into, + V: Into, + { + let env = EnvMap::from_iter(vars); + + let mode = + parse_mode_env(env.get("FLUXER_MEDIA_PROXY_MODE"))?.unwrap_or(DeploymentMode::Mp); + let secret_key = SecretString::new( + env.get("FLUXER_MEDIA_PROXY_SECRET_KEY") + .unwrap_or_default() + .to_owned(), + ); + anyhow::ensure!( + !secret_key.is_empty(), + "FLUXER_MEDIA_PROXY_SECRET_KEY is required" + ); + + Ok(Self { + node_env: env.get("NODE_ENV").unwrap_or("development").to_owned(), + bind_host: env + .get("FLUXER_MEDIA_PROXY_HOST") + .unwrap_or("0.0.0.0") + .to_owned(), + port: parse_u16( + "FLUXER_MEDIA_PROXY_PORT", + env.get("FLUXER_MEDIA_PROXY_PORT"), + 8080, + )?, + secret_key, + mode, + read_only: parse_bool( + "FLUXER_MEDIA_PROXY_READ_ONLY", + env.get("FLUXER_MEDIA_PROXY_READ_ONLY"), + )? + .unwrap_or(false), + shutdown_grace_ms: parse_u64( + "FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS", + env.get("FLUXER_MEDIA_PROXY_SHUTDOWN_GRACE_MS"), + 30_000, + 0, + 5 * 60 * 1000, + )?, + socket_io_timeout_ms: parse_u64( + "FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS", + env.get("FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS"), + 30_000, + 0, + 5 * 60 * 1000, + )?, + storage: StorageConfig::load(&env)?, + media: MediaServingConfig::load(&env)?, + upload_relay: UploadRelayConfig::load(&env, mode)?, + bunny_ip_gate_enabled: parse_bool( + "FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_ENABLED", + env.get("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_ENABLED"), + )? + .unwrap_or(false), + bunny_ip_gate_trusted_proxies: parse_ip_list_env( + "FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_TRUSTED_PROXIES", + env.get("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_TRUSTED_PROXIES"), + )?, + bunny_ip_gate_refresh_secs: parse_u64( + "FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_REFRESH_SECS", + env.get("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_REFRESH_SECS"), + 3_600, + 60, + 24 * 60 * 60, + )?, + }) + } +} + +impl StorageConfig { + fn load(env: &EnvMap) -> anyhow::Result { + let s3_force_path_style = parse_bool( + "FLUXER_S3_FORCE_PATH_STYLE", + env.get("FLUXER_S3_FORCE_PATH_STYLE"), + )? + .unwrap_or(true); + let bucket_cdn = env + .get("FLUXER_S3_BUCKET_CDN") + .map(ToOwned::to_owned) + .unwrap_or_else(|| "cdn".to_owned()); + let s3_read_endpoint = non_empty(env.get("FLUXER_S3_READ_ENDPOINT")); + if let Some(endpoint) = s3_read_endpoint.as_deref() { + validate_read_endpoint(endpoint)?; + } + let s3_read_bucket = + non_empty(env.get("FLUXER_S3_READ_BUCKET")).unwrap_or_else(|| bucket_cdn.clone()); + let s3_read_bucket_style = parse_bucket_style(env.get("FLUXER_S3_READ_BUCKET_STYLE"))? + .unwrap_or(if s3_force_path_style { + BucketStyle::Path + } else { + BucketStyle::VirtualHosted + }); + let s3_read_signed = parse_bool( + "FLUXER_S3_READ_SIGNED", + non_empty(env.get("FLUXER_S3_READ_SIGNED")).as_deref(), + )? + .unwrap_or(false); + Ok(Self { + backend: parse_storage_backend(env.get("FLUXER_MEDIA_PROXY_STORAGE_BACKEND"))? + .unwrap_or(StorageBackend::Local), + root: env + .get("FLUXER_MEDIA_PROXY_STORAGE_ROOT") + .unwrap_or("./media_proxy_storage") + .to_owned(), + s3_endpoint: env + .get("FLUXER_S3_ENDPOINT") + .map(ToOwned::to_owned) + .unwrap_or_default(), + s3_region: env + .get("FLUXER_S3_REGION") + .map(ToOwned::to_owned) + .unwrap_or_else(|| "us-east-1".to_owned()), + s3_access_key_id: env + .get("FLUXER_S3_ACCESS_KEY_ID") + .map(ToOwned::to_owned) + .unwrap_or_default(), + s3_secret_access_key: env + .get("FLUXER_S3_SECRET_ACCESS_KEY") + .map(ToOwned::to_owned) + .unwrap_or_default(), + s3_session_token: env.get("FLUXER_S3_SESSION_TOKEN").unwrap_or("").to_owned(), + s3_force_path_style, + s3_read_endpoint, + s3_read_bucket, + s3_read_bucket_style, + s3_read_signed, + bucket_cdn, + bucket_uploads: env + .get("FLUXER_S3_BUCKET_UPLOADS") + .map(ToOwned::to_owned) + .unwrap_or_else(|| "uploads".to_owned()), + bucket_static: env + .get("FLUXER_S3_BUCKET_STATIC") + .map(ToOwned::to_owned) + .unwrap_or_else(|| "static".to_owned()), + }) + } +} + +impl MediaServingConfig { + fn load(env: &EnvMap) -> anyhow::Result { + let max_native_transforms = parse_usize( + "FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS", + env.get("FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS"), + default_native_transform_concurrency(), + 1, + 128, + )?; + let transform_cache_capacity_bytes = parse_usize( + "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES", + env.get("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES"), + 256 * 1024 * 1024, + 0, + 4 * 1024 * 1024 * 1024, + )?; + let transform_cache_max_entry_bytes = parse_usize( + "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES", + env.get("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES"), + 64 * 1024 * 1024, + 0, + 512 * 1024 * 1024, + )?; + Ok(Self { + max_native_transforms, + worker_queue_capacity: parse_usize( + "FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY", + env.get("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY"), + max_native_transforms * 8, + 1, + 8192, + )?, + nsfw_service_endpoint: env + .get("FLUXER_NSFW_SERVICE_ENDPOINT") + .unwrap_or("") + .to_owned(), + nsfw_threshold: parse_f32( + "FLUXER_MEDIA_PROXY_NSFW_THRESHOLD", + env.get("FLUXER_MEDIA_PROXY_NSFW_THRESHOLD"), + 0.85, + 0.0, + 1.0, + )?, + transform_cache_capacity_bytes, + transform_cache_max_entry_bytes, + transform_cache_ttl_ms: parse_u64( + "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS", + env.get("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS"), + 120_000, + 0, + 60 * 60 * 1000, + )?, + transform_timeout_ms: parse_u64( + "FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS", + env.get("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS"), + 15_000, + 1_000, + 120_000, + )?, + max_encode_frames: parse_usize( + "FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES", + env.get("FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES"), + constants::MAX_ANIMATED_FRAMES_DEFAULT as usize, + 1, + 100_000, + )? as u32, + max_encode_duration_ms: parse_usize( + "FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS", + env.get("FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS"), + 30_000, + 100, + 10 * 60 * 1000, + )? as u32, + }) + } +} + +impl UploadRelayConfig { + fn load(env: &EnvMap, mode: DeploymentMode) -> anyhow::Result { + Ok(Self { + secret: decode_upload_relay_secret( + env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64"), + mode, + )?, + max_body_bytes: parse_u64( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES", + env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES"), + 500 * 1024 * 1024, + 1, + 5 * 1024 * 1024 * 1024, + )?, + token_ttl_secs: parse_u64( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS", + env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS"), + 3_600, + 1, + 7 * 24 * 60 * 60, + )?, + s3_timeout_ms: parse_u64( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS", + env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS"), + 900_000, + 1_000, + 60 * 60 * 1000, + )?, + buffered_retry_max_bytes: parse_u64( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES", + env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES"), + 32 * 1024 * 1024, + 0, + 256 * 1024 * 1024, + )?, + buffered_retry_total_bytes: parse_u64( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES", + env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES"), + 512 * 1024 * 1024, + 0, + 8 * 1024 * 1024 * 1024, + )?, + spool_dir: env + .get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir), + spool_chunk_bytes: parse_usize( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES", + env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES"), + 1024 * 1024, + 64 * 1024, + 64 * 1024 * 1024, + )?, + spool_max_total_bytes: parse_u64( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES", + env.get("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES"), + 8 * 1024 * 1024 * 1024, + 0, + 256 * 1024 * 1024 * 1024, + )?, + }) + } +} diff --git a/fluxer_media_proxy/src/config/parse.rs b/fluxer_media_proxy/src/config/parse.rs new file mode 100644 index 000000000..683120cfb --- /dev/null +++ b/fluxer_media_proxy/src/config/parse.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{BucketStyle, DeploymentMode, StorageBackend}; +use crate::secret::SecretBytes; +use base64::{Engine as _, engine::general_purpose}; +use std::net::IpAddr; + +#[derive(Debug, Default)] +pub(super) struct EnvMap(Vec<(String, String)>); + +impl EnvMap { + pub(super) fn from_iter(vars: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + Self( + vars.into_iter() + .map(|(k, v)| (k.into(), v.into())) + .collect(), + ) + } + + pub(super) fn get(&self, key: &str) -> Option<&str> { + self.0 + .iter() + .find_map(|(k, v)| (k == key).then_some(v.as_str())) + } +} + +fn parse_mode(raw: &str) -> Option { + match raw.to_ascii_lowercase().as_str() { + "mp" => Some(DeploymentMode::Mp), + "static" => Some(DeploymentMode::Static), + "upload" => Some(DeploymentMode::Upload), + _ => None, + } +} + +pub(super) fn parse_mode_env(raw: Option<&str>) -> anyhow::Result> { + let Some(raw) = raw else { + return Ok(None); + }; + let raw = raw.trim(); + parse_mode(raw).map(Some).ok_or_else(|| { + anyhow::anyhow!("FLUXER_MEDIA_PROXY_MODE must be one of: mp, static, upload") + }) +} + +pub(super) fn non_empty(raw: Option<&str>) -> Option { + raw.map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +pub(super) fn parse_bucket_style(raw: Option<&str>) -> anyhow::Result> { + let Some(raw) = non_empty(raw) else { + return Ok(None); + }; + match raw.to_ascii_lowercase().as_str() { + "path" => Ok(Some(BucketStyle::Path)), + "virtual" => Ok(Some(BucketStyle::VirtualHosted)), + "root" => Ok(Some(BucketStyle::Rooted)), + _ => Err(anyhow::anyhow!( + "FLUXER_S3_READ_BUCKET_STYLE must be one of: path, virtual, root" + )), + } +} + +pub(super) fn validate_read_endpoint(endpoint: &str) -> anyhow::Result<()> { + let parsed = url::Url::parse(endpoint) + .map_err(|err| anyhow::anyhow!("FLUXER_S3_READ_ENDPOINT is not a valid URL: {err}"))?; + anyhow::ensure!( + matches!(parsed.scheme(), "http" | "https"), + "FLUXER_S3_READ_ENDPOINT must be an http or https URL" + ); + anyhow::ensure!( + parsed.host_str().is_some_and(|host| !host.is_empty()), + "FLUXER_S3_READ_ENDPOINT must include a host" + ); + anyhow::ensure!( + parsed.username().is_empty() && parsed.password().is_none(), + "FLUXER_S3_READ_ENDPOINT must not contain credentials" + ); + anyhow::ensure!( + parsed.query().is_none() && parsed.fragment().is_none(), + "FLUXER_S3_READ_ENDPOINT must not contain a query string or fragment" + ); + Ok(()) +} + +pub(super) fn parse_storage_backend(raw: Option<&str>) -> anyhow::Result> { + let Some(raw) = raw else { + return Ok(None); + }; + let raw = raw.trim(); + match raw.to_ascii_lowercase().as_str() { + "local" => Ok(Some(StorageBackend::Local)), + "s3" => Ok(Some(StorageBackend::S3)), + _ => Err(anyhow::anyhow!( + "FLUXER_MEDIA_PROXY_STORAGE_BACKEND must be one of: local, s3" + )), + } +} + +pub(super) fn decode_upload_relay_secret( + raw: Option<&str>, + mode: DeploymentMode, +) -> anyhow::Result { + let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else { + anyhow::ensure!( + mode != DeploymentMode::Upload, + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 is required in upload mode" + ); + return Ok(SecretBytes::new(Vec::new())); + }; + let decoded = general_purpose::STANDARD.decode(raw).map_err(|_| { + anyhow::anyhow!("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must be base64") + })?; + anyhow::ensure!( + decoded.len() >= 32, + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64 must decode to at least 32 bytes" + ); + Ok(SecretBytes::new(decoded)) +} + +pub(super) fn parse_bool(var_name: &str, raw: Option<&str>) -> anyhow::Result> { + let Some(raw) = raw else { + return Ok(None); + }; + let raw = raw.trim(); + match raw.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" => Ok(Some(true)), + "false" | "0" | "no" => Ok(Some(false)), + _ => Err(anyhow::anyhow!( + "{var_name} must be a boolean: true, false, 1, 0, yes, or no" + )), + } +} + +pub(super) fn parse_u16( + var_name: &str, + raw: Option<&str>, + default_value: u16, +) -> anyhow::Result { + parse_number(var_name, raw, default_value, u16::MIN, u16::MAX) +} + +pub(super) fn parse_u64( + var_name: &str, + raw: Option<&str>, + default_value: u64, + min_value: u64, + max_value: u64, +) -> anyhow::Result { + parse_number(var_name, raw, default_value, min_value, max_value) +} + +pub(super) fn parse_usize( + var_name: &str, + raw: Option<&str>, + default_value: usize, + min_value: usize, + max_value: usize, +) -> anyhow::Result { + parse_number(var_name, raw, default_value, min_value, max_value) +} + +pub(super) fn parse_f32( + var_name: &str, + raw: Option<&str>, + default_value: f32, + min_value: f32, + max_value: f32, +) -> anyhow::Result { + let Some(raw) = raw else { + return Ok(default_value); + }; + let parsed = raw + .trim() + .parse::() + .map_err(|_| anyhow::anyhow!("{var_name} must be a number"))?; + anyhow::ensure!(parsed.is_finite(), "{var_name} must be a finite number"); + anyhow::ensure!( + (min_value..=max_value).contains(&parsed), + "{var_name} must be between {min_value} and {max_value}" + ); + Ok(parsed) +} + +fn parse_number( + var_name: &str, + raw: Option<&str>, + default_value: T, + min_value: T, + max_value: T, +) -> anyhow::Result +where + T: std::str::FromStr + PartialOrd + std::fmt::Display + Copy, +{ + let Some(raw) = raw else { + return Ok(default_value); + }; + let parsed = raw + .trim() + .parse::() + .map_err(|_| anyhow::anyhow!("{var_name} must be a number"))?; + anyhow::ensure!( + parsed >= min_value && parsed <= max_value, + "{var_name} must be between {min_value} and {max_value}" + ); + Ok(parsed) +} + +pub(super) fn parse_ip_list_env(var_name: &str, raw: Option<&str>) -> anyhow::Result> { + let Some(raw) = raw.map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for entry in raw.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + let ip = entry + .parse::() + .map_err(|_| anyhow::anyhow!("{var_name} contains invalid IP: {entry}"))?; + out.push(ip); + } + Ok(out) +} + +pub(super) fn default_native_transform_concurrency() -> usize { + std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(4) + .clamp(2, 8) +} diff --git a/fluxer_media_proxy/src/config/tests/mod.rs b/fluxer_media_proxy/src/config/tests/mod.rs new file mode 100644 index 000000000..7a30034d1 --- /dev/null +++ b/fluxer_media_proxy/src/config/tests/mod.rs @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod s3_read; + +use super::*; +use base64::{Engine as _, engine::general_purpose}; + +fn base_env() -> Vec<(&'static str, &'static str)> { + vec![("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret")] +} + +fn env_with(extra: &[(&'static str, &'static str)]) -> Vec<(&'static str, &'static str)> { + let mut env = base_env(); + env.extend_from_slice(extra); + env +} + +fn with_shared_runtime_env(release: &[(&str, &str)]) -> Vec<(String, String)> { + [ + ("NODE_ENV", "production"), + ("FLUXER_ENV", "production"), + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "shared-runtime-secret"), + ("FLUXER_S3_ENDPOINT", "https://ewr1.vultrobjects.com"), + ("FLUXER_S3_REGION", "ewr1"), + ("FLUXER_S3_ACCESS_KEY_ID", "AKIAIOSFODNN7EXAMPLE"), + ( + "FLUXER_S3_SECRET_ACCESS_KEY", + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + ), + ] + .iter() + .chain(release.iter()) + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() +} + +#[test] +fn requires_secret_key() { + let err = Config::load_from_iter(std::iter::empty::<(&str, &str)>()).unwrap_err(); + assert!(err.to_string().contains("FLUXER_MEDIA_PROXY_SECRET_KEY")); +} + +#[test] +fn default_config_matches_media_service() { + let cfg = Config::load_from_iter(base_env()).unwrap(); + assert_eq!("0.0.0.0", cfg.bind_host); + assert_eq!(8080, cfg.port); + assert_eq!(StorageBackend::Local, cfg.storage.backend); + assert_eq!(DeploymentMode::Mp, cfg.mode); + assert_eq!("cdn", cfg.storage.bucket_cdn); + assert_eq!("uploads", cfg.storage.bucket_uploads); + assert_eq!("static", cfg.storage.bucket_static); + assert!(cfg.media.max_native_transforms >= 2); + assert_eq!( + cfg.media.max_native_transforms * 8, + cfg.media.worker_queue_capacity + ); +} + +#[test] +fn canonical_media_proxy_env_overrides_apply() { + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_STORAGE_BACKEND", "s3"), + ("FLUXER_MEDIA_PROXY_STORAGE_ROOT", "/srv/fluxer-media"), + ("FLUXER_MEDIA_PROXY_READ_ONLY", "true"), + ("FLUXER_S3_FORCE_PATH_STYLE", "false"), + ("FLUXER_S3_SESSION_TOKEN", "token"), + ("FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS", "3"), + ("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY", "24"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS", "2000"), + ("FLUXER_NSFW_SERVICE_ENDPOINT", "http://nsfw:8000"), + ("FLUXER_MEDIA_PROXY_NSFW_THRESHOLD", "0.7"), + ]) + .unwrap(); + + assert_eq!(StorageBackend::S3, cfg.storage.backend); + assert_eq!("/srv/fluxer-media", cfg.storage.root); + assert!(cfg.read_only); + assert!(!cfg.storage.s3_force_path_style); + assert_eq!("token", cfg.storage.s3_session_token); + assert_eq!(3, cfg.media.max_native_transforms); + assert_eq!(24, cfg.media.worker_queue_capacity); + assert_eq!(2_000, cfg.media.transform_timeout_ms); + assert_eq!("http://nsfw:8000", cfg.media.nsfw_service_endpoint); + assert!((cfg.media.nsfw_threshold - 0.7).abs() < f32::EPSILON); +} + +#[test] +fn upload_mode_requires_relay_secret() { + let err = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_MODE", "upload"), + ]) + .unwrap_err(); + assert!( + err.to_string() + .contains("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64") + ); +} + +#[test] +fn parses_upload_relay_secret() { + let secret = general_purpose::STANDARD.encode([7u8; 32]); + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_MODE", "upload"), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64", + secret.as_str(), + ), + ]) + .unwrap(); + assert_eq!(&[7u8; 32][..], cfg.upload_relay.secret.expose()); +} + +#[test] +fn rejects_invalid_mode_env() { + let err = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_MODE", "worker"), + ]) + .unwrap_err(); + assert!(err.to_string().contains("FLUXER_MEDIA_PROXY_MODE")); +} + +#[test] +fn rejects_invalid_storage_backend_env() { + let err = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_STORAGE_BACKEND", "filesystem"), + ]) + .unwrap_err(); + assert!( + err.to_string() + .contains("FLUXER_MEDIA_PROXY_STORAGE_BACKEND") + ); +} + +#[test] +fn rejects_invalid_bool_env() { + let err = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_READ_ONLY", "maybe"), + ]) + .unwrap_err(); + assert!(err.to_string().contains("FLUXER_MEDIA_PROXY_READ_ONLY")); +} + +#[test] +fn rejects_invalid_number_env() { + let err = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY", "many"), + ]) + .unwrap_err(); + assert!( + err.to_string() + .contains("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY") + ); +} + +#[test] +fn rejects_out_of_range_number_env() { + let err = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS", "999999"), + ]) + .unwrap_err(); + assert!( + err.to_string() + .contains("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS") + ); +} + +#[test] +fn production_media_proxy_release_env_loads() { + let cfg = Config::load_from_iter(with_shared_runtime_env(&[ + ("RELEASE_CHANNEL", "canary"), + ( + "FLUXER_NSFW_SERVICE_ENDPOINT", + "http://int.flx-nyc-misc1.srv.fluxer.dev:8000", + ), + ("FLUXER_MEDIA_PROXY_MODE", "mp"), + ("FLUXER_MEDIA_PROXY_NSFW_THRESHOLD", "0.95"), + ("FLUXER_MEDIA_PROXY_STORAGE_BACKEND", "s3"), + ("FLUXER_MEDIA_PROXY_READ_ONLY", "true"), + ("FLUXER_MEDIA_PROXY_MAX_NATIVE_TRANSFORMS", "4"), + ("FLUXER_MEDIA_PROXY_WORKER_QUEUE_CAPACITY", "128"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_TIMEOUT_MS", "30000"), + ("FLUXER_MEDIA_PROXY_MAX_ENCODE_FRAMES", "4096"), + ("FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS", "30000"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES", "1073741824"), + ( + "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES", + "134217728", + ), + ("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_TTL_MS", "1800000"), + ("FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS", "30000"), + ])) + .unwrap(); + + assert_eq!("production", cfg.node_env); + assert_eq!(DeploymentMode::Mp, cfg.mode); + assert!(cfg.read_only); + assert_eq!(StorageBackend::S3, cfg.storage.backend); + assert_eq!("ewr1", cfg.storage.s3_region); + assert_eq!(4, cfg.media.max_native_transforms); + assert_eq!(128, cfg.media.worker_queue_capacity); + assert_eq!(30_000, cfg.media.transform_timeout_ms); + assert_eq!(4_096, cfg.media.max_encode_frames); + assert_eq!(30_000, cfg.media.max_encode_duration_ms); + assert_eq!(1 << 30, cfg.media.transform_cache_capacity_bytes); + assert_eq!(128 << 20, cfg.media.transform_cache_max_entry_bytes); + assert_eq!(1_800_000, cfg.media.transform_cache_ttl_ms); + assert_eq!(30_000, cfg.socket_io_timeout_ms); + assert!((cfg.media.nsfw_threshold - 0.95).abs() < f32::EPSILON); + assert_eq!( + "http://int.flx-nyc-misc1.srv.fluxer.dev:8000", + cfg.media.nsfw_service_endpoint + ); +} + +#[test] +fn production_static_proxy_release_env_loads() { + let cfg = Config::load_from_iter(with_shared_runtime_env(&[ + ("RELEASE_CHANNEL", "canary"), + ("FLUXER_MEDIA_PROXY_MODE", "static"), + ("FLUXER_MEDIA_PROXY_STORAGE_BACKEND", "s3"), + ("FLUXER_MEDIA_PROXY_READ_ONLY", "true"), + ("FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS", "30000"), + ])) + .unwrap(); + + assert_eq!(DeploymentMode::Static, cfg.mode); + assert!(cfg.read_only); + assert_eq!(StorageBackend::S3, cfg.storage.backend); + assert_eq!("static", cfg.storage.bucket_static); + assert_eq!(30_000, cfg.socket_io_timeout_ms); + assert!(cfg.upload_relay.secret.expose().is_empty()); +} + +#[test] +fn production_uploads_release_env_loads() { + let relay_secret = general_purpose::STANDARD.encode([9u8; 48]); + let cfg = Config::load_from_iter(with_shared_runtime_env(&[ + ("RELEASE_CHANNEL", "stable"), + ("FLUXER_MEDIA_PROXY_MODE", "upload"), + ("FLUXER_MEDIA_PROXY_STORAGE_BACKEND", "s3"), + ("FLUXER_MEDIA_PROXY_READ_ONLY", "false"), + ("FLUXER_MEDIA_PROXY_SOCKET_IO_TIMEOUT_MS", "300000"), + ("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_S3_TIMEOUT_MS", "900000"), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_BYTES", + "33554432", + ), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_BUFFERED_RETRY_TOTAL_BYTES", + "536870912", + ), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64", + relay_secret.as_str(), + ), + ])) + .unwrap(); + + assert_eq!(DeploymentMode::Upload, cfg.mode); + assert!(!cfg.read_only); + assert_eq!(300_000, cfg.socket_io_timeout_ms); + assert_eq!(900_000, cfg.upload_relay.s3_timeout_ms); + assert_eq!(32 << 20, cfg.upload_relay.buffered_retry_max_bytes); + assert_eq!(512 << 20, cfg.upload_relay.buffered_retry_total_bytes); + assert_eq!(500 * 1024 * 1024, cfg.upload_relay.max_body_bytes); + assert_eq!(&[9u8; 48][..], cfg.upload_relay.secret.expose()); +} + +#[test] +fn a_transform_cache_entry_ceiling_above_the_capacity_still_boots() { + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES", "1048576"), + ( + "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES", + "2097152", + ), + ]) + .unwrap(); + assert_eq!(1024 * 1024, cfg.media.transform_cache_capacity_bytes); + assert_eq!(2 * 1024 * 1024, cfg.media.transform_cache_max_entry_bytes); +} + +#[test] +fn both_transform_cache_env_keys_accept_their_whole_range() { + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES", "4294967296"), + ( + "FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES", + "536870912", + ), + ]) + .unwrap(); + assert_eq!( + 4 * 1024 * 1024 * 1024, + cfg.media.transform_cache_capacity_bytes + ); + assert_eq!(512 * 1024 * 1024, cfg.media.transform_cache_max_entry_bytes); + + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES", "0"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_MAX_ENTRY_BYTES", "0"), + ]) + .unwrap(); + assert_eq!(0, cfg.media.transform_cache_capacity_bytes); + assert_eq!(0, cfg.media.transform_cache_max_entry_bytes); +} + +#[test] +fn a_disabled_transform_cache_ignores_the_entry_ceiling() { + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_TRANSFORM_CACHE_BYTES", "0"), + ]) + .unwrap(); + assert_eq!(0, cfg.media.transform_cache_capacity_bytes); + assert_eq!(64 * 1024 * 1024, cfg.media.transform_cache_max_entry_bytes); +} + +#[test] +fn every_deployment_mode_and_storage_backend_variant_parses() { + let relay_secret = general_purpose::STANDARD.encode([3u8; 32]); + for (raw, expected) in [ + ("mp", DeploymentMode::Mp), + ("MP", DeploymentMode::Mp), + ("static", DeploymentMode::Static), + ("Static", DeploymentMode::Static), + ("upload", DeploymentMode::Upload), + (" UPLOAD ", DeploymentMode::Upload), + ] { + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_MODE", raw), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64", + relay_secret.as_str(), + ), + ]) + .unwrap(); + assert_eq!(expected, cfg.mode); + } + + for (raw, expected) in [ + ("local", StorageBackend::Local), + ("Local", StorageBackend::Local), + ("s3", StorageBackend::S3), + (" S3 ", StorageBackend::S3), + ] { + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ("FLUXER_MEDIA_PROXY_STORAGE_BACKEND", raw), + ]) + .unwrap(); + assert_eq!(expected, cfg.storage.backend); + } +} + +#[test] +fn upload_relay_spool_and_bunny_ip_gate_keys_apply() { + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR", + "/var/spool/flx", + ), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_CHUNK_BYTES", + "2097152", + ), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES", + "1073741824", + ), + ("FLUXER_MEDIA_PROXY_UPLOAD_RELAY_TOKEN_TTL_SECS", "600"), + ("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_ENABLED", "yes"), + ( + "FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_TRUSTED_PROXIES", + "10.0.0.1, 2001:db8::1 ,", + ), + ("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_REFRESH_SECS", "900"), + ]) + .unwrap(); + + assert_eq!( + std::path::Path::new("/var/spool/flx"), + cfg.upload_relay.spool_dir + ); + assert_eq!(2 << 20, cfg.upload_relay.spool_chunk_bytes); + assert_eq!(1 << 30, cfg.upload_relay.spool_max_total_bytes); + assert_eq!(600, cfg.upload_relay.token_ttl_secs); + assert!(cfg.bunny_ip_gate_enabled); + assert_eq!( + vec![ + "10.0.0.1".parse::().unwrap(), + "2001:db8::1".parse::().unwrap(), + ], + cfg.bunny_ip_gate_trusted_proxies + ); + assert_eq!(900, cfg.bunny_ip_gate_refresh_secs); +} + +#[test] +fn rejects_invalid_bunny_ip_gate_trusted_proxies() { + let err = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret"), + ( + "FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_TRUSTED_PROXIES", + "10.0.0.1,not-an-ip", + ), + ]) + .unwrap_err(); + assert!( + err.to_string() + .contains("FLUXER_MEDIA_PROXY_BUNNY_IP_GATE_TRUSTED_PROXIES") + ); +} + +#[test] +fn debug_output_never_reveals_a_secret() { + let relay_secret = general_purpose::STANDARD.encode(b"relay-secret-material-0123456789"); + let cfg = Config::load_from_iter([ + ("FLUXER_MEDIA_PROXY_SECRET_KEY", "signing-secret-material"), + ("FLUXER_MEDIA_PROXY_MODE", "upload"), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64", + relay_secret.as_str(), + ), + ]) + .unwrap(); + + let rendered = format!("{cfg:?}"); + assert!(!rendered.contains("signing-secret-material")); + assert!(!rendered.contains("relay-secret-material")); + assert_eq!(2, rendered.matches("[REDACTED]").count()); + assert_eq!("signing-secret-material", cfg.secret_key.expose()); +} diff --git a/fluxer_media_proxy/src/config/tests/s3_read.rs b/fluxer_media_proxy/src/config/tests/s3_read.rs new file mode 100644 index 000000000..768a7710a --- /dev/null +++ b/fluxer_media_proxy/src/config/tests/s3_read.rs @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{base_env, env_with}; +use crate::config::{BucketStyle, Config}; + +#[test] +fn read_endpoint_defaults_to_disabled() { + let cfg = Config::load_from_iter(base_env()).unwrap(); + assert_eq!(None, cfg.storage.s3_read_endpoint); + assert_eq!("cdn", cfg.storage.s3_read_bucket); + assert_eq!(BucketStyle::Path, cfg.storage.s3_read_bucket_style); + assert!(!cfg.storage.s3_read_signed); +} + +#[test] +fn empty_read_vars_are_treated_as_unset() { + let cfg = Config::load_from_iter(env_with(&[ + ("FLUXER_S3_READ_ENDPOINT", " "), + ("FLUXER_S3_READ_BUCKET", ""), + ("FLUXER_S3_READ_BUCKET_STYLE", ""), + ("FLUXER_S3_READ_SIGNED", ""), + ])) + .unwrap(); + assert_eq!(None, cfg.storage.s3_read_endpoint); + assert_eq!("cdn", cfg.storage.s3_read_bucket); + assert_eq!(BucketStyle::Path, cfg.storage.s3_read_bucket_style); + assert!(!cfg.storage.s3_read_signed); +} + +#[test] +fn every_new_var_tolerates_a_blank_value_individually() { + for var in [ + "FLUXER_S3_READ_ENDPOINT", + "FLUXER_S3_READ_BUCKET", + "FLUXER_S3_READ_BUCKET_STYLE", + "FLUXER_S3_READ_SIGNED", + ] { + for blank in ["", " "] { + let cfg = Config::load_from_iter(env_with(&[(var, blank)])) + .unwrap_or_else(|err| panic!("{var}={blank:?} must be treated as unset: {err}")); + assert_eq!(None, cfg.storage.s3_read_endpoint); + assert_eq!("cdn", cfg.storage.s3_read_bucket); + assert_eq!(BucketStyle::Path, cfg.storage.s3_read_bucket_style); + assert!(!cfg.storage.s3_read_signed); + } + } +} + +#[test] +fn read_bucket_defaults_to_cdn_bucket_and_can_be_overridden() { + let cfg = Config::load_from_iter(env_with(&[("FLUXER_S3_BUCKET_CDN", "fluxer")])).unwrap(); + assert_eq!("fluxer", cfg.storage.s3_read_bucket); + + let cfg = Config::load_from_iter(env_with(&[ + ("FLUXER_S3_BUCKET_CDN", "fluxer"), + ("FLUXER_S3_READ_BUCKET", "fluxer-static"), + ])) + .unwrap(); + assert_eq!("fluxer-static", cfg.storage.s3_read_bucket); +} + +#[test] +fn read_bucket_style_inherits_force_path_style() { + let cfg = Config::load_from_iter(env_with(&[("FLUXER_S3_FORCE_PATH_STYLE", "false")])).unwrap(); + assert_eq!(BucketStyle::VirtualHosted, cfg.storage.s3_read_bucket_style); + + let cfg = Config::load_from_iter(env_with(&[ + ("FLUXER_S3_FORCE_PATH_STYLE", "false"), + ("FLUXER_S3_READ_BUCKET_STYLE", "root"), + ])) + .unwrap(); + assert_eq!(BucketStyle::Rooted, cfg.storage.s3_read_bucket_style); + assert!(!cfg.storage.s3_force_path_style); +} + +#[test] +fn read_bucket_style_parses_all_values_case_insensitively() { + for (raw, expected) in [ + ("path", BucketStyle::Path), + ("PATH", BucketStyle::Path), + ("virtual", BucketStyle::VirtualHosted), + (" Virtual ", BucketStyle::VirtualHosted), + ("root", BucketStyle::Rooted), + ("ROOT", BucketStyle::Rooted), + ] { + let cfg = + Config::load_from_iter(env_with(&[("FLUXER_S3_READ_BUCKET_STYLE", raw)])).unwrap(); + assert_eq!(expected, cfg.storage.s3_read_bucket_style, "raw={raw}"); + } +} + +#[test] +fn read_bucket_style_rejects_unknown_value() { + let err = + Config::load_from_iter(env_with(&[("FLUXER_S3_READ_BUCKET_STYLE", "cdn")])).unwrap_err(); + assert!(err.to_string().contains("FLUXER_S3_READ_BUCKET_STYLE")); +} + +#[test] +fn read_signed_parses_boolean() { + let cfg = Config::load_from_iter(env_with(&[("FLUXER_S3_READ_SIGNED", "true")])).unwrap(); + assert!(cfg.storage.s3_read_signed); + + let err = Config::load_from_iter(env_with(&[("FLUXER_S3_READ_SIGNED", "maybe")])).unwrap_err(); + assert!(err.to_string().contains("FLUXER_S3_READ_SIGNED")); +} + +#[test] +fn read_endpoint_is_validated_at_startup() { + let cfg = Config::load_from_iter(env_with(&[( + "FLUXER_S3_READ_ENDPOINT", + "https://cdn.example.net", + )])) + .unwrap(); + assert_eq!( + Some("https://cdn.example.net".to_owned()), + cfg.storage.s3_read_endpoint + ); + + for (bad, expected) in [ + ("cdn.example.net", "not a valid URL"), + ("ftp://cdn.example.net", "must be an http or https URL"), + ("https://", "not a valid URL"), + ( + "https://user:pw@cdn.example.net", + "must not contain credentials", + ), + ( + "https://cdn.example.net/?token=abc", + "must not contain a query string or fragment", + ), + ( + "https://cdn.example.net/#frag", + "must not contain a query string or fragment", + ), + ] { + let err = + Config::load_from_iter(env_with(&[("FLUXER_S3_READ_ENDPOINT", bad)])).unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("FLUXER_S3_READ_ENDPOINT") && message.contains(expected), + "bad={bad} err={message}" + ); + } +} diff --git a/fluxer_media_proxy/src/constants.rs b/fluxer_media_proxy/src/constants.rs index f46b79c9a..620514f42 100644 --- a/fluxer_media_proxy/src/constants.rs +++ b/fluxer_media_proxy/src/constants.rs @@ -1,7 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering}; - pub const MAX_MEDIA_PROXY_BYTES: usize = 500 * 1024 * 1024; pub const OUTBOUND_USER_AGENT: &str = @@ -19,45 +17,6 @@ pub const MAX_ANIMATED_FRAMES_DEFAULT: u32 = 20_000; pub const MAX_ANIMATED_TOTAL_PIXELS_DEFAULT: usize = 4 * MAX_MEDIA_IMAGE_PIXELS_DEFAULT; const _: () = assert!(MAX_ANIMATED_FRAMES_DEFAULT >= 20_000); -static IMAGE_DIMENSION: AtomicU32 = AtomicU32::new(MAX_MEDIA_IMAGE_DIMENSION_DEFAULT); -static IMAGE_PIXELS: AtomicUsize = AtomicUsize::new(MAX_MEDIA_IMAGE_PIXELS_DEFAULT); -static ANIMATED_FRAMES: AtomicU32 = AtomicU32::new(MAX_ANIMATED_FRAMES_DEFAULT); -static ANIMATED_TOTAL_PIXELS: AtomicUsize = AtomicUsize::new(MAX_ANIMATED_TOTAL_PIXELS_DEFAULT); - -pub struct Limits; - -impl Limits { - pub fn image_dimension() -> u32 { - IMAGE_DIMENSION.load(Ordering::Acquire) - } - - pub fn image_pixels() -> usize { - IMAGE_PIXELS.load(Ordering::Acquire) - } - - pub fn animated_frames() -> u32 { - ANIMATED_FRAMES.load(Ordering::Acquire) - } - - pub fn animated_total_pixels() -> usize { - ANIMATED_TOTAL_PIXELS.load(Ordering::Acquire) - } - - pub fn set_image_dimension(value: u32) { - let clamped = value.max(16); - IMAGE_DIMENSION.store(clamped, Ordering::Release); - IMAGE_PIXELS.store(clamped as usize * clamped as usize, Ordering::Release); - } - - pub fn set_animated_frames(value: u32) { - ANIMATED_FRAMES.store(value.max(1), Ordering::Release); - } - - pub fn set_animated_total_pixels(value: usize) { - ANIMATED_TOTAL_PIXELS.store(value.max(1024), Ordering::Release); - } -} - pub const IMAGE_SIZES: &[u32] = &[ 16, 20, 22, 24, 28, 32, 40, 44, 48, 56, 60, 64, 80, 96, 100, 128, 160, 240, 256, 300, 320, 480, 512, 600, 640, 1024, 1280, 1536, 2048, 3072, 4096, 8192, 16384, @@ -186,6 +145,7 @@ pub fn clamp_size(raw_target: u32, kind: AssetKind) -> u32 { #[cfg(test)] mod tests { use super::*; + use crate::media_limits::MediaLimits; fn asset_cache_key(raw: Option<&str>, kind: AssetKind) -> u32 { clamp_size(parse_image_size(raw), kind) @@ -265,6 +225,9 @@ mod tests { #[test] fn animated_frame_default_allows_dense_short_clips() { - assert_eq!(MAX_ANIMATED_FRAMES_DEFAULT, Limits::animated_frames()); + assert_eq!( + MAX_ANIMATED_FRAMES_DEFAULT, + MediaLimits::default_from_config().animated_frames() + ); } } diff --git a/fluxer_media_proxy/src/disposition.rs b/fluxer_media_proxy/src/disposition.rs index 308f72faf..d5d37f6e1 100644 --- a/fluxer_media_proxy/src/disposition.rs +++ b/fluxer_media_proxy/src/disposition.rs @@ -1,5 +1,18 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use http::HeaderValue; +use thiserror::Error; + +pub const PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES: usize = 8 * 1024; +const CONTENT_DISPOSITION_DIRECTIVE_BYTES_MAX: usize = "attachment".len(); +const CONTENT_DISPOSITION_FIXED_BYTES_MAX: usize = CONTENT_DISPOSITION_DIRECTIVE_BYTES_MAX + 40; +pub const CONTENT_DISPOSITION_FILENAME_BYTES_MAX: usize = + (PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES - CONTENT_DISPOSITION_FIXED_BYTES_MAX) / 4; +const _: () = assert!( + CONTENT_DISPOSITION_FILENAME_BYTES_MAX * 4 + CONTENT_DISPOSITION_FIXED_BYTES_MAX + <= PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES +); + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Decision { Inline, @@ -10,6 +23,32 @@ impl Decision { pub fn is_attachment(self) -> bool { self == Self::Attachment } + + pub fn header_value(self) -> HeaderValue { + HeaderValue::from_static(self.directive()) + } + + fn directive(self) -> &'static str { + match self { + Self::Inline => "inline", + Self::Attachment => "attachment", + } + } +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum ContentDispositionError { + #[error("content disposition allocation failed")] + AllocationFailed, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContentDisposition(HeaderValue); + +impl ContentDisposition { + pub fn into_header_value(self) -> HeaderValue { + self.0 + } } pub fn decide(content_type: &str, requested_download: bool) -> Decision { @@ -32,11 +71,12 @@ pub fn is_inline_viewable(content_type: &str) -> bool { if is_scriptable_document(mime) { return false; } - if mime.len() >= 6 && mime[..6].eq_ignore_ascii_case("image/") { - return true; - } - if mime.len() >= 6 && mime[..6].eq_ignore_ascii_case("video/") { - return true; + let bytes = mime.as_bytes(); + if bytes.len() >= 6 { + let prefix = &bytes[..6]; + if prefix.eq_ignore_ascii_case(b"image/") || prefix.eq_ignore_ascii_case(b"video/") { + return true; + } } false } @@ -46,9 +86,11 @@ fn is_scriptable_document(mime: &str) -> bool { } fn is_safe_quoted_filename(s: &str) -> bool { - !s.is_empty() - && s.bytes() - .all(|b| (0x20..0x7f).contains(&b) && !matches!(b, b'"' | b'\\' | b'/' | 0x80)) + !s.is_empty() && s.bytes().all(is_safe_quoted_byte) +} + +fn is_safe_quoted_byte(b: u8) -> bool { + (0x20..0x7f).contains(&b) && !matches!(b, b'"' | b'\\' | b'/') } fn is_attr_char(b: u8) -> bool { @@ -59,9 +101,8 @@ fn is_attr_char(b: u8) -> bool { ) } -fn percent_encoded(bytes: &[u8]) -> String { +fn append_percent_encoded(out: &mut String, bytes: &[u8]) { const HEX: &[u8; 16] = b"0123456789ABCDEF"; - let mut out = String::new(); for &b in bytes { if is_attr_char(b) { out.push(char::from(b)); @@ -71,44 +112,76 @@ fn percent_encoded(bytes: &[u8]) -> String { out.push(char::from(HEX[(b & 0x0f) as usize])); } } - out } -pub fn format_header(decision: Decision, filename: Option<&str>) -> String { - let directive = match decision { - Decision::Inline => "inline", - Decision::Attachment => "attachment", - }; - let Some(name) = filename else { - return directive.to_owned(); - }; - if name.is_empty() { - return directive.to_owned(); +pub(crate) fn truncate_on_char_boundary(name: &str, max_bytes: usize) -> &str { + if name.len() <= max_bytes { + return name; } - if is_safe_quoted_filename(name) { - return format!("{directive}; filename=\"{name}\""); + let mut end = max_bytes; + while end > 0 && !name.is_char_boundary(end) { + end -= 1; } + &name[..end] +} - let fallback: String = name - .bytes() - .map(|b| { - if (0x20..0x7f).contains(&b) && !matches!(b, b'"' | b'\\') { - char::from(b) +pub fn header( + decision: Decision, + filename: Option<&str>, +) -> Result { + let directive = decision.directive(); + let Some(name) = filename + .map(|name| truncate_on_char_boundary(name, CONTENT_DISPOSITION_FILENAME_BYTES_MAX)) + .filter(|name| !name.is_empty()) + else { + return Ok(ContentDisposition(decision.header_value())); + }; + let safe_quoted_filename = is_safe_quoted_filename(name); + let filename_capacity = if safe_quoted_filename { + name.len() + } else { + name.len() * 4 + }; + let value_capacity = filename_capacity + directive.len() + 40; + let mut value = String::new(); + value + .try_reserve_exact(value_capacity) + .map_err(|_| ContentDispositionError::AllocationFailed)?; + value.push_str(directive); + value.push_str("; filename=\""); + if safe_quoted_filename { + value.push_str(name); + value.push('"'); + } else { + for byte in name.bytes() { + value.push(if is_safe_quoted_byte(byte) { + char::from(byte) } else { '_' - } - }) - .collect(); - format!( - "{directive}; filename=\"{fallback}\"; filename*=UTF-8''{}", - percent_encoded(name.as_bytes()) - ) + }); + } + value.push_str("\"; filename*=UTF-8''"); + append_percent_encoded(&mut value, name.as_bytes()); + } + Ok(ContentDisposition( + HeaderValue::from_bytes(value.as_bytes()) + .expect("sanitised content disposition must be a valid header value"), + )) } #[cfg(test)] mod tests { use super::*; + fn header_string(decision: Decision, filename: Option<&str>) -> String { + header(decision, filename) + .expect("disposition header") + .into_header_value() + .to_str() + .expect("disposition header is ascii") + .to_owned() + } + #[test] fn inline_vs_attachment_based_on_mime() { assert_eq!(Decision::Inline, decide("image/png", false)); @@ -142,22 +215,35 @@ mod tests { assert_eq!(Decision::Attachment, decide("Image/Svg+Xml", false)); } + #[test] + fn a_multibyte_boundary_inside_the_mime_prefix_is_not_a_panic() { + assert_eq!(Decision::Attachment, decide("XX#-X\u{605}", false)); + assert!(!is_inline_viewable("XX#-X\u{605}")); + assert!(!is_inline_viewable("\u{1f600}\u{1f600}")); + assert!(!is_inline_viewable("imag\u{e9}/png")); + for input in crate::test_fixtures::ADVERSARIAL_TEXT_INPUTS { + let _ = decide(input, false); + let _ = decide(input, true); + } + } + #[test] fn format_header_ascii_filename_quoted() { assert_eq!( "attachment; filename=\"report.pdf\"", - format_header(Decision::Attachment, Some("report.pdf")) + header_string(Decision::Attachment, Some("report.pdf")) ); } #[test] fn format_header_inline_no_filename() { - assert_eq!("inline", format_header(Decision::Inline, None)); + assert_eq!("inline", header_string(Decision::Inline, None)); + assert_eq!("inline", header_string(Decision::Inline, Some(""))); } #[test] fn format_header_non_ascii_filename_uses_rfc5987_ext_form() { - let out = format_header(Decision::Attachment, Some("naïve résumé.pdf")); + let out = header_string(Decision::Attachment, Some("naïve résumé.pdf")); assert!(out.contains("filename*=UTF-8''")); assert!(out.contains("%C3%A9")); assert!(out.contains("filename=\"")); @@ -165,11 +251,54 @@ mod tests { #[test] fn format_header_strips_embedded_quote_and_backslash() { - let out = format_header(Decision::Attachment, Some("evil\"name\\.txt")); + let out = header_string(Decision::Attachment, Some("evil\"name\\.txt")); assert!(!out.contains("evil\"")); assert!(!out.contains('\\')); assert!(out.contains("filename*=UTF-8''")); assert!(out.contains("%22")); assert!(out.contains("%5C")); } + + #[test] + fn sanitized_fallback_filename_replaces_path_separators() { + assert_eq!( + "attachment; filename=\".._.._x.png\"; filename*=UTF-8''..%2F..%2Fx.png", + header_string(Decision::Attachment, Some("../../x.png")) + ); + } + + #[test] + fn filename_bound_leaves_room_for_the_worst_case_expansion() { + assert_eq!(2035, CONTENT_DISPOSITION_FILENAME_BYTES_MAX); + let widest = "\u{7f}".repeat(CONTENT_DISPOSITION_FILENAME_BYTES_MAX); + assert!(header(Decision::Attachment, Some(&widest)).is_ok()); + } + + #[test] + fn oversized_filenames_are_truncated_instead_of_dropped() { + let oversized = "\u{7f}".repeat(PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); + let value = header_string(Decision::Attachment, Some(&oversized)); + assert!(value.starts_with("attachment; filename=\"")); + assert!(value.len() <= PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); + } + + #[test] + fn long_multi_byte_filenames_keep_a_bounded_header_for_both_dispositions() { + let filename = "\u{e9}".repeat(1100); + for (decision, expected_prefix) in [ + (Decision::Inline, "inline; filename=\""), + (Decision::Attachment, "attachment; filename=\""), + ] { + let value = header_string(decision, Some(&filename)); + assert!(value.starts_with(expected_prefix), "{value}"); + assert!(value.len() <= PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); + let encoded = value + .split_once("filename*=UTF-8''") + .expect("percent encoded filename") + .1; + assert_eq!(0, encoded.len() % 3); + assert!(encoded.ends_with("%A9")); + assert!(encoded.len() < filename.len() * 3); + } + } } diff --git a/fluxer_media_proxy/src/external_path.rs b/fluxer_media_proxy/src/external_path.rs index becd71444..c5643189c 100644 --- a/fluxer_media_proxy/src/external_path.rs +++ b/fluxer_media_proxy/src/external_path.rs @@ -8,7 +8,97 @@ pub use fluxer_common::external_media_path::{ #[cfg(test)] mod tests { use super::*; - use crate::public_net_policy; + use crate::{public_net_policy, signing, test_fixtures::ADVERSARIAL_TEXT_INPUTS}; + use proptest::prelude::*; + + const SECRET: &[u8] = b"external-media-proxy-secret"; + + fn without_fragment(url: &str) -> &str { + url.split_once('#').map_or(url, |(head, _)| head) + } + + fn signed_round_trip(url: &str) -> String { + let path = build_external_media_proxy_path(url).expect("the external path builds"); + let signature = signing::create_signature(&path, SECRET); + assert!( + signing::verify_signature(&path, &signature, SECRET), + "{url}" + ); + assert!( + !signing::verify_signature(&path, &signature, b"other-secret"), + "{url}" + ); + reconstruct_original_url(&path).expect("the original url reconstructs") + } + + fn adversarial_urls(text: &str) -> [String; 4] { + [ + format!("https://example.com/{text}"), + format!("https://example.com/media/{text}/photo.webp"), + format!("http://example.com:8443/x?v={text}"), + format!("https://example.com/{text}/{text}?{text}=1"), + ] + } + + #[test] + fn adversarial_text_round_trips_through_the_signed_legacy_path() { + for text in ADVERSARIAL_TEXT_INPUTS { + for url in adversarial_urls(text) { + assert_eq!(without_fragment(&url), signed_round_trip(&url)); + } + } + } + + #[test] + fn adversarial_text_round_trips_through_the_signed_opaque_path() { + for text in ADVERSARIAL_TEXT_INPUTS { + for url in adversarial_urls(text) { + let path = build_opaque_external_media_proxy_path(&url); + let signature = signing::create_signature(&path, SECRET); + assert!( + signing::verify_signature(&path, &signature, SECRET), + "{url}" + ); + assert_eq!( + url, + reconstruct_original_url(&path).expect("the opaque path reconstructs") + ); + } + } + } + + #[test] + fn a_signature_computed_over_a_different_path_never_verifies() { + for text in ADVERSARIAL_TEXT_INPUTS { + let path = build_external_media_proxy_path(&format!("https://example.com/{text}")) + .expect("the external path builds"); + for other in [ + format!("https://example.com/other/{text}"), + format!("https://example.org/{text}"), + format!("http://example.com/{text}"), + ] { + let other_path = + build_external_media_proxy_path(&other).expect("the external path builds"); + assert_ne!(path, other_path, "{other}"); + let signature = signing::create_signature(&other_path, SECRET); + assert!( + !signing::verify_signature(&path, &signature, SECRET), + "{other}" + ); + } + } + } + + #[test] + fn adversarial_text_is_never_a_usable_external_path_on_its_own() { + for text in ADVERSARIAL_TEXT_INPUTS { + assert_eq!( + Err(ExternalPathError::InvalidExternalPath), + build_external_media_proxy_path(text) + ); + assert!(reconstruct_original_url(text).is_err(), "{text:?}"); + } + } #[test] fn a_signed_path_never_decodes_into_a_url_the_fetch_policy_refuses() { @@ -41,4 +131,40 @@ mod tests { .path_query ); } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 256, + failure_persistence: None, + ..ProptestConfig::default() + })] + + #[test] + fn every_generated_media_url_survives_the_sign_and_reconstruct_round_trip( + scheme in prop::sample::select(vec!["http", "https"]), + host in "[a-z][a-z0-9.-]{0,24}", + port in prop::option::of(1u16..=65_535), + path in prop::collection::vec("[^/?#]{0,12}", 1..5), + query in prop::option::of("[^\u{0}#]{1,24}"), + ) { + let authority = match port { + Some(port) => format!("{host}:{port}"), + None => host, + }; + let url = format!( + "{scheme}://{authority}/{}{}", + path.join("/"), + query.map(|query| format!("?{query}")).unwrap_or_default() + ); + let proxy_path = build_external_media_proxy_path(&url) + .expect("a generated media url always builds a proxy path"); + let signature = signing::create_signature(&proxy_path, SECRET); + prop_assert!(signing::verify_signature(&proxy_path, &signature, SECRET)); + prop_assert_eq!( + url, + reconstruct_original_url(&proxy_path) + .expect("a generated proxy path always reconstructs") + ); + } + } } diff --git a/fluxer_media_proxy/src/http_client.rs b/fluxer_media_proxy/src/http_client.rs deleted file mode 100644 index 273ae2e0d..000000000 --- a/fluxer_media_proxy/src/http_client.rs +++ /dev/null @@ -1,234 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::metrics; -use reqwest::StatusCode; -use reqwest_middleware::{ClientBuilder, ClientWithMiddleware, Error as MiddlewareError}; -use reqwest_retry::{ - RetryDecision, RetryPolicy, RetryTransientMiddleware, Retryable, RetryableStrategy, - policies::ExponentialBackoff, -}; -use std::sync::atomic::Ordering; -use std::time::{Duration, SystemTime}; - -pub type HttpClient = ClientWithMiddleware; - -#[derive(Clone, Copy, Debug)] -pub struct Options { - pub connect_timeout_ms: u64, - pub timeout_ms: u64, - pub max_retries: u32, - pub min_retry_delay_ms: u64, - pub max_retry_delay_ms: u64, - pub restrict_to_public: bool, -} - -impl Default for Options { - fn default() -> Self { - Self { - connect_timeout_ms: 1_500, - timeout_ms: 30_000, - max_retries: 2, - min_retry_delay_ms: 25, - max_retry_delay_ms: 500, - restrict_to_public: false, - } - } -} - -pub fn build_raw(options: Options) -> Result { - let mut builder = reqwest::Client::builder() - .connect_timeout(Duration::from_millis(options.connect_timeout_ms.max(1))) - .timeout(Duration::from_millis(options.timeout_ms.max(1))) - .redirect(reqwest::redirect::Policy::none()) - .user_agent(crate::constants::OUTBOUND_USER_AGENT); - if options.restrict_to_public { - builder = builder.dns_resolver(std::sync::Arc::new( - crate::public_net_policy::PinnedDnsResolver, - )); - } - builder.build() -} - -pub fn build(options: Options) -> Result { - let client = build_raw(options)?; - let builder = ClientBuilder::new(client); - if options.max_retries == 0 { - return Ok(builder.build()); - } - let retry_policy = ExponentialBackoff::builder() - .retry_bounds( - Duration::from_millis(options.min_retry_delay_ms.max(1)), - Duration::from_millis( - options - .max_retry_delay_ms - .max(options.min_retry_delay_ms) - .max(1), - ), - ) - .build_with_max_retries(options.max_retries); - Ok(builder - .with(RetryTransientMiddleware::new_with_policy_and_strategy( - ObservableRetryPolicy { - inner: retry_policy, - }, - MediaProxyRetryStrategy, - )) - .build()) -} - -pub fn build_default() -> HttpClient { - build(Options::default()).expect("default HTTP client configuration is valid") -} - -pub fn build_raw_default() -> reqwest::Client { - build_raw(Options::default()).expect("default HTTP client configuration is valid") -} - -struct ObservableRetryPolicy { - inner: ExponentialBackoff, -} - -impl RetryPolicy for ObservableRetryPolicy { - fn should_retry(&self, request_start_time: SystemTime, n_past_retries: u32) -> RetryDecision { - let decision = self.inner.should_retry(request_start_time, n_past_retries); - match decision { - RetryDecision::Retry { .. } => { - metrics::GLOBAL.http_retries.fetch_add(1, Ordering::Relaxed); - } - RetryDecision::DoNotRetry => { - metrics::GLOBAL - .http_retries_exhausted - .fetch_add(1, Ordering::Relaxed); - } - } - decision - } -} - -struct MediaProxyRetryStrategy; - -impl RetryableStrategy for MediaProxyRetryStrategy { - fn handle(&self, result: &Result) -> Option { - match result { - Ok(response) => retryable_status(response.status()).map(|retryable| { - if retryable == Retryable::Transient { - metrics::GLOBAL - .http_retryable_status - .fetch_add(1, Ordering::Relaxed); - } - retryable - }), - Err(error) => retryable_error(error).map(|retryable| { - if retryable == Retryable::Transient { - metrics::GLOBAL - .http_retryable_error - .fetch_add(1, Ordering::Relaxed); - } - retryable - }), - } - } -} - -fn retryable_status(status: StatusCode) -> Option { - match status { - StatusCode::REQUEST_TIMEOUT - | StatusCode::TOO_MANY_REQUESTS - | StatusCode::INTERNAL_SERVER_ERROR - | StatusCode::BAD_GATEWAY - | StatusCode::SERVICE_UNAVAILABLE - | StatusCode::GATEWAY_TIMEOUT => Some(Retryable::Transient), - status if status.is_client_error() || status.is_server_error() => Some(Retryable::Fatal), - _ => None, - } -} - -fn retryable_error(error: &MiddlewareError) -> Option { - match error { - MiddlewareError::Middleware(_) => Some(Retryable::Fatal), - MiddlewareError::Reqwest(error) => { - #[cfg(not(target_arch = "wasm32"))] - let is_connect = error.is_connect(); - #[cfg(target_arch = "wasm32")] - let is_connect = false; - - if error.is_timeout() || is_connect { - Some(Retryable::Transient) - } else if error.is_body() - || error.is_decode() - || error.is_builder() - || error.is_redirect() - || error.is_status() - { - Some(Retryable::Fatal) - } else { - None - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn builds_retrying_client() { - let client = build(Options { - connect_timeout_ms: 0, - timeout_ms: 0, - max_retries: 1, - min_retry_delay_ms: 0, - max_retry_delay_ms: 0, - restrict_to_public: false, - }); - assert!(client.is_ok()); - } - - #[test] - fn builds_non_retrying_client() { - let client = build(Options { - max_retries: 0, - ..Options::default() - }); - assert!(client.is_ok()); - } - - #[test] - fn retry_strategy_retries_only_explicit_transient_statuses() { - assert!(matches!( - retryable_status(StatusCode::REQUEST_TIMEOUT), - Some(Retryable::Transient) - )); - assert!(matches!( - retryable_status(StatusCode::TOO_MANY_REQUESTS), - Some(Retryable::Transient) - )); - assert!(matches!( - retryable_status(StatusCode::INTERNAL_SERVER_ERROR), - Some(Retryable::Transient) - )); - assert!(matches!( - retryable_status(StatusCode::BAD_GATEWAY), - Some(Retryable::Transient) - )); - assert!(matches!( - retryable_status(StatusCode::SERVICE_UNAVAILABLE), - Some(Retryable::Transient) - )); - assert!(matches!( - retryable_status(StatusCode::GATEWAY_TIMEOUT), - Some(Retryable::Transient) - )); - assert!(matches!( - retryable_status(StatusCode::NOT_FOUND), - Some(Retryable::Fatal) - )); - assert!(matches!( - retryable_status(StatusCode::NOT_IMPLEMENTED), - Some(Retryable::Fatal) - )); - assert!(retryable_status(StatusCode::OK).is_none()); - assert!(retryable_status(StatusCode::FOUND).is_none()); - } -} diff --git a/fluxer_media_proxy/src/http_client/mod.rs b/fluxer_media_proxy/src/http_client/mod.rs new file mode 100644 index 000000000..e04118e5e --- /dev/null +++ b/fluxer_media_proxy/src/http_client/mod.rs @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod retry; + +use crate::metrics::http_client::HTTPClientMetrics; +use crate::public_net_policy::PinnedDnsResolver; +use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; +use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff}; +use retry::{MediaProxyRetryStrategy, ObservableRetryPolicy}; +use std::num::{NonZeroU32, NonZeroU64}; +use std::sync::Arc; +use std::time::Duration; + +pub type HttpClient = ClientWithMiddleware; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct HTTPClientOptions { + connect_timeout_ms: NonZeroU64, + request_timeout: HTTPRequestTimeout, + retries: HTTPRetryPolicy, + address_policy: HTTPAddressPolicy, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HTTPRequestTimeout { + Bounded(NonZeroU64), + Disabled, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HTTPRetryPolicy { + Disabled, + Enabled { + max_retries: NonZeroU32, + min_delay_ms: NonZeroU64, + max_delay_ms: NonZeroU64, + }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum HTTPAddressPolicy { + Any, + PublicOnly, +} + +impl HTTPClientOptions { + pub fn new(connect_timeout_ms: NonZeroU64, timeout_ms: NonZeroU64) -> Self { + Self { + connect_timeout_ms, + request_timeout: HTTPRequestTimeout::Bounded(timeout_ms), + ..Self::default() + } + } + + pub fn without_request_timeout(mut self) -> Self { + self.request_timeout = HTTPRequestTimeout::Disabled; + self + } + + pub fn restrict_to_public(mut self) -> Self { + self.address_policy = HTTPAddressPolicy::PublicOnly; + self + } + + pub fn without_retries(mut self) -> Self { + self.retries = HTTPRetryPolicy::Disabled; + self + } +} + +impl Default for HTTPClientOptions { + fn default() -> Self { + Self { + connect_timeout_ms: NonZeroU64::new(1_500) + .expect("default connection timeout must be nonzero"), + request_timeout: HTTPRequestTimeout::Bounded( + NonZeroU64::new(30_000).expect("default request timeout must be nonzero"), + ), + retries: HTTPRetryPolicy::Enabled { + max_retries: NonZeroU32::new(2) + .expect("default maximum retry count must be nonzero"), + min_delay_ms: NonZeroU64::new(25) + .expect("default minimum retry delay must be nonzero"), + max_delay_ms: NonZeroU64::new(500) + .expect("default maximum retry delay must be nonzero"), + }, + address_policy: HTTPAddressPolicy::Any, + } + } +} + +pub fn build_raw(options: HTTPClientOptions) -> Result { + // System proxy discovery stays on. HTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY are part + // of the deployment's egress configuration, so an operator who funnels egress through a + // gateway gets storage reads and external fetches routed through it. The healthcheck probe + // is the one client that opts out, because it only ever dials this process on loopback. + let mut builder = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(options.connect_timeout_ms.get())) + .redirect(reqwest::redirect::Policy::none()) + .user_agent(crate::constants::OUTBOUND_USER_AGENT); + if let HTTPRequestTimeout::Bounded(timeout_ms) = options.request_timeout { + builder = builder.timeout(Duration::from_millis(timeout_ms.get())); + } + if options.address_policy == HTTPAddressPolicy::PublicOnly { + builder = builder.dns_resolver(Arc::new(PinnedDnsResolver)); + } + builder.build() +} + +pub fn build( + options: HTTPClientOptions, + metrics: Arc, +) -> Result { + let client = build_raw(options)?; + let builder = ClientBuilder::new(client); + let HTTPRetryPolicy::Enabled { + max_retries, + min_delay_ms, + max_delay_ms, + } = options.retries + else { + return Ok(builder.build()); + }; + let retry_policy = ExponentialBackoff::builder() + .retry_bounds( + Duration::from_millis(min_delay_ms.get()), + Duration::from_millis(max_delay_ms.max(min_delay_ms).get()), + ) + .build_with_max_retries(max_retries.get()); + Ok(builder + .with(RetryTransientMiddleware::new_with_policy_and_strategy( + ObservableRetryPolicy::new(retry_policy, Arc::clone(&metrics)), + MediaProxyRetryStrategy::new(metrics), + )) + .build()) +} + +pub fn build_default(metrics: Arc) -> HttpClient { + build(HTTPClientOptions::default(), metrics) + .expect("default HTTP client configuration is valid") +} + +pub fn build_raw_default() -> reqwest::Client { + build_raw(HTTPClientOptions::default()).expect("default HTTP client configuration is valid") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metrics::Metrics; + + fn millis(value: u64) -> NonZeroU64 { + NonZeroU64::new(value).expect("test timeout must be nonzero") + } + + #[test] + fn builds_retrying_client() { + let client = build( + HTTPClientOptions::new(millis(1), millis(1)), + Metrics::new().http_client(), + ); + assert!(client.is_ok()); + } + + #[test] + fn builds_non_retrying_client() { + let client = build( + HTTPClientOptions::default().without_retries(), + Metrics::new().http_client(), + ); + assert!(client.is_ok()); + } + + #[test] + fn default_options_carry_the_frozen_timeout_and_retry_budget() { + let options = HTTPClientOptions::default(); + assert_eq!(options.connect_timeout_ms, millis(1_500)); + assert_eq!( + options.request_timeout, + HTTPRequestTimeout::Bounded(millis(30_000)) + ); + assert_eq!( + options.retries, + HTTPRetryPolicy::Enabled { + max_retries: NonZeroU32::new(2).expect("nonzero"), + min_delay_ms: millis(25), + max_delay_ms: millis(500), + } + ); + assert_eq!(options.address_policy, HTTPAddressPolicy::Any); + } + + #[test] + fn new_overrides_only_the_two_timeouts_and_keeps_the_default_retry_budget() { + let options = HTTPClientOptions::new(millis(250), millis(4_000)); + assert_eq!(options.connect_timeout_ms, millis(250)); + assert_eq!( + options.request_timeout, + HTTPRequestTimeout::Bounded(millis(4_000)) + ); + assert_eq!(options.retries, HTTPClientOptions::default().retries); + assert_eq!(options.address_policy, HTTPAddressPolicy::Any); + } + + #[test] + fn each_modifier_changes_exactly_one_facet_and_composes_with_the_others() { + let base = HTTPClientOptions::new(millis(250), millis(4_000)); + let stripped = base + .without_request_timeout() + .restrict_to_public() + .without_retries(); + assert_eq!(stripped.connect_timeout_ms, millis(250)); + assert_eq!(stripped.request_timeout, HTTPRequestTimeout::Disabled); + assert_eq!(stripped.retries, HTTPRetryPolicy::Disabled); + assert_eq!(stripped.address_policy, HTTPAddressPolicy::PublicOnly); + assert_eq!( + base.without_request_timeout(), + HTTPClientOptions { + request_timeout: HTTPRequestTimeout::Disabled, + ..base + } + ); + assert_eq!( + base.restrict_to_public(), + HTTPClientOptions { + address_policy: HTTPAddressPolicy::PublicOnly, + ..base + } + ); + assert_eq!( + base.without_retries(), + HTTPClientOptions { + retries: HTTPRetryPolicy::Disabled, + ..base + } + ); + } + + #[test] + fn every_option_shape_produces_a_usable_transport_client() { + let base = HTTPClientOptions::new(millis(250), millis(4_000)); + assert!(build_raw(base).is_ok()); + assert!(build_raw(base.without_request_timeout()).is_ok()); + assert!(build_raw(base.restrict_to_public()).is_ok()); + assert!(build_raw(HTTPClientOptions::default()).is_ok()); + } + + #[test] + fn every_transport_client_keeps_system_proxy_discovery() { + // reqwest hands out no accessor for a built client's proxy matchers and renders the + // field only while one survives, so Debug is the only way to catch a stray no_proxy(). + for options in [ + HTTPClientOptions::default(), + HTTPClientOptions::default().restrict_to_public(), + HTTPClientOptions::new(millis(250), millis(4_000)), + ] { + let client = build_raw(options).expect("transport client builds"); + assert!( + format!("{client:?}").contains("proxies"), + "outbound clients must honour HTTP_PROXY/HTTPS_PROXY/ALL_PROXY: {client:?}" + ); + } + } + + #[tokio::test] + async fn a_pinned_resolver_rejection_survives_the_transport_error_chain() { + let client = + build_raw(HTTPClientOptions::new(millis(250), millis(1_000)).restrict_to_public()) + .expect("the public-only client configuration is valid"); + let error = client + .get("http://localhost/") + .send() + .await + .expect_err("the pinned resolver rejects a loopback host"); + assert!(crate::public_net_policy::is_pinned_dns_failure(&error)); + } +} diff --git a/fluxer_media_proxy/src/http_client/retry.rs b/fluxer_media_proxy/src/http_client/retry.rs new file mode 100644 index 000000000..d4e4b913e --- /dev/null +++ b/fluxer_media_proxy/src/http_client/retry.rs @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::metrics::http_client::HTTPClientMetrics; +use reqwest::StatusCode; +use reqwest_middleware::Error as MiddlewareError; +use reqwest_retry::{ + RetryDecision, RetryPolicy, Retryable, RetryableStrategy, policies::ExponentialBackoff, +}; +use std::sync::Arc; +use std::time::SystemTime; + +pub(super) struct ObservableRetryPolicy { + inner: ExponentialBackoff, + metrics: Arc, +} + +impl ObservableRetryPolicy { + pub(super) fn new(inner: ExponentialBackoff, metrics: Arc) -> Self { + Self { inner, metrics } + } +} + +impl RetryPolicy for ObservableRetryPolicy { + fn should_retry(&self, request_start_time: SystemTime, n_past_retries: u32) -> RetryDecision { + let decision = self.inner.should_retry(request_start_time, n_past_retries); + match decision { + RetryDecision::Retry { .. } => self.metrics.record_retry(), + RetryDecision::DoNotRetry => self.metrics.record_retries_exhausted(), + } + decision + } +} + +pub(super) struct MediaProxyRetryStrategy { + metrics: Arc, +} + +impl MediaProxyRetryStrategy { + pub(super) fn new(metrics: Arc) -> Self { + Self { metrics } + } +} + +impl RetryableStrategy for MediaProxyRetryStrategy { + fn handle(&self, result: &Result) -> Option { + match result { + Ok(response) => retryable_status(response.status()).map(|retryable| { + if retryable == Retryable::Transient { + self.metrics.record_retryable_status(); + } + retryable + }), + Err(error) => retryable_error(error).map(|retryable| { + if retryable == Retryable::Transient { + self.metrics.record_retryable_error(); + } + retryable + }), + } + } +} + +fn retryable_status(status: StatusCode) -> Option { + match status { + StatusCode::REQUEST_TIMEOUT + | StatusCode::TOO_MANY_REQUESTS + | StatusCode::INTERNAL_SERVER_ERROR + | StatusCode::BAD_GATEWAY + | StatusCode::SERVICE_UNAVAILABLE + | StatusCode::GATEWAY_TIMEOUT => Some(Retryable::Transient), + status if status.is_client_error() || status.is_server_error() => Some(Retryable::Fatal), + _ => None, + } +} + +fn retryable_error(error: &MiddlewareError) -> Option { + // A pinned-resolver rejection is a policy decision, not a transient fault, and retrying it only + // repeats the same lookup against a name the caller supplied. + if crate::public_net_policy::is_pinned_dns_failure(error) { + return Some(Retryable::Fatal); + } + match error { + MiddlewareError::Middleware(_) => Some(Retryable::Fatal), + MiddlewareError::Reqwest(error) => { + #[cfg(not(target_arch = "wasm32"))] + let is_connect = error.is_connect(); + #[cfg(target_arch = "wasm32")] + let is_connect = false; + + if error.is_timeout() || is_connect { + Some(Retryable::Transient) + } else if error.is_body() + || error.is_decode() + || error.is_builder() + || error.is_redirect() + || error.is_status() + { + Some(Retryable::Fatal) + } else { + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metrics::Metrics; + use std::time::Duration; + + fn response_with_status(status: StatusCode) -> Result { + let response = http::Response::builder() + .status(status) + .body("") + .expect("building a synthetic response cannot fail"); + Ok(reqwest::Response::from(response)) + } + + fn counter_line(rendered: &str, name: &str) -> String { + rendered + .lines() + .find(|line| line.starts_with(name) && !line.starts_with('#')) + .unwrap_or_else(|| panic!("{name} is missing from the rendered metrics")) + .to_owned() + } + + #[test] + fn retry_strategy_retries_only_explicit_transient_statuses() { + assert!(matches!( + retryable_status(StatusCode::REQUEST_TIMEOUT), + Some(Retryable::Transient) + )); + assert!(matches!( + retryable_status(StatusCode::TOO_MANY_REQUESTS), + Some(Retryable::Transient) + )); + assert!(matches!( + retryable_status(StatusCode::INTERNAL_SERVER_ERROR), + Some(Retryable::Transient) + )); + assert!(matches!( + retryable_status(StatusCode::BAD_GATEWAY), + Some(Retryable::Transient) + )); + assert!(matches!( + retryable_status(StatusCode::SERVICE_UNAVAILABLE), + Some(Retryable::Transient) + )); + assert!(matches!( + retryable_status(StatusCode::GATEWAY_TIMEOUT), + Some(Retryable::Transient) + )); + assert!(matches!( + retryable_status(StatusCode::NOT_FOUND), + Some(Retryable::Fatal) + )); + assert!(matches!( + retryable_status(StatusCode::NOT_IMPLEMENTED), + Some(Retryable::Fatal) + )); + assert!(retryable_status(StatusCode::OK).is_none()); + assert!(retryable_status(StatusCode::FOUND).is_none()); + } + + #[test] + fn retryable_status_ignores_success_and_redirect_statuses() { + assert!(retryable_status(StatusCode::OK).is_none()); + assert!(retryable_status(StatusCode::FOUND).is_none()); + } + + #[test] + fn server_errors_outside_the_whitelist_stay_fatal() { + for status in [ + StatusCode::NOT_IMPLEMENTED, + StatusCode::HTTP_VERSION_NOT_SUPPORTED, + StatusCode::INSUFFICIENT_STORAGE, + StatusCode::LOOP_DETECTED, + StatusCode::NETWORK_AUTHENTICATION_REQUIRED, + ] { + assert!( + matches!(retryable_status(status), Some(Retryable::Fatal)), + "{status} must not be retried" + ); + } + } + + #[test] + fn middleware_failures_are_fatal_and_are_not_counted_as_transient() { + let metrics = Metrics::new(); + let strategy = MediaProxyRetryStrategy::new(metrics.http_client()); + let error = Err(MiddlewareError::Middleware(anyhow::anyhow!( + "middleware refused the request" + ))); + assert!(matches!(strategy.handle(&error), Some(Retryable::Fatal))); + let rendered = metrics.render(); + assert_eq!( + counter_line( + &rendered, + "fluxer_media_proxy_http_retryable_classifications_total{reason=\"error\"}" + ), + "fluxer_media_proxy_http_retryable_classifications_total{reason=\"error\"} 0" + ); + } + + #[test] + fn transient_status_classifications_are_counted_once_and_fatal_ones_are_not() { + let metrics = Metrics::new(); + let strategy = MediaProxyRetryStrategy::new(metrics.http_client()); + assert!(matches!( + strategy.handle(&response_with_status(StatusCode::SERVICE_UNAVAILABLE)), + Some(Retryable::Transient) + )); + assert!(matches!( + strategy.handle(&response_with_status(StatusCode::NOT_FOUND)), + Some(Retryable::Fatal) + )); + assert!( + strategy + .handle(&response_with_status(StatusCode::OK)) + .is_none() + ); + let rendered = metrics.render(); + assert_eq!( + counter_line( + &rendered, + "fluxer_media_proxy_http_retryable_classifications_total{reason=\"status\"}" + ), + "fluxer_media_proxy_http_retryable_classifications_total{reason=\"status\"} 1" + ); + } + + #[test] + fn every_retry_decision_reaches_the_injected_metrics_handle() { + let metrics = Metrics::new(); + let backoff = ExponentialBackoff::builder() + .retry_bounds(Duration::from_millis(1), Duration::from_millis(2)) + .build_with_max_retries(1); + let policy = ObservableRetryPolicy::new(backoff, metrics.http_client()); + let started = SystemTime::now(); + assert!(matches!( + policy.should_retry(started, 0), + RetryDecision::Retry { .. } + )); + assert!(matches!( + policy.should_retry(started, 1), + RetryDecision::DoNotRetry + )); + let rendered = metrics.render(); + assert_eq!( + counter_line(&rendered, "fluxer_media_proxy_http_retries_total"), + "fluxer_media_proxy_http_retries_total 1" + ); + assert_eq!( + counter_line(&rendered, "fluxer_media_proxy_http_retries_exhausted_total"), + "fluxer_media_proxy_http_retries_exhausted_total 1" + ); + } +} diff --git a/fluxer_media_proxy/src/http_headers.rs b/fluxer_media_proxy/src/http_headers.rs index 0f2a90692..d4c95deda 100644 --- a/fluxer_media_proxy/src/http_headers.rs +++ b/fluxer_media_proxy/src/http_headers.rs @@ -9,6 +9,16 @@ pub const STRICT_TRANSPORT_SECURITY: &str = "max-age=31536000; includeSubDomains pub const REFERRER_POLICY: &str = "strict-origin-when-cross-origin"; pub const PERMISSIONS_POLICY: &str = "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"; +pub fn parse_content_length(headers: &HeaderMap) -> Option { + let content_lengths = headers.get_all(header::CONTENT_LENGTH); + let mut values = content_lengths.iter(); + let raw = values.next()?.to_str().ok()?; + if values.next().is_some() || raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + raw.parse().ok() +} + pub fn add_security_headers(headers: &mut HeaderMap) { set_static_header( headers, @@ -104,6 +114,212 @@ pub fn add_unsatisfiable_headers(headers: &mut HeaderMap, size: usize) { mod tests { use super::*; + fn header_names(headers: &HeaderMap) -> Vec { + let mut names: Vec = headers + .keys() + .map(|name| name.as_str().to_owned()) + .collect(); + names.sort(); + names + } + + fn value(headers: &HeaderMap, name: &str) -> String { + headers + .get(name) + .unwrap_or_else(|| panic!("missing {name}")) + .to_str() + .expect("header value is ASCII") + .to_owned() + } + + const MEDIA_HEADER_NAMES: [&str; 13] = [ + "accept-ranges", + "access-control-allow-origin", + "cache-control", + "cdn-cache-control", + "content-security-policy", + "content-type", + "permissions-policy", + "referrer-policy", + "strict-transport-security", + "vary", + "x-content-type-options", + "x-frame-options", + "x-robots-tag", + ]; + + #[test] + fn media_headers_distinguish_streamable_content_and_ranges() { + let mut image_headers = HeaderMap::new(); + add_media_headers(&mut image_headers, 100, "image/png", None); + assert_eq!(header_names(&image_headers), MEDIA_HEADER_NAMES.to_vec()); + assert_eq!( + value(&image_headers, "cache-control"), + "public, max-age=31536000" + ); + assert_eq!(value(&image_headers, "content-type"), "image/png"); + assert!(image_headers.get(header::CONTENT_RANGE).is_none()); + + let mut video_headers = HeaderMap::new(); + add_media_headers( + &mut video_headers, + 100, + "video/mp4", + Some(ByteRange { start: 10, end: 19 }), + ); + let mut ranged_names = MEDIA_HEADER_NAMES.to_vec(); + ranged_names.push("content-range"); + ranged_names.sort(); + assert_eq!(header_names(&video_headers), ranged_names); + assert_eq!( + value(&video_headers, "cache-control"), + "public, max-age=31536000, no-transform" + ); + assert_eq!(value(&video_headers, "content-range"), "bytes 10-19/100"); + + let mut audio_headers = HeaderMap::new(); + add_media_headers(&mut audio_headers, 100, "audio/mpeg", None); + assert_eq!( + value(&audio_headers, "cache-control"), + "public, max-age=31536000, no-transform" + ); + } + + #[test] + fn media_headers_carry_the_frozen_policy_values_and_no_entity_tag() { + let mut headers = HeaderMap::new(); + add_media_headers(&mut headers, 100, "image/png", None); + assert_eq!(value(&headers, "accept-ranges"), "bytes"); + assert_eq!(value(&headers, "access-control-allow-origin"), "*"); + assert_eq!( + value(&headers, "cdn-cache-control"), + "public, max-age=31536000" + ); + assert_eq!(value(&headers, "content-security-policy"), MEDIA_CSP); + assert!(headers.get(header::EXPIRES).is_none()); + assert!(headers.get(header::LAST_MODIFIED).is_none()); + assert_eq!( + value(&headers, "strict-transport-security"), + STRICT_TRANSPORT_SECURITY + ); + assert_eq!(value(&headers, "referrer-policy"), REFERRER_POLICY); + assert_eq!(value(&headers, "permissions-policy"), PERMISSIONS_POLICY); + assert_eq!(value(&headers, "x-frame-options"), "DENY"); + assert_eq!(value(&headers, "x-content-type-options"), "nosniff"); + assert_eq!(value(&headers, "x-robots-tag"), ROBOTS); + assert_eq!(value(&headers, "vary"), "Accept-Encoding"); + assert!(headers.get(header::ETAG).is_none()); + } + + #[test] + fn media_headers_overwrite_inherited_security_values_and_fall_back_on_invalid_content_types() { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src *"), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("sniff-everything"), + ); + add_media_headers(&mut headers, 100, "image/\u{7f}png", None); + assert_eq!(value(&headers, "content-security-policy"), MEDIA_CSP); + assert_eq!(value(&headers, "x-content-type-options"), "nosniff"); + assert_eq!(value(&headers, "content-type"), "application/octet-stream"); + assert_eq!( + headers + .get_all(header::CONTENT_SECURITY_POLICY) + .iter() + .count(), + 1 + ); + } + + #[test] + fn shorter_cache_policies_replace_the_stored_media_policy_completely() { + for policy in [ + "public, max-age=300", + "public, max-age=86400", + "no-store", + "private, no-store", + ] { + let mut headers = HeaderMap::new(); + add_media_headers(&mut headers, 100, "image/png", None); + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(policy)); + headers.insert("CDN-Cache-Control", HeaderValue::from_static(policy)); + assert_eq!(headers.get_all(header::CACHE_CONTROL).iter().count(), 1); + assert_eq!(value(&headers, "cache-control"), policy); + assert_eq!(value(&headers, "cdn-cache-control"), policy); + } + } + + #[test] + fn unsatisfiable_headers_state_the_full_size_without_a_body_representation() { + let mut headers = HeaderMap::new(); + add_unsatisfiable_headers(&mut headers, 4096); + assert_eq!( + header_names(&headers), + vec![ + "accept-ranges", + "access-control-allow-origin", + "content-range", + "content-security-policy", + "permissions-policy", + "referrer-policy", + "strict-transport-security", + "vary", + "x-content-type-options", + "x-frame-options", + "x-robots-tag", + ] + ); + assert_eq!(value(&headers, "content-range"), "bytes */4096"); + assert_eq!(value(&headers, "vary"), "Accept-Encoding"); + assert_eq!(value(&headers, "x-robots-tag"), ROBOTS); + assert!(headers.get(header::CONTENT_TYPE).is_none()); + assert!(headers.get(header::CACHE_CONTROL).is_none()); + assert!(headers.get("CDN-Cache-Control").is_none()); + } + + #[test] + fn content_length_parser_requires_one_canonical_decimal_value() { + let mut headers = HeaderMap::new(); + assert_eq!(parse_content_length(&headers), None); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("123")); + assert_eq!(parse_content_length(&headers), Some(123)); + headers.append(header::CONTENT_LENGTH, HeaderValue::from_static("123")); + assert_eq!(parse_content_length(&headers), None); + headers.clear(); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("+1")); + assert_eq!(parse_content_length(&headers), None); + headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_static("18446744073709551616"), + ); + assert_eq!(parse_content_length(&headers), None); + for raw in ["", " 1", "1 ", "0x10", "1_0", "12.0", "-1", "1,1"] { + headers.clear(); + let header_value = HeaderValue::from_str(raw).expect("visible ascii header value"); + headers.insert(header::CONTENT_LENGTH, header_value); + assert_eq!(parse_content_length(&headers), None, "accepted {raw:?}"); + } + headers.clear(); + headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_bytes(&[0xC3, 0x28]).expect("opaque header value"), + ); + assert_eq!(parse_content_length(&headers), None); + headers.clear(); + headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_static("18446744073709551615"), + ); + assert_eq!(parse_content_length(&headers), Some(u64::MAX)); + headers.clear(); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("00")); + assert_eq!(parse_content_length(&headers), Some(0)); + } + #[test] fn media_headers_always_cache_forever() { let mut headers = HeaderMap::new(); diff --git a/fluxer_media_proxy/src/image_quality.rs b/fluxer_media_proxy/src/image_quality.rs new file mode 100644 index 000000000..76163d4a4 --- /dev/null +++ b/fluxer_media_proxy/src/image_quality.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ImageQuality { + Low, + High, + Lossless, + Auto, +} + +impl ImageQuality { + pub fn parse_lenient(raw: &str) -> Self { + match raw { + "low" => Self::Low, + "lossless" => Self::Lossless, + "auto" => Self::Auto, + _ => Self::High, + } + } + + pub const fn cache_serialization(self) -> &'static str { + match self { + Self::Low => "low", + Self::High => "high", + Self::Lossless => "lossless", + Self::Auto => "auto", + } + } + + pub const fn is_auto(self) -> bool { + matches!(self, Self::Auto) + } + + pub const fn resolve_static(self) -> ResolvedImageQuality { + match self { + Self::Low => ResolvedImageQuality::Low, + Self::High | Self::Auto => ResolvedImageQuality::High, + Self::Lossless => ResolvedImageQuality::Lossless, + } + } +} + +impl fmt::Display for ImageQuality { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.cache_serialization()) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResolvedImageQuality { + Low, + High, + Lossless, +} + +impl ResolvedImageQuality { + pub const fn encoder_quality(self) -> u8 { + match self { + Self::Low => 65, + Self::High => 85, + Self::Lossless => 100, + } + } + + pub const fn is_lossless(self) -> bool { + matches!(self, Self::Lossless) + } + + pub const fn default_effort(self, animated: bool) -> u8 { + if animated || matches!(self, Self::Low) { + 2 + } else { + 4 + } + } +} + +impl fmt::Display for ResolvedImageQuality { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = match self { + Self::Low => "low", + Self::High => "high", + Self::Lossless => "lossless", + }; + formatter.write_str(value) + } +} + +impl From for ImageQuality { + fn from(value: ResolvedImageQuality) -> Self { + match value { + ResolvedImageQuality::Low => Self::Low, + ResolvedImageQuality::High => Self::High, + ResolvedImageQuality::Lossless => Self::Lossless, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lenient_parsing_keeps_the_v1_query_contract() { + assert_eq!(ImageQuality::Low, ImageQuality::parse_lenient("low")); + assert_eq!(ImageQuality::High, ImageQuality::parse_lenient("high")); + assert_eq!( + ImageQuality::Lossless, + ImageQuality::parse_lenient("lossless") + ); + assert_eq!(ImageQuality::Auto, ImageQuality::parse_lenient("auto")); + assert_eq!(ImageQuality::High, ImageQuality::parse_lenient("nonsense")); + assert_eq!(ImageQuality::High, ImageQuality::parse_lenient("")); + assert_eq!(ImageQuality::High, ImageQuality::parse_lenient("LOW")); + } + + #[test] + fn encoder_numbers_match_the_v1_quality_table() { + assert_eq!(65, ResolvedImageQuality::Low.encoder_quality()); + assert_eq!(85, ResolvedImageQuality::High.encoder_quality()); + assert_eq!(100, ResolvedImageQuality::Lossless.encoder_quality()); + assert!(ResolvedImageQuality::Lossless.is_lossless()); + assert!(!ResolvedImageQuality::High.is_lossless()); + assert_eq!( + ResolvedImageQuality::High, + ImageQuality::Auto.resolve_static() + ); + assert!(ImageQuality::Auto.is_auto()); + assert!(!ImageQuality::High.is_auto()); + } + + #[test] + fn serialization_round_trips_through_the_cache_key_alphabet() { + for quality in [ + ImageQuality::Low, + ImageQuality::High, + ImageQuality::Lossless, + ImageQuality::Auto, + ] { + assert_eq!( + quality, + ImageQuality::parse_lenient(quality.cache_serialization()) + ); + assert_eq!(quality.cache_serialization(), quality.to_string()); + } + for resolved in [ + ResolvedImageQuality::Low, + ResolvedImageQuality::High, + ResolvedImageQuality::Lossless, + ] { + assert_eq!( + ImageQuality::from(resolved).cache_serialization(), + resolved.to_string() + ); + } + } +} diff --git a/fluxer_media_proxy/src/image_transform.rs b/fluxer_media_proxy/src/image_transform.rs new file mode 100644 index 000000000..376db5a70 --- /dev/null +++ b/fluxer_media_proxy/src/image_transform.rs @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{image_quality::ImageQuality, output_format::OutputFormat}; +use std::num::NonZeroU32; + +pub const MAX_ENCODE_EFFORT: u8 = 9; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct EncodeEffort(u8); + +impl EncodeEffort { + pub fn parse_lenient(raw: &str) -> Option { + if raw.is_empty() { + return None; + } + raw.parse::() + .ok() + .map(|value| Self(value.min(MAX_ENCODE_EFFORT))) + } + + pub const fn minimum() -> Self { + Self(0) + } + + pub const fn get(self) -> u8 { + self.0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ResizeMode { + Fit, + Cover, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AnimationLimits { + max_frames: NonZeroU32, + max_duration_ms: NonZeroU32, +} + +impl AnimationLimits { + pub fn new(max_frames: u32, max_duration_ms: u32) -> anyhow::Result { + let max_frames = NonZeroU32::new(max_frames) + .ok_or_else(|| anyhow::anyhow!("animation encode frame limit must be nonzero"))?; + let max_duration_ms = NonZeroU32::new(max_duration_ms) + .ok_or_else(|| anyhow::anyhow!("animation encode duration limit must be nonzero"))?; + anyhow::ensure!( + max_frames.get() <= i32::MAX as u32, + "animation encode frame limit must fit the native codec boundary" + ); + anyhow::ensure!( + max_duration_ms.get() <= i32::MAX as u32, + "animation encode duration limit must fit the native codec boundary" + ); + Ok(Self { + max_frames, + max_duration_ms, + }) + } + + pub const fn single_frame() -> Self { + Self { + max_frames: NonZeroU32::MIN, + max_duration_ms: NonZeroU32::MIN, + } + } + + pub const fn max_frames(self) -> NonZeroU32 { + self.max_frames + } + + pub const fn max_duration_ms(self) -> NonZeroU32 { + self.max_duration_ms + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AnimationMode { + Static, + Animated(AnimationLimits), +} + +impl AnimationMode { + pub const fn new(animated: bool, limits: AnimationLimits) -> Self { + if animated { + Self::Animated(limits) + } else { + Self::Static + } + } + + pub const fn is_animated(self) -> bool { + matches!(self, Self::Animated(_)) + } + + pub const fn encode_limits(self) -> AnimationLimits { + match self { + Self::Static => AnimationLimits::single_frame(), + Self::Animated(limits) => limits, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ImageOptions { + pub width: Option, + pub height: Option, + pub format: OutputFormat, + pub quality: ImageQuality, + pub animation: AnimationMode, + pub effort_override: Option, + pub resize_mode: ResizeMode, + pub deadline_ms: Option, +} + +impl ImageOptions { + pub const fn is_animated(&self) -> bool { + self.animation.is_animated() + } + + pub const fn wants_cover_crop(&self) -> bool { + matches!(self.resize_mode, ResizeMode::Cover) + } +} + +impl Default for ImageOptions { + fn default() -> Self { + Self { + width: None, + height: None, + format: OutputFormat::WebP, + quality: ImageQuality::High, + animation: AnimationMode::Static, + effort_override: None, + resize_mode: ResizeMode::Fit, + deadline_ms: None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effort_parsing_stays_lenient_and_clamps_at_nine() { + assert_eq!(None, EncodeEffort::parse_lenient("")); + assert_eq!(None, EncodeEffort::parse_lenient("not-a-number")); + assert_eq!(None, EncodeEffort::parse_lenient("-1")); + assert_eq!( + Some(0), + EncodeEffort::parse_lenient("0").map(EncodeEffort::get) + ); + assert_eq!( + Some(6), + EncodeEffort::parse_lenient("6").map(EncodeEffort::get) + ); + assert_eq!( + Some(9), + EncodeEffort::parse_lenient("9").map(EncodeEffort::get) + ); + assert_eq!( + Some(9), + EncodeEffort::parse_lenient("250").map(EncodeEffort::get) + ); + assert_eq!(None, EncodeEffort::parse_lenient("256")); + assert_eq!(0, EncodeEffort::minimum().get()); + } + + #[test] + fn animation_limits_reject_zero_and_out_of_range_bounds() { + assert!(AnimationLimits::new(0, 30_000).is_err()); + assert!(AnimationLimits::new(4096, 0).is_err()); + assert!(AnimationLimits::new(u32::MAX, 30_000).is_err()); + assert!(AnimationLimits::new(4096, u32::MAX).is_err()); + let limits = AnimationLimits::new(4096, 30_000).expect("valid animation limits"); + assert_eq!(4096, limits.max_frames().get()); + assert_eq!(30_000, limits.max_duration_ms().get()); + } + + #[test] + fn static_mode_collapses_to_a_single_frame_budget() { + let limits = AnimationLimits::new(4096, 30_000).expect("valid animation limits"); + assert!(!AnimationMode::new(false, limits).is_animated()); + assert!(AnimationMode::new(true, limits).is_animated()); + assert_eq!( + AnimationLimits::single_frame(), + AnimationMode::Static.encode_limits() + ); + assert_eq!(1, AnimationLimits::single_frame().max_frames().get()); + assert_eq!(1, AnimationLimits::single_frame().max_duration_ms().get()); + assert_eq!(limits, AnimationMode::Animated(limits).encode_limits()); + } + + #[test] + fn default_options_render_a_static_high_quality_webp() { + let options = ImageOptions::default(); + assert_eq!(OutputFormat::WebP, options.format); + assert_eq!(ImageQuality::High, options.quality); + assert!(!options.is_animated()); + assert!(!options.wants_cover_crop()); + assert_eq!(None, options.effort_override); + assert_eq!(None, options.deadline_ms); + } +} diff --git a/fluxer_media_proxy/src/lib.rs b/fluxer_media_proxy/src/lib.rs index 21ac408ae..c6fde2827 100644 --- a/fluxer_media_proxy/src/lib.rs +++ b/fluxer_media_proxy/src/lib.rs @@ -1,11 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +mod aggregate_error; pub mod asset_hash; +mod asset_size; pub mod aws_sigv4; pub mod bunny_ip_gate; -pub mod byte_cache; +mod byte_budget; +mod byte_cache; pub mod cli; -pub mod coalescer; +mod coalescer; pub mod codec; pub mod config; pub mod constants; @@ -14,22 +17,40 @@ pub mod external_path; pub mod healthcheck; pub mod http_client; pub mod http_headers; +pub mod image_quality; +pub mod image_transform; +mod media_limits; pub mod media_process; +mod media_type; pub mod metrics; pub mod mime; pub mod native; pub mod nsfw; pub mod output_format; +pub mod percent_decode; pub mod public_net_policy; pub mod query; pub mod range; pub mod request_log; +mod response_body_limit; +mod secret; pub mod server; pub mod signing; pub mod spool; pub mod storage; pub mod thumbhash; pub mod timed_semaphore; +mod transform_cache; pub mod upload_relay; +#[cfg(test)] +mod component_tests; +#[cfg(test)] +mod policy_tests; +#[cfg(test)] +mod test_fixtures; + pub use server::run; + +#[cfg(test)] +mod tests; diff --git a/fluxer_media_proxy/src/media_limits.rs b/fluxer_media_proxy/src/media_limits.rs new file mode 100644 index 000000000..17f21994c --- /dev/null +++ b/fluxer_media_proxy/src/media_limits.rs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::constants::{ + MAX_ANIMATED_FRAMES_DEFAULT, MAX_ANIMATED_TOTAL_PIXELS_DEFAULT, + MAX_MEDIA_IMAGE_DIMENSION_DEFAULT, MAX_MEDIA_IMAGE_PIXELS_DEFAULT, MAX_MEDIA_PROXY_BYTES, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MediaLimits { + image_dimension: u32, + image_pixels: usize, + animated_frames: u32, + animated_total_pixels: usize, + max_media_proxy_bytes: usize, + max_internal_request_body_bytes: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct MediaLimitConfiguration { + pub image_dimension: u32, + pub image_pixels: usize, + pub animated_frames: u32, + pub animated_total_pixels: usize, + pub max_media_proxy_bytes: usize, +} + +impl MediaLimits { + pub(crate) fn new(configuration: MediaLimitConfiguration) -> anyhow::Result { + let MediaLimitConfiguration { + image_dimension, + image_pixels, + animated_frames, + animated_total_pixels, + max_media_proxy_bytes, + } = configuration; + anyhow::ensure!( + (16..=MAX_MEDIA_IMAGE_DIMENSION_DEFAULT).contains(&image_dimension), + "media image dimension must be between 16 and {MAX_MEDIA_IMAGE_DIMENSION_DEFAULT}" + ); + anyhow::ensure!( + (256..=MAX_MEDIA_IMAGE_PIXELS_DEFAULT).contains(&image_pixels), + "media image pixels must be between 256 and {MAX_MEDIA_IMAGE_PIXELS_DEFAULT}" + ); + anyhow::ensure!(animated_frames >= 1, "animated frame limit must be nonzero"); + anyhow::ensure!( + i32::try_from(animated_frames).is_ok(), + "animated frame limit must fit the native codec boundary" + ); + anyhow::ensure!( + (1024..=MAX_ANIMATED_TOTAL_PIXELS_DEFAULT).contains(&animated_total_pixels), + "animated total pixel limit must be between 1024 and {MAX_ANIMATED_TOTAL_PIXELS_DEFAULT}" + ); + anyhow::ensure!( + (1..=MAX_MEDIA_PROXY_BYTES).contains(&max_media_proxy_bytes), + "media proxy byte limit must be between 1 and {MAX_MEDIA_PROXY_BYTES}" + ); + let max_internal_request_body_bytes = max_media_proxy_bytes + .div_ceil(3) + .checked_mul(4) + .and_then(|bytes| bytes.checked_add(1024 * 1024)) + .ok_or_else(|| anyhow::anyhow!("internal request body limit overflowed"))?; + Ok(Self { + image_dimension, + image_pixels, + animated_frames, + animated_total_pixels, + max_media_proxy_bytes, + max_internal_request_body_bytes, + }) + } + + pub fn default_from_config() -> Self { + Self::new(MediaLimitConfiguration { + image_dimension: MAX_MEDIA_IMAGE_DIMENSION_DEFAULT, + image_pixels: MAX_MEDIA_IMAGE_PIXELS_DEFAULT, + animated_frames: MAX_ANIMATED_FRAMES_DEFAULT, + animated_total_pixels: MAX_ANIMATED_TOTAL_PIXELS_DEFAULT, + max_media_proxy_bytes: MAX_MEDIA_PROXY_BYTES, + }) + .expect("compiled-in media limit defaults are always within their own bounds") + } + + pub fn image_dimension(&self) -> u32 { + self.image_dimension + } + + pub fn image_pixels(&self) -> usize { + self.image_pixels + } + + pub fn animated_frames(&self) -> u32 { + self.animated_frames + } + + pub fn animated_total_pixels(&self) -> usize { + self.animated_total_pixels + } + + pub fn max_media_proxy_bytes(&self) -> usize { + self.max_media_proxy_bytes + } + + pub fn max_internal_request_body_bytes(&self) -> usize { + self.max_internal_request_body_bytes + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::MAX_INTERNAL_REQUEST_BODY_BYTES; + + #[test] + fn default_limits_carry_the_frozen_old_era_numbers() { + let limits = MediaLimits::default_from_config(); + assert_eq!(16_384, limits.image_dimension()); + assert_eq!(16_384 * 16_384, limits.image_pixels()); + assert_eq!(20_000, limits.animated_frames()); + assert_eq!(4 * 16_384 * 16_384, limits.animated_total_pixels()); + assert_eq!(500 * 1024 * 1024, limits.max_media_proxy_bytes()); + assert_eq!( + MAX_INTERNAL_REQUEST_BODY_BYTES, + limits.max_internal_request_body_bytes() + ); + } + + #[test] + fn configuration_boundaries_are_enforced() { + let valid = MediaLimitConfiguration { + image_dimension: 4096, + image_pixels: 16 * 1024 * 1024, + animated_frames: 512, + animated_total_pixels: 32 * 1024 * 1024, + max_media_proxy_bytes: 64 * 1024 * 1024, + }; + assert!(MediaLimits::new(valid).is_ok()); + assert!( + MediaLimits::new(MediaLimitConfiguration { + image_dimension: 15, + ..valid + }) + .is_err() + ); + assert!( + MediaLimits::new(MediaLimitConfiguration { + image_dimension: MAX_MEDIA_IMAGE_DIMENSION_DEFAULT + 1, + ..valid + }) + .is_err() + ); + assert!( + MediaLimits::new(MediaLimitConfiguration { + image_pixels: 255, + ..valid + }) + .is_err() + ); + assert!( + MediaLimits::new(MediaLimitConfiguration { + animated_frames: 0, + ..valid + }) + .is_err() + ); + assert!( + MediaLimits::new(MediaLimitConfiguration { + animated_total_pixels: 1023, + ..valid + }) + .is_err() + ); + assert!( + MediaLimits::new(MediaLimitConfiguration { + max_media_proxy_bytes: 0, + ..valid + }) + .is_err() + ); + assert!( + MediaLimits::new(MediaLimitConfiguration { + max_media_proxy_bytes: MAX_MEDIA_PROXY_BYTES + 1, + ..valid + }) + .is_err() + ); + } +} diff --git a/fluxer_media_proxy/src/media_process.rs b/fluxer_media_proxy/src/media_process.rs deleted file mode 100644 index 5f1555c37..000000000 --- a/fluxer_media_proxy/src/media_process.rs +++ /dev/null @@ -1,3136 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::{ - constants::{self, AssetExtension, Limits}, - metrics, mime, native, nsfw, thumbhash, -}; -use base64::{Engine as _, engine::general_purpose}; -use libc::{c_int, c_void, size_t}; -use serde::Serialize; -use sha2::{Digest, Sha256}; -use std::{ffi::CString, ptr, slice, sync::OnceLock, sync::atomic::Ordering}; -use thiserror::Error; - -const ANIMATED_ENCODE_FLUSH_HEADROOM_MS: i64 = 3_000; -const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; -const APNG_FRAME_PNG_SUFFIX: &str = ".png[strip,compression=9,filter=all]"; -const VIPS_WEBP_MAX_EFFORT: u8 = 6; -static PNG_CRC_TABLE: OnceLock<[u32; 256]> = OnceLock::new(); - -#[derive(Clone, Debug)] -pub struct ImageOptions { - pub width: Option, - pub height: Option, - pub format: AssetExtension, - pub quality: String, - pub animated: bool, - pub effort_override: Option, - pub cover_crop: bool, - pub deadline_ms: Option, - pub max_encode_frames: Option, - pub max_encode_duration_ms: Option, -} - -impl Default for ImageOptions { - fn default() -> Self { - Self { - width: None, - height: None, - format: AssetExtension::Webp, - quality: "high".to_owned(), - animated: false, - effort_override: None, - cover_crop: false, - deadline_ms: None, - max_encode_frames: None, - max_encode_duration_ms: None, - } - } -} - -#[derive(Clone, Debug)] -pub struct ProcessedMedia { - pub bytes: Vec, - pub content_type: &'static str, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ImageDimensions { - pub width: u32, - pub height: u32, - pub pages: u32, -} - -#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] -pub enum MediaError { - #[error("native media init failed")] - VipsInitFailed, - #[error("media decode failed")] - MediaDecodeFailed, - #[error("media encode failed")] - MediaEncodeFailed, - #[error("media transform failed")] - MediaTransformFailed, - #[error("invalid image dimensions")] - InvalidImageDimensions, - #[error("unsupported media type")] - UnsupportedMediaType, - #[error("unsupported output format")] - UnsupportedOutputFormat, - #[error("stream too long")] - StreamTooLong, - #[error("request timed out")] - RequestTimeout, - #[error("nsfw scan unavailable")] - NsfwScanUnavailable, -} - -#[derive(Clone, Copy, Debug)] -struct AnimatedProbe { - width: c_int, - height: c_int, - pages: c_int, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct AnimLimits { - pub deadline_unix_ms: Option, - pub max_frames: Option, - pub max_duration_ms: Option, -} - -static VIPS_INIT: OnceLock> = OnceLock::new(); - -pub fn warmup_vips() -> Result<(), MediaError> { - ensure_vips_init() -} - -pub fn ensure_vips_init() -> Result<(), MediaError> { - *VIPS_INIT.get_or_init(|| { - let argv0 = CString::new("fluxer-media-proxy").expect("static string has no NUL"); - let rc = unsafe { native::fluxer_vips_init(argv0.as_ptr()) }; - if rc != 0 { - unsafe { native::fluxer_vips_error_clear() }; - return Err(MediaError::VipsInitFailed); - } - unsafe { native::fluxer_vips_tune_for_server(1) }; - Ok(()) - }) -} - -fn clear_vips_error() { - unsafe { native::fluxer_vips_error_clear() }; -} - -fn last_vips_error() -> String { - let ptr = unsafe { native::fluxer_vips_error_buffer() }; - if ptr.is_null() { - return String::new(); - } - let cstr = unsafe { std::ffi::CStr::from_ptr(ptr) }; - cstr.to_string_lossy().trim().chars().take(512).collect() -} - -fn vips_buffer_to_vec(buffer: &native::VipsBuffer) -> Result, MediaError> { - if buffer.len() > constants::MAX_MEDIA_PROXY_BYTES { - return Err(MediaError::StreamTooLong); - } - Ok(buffer.to_vec()) -} - -fn webp_buffer_to_vec(buffer: &native::WebpBuffer) -> Result, MediaError> { - if buffer.len() > constants::MAX_MEDIA_PROXY_BYTES { - return Err(MediaError::StreamTooLong); - } - Ok(buffer.to_vec()) -} - -fn quality_number(quality: &str) -> u8 { - match quality { - "low" => 65, - "lossless" => 100, - _ => 85, - } -} - -fn is_auto_quality(quality: &str) -> bool { - quality == "auto" -} - -fn is_lossless(quality: &str) -> bool { - quality == "lossless" -} - -fn effort_for(quality: &str, animated: bool) -> u8 { - if animated || quality == "low" { 2 } else { 4 } -} - -fn output_suffix( - format: AssetExtension, - quality: &str, - page_height: Option, - effort_override: Option, -) -> Result { - if matches!( - format, - AssetExtension::Avif - | AssetExtension::Heic - | AssetExtension::Heif - | AssetExtension::Jxl - | AssetExtension::Svg - ) { - return Err(MediaError::UnsupportedOutputFormat); - } - let q = quality_number(quality); - let animated = page_height.is_some(); - let lossless = if is_lossless(quality) { - "true" - } else { - "false" - }; - let effort = effort_override - .map(|v| v.min(VIPS_WEBP_MAX_EFFORT)) - .unwrap_or_else(|| effort_for(quality, animated)); - let suffix = match format { - AssetExtension::Jpeg => format!(".jpg[Q={q},strip,interlace=true,optimize_coding=true]"), - AssetExtension::Webp => match page_height { - Some(ph) => format!( - ".webp[Q={q},lossless={lossless},strip,effort={effort},smart_subsample=true,alpha_q=90,page-height={ph}]" - ), - None => format!( - ".webp[Q={q},lossless={lossless},strip,effort={effort},smart_subsample=true,alpha_q=90]" - ), - }, - AssetExtension::Png | AssetExtension::Apng => match page_height { - Some(ph) if format == AssetExtension::Apng => { - format!(".png[strip,compression=9,filter=all,page-height={ph}]") - } - _ => ".png[strip,compression=9,filter=all]".to_owned(), - }, - AssetExtension::Gif => match page_height { - Some(ph) => { - format!(".gif[strip,dither=1.0,effort=7,interframe_maxerror=8.0,page-height={ph}]") - } - None => ".gif[strip,dither=1.0,effort=7]".to_owned(), - }, - AssetExtension::Avif - | AssetExtension::Heic - | AssetExtension::Heif - | AssetExtension::Jxl - | AssetExtension::Svg => { - unreachable!("guarded above") - } - }; - CString::new(suffix).map_err(|_| MediaError::MediaEncodeFailed) -} - -fn validate_dimensions_u32(width: u32, height: u32) -> Result<(), MediaError> { - let max_dim = Limits::image_dimension(); - if width == 0 || height == 0 || width > max_dim || height > max_dim { - return Err(MediaError::InvalidImageDimensions); - } - let pixels = width as usize * height as usize; - if pixels > Limits::image_pixels() { - return Err(MediaError::InvalidImageDimensions); - } - Ok(()) -} - -fn validate_dimensions(width: c_int, height: c_int) -> Result<(), MediaError> { - if width <= 0 || height <= 0 { - return Err(MediaError::InvalidImageDimensions); - } - validate_dimensions_u32(width as u32, height as u32) -} - -fn validate_vips_image(image: &native::VipsImageHandle) -> Result<(), MediaError> { - let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; - let height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; - let mut page_height = 0; - let has_page_height = unsafe { - let field = CString::new("page-height").expect("static string has no NUL"); - native::fluxer_vips_image_get_int(image.as_ptr(), field.as_ptr(), &mut page_height) == 0 - }; - if has_page_height && page_height > 0 { - validate_dimensions(width, page_height)?; - if height <= 0 || height % page_height != 0 { - return Err(MediaError::InvalidImageDimensions); - } - let frames = height / page_height; - if frames as u32 > Limits::animated_frames() { - return Err(MediaError::InvalidImageDimensions); - } - let total_budget = Limits::animated_total_pixels(); - let per_frame = width as usize * page_height as usize; - if per_frame > total_budget || per_frame.saturating_mul(frames as usize) > total_budget { - return Err(MediaError::InvalidImageDimensions); - } - } else { - validate_dimensions(width, height)?; - } - let bands = unsafe { native::fluxer_vips_image_get_bands(image.as_ptr()) }; - if bands <= 0 || bands > 16 { - return Err(MediaError::InvalidImageDimensions); - } - Ok(()) -} - -fn resize_loaded_image( - image: native::VipsImageHandle, - options: &ImageOptions, -) -> Result { - let (Some(target_width), Some(target_height)) = (options.width, options.height) else { - return resize_loaded_image_fit_inside(image, options); - }; - if options.animated || !options.cover_crop { - return resize_loaded_image_fit_inside(image, options); - } - - let source_width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; - let source_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; - validate_dimensions(source_width, source_height)?; - - let scale_w = target_width as f64 / source_width as f64; - let scale_h = target_height as f64 / source_height as f64; - let scale = scale_w.max(scale_h).min(1.0); - let current = resize_loaded_image_by_scale(image, scale)?; - let scaled_width = unsafe { native::fluxer_vips_image_get_width(current.as_ptr()) }; - let scaled_height = unsafe { native::fluxer_vips_image_get_height(current.as_ptr()) }; - validate_dimensions(scaled_width, scaled_height)?; - - let final_width = scaled_width.min(target_width as c_int); - let final_height = scaled_height.min(target_height as c_int); - if final_width == scaled_width && final_height == scaled_height { - return Ok(current); - } - - let had_page_height = page_height(¤t).is_some(); - let left = (scaled_width - final_width) / 2; - let top = (scaled_height - final_height) / 2; - let mut cropped_raw = ptr::null_mut(); - let rc = unsafe { - native::fluxer_vips_extract_area( - current.as_ptr(), - &mut cropped_raw, - left, - top, - final_width, - final_height, - ) - }; - if rc != 0 || cropped_raw.is_null() { - clear_vips_error(); - return Err(MediaError::MediaTransformFailed); - } - drop(current); - let cropped = - native::VipsImageHandle::new(cropped_raw).ok_or(MediaError::MediaTransformFailed)?; - if had_page_height { - unsafe { native::fluxer_vips_set_page_height(cropped.as_ptr(), final_height) }; - } - validate_vips_image(&cropped)?; - Ok(cropped) -} - -fn resize_loaded_image_fit_inside( - image: native::VipsImageHandle, - options: &ImageOptions, -) -> Result { - if options.width.is_none() && options.height.is_none() { - return Ok(image); - } - let source_width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; - let total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; - let source_height = if options.animated { - page_height(&image).unwrap_or(total_height) - } else { - total_height - }; - validate_dimensions(source_width, source_height)?; - - let scale_w = options - .width - .map(|width| width as f64 / source_width as f64) - .unwrap_or(f64::INFINITY); - let scale_h = options - .height - .map(|height| height as f64 / source_height as f64) - .unwrap_or(f64::INFINITY); - let scale = scale_w.min(scale_h).min(1.0); - resize_loaded_image_by_scale(image, scale) -} - -fn resize_loaded_image_by_scale( - image: native::VipsImageHandle, - scale: f64, -) -> Result { - if scale >= 0.999 { - return Ok(image); - } - let old_page_count = page_height(&image).and_then(|old_page_height| { - let old_total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; - (old_page_height > 0 && old_total_height > 0 && old_total_height % old_page_height == 0) - .then_some(old_total_height / old_page_height) - }); - let mut resized_raw = ptr::null_mut(); - let rc = unsafe { native::fluxer_vips_resize(image.as_ptr(), &mut resized_raw, scale) }; - if rc != 0 || resized_raw.is_null() { - clear_vips_error(); - return Err(MediaError::MediaTransformFailed); - } - drop(image); - let resized = - native::VipsImageHandle::new(resized_raw).ok_or(MediaError::MediaTransformFailed)?; - if let Some(page_count) = old_page_count { - let new_total_height = unsafe { native::fluxer_vips_image_get_height(resized.as_ptr()) }; - if page_count > 0 && new_total_height > 0 && new_total_height % page_count == 0 { - unsafe { - native::fluxer_vips_set_page_height(resized.as_ptr(), new_total_height / page_count) - }; - } - } - validate_vips_image(&resized)?; - Ok(resized) -} - -fn probe_animated(input: &[u8]) -> Result, MediaError> { - let mut width = 0; - let mut height = 0; - let mut pages = 0; - let rc = unsafe { - native::fluxer_vips_probe_animated( - input.as_ptr().cast(), - input.len(), - &mut width, - &mut height, - &mut pages, - ) - }; - if rc != 0 { - clear_vips_error(); - return Ok(None); - } - if pages < 0 || width <= 0 || height <= 0 { - return Err(MediaError::InvalidImageDimensions); - } - validate_dimensions(width, height)?; - let page_count = pages as usize; - if page_count > Limits::animated_frames() as usize { - return Err(MediaError::InvalidImageDimensions); - } - if page_count > 1 { - let per_frame = width as usize * height as usize; - let max_total = Limits::animated_total_pixels(); - if per_frame > max_total || per_frame.saturating_mul(page_count) > max_total { - return Err(MediaError::InvalidImageDimensions); - } - } - Ok(Some(AnimatedProbe { - width, - height, - pages, - })) -} - -pub fn probe_image_dims(input: &[u8]) -> Result { - ensure_vips_init()?; - if let Some(probe) = probe_animated(input)? { - return Ok(ImageDimensions { - width: probe.width as u32, - height: probe.height as u32, - pages: probe.pages.max(1) as u32, - }); - } - let image = load_image(input, "access=sequential")?; - validate_vips_image(&image)?; - Ok(ImageDimensions { - width: unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) as u32 }, - height: unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) as u32 }, - pages: page_count(&image).unwrap_or(1) as u32, - }) -} - -fn load_image(input: &[u8], options: &str) -> Result { - let options = CString::new(options).map_err(|_| MediaError::MediaDecodeFailed)?; - let raw = unsafe { - native::fluxer_vips_image_new_from_buffer( - input.as_ptr().cast(), - input.len(), - options.as_ptr(), - ) - }; - native::VipsImageHandle::new(raw).ok_or_else(|| { - clear_vips_error(); - MediaError::MediaDecodeFailed - }) -} - -fn page_height(image: &native::VipsImageHandle) -> Option { - let mut page_height = 0; - let field = CString::new("page-height").expect("static string has no NUL"); - let rc = unsafe { - native::fluxer_vips_image_get_int(image.as_ptr(), field.as_ptr(), &mut page_height) - }; - let total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; - (rc == 0 && page_height > 0 && total_height > 0 && total_height % page_height == 0) - .then_some(page_height) -} - -fn page_count(image: &native::VipsImageHandle) -> Option { - let ph = page_height(image)?; - let height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; - Some(height / ph) -} - -fn animated_probe_from_image(image: &native::VipsImageHandle) -> Option { - let page_height = page_height(image)?; - let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; - let pages = page_count(image)?; - (width > 0 && page_height > 0 && pages > 0).then_some(AnimatedProbe { - width, - height: page_height, - pages, - }) -} - -fn anim_limits_from_options(options: &ImageOptions) -> AnimLimits { - AnimLimits { - deadline_unix_ms: options.deadline_ms.map(|deadline| { - if deadline > ANIMATED_ENCODE_FLUSH_HEADROOM_MS { - deadline - ANIMATED_ENCODE_FLUSH_HEADROOM_MS - } else { - deadline - } - }), - max_frames: options.max_encode_frames, - max_duration_ms: options.max_encode_duration_ms, - } -} - -struct StreamingWriteCtx { - out: Vec, - cap: usize, - failed: bool, -} - -unsafe extern "C" fn streaming_write_cb( - user_data: *mut c_void, - bytes: *const c_void, - len: size_t, -) -> c_int { - if user_data.is_null() || bytes.is_null() { - return -1; - } - let ctx = unsafe { &mut *(user_data as *mut StreamingWriteCtx) }; - if ctx.out.len().saturating_add(len) > ctx.cap { - ctx.failed = true; - return -1; - } - let chunk = unsafe { slice::from_raw_parts(bytes.cast::(), len) }; - ctx.out.extend_from_slice(chunk); - 0 -} - -#[derive(Clone, Copy, Debug)] -struct PngChunk<'a> { - kind: [u8; 4], - data: &'a [u8], -} - -#[derive(Debug)] -struct ApngFrame { - idat_chunks: Vec>, - delay_ms: u32, -} - -fn parse_png_chunks(bytes: &[u8]) -> Result>, MediaError> { - if bytes.len() < PNG_SIGNATURE.len() || &bytes[..PNG_SIGNATURE.len()] != PNG_SIGNATURE { - return Err(MediaError::MediaEncodeFailed); - } - let mut chunks = Vec::new(); - let mut offset = PNG_SIGNATURE.len(); - while offset + 12 <= bytes.len() { - let len = u32::from_be_bytes([ - bytes[offset], - bytes[offset + 1], - bytes[offset + 2], - bytes[offset + 3], - ]) as usize; - let kind: [u8; 4] = bytes[offset + 4..offset + 8] - .try_into() - .map_err(|_| MediaError::MediaEncodeFailed)?; - let data_start = offset + 8; - let data_end = data_start - .checked_add(len) - .ok_or(MediaError::MediaEncodeFailed)?; - let crc_end = data_end - .checked_add(4) - .ok_or(MediaError::MediaEncodeFailed)?; - if crc_end > bytes.len() { - return Err(MediaError::MediaEncodeFailed); - } - chunks.push(PngChunk { - kind, - data: &bytes[data_start..data_end], - }); - offset = crc_end; - if kind == *b"IEND" { - return Ok(chunks); - } - } - Err(MediaError::MediaEncodeFailed) -} - -fn png_ihdr_dimensions(ihdr: &[u8]) -> Option<(u32, u32)> { - if ihdr.len() != 13 { - return None; - } - Some(( - u32::from_be_bytes(ihdr[0..4].try_into().ok()?), - u32::from_be_bytes(ihdr[4..8].try_into().ok()?), - )) -} - -fn png_crc32(kind: &[u8; 4], payload: &[u8]) -> u32 { - let table = PNG_CRC_TABLE.get_or_init(|| { - let mut table = [0u32; 256]; - for (slot, value) in table.iter_mut().zip(0u32..=255) { - let mut crc = value; - for _ in 0..8 { - let mask = 0u32.wrapping_sub(crc & 1); - crc = (crc >> 1) ^ (0xedb8_8320u32 & mask); - } - *slot = crc; - } - table - }); - let mut crc = 0xffff_ffffu32; - for byte in kind.iter().copied().chain(payload.iter().copied()) { - crc = table[((crc ^ byte as u32) & 0xff) as usize] ^ (crc >> 8); - } - crc ^ 0xffff_ffffu32 -} - -fn append_be_u32(out: &mut Vec, value: u32) { - out.extend_from_slice(&value.to_be_bytes()); -} - -fn append_be_u16(out: &mut Vec, value: u16) { - out.extend_from_slice(&value.to_be_bytes()); -} - -fn append_png_chunk(out: &mut Vec, kind: &[u8; 4], payload: &[u8]) -> Result<(), MediaError> { - let payload_len = u32::try_from(payload.len()).map_err(|_| MediaError::StreamTooLong)?; - if out.len().saturating_add(12).saturating_add(payload.len()) > constants::MAX_MEDIA_PROXY_BYTES - { - return Err(MediaError::StreamTooLong); - } - append_be_u32(out, payload_len); - out.extend_from_slice(kind); - out.extend_from_slice(payload); - append_be_u32(out, png_crc32(kind, payload)); - Ok(()) -} - -fn gcd_u32(mut a: u32, mut b: u32) -> u32 { - while b != 0 { - let rem = a % b; - a = b; - b = rem; - } - a.max(1) -} - -fn apng_delay_fraction(delay_ms: u32) -> (u16, u16) { - let delay_ms = delay_ms.clamp(20, u16::MAX as u32 * 1_000); - let divisor = gcd_u32(delay_ms, 1_000); - let num = delay_ms / divisor; - let den = 1_000 / divisor; - if num <= u16::MAX as u32 && den <= u16::MAX as u32 { - return (num as u16, den as u16); - } - (delay_ms.div_ceil(1_000).min(u16::MAX as u32) as u16, 1) -} - -fn vips_delays_ms(image: &native::VipsImageHandle, n_pages: usize) -> Vec { - let mut out_ptr: *mut c_int = ptr::null_mut(); - let mut out_len: c_int = 0; - let rc = unsafe { - native::fluxer_vips_read_delays_ms( - image.as_ptr(), - n_pages.min(c_int::MAX as usize) as c_int, - &mut out_ptr, - &mut out_len, - ) - }; - if rc != 0 || out_ptr.is_null() || out_len <= 0 { - return vec![100; n_pages]; - } - let copy_len = (out_len as usize).min(n_pages); - let mut delays = vec![100; n_pages]; - let src = unsafe { slice::from_raw_parts(out_ptr, copy_len) }; - for (dst, src) in delays.iter_mut().zip(src.iter().copied()) { - *dst = if src >= 20 { src as u32 } else { 100 }; - } - unsafe { native::fluxer_free_int_array(out_ptr) }; - delays -} - -fn encode_png_strip( - image: &native::VipsImageHandle, - frame_index: usize, - width: c_int, - page_height: c_int, -) -> Result, MediaError> { - let top = c_int::try_from(frame_index) - .ok() - .and_then(|index| index.checked_mul(page_height)) - .ok_or(MediaError::InvalidImageDimensions)?; - let mut strip_raw = ptr::null_mut(); - let rc = unsafe { - native::fluxer_vips_extract_area(image.as_ptr(), &mut strip_raw, 0, top, width, page_height) - }; - if rc != 0 || strip_raw.is_null() { - clear_vips_error(); - return Err(MediaError::MediaTransformFailed); - } - let strip = native::VipsImageHandle::new(strip_raw).ok_or(MediaError::MediaTransformFailed)?; - let expected_rgba_size = (width as usize) - .checked_mul(page_height as usize) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or(MediaError::InvalidImageDimensions)?; - let mut rgba_ptr: *mut c_void = ptr::null_mut(); - let mut rgba_size: size_t = 0; - let rc = - unsafe { native::fluxer_vips_extract_rgba(strip.as_ptr(), &mut rgba_ptr, &mut rgba_size) }; - if rc != 0 || rgba_ptr.is_null() || rgba_size != expected_rgba_size { - if !rgba_ptr.is_null() { - unsafe { native::fluxer_vips_free(rgba_ptr) }; - } - clear_vips_error(); - return Err(MediaError::MediaTransformFailed); - } - let rgba_buffer = - native::VipsBuffer::new(rgba_ptr, rgba_size).ok_or(MediaError::MediaTransformFailed)?; - let rgba_image_raw = unsafe { - native::fluxer_vips_image_new_from_memory_copy( - rgba_buffer.as_ptr(), - rgba_buffer.len(), - width, - page_height, - 4, - native::fluxer_vips_format_uchar, - ) - }; - let rgba_image = - native::VipsImageHandle::new(rgba_image_raw).ok_or(MediaError::MediaTransformFailed)?; - let suffix = CString::new(APNG_FRAME_PNG_SUFFIX).expect("static string has no NUL"); - let mut out_ptr: *mut c_void = ptr::null_mut(); - let mut out_size: size_t = 0; - let rc = unsafe { - native::fluxer_vips_image_write_to_buffer( - rgba_image.as_ptr(), - suffix.as_ptr(), - &mut out_ptr, - &mut out_size, - ) - }; - if rc != 0 || out_ptr.is_null() { - clear_vips_error(); - return Err(MediaError::MediaEncodeFailed); - } - let buffer = native::VipsBuffer::new(out_ptr, out_size).ok_or(MediaError::MediaEncodeFailed)?; - vips_buffer_to_vec(&buffer) -} - -fn png_frame_parts( - png: &[u8], - expected_width: u32, - expected_height: u32, -) -> Result<(Vec, Vec>), MediaError> { - let chunks = parse_png_chunks(png)?; - let ihdr = chunks - .iter() - .find(|chunk| chunk.kind == *b"IHDR") - .ok_or(MediaError::MediaEncodeFailed)? - .data - .to_vec(); - if png_ihdr_dimensions(&ihdr) != Some((expected_width, expected_height)) { - return Err(MediaError::MediaEncodeFailed); - } - let idat_chunks = chunks - .iter() - .filter(|chunk| chunk.kind == *b"IDAT") - .map(|chunk| chunk.data.to_vec()) - .collect::>(); - if idat_chunks.is_empty() { - return Err(MediaError::MediaEncodeFailed); - } - Ok((ihdr, idat_chunks)) -} - -fn encode_animated_apng( - image: &native::VipsImageHandle, - page_height: c_int, - limits: AnimLimits, -) -> Result, MediaError> { - let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; - let total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; - validate_dimensions(width, page_height)?; - if total_height <= 0 || total_height % page_height != 0 { - return Err(MediaError::InvalidImageDimensions); - } - let n_pages = usize::try_from(total_height / page_height) - .map_err(|_| MediaError::InvalidImageDimensions)?; - if n_pages == 0 { - return Err(MediaError::InvalidImageDimensions); - } - let delays = vips_delays_ms(image, n_pages); - let max_frames = limits.max_frames.unwrap_or(0) as usize; - let max_duration_ms = limits.max_duration_ms.unwrap_or(0); - let deadline_unix_ms = limits.deadline_unix_ms.unwrap_or(0); - let expected_width = width as u32; - let expected_height = page_height as u32; - - let mut ihdr: Option> = None; - let mut frames = Vec::new(); - let mut timestamp_ms = 0u32; - for frame_index in 0..n_pages { - if max_frames > 0 && frames.len() >= max_frames { - break; - } - if max_duration_ms > 0 && timestamp_ms >= max_duration_ms { - break; - } - if deadline_unix_ms > 0 && metrics::now_ms() >= deadline_unix_ms { - break; - } - - let frame_png = match encode_png_strip(image, frame_index, width, page_height) { - Ok(bytes) => bytes, - Err(err) if frames.is_empty() => return Err(err), - Err(_) => break, - }; - let (frame_ihdr, idat_chunks) = - png_frame_parts(&frame_png, expected_width, expected_height)?; - if let Some(existing_ihdr) = ihdr.as_ref() { - if existing_ihdr.as_slice() != frame_ihdr.as_slice() { - return Err(MediaError::MediaEncodeFailed); - } - } else { - ihdr = Some(frame_ihdr); - } - let delay_ms = delays.get(frame_index).copied().unwrap_or(100); - frames.push(ApngFrame { - idat_chunks, - delay_ms, - }); - timestamp_ms = timestamp_ms.saturating_add(delay_ms); - } - if frames.is_empty() { - return Err(MediaError::MediaEncodeFailed); - } - - let ihdr = ihdr.ok_or(MediaError::MediaEncodeFailed)?; - let estimated_len = frames - .iter() - .flat_map(|frame| frame.idat_chunks.iter()) - .map(Vec::len) - .try_fold(256usize, usize::checked_add) - .unwrap_or(constants::MAX_MEDIA_PROXY_BYTES); - let mut out = Vec::with_capacity(estimated_len.min(constants::MAX_MEDIA_PROXY_BYTES)); - out.extend_from_slice(PNG_SIGNATURE); - append_png_chunk(&mut out, b"IHDR", &ihdr)?; - let mut actl = Vec::with_capacity(8); - append_be_u32(&mut actl, frames.len().min(u32::MAX as usize) as u32); - append_be_u32(&mut actl, 0); - append_png_chunk(&mut out, b"acTL", &actl)?; - - let mut sequence_number = 0u32; - for (frame_index, frame) in frames.iter().enumerate() { - let (delay_num, delay_den) = apng_delay_fraction(frame.delay_ms); - let mut fctl = Vec::with_capacity(26); - append_be_u32(&mut fctl, sequence_number); - sequence_number = sequence_number.wrapping_add(1); - append_be_u32(&mut fctl, expected_width); - append_be_u32(&mut fctl, expected_height); - append_be_u32(&mut fctl, 0); - append_be_u32(&mut fctl, 0); - append_be_u16(&mut fctl, delay_num); - append_be_u16(&mut fctl, delay_den); - fctl.push(0); - fctl.push(0); - append_png_chunk(&mut out, b"fcTL", &fctl)?; - - for idat in &frame.idat_chunks { - if frame_index == 0 { - append_png_chunk(&mut out, b"IDAT", idat)?; - } else { - let mut fdat = Vec::with_capacity(idat.len().saturating_add(4)); - append_be_u32(&mut fdat, sequence_number); - sequence_number = sequence_number.wrapping_add(1); - fdat.extend_from_slice(idat); - append_png_chunk(&mut out, b"fdAT", &fdat)?; - } - } - } - append_png_chunk(&mut out, b"IEND", &[])?; - Ok(out) -} - -fn encode_vips_image( - image: &native::VipsImageHandle, - format: AssetExtension, - quality: &str, - page_height: Option, - effort_override: Option, - limits: AnimLimits, - full_canvas_animation: bool, -) -> Result, MediaError> { - if format == AssetExtension::Webp && page_height.is_some_and(|ph| ph > 0) { - return encode_animated_webp( - image, - quality, - effort_override, - limits, - full_canvas_animation, - ); - } - if format == AssetExtension::Apng - && let Some(ph) = page_height - && ph > 0 - { - return encode_animated_apng(image, ph, limits); - } - let suffix = output_suffix(format, quality, page_height, effort_override)?; - let mut ctx = StreamingWriteCtx { - out: Vec::with_capacity(16 * 1024), - cap: constants::MAX_MEDIA_PROXY_BYTES, - failed: false, - }; - let rc = unsafe { - native::fluxer_vips_image_write_to_callback( - image.as_ptr(), - suffix.as_ptr(), - Some(streaming_write_cb), - (&mut ctx as *mut StreamingWriteCtx).cast(), - ) - }; - if rc != 0 { - clear_vips_error(); - return if ctx.failed { - Err(MediaError::StreamTooLong) - } else { - Err(MediaError::MediaEncodeFailed) - }; - } - Ok(ctx.out) -} - -fn encode_animated_webp( - image: &native::VipsImageHandle, - quality: &str, - effort_override: Option, - limits: AnimLimits, - full_canvas_frames: bool, -) -> Result, MediaError> { - let q = quality_number(quality); - let effort = effort_override - .map(|v| v.min(9)) - .unwrap_or_else(|| effort_for(quality, true)); - let c_limits = native::WebpAnimLimits { - max_frames: limits - .max_frames - .map(|v| v.min(c_int::MAX as u32) as c_int) - .unwrap_or(0), - max_duration_ms: limits - .max_duration_ms - .map(|v| v.min(c_int::MAX as u32) as c_int) - .unwrap_or(0), - deadline_unix_ms: limits.deadline_unix_ms.unwrap_or(0), - }; - let mut out_ptr: *mut c_void = ptr::null_mut(); - let mut out_size: size_t = 0; - let rc = unsafe { - native::fluxer_webp_encode_animated( - image.as_ptr(), - q as c_int, - if is_lossless(quality) { 1 } else { 0 }, - effort as c_int, - 90, - 1, - 0, - if full_canvas_frames { 1 } else { 0 }, - &c_limits, - ptr::null_mut(), - 0, - &mut out_ptr, - &mut out_size, - ) - }; - if rc != 0 || out_ptr.is_null() { - clear_vips_error(); - return Err(MediaError::MediaEncodeFailed); - } - let out = native::WebpBuffer::new(out_ptr, out_size).ok_or(MediaError::MediaEncodeFailed)?; - webp_buffer_to_vec(&out) -} - -fn source_is_palette_animation(input: &[u8]) -> bool { - matches!(mime::sniff(input).mime, "image/gif" | "image/apng") -} - -const AUTO_LOSSLESS_PALETTE_ANIMATION_MAX_BYTES: usize = 4 * 1024 * 1024; -const AUTO_LOSSLESS_PALETTE_ANIMATION_MAX_PIXELS: usize = 16 * 1024 * 1024; - -fn animated_probe_pixels(probe: AnimatedProbe) -> Option { - if probe.width <= 0 || probe.height <= 0 || probe.pages <= 0 { - return None; - } - (probe.width as usize) - .checked_mul(probe.height as usize)? - .checked_mul(probe.pages as usize) -} - -fn is_auto_palette_animated_webp( - format: AssetExtension, - animated: bool, - input: &[u8], - quality: &str, -) -> bool { - is_auto_quality(quality) - && animated - && format == AssetExtension::Webp - && source_is_palette_animation(input) -} - -fn should_auto_lossless_animated( - format: AssetExtension, - animated: bool, - input: &[u8], - quality: &str, - probe: Option, -) -> bool { - if !is_auto_palette_animated_webp(format, animated, input, quality) { - return false; - } - if input.len() > AUTO_LOSSLESS_PALETTE_ANIMATION_MAX_BYTES { - return false; - } - if let Some(probe) = probe - && animated_probe_pixels(probe) - .is_none_or(|pixels| pixels > AUTO_LOSSLESS_PALETTE_ANIMATION_MAX_PIXELS) - { - return false; - } - true -} - -fn effective_quality( - format: AssetExtension, - animated: bool, - input: &[u8], - quality: &str, - probe: Option, -) -> String { - if should_auto_lossless_animated(format, animated, input, quality, probe) { - "lossless".to_owned() - } else if is_auto_quality(quality) { - "high".to_owned() - } else { - quality.to_owned() - } -} - -fn effective_effort_override( - format: AssetExtension, - animated: bool, - input: &[u8], - quality: &str, - probe: Option, - override_value: Option, -) -> Option { - if override_value.is_some() { - return override_value; - } - if !is_auto_palette_animated_webp(format, animated, input, quality) { - return None; - } - if should_auto_lossless_animated(format, animated, input, quality, probe) { - None - } else { - Some(0) - } -} - -fn output_is_sdr(format: AssetExtension) -> bool { - matches!( - format, - AssetExtension::Jpeg - | AssetExtension::Webp - | AssetExtension::Png - | AssetExtension::Gif - | AssetExtension::Apng - ) -} - -fn source_maybe_hdr(sniffed_mime: &str) -> bool { - matches!(sniffed_mime, "image/avif" | "image/heic" | "image/heif") -} - -fn tone_map_if_hdr(image: native::VipsImageHandle) -> Result { - let is_hdr = unsafe { native::fluxer_vips_image_is_hdr(image.as_ptr()) }; - if is_hdr <= 0 { - return Ok(image); - } - let mut mapped = ptr::null_mut(); - let rc = unsafe { native::fluxer_vips_tone_map_hdr_to_sdr(image.as_ptr(), &mut mapped) }; - if rc != 0 || mapped.is_null() { - clear_vips_error(); - return Ok(image); - } - metrics::GLOBAL - .hdr_tone_map_count - .fetch_add(1, Ordering::Relaxed); - drop(image); - native::VipsImageHandle::new(mapped).ok_or(MediaError::MediaTransformFailed) -} - -fn try_decode_heif( - input: &[u8], - animated: bool, -) -> Result, MediaError> { - let mut raw = ptr::null_mut(); - let mut was_hdr = 0; - let mut had_gain_map = 0; - let rc = unsafe { - native::fluxer_heif_decode_animated_ex2( - input.as_ptr().cast(), - input.len(), - &mut raw, - if animated { -1 } else { 1 }, - Limits::animated_total_pixels(), - &mut was_hdr, - &mut had_gain_map, - ) - }; - if rc != 0 || raw.is_null() { - clear_vips_error(); - metrics::GLOBAL - .avif_libheif_decode_failures - .fetch_add(1, Ordering::Relaxed); - return Ok(None); - } - metrics::GLOBAL - .avif_libheif_decode_count - .fetch_add(1, Ordering::Relaxed); - if had_gain_map != 0 { - metrics::GLOBAL - .heif_hdr_gain_map_count - .fetch_add(1, Ordering::Relaxed); - } - Ok(native::VipsImageHandle::new(raw)) -} - -fn try_decode_apng( - input: &[u8], - animated: bool, -) -> Result, MediaError> { - let mut raw = ptr::null_mut(); - let rc = unsafe { - native::fluxer_ffmpeg_decode_apng( - input.as_ptr().cast(), - input.len(), - &mut raw, - if animated { - Limits::animated_frames().min(c_int::MAX as u32) as c_int - } else { - 1 - }, - Limits::animated_total_pixels(), - ) - }; - if rc != 0 || raw.is_null() { - clear_vips_error(); - return Ok(None); - } - Ok(native::VipsImageHandle::new(raw)) -} - -#[derive(Clone, Copy, Debug)] -struct GifResizeDims { - width: c_int, - height: c_int, -} - -fn gif_resize_dims(sniffed: mime::SniffInfo, options: &ImageOptions) -> Option { - if sniffed.width == 0 || sniffed.height == 0 { - return None; - } - let src_w = sniffed.width; - let src_h = sniffed.height; - let scale = match (options.width, options.height) { - (Some(0), _) | (_, Some(0)) => return None, - (Some(w), Some(h)) => (w as f64 / src_w as f64).min(h as f64 / src_h as f64), - (Some(w), None) => w as f64 / src_w as f64, - (None, Some(h)) => h as f64 / src_h as f64, - (None, None) => return None, - } - .min(1.0); - let target_w = ((src_w as f64) * scale).round().max(1.0) as u32; - let target_h = ((src_h as f64) * scale).round().max(1.0) as u32; - if target_w == src_w && target_h == src_h { - return None; - } - Some(GifResizeDims { - width: target_w as c_int, - height: target_h as c_int, - }) -} - -fn resize_animated_gif_with_ffmpeg( - input: &[u8], - dims: GifResizeDims, - options: &ImageOptions, -) -> Result, MediaError> { - let mut out_ptr: *mut c_void = ptr::null_mut(); - let mut out_size: size_t = 0; - let rc = unsafe { - native::fluxer_ffmpeg_resize_gif( - input.as_ptr().cast(), - input.len(), - dims.width, - dims.height, - options.deadline_ms.unwrap_or(0), - i64::from(Limits::animated_frames()), - Limits::animated_total_pixels().min(i64::MAX as usize) as i64, - &mut out_ptr, - &mut out_size, - ) - }; - if rc == -2 { - return Err(MediaError::RequestTimeout); - } - if rc == -3 { - clear_vips_error(); - return Err(MediaError::InvalidImageDimensions); - } - if rc != 0 || out_ptr.is_null() { - clear_vips_error(); - return Err(MediaError::MediaTransformFailed); - } - let out = native::WebpBuffer::new(out_ptr, out_size).ok_or(MediaError::MediaTransformFailed)?; - webp_buffer_to_vec(&out) -} - -fn source_supports_pages(mime: &str) -> bool { - matches!( - mime, - "image/webp" | "image/gif" | "image/apng" | "image/heic" | "image/heif" | "image/avif" - ) -} - -fn should_use_resize_path(options: &ImageOptions, probe: Option) -> bool { - if options.width.is_none() && options.height.is_none() { - return false; - } - if options.animated { - return true; - } - match probe { - Some(p) => p.pages <= 1, - None => true, - } -} - -fn effective_transform_format( - sniffed_mime: &str, - requested: AssetExtension, - animated: bool, -) -> AssetExtension { - if animated && sniffed_mime == "image/apng" && requested == AssetExtension::Png { - AssetExtension::Apng - } else { - requested - } -} - -pub fn transform_image(input: &[u8], options: &ImageOptions) -> Result { - if input.len() > constants::MAX_MEDIA_PROXY_BYTES { - return Err(MediaError::StreamTooLong); - } - if let Some(width) = options.width - && (width == 0 || width > Limits::image_dimension()) - { - return Err(MediaError::InvalidImageDimensions); - } - if let Some(height) = options.height - && (height == 0 || height > Limits::image_dimension()) - { - return Err(MediaError::InvalidImageDimensions); - } - ensure_vips_init()?; - let sniffed = mime::sniff(input); - let format = effective_transform_format(sniffed.mime, options.format, options.animated); - let full_canvas_animation = options.animated - && format == AssetExtension::Webp - && matches!(sniffed.mime, "image/gif" | "image/apng"); - if options.animated - && format == AssetExtension::Gif - && !options.cover_crop - && sniffed.mime == "image/gif" - { - probe_animated(input)?; - let bytes = if let Some(dims) = gif_resize_dims(sniffed, options) { - resize_animated_gif_with_ffmpeg(input, dims, options)? - } else { - input.to_vec() - }; - return Ok(ProcessedMedia { - bytes, - content_type: "image/gif", - }); - } - let decoded_apng = if sniffed.mime == "image/apng" { - try_decode_apng(input, options.animated)? - } else { - None - }; - let animated_probe = if let Some(image) = decoded_apng.as_ref() { - animated_probe_from_image(image) - } else if options.animated { - probe_animated(input)? - } else { - None - }; - let effective_quality = effective_quality( - format, - options.animated, - input, - &options.quality, - animated_probe, - ); - let effective_effort_override = effective_effort_override( - format, - options.animated, - input, - &options.quality, - animated_probe, - options.effort_override, - ); - let tone_map_eligible = source_maybe_hdr(sniffed.mime) && output_is_sdr(format); - let use_heif_path = matches!(sniffed.mime, "image/avif" | "image/heic" | "image/heif") - && (options.animated || tone_map_eligible); - if use_heif_path - && let Some(mut image) = try_decode_heif(input, options.animated || sniffed.animated)? - { - validate_vips_image(&image)?; - if tone_map_eligible { - image = tone_map_if_hdr(image)?; - } - image = resize_loaded_image(image, options)?; - let page_height = if options.animated { - page_height(&image) - } else { - None - }; - let bytes = encode_vips_image( - &image, - format, - &effective_quality, - page_height, - effective_effort_override, - anim_limits_from_options(options), - full_canvas_animation, - )?; - return Ok(ProcessedMedia { - bytes, - content_type: format.mime(), - }); - } - - if let Some(mut image) = decoded_apng { - validate_vips_image(&image)?; - image = resize_loaded_image(image, options)?; - let page_height = if options.animated { - page_height(&image) - } else { - None - }; - let bytes = encode_vips_image( - &image, - format, - &effective_quality, - page_height, - effective_effort_override, - anim_limits_from_options(options), - full_canvas_animation, - )?; - return Ok(ProcessedMedia { - bytes, - content_type: format.mime(), - }); - } - - if should_use_resize_path(options, animated_probe) { - let mut raw = ptr::null_mut(); - let crop = if options.cover_crop && options.width.is_some() && options.height.is_some() { - native::THUMB_CROP_CENTRE - } else { - native::THUMB_CROP_NONE - }; - let n_pages: c_int = if options.animated && source_supports_pages(sniffed.mime) { - -1 - } else { - 1 - }; - let rc = unsafe { - native::fluxer_vips_thumbnail_buffer_ex( - input.as_ptr().cast(), - input.len(), - &mut raw, - options.width.unwrap_or(0) as c_int, - options.height.unwrap_or(0) as c_int, - n_pages, - crop, - ) - }; - if rc != 0 || raw.is_null() { - let err = last_vips_error(); - clear_vips_error(); - tracing::error!( - target: "fluxer_media_proxy::transform_debug", - stage = "thumbnail_buffer_ex", - sniffed_mime = %sniffed.mime, - animated = options.animated, - w = options.width.unwrap_or(0), - h = options.height.unwrap_or(0), - pages = animated_probe.map(|p| p.pages).unwrap_or(0), - vips_err = %err, - "transform failed" - ); - return Err(MediaError::MediaTransformFailed); - } - let mut image = - native::VipsImageHandle::new(raw).ok_or(MediaError::MediaTransformFailed)?; - validate_vips_image(&image)?; - if tone_map_eligible { - image = tone_map_if_hdr(image)?; - } - let page_height = if options.animated { - page_height(&image) - } else { - None - }; - let bytes = encode_vips_image( - &image, - format, - &effective_quality, - page_height, - effective_effort_override, - anim_limits_from_options(options), - full_canvas_animation, - )?; - return Ok(ProcessedMedia { - bytes, - content_type: format.mime(), - }); - } - - let loader_options = if options.animated && source_supports_pages(sniffed.mime) { - if sniffed.mime == "image/jpeg" { - "n=-1,access=sequential" - } else { - "n=-1,access=sequential,fail=true" - } - } else if sniffed.mime == "image/jpeg" { - "access=sequential" - } else { - "access=sequential,fail=true" - }; - let loaded = load_image(input, loader_options)?; - validate_vips_image(&loaded)?; - let mut oriented_raw = ptr::null_mut(); - let rc = unsafe { native::fluxer_vips_autorot(loaded.as_ptr(), &mut oriented_raw) }; - if rc != 0 || oriented_raw.is_null() { - clear_vips_error(); - return Err(MediaError::MediaTransformFailed); - } - let mut base = - native::VipsImageHandle::new(oriented_raw).ok_or(MediaError::MediaTransformFailed)?; - validate_vips_image(&base)?; - if tone_map_eligible { - base = tone_map_if_hdr(base)?; - } - let page_height = if options.animated { - page_height(&base) - } else { - None - }; - let bytes = encode_vips_image( - &base, - format, - &effective_quality, - page_height, - effective_effort_override, - anim_limits_from_options(options), - full_canvas_animation, - )?; - Ok(ProcessedMedia { - bytes, - content_type: format.mime(), - }) -} - -pub fn encode_thumbhash(input: &[u8]) -> Result, MediaError> { - ensure_vips_init()?; - let mut raw = ptr::null_mut(); - let rc = unsafe { - native::fluxer_vips_thumbnail_buffer_ex( - input.as_ptr().cast(), - input.len(), - &mut raw, - thumbhash::MAX_DIM as c_int, - thumbhash::MAX_DIM as c_int, - 1, - native::THUMB_CROP_NONE, - ) - }; - if rc != 0 || raw.is_null() { - clear_vips_error(); - return Err(MediaError::MediaTransformFailed); - } - let image = native::VipsImageHandle::new(raw).ok_or(MediaError::MediaTransformFailed)?; - validate_vips_image(&image)?; - let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) as u32 }; - let height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) as u32 }; - if width == 0 || height == 0 || width > thumbhash::MAX_DIM || height > thumbhash::MAX_DIM { - return Err(MediaError::InvalidImageDimensions); - } - let mut rgba_ptr: *mut c_void = ptr::null_mut(); - let mut rgba_size: size_t = 0; - let rc = - unsafe { native::fluxer_vips_extract_rgba(image.as_ptr(), &mut rgba_ptr, &mut rgba_size) }; - if rc != 0 || rgba_ptr.is_null() { - clear_vips_error(); - return Err(MediaError::MediaTransformFailed); - } - let rgba = native::VipsBuffer::new(rgba_ptr, rgba_size) - .ok_or(MediaError::MediaTransformFailed)? - .to_vec(); - thumbhash::encode_rgba(&rgba, width, height).map_err(|_| MediaError::InvalidImageDimensions) -} - -#[derive(Clone, Debug)] -pub struct MetadataOptions { - pub placeholder: bool, - pub nsfw: nsfw::Config, -} - -impl Default for MetadataOptions { - fn default() -> Self { - Self { - placeholder: true, - nsfw: nsfw::Config::disabled(), - } - } -} - -#[derive(Serialize)] -struct MetadataResponse { - content_type: String, - size: usize, - content_hash: String, - format: String, - width: Option, - height: Option, - animated: bool, - #[serde(skip_serializing_if = "Option::is_none")] - duration: Option, - #[serde(skip_serializing_if = "Option::is_none")] - placeholder: Option, - nsfw: bool, - nsfw_probability: f32, -} - -#[derive(Clone, Copy, Debug, Default)] -pub struct AvProbe { - pub has_video: bool, - pub has_audio: bool, - pub duration_seconds: Option, -} - -pub fn probe_av_media(input: &[u8]) -> Option { - if input.is_empty() { - return None; - } - let mut has_video: c_int = 0; - let mut has_audio: c_int = 0; - let mut duration_seconds: f64 = 0.0; - let rc = unsafe { - native::fluxer_av_probe( - input.as_ptr().cast(), - input.len(), - &mut has_video, - &mut has_audio, - &mut duration_seconds, - ) - }; - if rc != 0 { - return None; - } - Some(AvProbe { - has_video: has_video != 0, - has_audio: has_audio != 0, - duration_seconds: if duration_seconds.is_finite() && duration_seconds > 0.0 { - Some(duration_seconds) - } else { - None - }, - }) -} - -fn metadata_content_type(sniffed_mime: &'static str, av_probe: Option<&AvProbe>) -> &'static str { - if sniffed_mime == "video/mp4" - && av_probe.is_some_and(|probe| probe.has_audio && !probe.has_video) - { - return "audio/mp4"; - } - sniffed_mime -} - -fn metadata_format(sniffed_mime: &str) -> String { - match sniffed_mime { - "audio/mpeg" => "mp3".to_owned(), - "video/quicktime" => "mov".to_owned(), - "video/x-matroska" => "mkv".to_owned(), - "image/svg+xml" => "svg".to_owned(), - "audio/mp4" => "m4a".to_owned(), - "image/avif-sequence" => "avif".to_owned(), - "image/apng" => "apng".to_owned(), - other => other - .rsplit_once('/') - .map(|(_, suffix)| suffix.strip_prefix("x-").unwrap_or(suffix)) - .unwrap_or("bin") - .to_owned(), - } -} - -fn validate_metadata_image_dimensions( - width: u32, - height: u32, - frames: u32, -) -> Result<(), MediaError> { - validate_dimensions_u32(width, height)?; - let frame_count = frames.max(1); - if frame_count > Limits::animated_frames() { - return Err(MediaError::InvalidImageDimensions); - } - if frame_count > 1 { - let w = width as usize; - let h = height as usize; - let per_frame = w.saturating_mul(h); - let max_total = Limits::animated_total_pixels(); - let fc = frame_count as usize; - if per_frame > max_total / fc.max(1) { - return Err(MediaError::InvalidImageDimensions); - } - } - Ok(()) -} - -async fn scan_for_nsfw( - client: &reqwest::Client, - cfg: &nsfw::Config, - sniffed_mime: &str, - animated: bool, - input: &[u8], -) -> Result { - let cat = mime::category(sniffed_mime); - if cat == Some(mime::Category::Video) { - let frames = match extract_video_frames_for_nsfw(input) { - Ok(frames) => frames, - Err(err) => { - tracing::warn!("nsfw video frame extract failed: {err:?}"); - return Err(MediaError::NsfwScanUnavailable); - } - }; - return match nsfw::check_buffers(client, cfg, &frames).await { - Ok(verdict) => Ok(verdict), - Err(err) => { - tracing::warn!("nsfw video classify failed: {err}"); - Err(MediaError::NsfwScanUnavailable) - } - }; - } - if cat == Some(mime::Category::Image) { - if animated { - match extract_animated_image_frames_for_nsfw(input) { - Ok(frames) => match nsfw::check_buffers(client, cfg, &frames).await { - Ok(verdict) => return Ok(verdict), - Err(err) => { - tracing::warn!("nsfw animated classify failed: {err}"); - return Err(MediaError::NsfwScanUnavailable); - } - }, - Err(err) => { - tracing::warn!( - "nsfw animated frame extract failed: {err:?} - falling back to static scan" - ); - } - } - } - let jpeg = match encode_static_image_for_nsfw(input) { - Ok(bytes) => bytes, - Err(err) => { - tracing::warn!("nsfw static JPEG encode failed: {err:?}"); - return Err(MediaError::NsfwScanUnavailable); - } - }; - return match nsfw::check(client, cfg, &jpeg).await { - Ok(verdict) => Ok(verdict), - Err(err) => { - tracing::warn!("nsfw static classify failed: {err}"); - Err(MediaError::NsfwScanUnavailable) - } - }; - } - Ok(nsfw::Result { - probability: 0.0, - is_nsfw: false, - }) -} - -fn extract_animated_image_frames_for_nsfw(input: &[u8]) -> Result>, MediaError> { - ensure_vips_init()?; - let loaded = load_image(input, "n=-1,access=sequential")?; - let total_height = unsafe { native::fluxer_vips_image_get_height(loaded.as_ptr()) }; - let width = unsafe { native::fluxer_vips_image_get_width(loaded.as_ptr()) }; - if total_height <= 0 || width <= 0 { - return Err(MediaError::InvalidImageDimensions); - } - let page_h = page_height(&loaded).unwrap_or(total_height); - let n_pages: u32 = if page_h <= 0 { - 1 - } else { - (total_height / page_h).max(1) as u32 - }; - let indices: [u32; 3] = if n_pages <= 1 { - [0, 0, 0] - } else if n_pages == 2 { - [0, 1, 1] - } else { - [0, n_pages / 2, n_pages - 1] - }; - let mut unique: Vec = Vec::with_capacity(3); - for idx in indices { - if !unique.contains(&idx) { - unique.push(idx); - } - } - let mut out: Vec> = Vec::with_capacity(unique.len()); - for idx in unique { - let top = (idx as c_int) * page_h.max(1); - let mut sub_raw = ptr::null_mut(); - let rc = unsafe { - native::fluxer_vips_extract_area( - loaded.as_ptr(), - &mut sub_raw, - 0, - top, - width, - page_h.max(1), - ) - }; - if rc != 0 || sub_raw.is_null() { - clear_vips_error(); - continue; - } - let sub = native::VipsImageHandle::new(sub_raw).ok_or(MediaError::MediaTransformFailed)?; - let suffix = output_suffix(AssetExtension::Jpeg, "low", None, None)?; - let mut out_ptr: *mut c_void = ptr::null_mut(); - let mut out_size: size_t = 0; - let rc = unsafe { - native::fluxer_vips_image_write_to_buffer( - sub.as_ptr(), - suffix.as_ptr(), - &mut out_ptr, - &mut out_size, - ) - }; - if rc != 0 || out_ptr.is_null() { - clear_vips_error(); - continue; - } - let buf = - native::VipsBuffer::new(out_ptr, out_size).ok_or(MediaError::MediaEncodeFailed)?; - out.push(vips_buffer_to_vec(&buf)?); - } - if out.is_empty() { - return Err(MediaError::MediaDecodeFailed); - } - Ok(out) -} - -fn compute_frame_sample_timestamps( - duration_seconds: Option, - prng: &mut rand_chacha::ChaCha8Rng, -) -> [f64; 3] { - use rand::RngExt; - let valid: Option = duration_seconds.filter(|d| d.is_finite() && *d > 0.0); - let fallback: f64 = valid.unwrap_or(1.0); - - let clamp = |v: f64| -> f64 { - if !v.is_finite() { - return 0.0; - } - match valid { - Some(max_v) => v.clamp(0.0, max_v), - None => v.max(0.0), - } - }; - - let start_base = clamp((fallback * 0.1 + 0.5).clamp(1.0, 2.0)); - let middle_base = clamp(fallback / 2.0); - let end_candidate = if fallback > 2.0 { - fallback - 1.0 - } else { - fallback * 0.95 - }; - let min_end = start_base + 0.5; - let end_base = clamp(end_candidate.max(min_end)); - - let mut jitter = |v: f64| -> f64 { - let radius = (v.abs() * 0.1).max(0.05); - let r: f64 = prng.random(); - clamp(v + (r * 2.0 - 1.0) * radius) - }; - - [jitter(start_base), jitter(middle_base), jitter(end_base)] -} - -fn nsfw_frame_seed(input: &[u8]) -> u64 { - let take = input.len().min(4096); - wyhash::wyhash(&input[..take], 0) -} - -fn extract_video_frames_for_nsfw(input: &[u8]) -> Result>, MediaError> { - use rand::SeedableRng as _; - if input.is_empty() || input.len() > constants::MAX_MEDIA_PROXY_BYTES { - return Err(MediaError::StreamTooLong); - } - ensure_vips_init()?; - - let duration = probe_av_media(input).and_then(|p| p.duration_seconds); - let seed = nsfw_frame_seed(input); - let mut prng = rand_chacha::ChaCha8Rng::seed_from_u64(seed); - let timestamps = compute_frame_sample_timestamps(duration, &mut prng); - - let mut slots: [native::FluxerNsfwFrameOut; 3] = [native::FluxerNsfwFrameOut::empty(); 3]; - let produced = unsafe { - native::fluxer_av_extract_frames_for_nsfw( - input.as_ptr().cast(), - input.len(), - timestamps.as_ptr(), - timestamps.len(), - slots.as_mut_ptr(), - ) - }; - let mut out: Vec> = Vec::with_capacity(3); - if produced > 0 { - for slot in slots.iter() { - if !slot.data.is_null() && slot.len > 0 { - let bytes = - unsafe { slice::from_raw_parts(slot.data.cast::(), slot.len).to_vec() }; - out.push(bytes); - } - } - } - unsafe { - native::fluxer_nsfw_frames_free(slots.as_mut_ptr(), slots.len()); - } - if out.is_empty() { - return Err(MediaError::MediaDecodeFailed); - } - Ok(out) -} - -pub async fn metadata_json_with_options( - input: &[u8], - _filename: &str, - options: MetadataOptions, - nsfw_client: &reqwest::Client, -) -> Result { - if input.len() > constants::MAX_MEDIA_PROXY_BYTES { - return Err(MediaError::StreamTooLong); - } - let sniffed = mime::sniff(input); - if !mime::is_supported_media_mime(sniffed.mime) { - return Err(MediaError::UnsupportedMediaType); - } - let initial_category = mime::category(sniffed.mime).ok_or(MediaError::UnsupportedMediaType)?; - let is_image = initial_category == mime::Category::Image; - let dims = if is_image { - Some(probe_image_dims(input)?) - } else { - None - }; - let frames_count = dims.map(|d| d.pages).unwrap_or(sniffed.frames); - let mut width = dims.map(|d| d.width).unwrap_or(sniffed.width); - let mut height = dims.map(|d| d.height).unwrap_or(sniffed.height); - if is_image && (width > 0 || height > 0 || frames_count > 1) { - validate_metadata_image_dimensions(width, height, frames_count)?; - } - - let av_probe = if matches!( - initial_category, - mime::Category::Video | mime::Category::Audio - ) { - Some(probe_av_media(input).ok_or(MediaError::MediaDecodeFailed)?) - } else { - None - }; - let content_type = metadata_content_type(sniffed.mime, av_probe.as_ref()); - let category = mime::category(content_type).ok_or(MediaError::UnsupportedMediaType)?; - if let Some(probe) = av_probe.as_ref() - && category == mime::Category::Audio - && !probe.has_audio - { - return Err(MediaError::MediaDecodeFailed); - } - - let mut video_thumb_jpeg: Option> = None; - if category == mime::Category::Video - && av_probe.as_ref().is_some_and(|p| p.has_video) - && let Ok(thumb) = extract_video_thumbnail(input, AssetExtension::Jpeg) - { - if let Ok(d) = probe_image_dims(&thumb.bytes) { - width = d.width; - height = d.height; - } - video_thumb_jpeg = Some(thumb.bytes); - } - - let placeholder = if options.placeholder { - let source: Option<&[u8]> = if is_image { - Some(input) - } else { - video_thumb_jpeg.as_deref() - }; - source - .and_then(|src| encode_thumbhash(src).ok()) - .map(|bytes| general_purpose::STANDARD.encode(bytes)) - } else { - None - }; - - let nsfw_enabled = nsfw::is_enabled(&options.nsfw); - let should_scan = nsfw_enabled - && (is_image - || (category == mime::Category::Video - && av_probe.as_ref().is_some_and(|p| p.has_video))); - let (nsfw_flag, nsfw_probability) = if should_scan { - let verdict = scan_for_nsfw( - nsfw_client, - &options.nsfw, - sniffed.mime, - sniffed.animated || dims.is_some_and(|d| d.pages > 1), - input, - ) - .await?; - (verdict.is_nsfw, verdict.probability) - } else { - (false, 0.0) - }; - - let duration = av_probe.as_ref().and_then(|p| { - p.duration_seconds - .filter(|d| d.is_finite() && *d > 0.0) - .map(|d| d.ceil() as u32) - }); - let (response_width, response_height) = if width > 0 && height > 0 { - (Some(width), Some(height)) - } else { - (None, None) - }; - - let format = metadata_format(content_type); - let content_hash = hex::encode(Sha256::digest(input)); - let response = MetadataResponse { - content_type: content_type.to_owned(), - size: input.len(), - content_hash, - format, - width: response_width, - height: response_height, - animated: sniffed.animated || dims.is_some_and(|d| d.pages > 1), - duration, - placeholder, - nsfw: nsfw_flag, - nsfw_probability, - }; - serde_json::to_string(&response).map_err(|_| MediaError::MediaEncodeFailed) -} - -pub async fn metadata_json(input: &[u8], filename: &str) -> Result { - let client = reqwest::Client::builder() - .user_agent(constants::OUTBOUND_USER_AGENT) - .build() - .expect("reqwest::Client::builder with only user_agent always builds"); - metadata_json_with_options(input, filename, MetadataOptions::default(), &client).await -} - -pub fn encode_static_image_for_nsfw(input: &[u8]) -> Result, MediaError> { - let mut options = ImageOptions { - width: Some(512), - height: Some(512), - format: AssetExtension::Jpeg, - quality: "low".to_owned(), - animated: false, - cover_crop: false, - ..Default::default() - }; - options.animated = false; - transform_image(input, &options).map(|media| media.bytes) -} - -pub fn extract_video_thumbnail( - input: &[u8], - format: AssetExtension, -) -> Result { - if input.len() > constants::MAX_MEDIA_PROXY_BYTES { - return Err(MediaError::StreamTooLong); - } - ensure_vips_init()?; - if !matches!( - format, - AssetExtension::Jpeg - | AssetExtension::Png - | AssetExtension::Webp - | AssetExtension::Gif - | AssetExtension::Apng - ) { - return Err(MediaError::UnsupportedOutputFormat); - } - let suffix = output_suffix(format, "high", None, None)?; - let mut out_ptr: *mut c_void = ptr::null_mut(); - let mut out_size: size_t = 0; - let rc = unsafe { - native::fluxer_ffmpeg_video_thumbnail( - input.as_ptr().cast(), - input.len(), - suffix.as_ptr(), - constants::MAX_VIDEO_PACKETS_FOR_THUMBNAIL as c_int, - &mut out_ptr, - &mut out_size, - ) - }; - if rc != 0 || out_ptr.is_null() { - clear_vips_error(); - return Err(MediaError::MediaDecodeFailed); - } - let out = native::VipsBuffer::new(out_ptr, out_size).ok_or(MediaError::MediaDecodeFailed)?; - let bytes = vips_buffer_to_vec(&out)?; - Ok(ProcessedMedia { - bytes, - content_type: format.mime(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn synthetic_png(width: u32, height: u32) -> Vec { - ensure_vips_init().unwrap(); - let mut pixels = vec![0u8; width as usize * height as usize * 4]; - for y in 0..height { - for x in 0..width { - let i = (y as usize * width as usize + x as usize) * 4; - pixels[i] = (x * 255 / width.max(1)) as u8; - pixels[i + 1] = (y * 255 / height.max(1)) as u8; - pixels[i + 2] = 120; - pixels[i + 3] = 255; - } - } - let image = unsafe { - native::fluxer_vips_image_new_from_memory_copy( - pixels.as_ptr().cast(), - pixels.len(), - width as c_int, - height as c_int, - 4, - native::fluxer_vips_format_uchar, - ) - }; - let image = native::VipsImageHandle::new(image).unwrap(); - let suffix = CString::new(".png[strip]").unwrap(); - let mut out_ptr: *mut c_void = ptr::null_mut(); - let mut out_size: size_t = 0; - let rc = unsafe { - native::fluxer_vips_image_write_to_buffer( - image.as_ptr(), - suffix.as_ptr(), - &mut out_ptr, - &mut out_size, - ) - }; - assert_eq!(0, rc); - native::VipsBuffer::new(out_ptr, out_size).unwrap().to_vec() - } - - fn synthetic_wav() -> Vec { - let sample_rate = 8_000u32; - let channels = 1u16; - let bits_per_sample = 8u16; - let data_len = sample_rate; - let byte_rate = sample_rate * u32::from(channels) * u32::from(bits_per_sample) / 8; - let block_align = channels * bits_per_sample / 8; - let mut wav = Vec::with_capacity(44 + data_len as usize); - wav.extend_from_slice(b"RIFF"); - wav.extend_from_slice(&(36 + data_len).to_le_bytes()); - wav.extend_from_slice(b"WAVEfmt "); - wav.extend_from_slice(&16u32.to_le_bytes()); - wav.extend_from_slice(&1u16.to_le_bytes()); - wav.extend_from_slice(&channels.to_le_bytes()); - wav.extend_from_slice(&sample_rate.to_le_bytes()); - wav.extend_from_slice(&byte_rate.to_le_bytes()); - wav.extend_from_slice(&block_align.to_le_bytes()); - wav.extend_from_slice(&bits_per_sample.to_le_bytes()); - wav.extend_from_slice(b"data"); - wav.extend_from_slice(&data_len.to_le_bytes()); - wav.extend(std::iter::repeat_n(128u8, data_len as usize)); - wav - } - - fn fixture_audio_only_mp4() -> Vec { - base64::engine::general_purpose::STANDARD - .decode("AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAxptb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAA+gABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAACRXRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAA+gAAAAAAAAAAAAAAAQEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAPoAAAQAAAEAAAAAAb1tZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAAB9AAAAL0FXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAAFobWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAAEsc3RibAAAAH5zdHNkAAAAAAAAAAEAAABubXA0YQAAAAAAAAABAAAAAAAAAAAAAQAQAAAAAB9AAAAAAAA2ZXNkcwAAAAADgICAJQABAASAgIAXQBUAAAAAAD6AAAACZQWAgIAFFYhW5QAGgICAAQIAAAAUYnRydAAAAAAAAD6AAAACZQAAACBzdHRzAAAAAAAAAAIAAAACAAAEAAAAAAEAAAPQAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAADAAAAAQAAACBzdHN6AAAAAAAAAAAAAAADAAAAFQAAAAQAAAAEAAAAFHN0Y28AAAAAAAAAAQAAA0YAAAAac2dwZAEAAAByb2xsAAAAAgAAAAH//wAAABxzYmdwAAAAAHJvbGwAAAABAAAAAwAAAAEAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYxLjcuMTAyAAAACGZyZWUAAAAlbWRhdN4CAExhdmM2MS4xOS4xMDEAAjBADgEYIAcBGCAH") - .unwrap() - } - - fn fixture_audio_mp4_with_attached_picture() -> Vec { - base64::engine::general_purpose::STANDARD - .decode("AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAABBNtb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAA+gABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAACRXRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAA+gAAAAAAAAAAAAAAAQEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAPoAAAQAAAEAAAAAAb1tZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAAB9AAAAL0FXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAAFobWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAAEsc3RibAAAAH5zdHNkAAAAAAAAAAEAAABubXA0YQAAAAAAAAABAAAAAAAAAAAAAQAQAAAAAB9AAAAAAAA2ZXNkcwAAAAADgICAJQABAASAgIAXQBUAAAAAAAJlAAACZQWAgIAFFYhW5QAGgICAAQIAAAAUYnRydAAAAAAAAAJlAAACZQAAACBzdHRzAAAAAAAAAAIAAAACAAAEAAAAAAEAAAPQAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAADAAAAAQAAACBzdHN6AAAAAAAAAAAAAAADAAAAFQAAAAQAAAAEAAAAFHN0Y28AAAAAAAAAAQAABD8AAAAac2dwZAEAAAByb2xsAAAAAgAAAAH//wAAABxzYmdwAAAAAHJvbGwAAAABAAAAAwAAAAEAAAFadWR0YQAAAVJtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAASVpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYxLjcuMTAyAAAA+WNvdnIAAADxZGF0YQAAAA0AAAAA/9j/4AAQSkZJRgABAgAAAQABAAD//gAQTGF2YzYxLjE5LjEwMQD/2wBDAAgEBAQEBAUFBQUFBQYGBgYGBgYGBgYGBgYHBwcICAgHBwcGBgcHCAgICAkJCQgICAgJCQoKCgwMCwsODg4RERT/xABMAAEBAAAAAAAAAAAAAAAAAAAABgEBAQAAAAAAAAAAAAAAAAAABgcQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/wAARCAACAAIDASIAAhEAAxEA/9oADAMBAAIRAxEAPwCLAFF/f//ZAAAACGZyZWUAAAAlbWRhdN4CAExhdmM2MS4xOS4xMDEAAjBADgEYIAcBGCAH") - .unwrap() - } - - fn animated_gif_fixture() -> Vec { - base64::engine::general_purpose::STANDARD - .decode("R0lGODlhIAAgAPEAAAAAAP8AAP///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJFAAAACwAAAAAIAAgAAACPYyPGcDtD5Q8sLY5rcVSV654EJiID4mYDkqpDGu4LyxHtAwv+O3mtb9j1YbEovGITCqXzKbzCY1Kp9QqsQAAIfkECRQAAAAsAAAAACAAIACDAAAAAAD/AAD/AAD/AAD/AAD/AAD/AAD/AAD/AAD/////AAAAAAAAAAAAAAAAAAAABFsQyEmrvTjrzbv/YCiOZGmeaKqubOu+MBUIA1EQgxCYBpH8wATBQDoEj7+DyIBsEj8BX/NIACGmTdAAiwRJuUBQARz0ksOf7TlhXbOhX24VxCQ/QUauctRrDiURADs=") - .unwrap() - } - - fn metadata_value(input: &[u8], filename: &str) -> serde_json::Value { - let meta = tokio::runtime::Builder::new_current_thread() - .build() - .unwrap() - .block_on(async { - let client = reqwest::Client::new(); - metadata_json_with_options(input, filename, MetadataOptions::default(), &client) - .await - .unwrap() - }); - serde_json::from_str(&meta).unwrap() - } - - fn read_u24_le(bytes: &[u8]) -> Option { - (bytes.len() >= 3) - .then(|| bytes[0] as u32 | ((bytes[1] as u32) << 8) | ((bytes[2] as u32) << 16)) - } - - fn webp_chunk_payloads<'a>(bytes: &'a [u8], fourcc: &[u8; 4]) -> Vec<&'a [u8]> { - if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { - return Vec::new(); - } - let mut chunks = Vec::new(); - let mut offset = 12usize; - while offset + 8 <= bytes.len() { - let chunk_size = u32::from_le_bytes([ - bytes[offset + 4], - bytes[offset + 5], - bytes[offset + 6], - bytes[offset + 7], - ]) as usize; - let payload_start = offset + 8; - let Some(payload_end) = payload_start.checked_add(chunk_size) else { - break; - }; - if payload_end > bytes.len() { - break; - } - if &bytes[offset..offset + 4] == fourcc { - chunks.push(&bytes[payload_start..payload_end]); - } - offset = payload_end + (chunk_size & 1); - } - chunks - } - - fn webp_canvas_size(bytes: &[u8]) -> Option<(u32, u32, u8)> { - let vp8x = webp_chunk_payloads(bytes, b"VP8X").into_iter().next()?; - if vp8x.len() < 10 { - return None; - } - let width = read_u24_le(&vp8x[4..7])? + 1; - let height = read_u24_le(&vp8x[7..10])? + 1; - Some((width, height, vp8x[0])) - } - - fn first_webp_anim_frame_size(bytes: &[u8]) -> Option<(u32, u32)> { - let anmf = webp_chunk_payloads(bytes, b"ANMF").into_iter().next()?; - if anmf.len() < 16 { - return None; - } - let width = read_u24_le(&anmf[6..9])? + 1; - let height = read_u24_le(&anmf[9..12])? + 1; - Some((width, height)) - } - - fn gif_frame_delays_cs(bytes: &[u8]) -> Vec { - if bytes.len() < 13 || (&bytes[..6] != b"GIF89a" && &bytes[..6] != b"GIF87a") { - return Vec::new(); - } - let mut offset = 13usize; - if bytes[10] & 0x80 != 0 { - let entries = 1usize << ((bytes[10] & 0x07) + 1); - offset = offset.saturating_add(entries.saturating_mul(3)); - } - let mut delays = Vec::new(); - while offset < bytes.len() { - match bytes[offset] { - 0x21 => { - if offset + 1 >= bytes.len() { - break; - } - if bytes[offset + 1] == 0xf9 { - if offset + 7 >= bytes.len() || bytes[offset + 2] != 4 { - break; - } - delays.push(u16::from_le_bytes([bytes[offset + 4], bytes[offset + 5]])); - offset += 8; - continue; - } - offset += 2; - while offset < bytes.len() { - let len = bytes[offset] as usize; - offset += 1; - if len == 0 { - break; - } - offset = offset.saturating_add(len); - } - } - 0x2c => { - if offset + 9 >= bytes.len() { - break; - } - let local_entries = if bytes[offset + 9] & 0x80 != 0 { - 1usize << ((bytes[offset + 9] & 0x07) + 1) - } else { - 0 - }; - offset += 10 + local_entries * 3 + 1; - while offset < bytes.len() { - let len = bytes[offset] as usize; - offset += 1; - if len == 0 { - break; - } - offset = offset.saturating_add(len); - } - } - 0x3b => break, - _ => break, - } - } - delays - } - - #[test] - fn webp_effort_override_is_clamped_to_the_encoder_maximum() { - for requested in [7u8, 8, 9, 200] { - let suffix = - output_suffix(AssetExtension::Webp, "high", None, Some(requested)).unwrap(); - let suffix = suffix.to_str().unwrap().to_owned(); - assert!( - suffix.contains("effort=6"), - "effort={requested} produced {suffix}, but libvips webpsave rejects effort above 6 \ - and silently falls back to its own default" - ); - } - let suffix = output_suffix(AssetExtension::Webp, "high", None, Some(5)).unwrap(); - assert!(suffix.to_str().unwrap().contains("effort=5")); - } - - #[test] - fn animated_webp_default_effort_matches_fast_tier() { - assert_eq!(2, effort_for("low", true)); - assert_eq!(2, effort_for("high", true)); - assert_eq!(4, effort_for("high", false)); - } - - #[test] - fn auto_animated_palette_quality_matches_v1_thresholds() { - let gif_header = b"GIF89a\x01\x00\x01\x00"; - let small_probe = AnimatedProbe { - width: 300, - height: 225, - pages: 100, - }; - let large_probe = AnimatedProbe { - width: 480, - height: 480, - pages: 240, - }; - - assert_eq!( - "lossless", - effective_quality( - AssetExtension::Webp, - true, - gif_header, - "auto", - Some(small_probe) - ) - ); - assert_eq!( - "high", - effective_quality( - AssetExtension::Webp, - true, - gif_header, - "auto", - Some(large_probe) - ) - ); - assert_eq!( - "high", - effective_quality( - AssetExtension::Webp, - true, - gif_header, - "high", - Some(small_probe) - ) - ); - assert_eq!( - "low", - effective_quality( - AssetExtension::Webp, - true, - gif_header, - "low", - Some(small_probe) - ) - ); - assert_eq!( - None, - effective_effort_override( - AssetExtension::Webp, - true, - gif_header, - "auto", - Some(small_probe), - None - ) - ); - assert_eq!( - Some(0), - effective_effort_override( - AssetExtension::Webp, - true, - gif_header, - "auto", - Some(large_probe), - None - ) - ); - assert_eq!( - Some(2), - effective_effort_override( - AssetExtension::Webp, - true, - gif_header, - "auto", - Some(large_probe), - Some(2) - ) - ); - } - - #[test] - fn animated_encode_deadline_keeps_flush_headroom() { - assert_eq!( - Some(17_000), - anim_limits_from_options(&ImageOptions { - deadline_ms: Some(20_000), - ..Default::default() - }) - .deadline_unix_ms - ); - assert_eq!( - Some(1_500), - anim_limits_from_options(&ImageOptions { - deadline_ms: Some(1_500), - ..Default::default() - }) - .deadline_unix_ms - ); - assert_eq!( - None, - anim_limits_from_options(&ImageOptions::default()).deadline_unix_ms - ); - } - - #[test] - fn animated_gif_encodes_to_animated_webp_with_alpha() { - let gif = animated_gif_fixture(); - - let animated_webp = transform_image( - &gif, - &ImageOptions { - width: Some(32), - height: Some(32), - format: AssetExtension::Webp, - quality: "lossless".to_owned(), - animated: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/webp", animated_webp.content_type); - assert!(animated_webp.bytes.starts_with(b"RIFF")); - assert_eq!(&animated_webp.bytes[8..12], b"WEBP"); - assert!(animated_webp.bytes.windows(4).any(|chunk| chunk == b"ANIM")); - assert!( - animated_webp.bytes.windows(4).any(|chunk| chunk == b"ALPH") - || animated_webp.bytes.windows(4).any(|chunk| chunk == b"VP8L") - ); - let (canvas_width, canvas_height, feature_flags) = - webp_canvas_size(&animated_webp.bytes).unwrap(); - assert_eq!((32, 32), (canvas_width, canvas_height)); - assert_ne!(0, feature_flags & 0x02); - assert_ne!(0, feature_flags & 0x10); - assert_eq!( - Some((32, 32)), - first_webp_anim_frame_size(&animated_webp.bytes) - ); - - let static_webp = transform_image( - &gif, - &ImageOptions { - width: Some(32), - height: Some(32), - format: AssetExtension::Webp, - quality: "lossless".to_owned(), - animated: false, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/webp", static_webp.content_type); - assert!(static_webp.bytes.starts_with(b"RIFF")); - assert_eq!(&static_webp.bytes[8..12], b"WEBP"); - - let animated_gif = transform_image( - &gif, - &ImageOptions { - width: Some(32), - height: Some(32), - format: AssetExtension::Gif, - quality: "lossless".to_owned(), - animated: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/gif", animated_gif.content_type); - assert!( - animated_gif.bytes.starts_with(b"GIF89a") || animated_gif.bytes.starts_with(b"GIF87a") - ); - - let animated_png = transform_image( - &gif, - &ImageOptions { - width: Some(32), - height: Some(32), - format: AssetExtension::Apng, - quality: "lossless".to_owned(), - animated: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/apng", animated_png.content_type); - let apng_chunks = parse_png_chunks(&animated_png.bytes).unwrap(); - assert_eq!(Some((32, 32)), png_ihdr_dimensions(apng_chunks[0].data)); - assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"acTL")); - assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"fcTL")); - assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"fdAT")); - } - - #[test] - fn animated_gif_resize_preserves_last_frame_delay() { - let gif = animated_gif_fixture(); - assert_eq!(vec![20, 20], gif_frame_delays_cs(&gif)); - - let resized = transform_image( - &gif, - &ImageOptions { - width: Some(16), - height: Some(16), - format: AssetExtension::Gif, - quality: "lossless".to_owned(), - animated: true, - ..Default::default() - }, - ) - .unwrap(); - - assert_eq!("image/gif", resized.content_type); - assert_eq!(vec![20, 20], gif_frame_delays_cs(&resized.bytes)); - } - - #[test] - fn animated_gif_two_bounds_fit_inside_without_distortion() { - let dims = gif_resize_dims( - mime::SniffInfo { - mime: "image/gif", - animated: true, - width: 320, - height: 240, - ..Default::default() - }, - &ImageOptions { - width: Some(240), - height: Some(240), - format: AssetExtension::Gif, - animated: true, - ..Default::default() - }, - ) - .expect("4:3 GIF should be reduced to fit in a 240px square"); - - assert_eq!(240, dims.width); - assert_eq!(180, dims.height); - } - - #[test] - fn animated_apng_input_transforms_through_ffmpeg_decode_path() { - let fixture_b64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAAAAEABQAAaBqIGAAAAK1JREFUeJxjqGf4hxUxIKF/DH+BiOH/fxAiU4Mcgx0aYkBCtgy2QMRgZwdCg1yDCQMvEL1oMACix00mQCTFwANE/gx8QHSrUwuI0DXsbpkCVJ3IwBDEwADUsLprLkTD2ZoeoOp8OQZgaIEQxG5IiNqCXQARQw5pqJOooCFPj+Fcn96x9gxTBpS49NBk2DwlhBoaaOkHZUOGqYvDnrWJt6kRF6xADUAE1ABEuCIOAPEY5L3Pr8FWAAAAGmZjVEwAAAABAAAAAQAAAAEAAAAAAAAAAAABAAUAAMpQnTkAAAAQZmRBVAAAAAJ4nGOoZ/gHAAJ/AX511aUxAAAAAElFTkSuQmCC"; - let apng = base64::engine::general_purpose::STANDARD - .decode(fixture_b64) - .unwrap(); - - let animated_webp = transform_image( - &apng, - &ImageOptions { - width: Some(8), - format: AssetExtension::Webp, - quality: "lossless".to_owned(), - animated: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/webp", animated_webp.content_type); - assert!(animated_webp.bytes.windows(4).any(|chunk| chunk == b"ANIM")); - assert_eq!( - Some((8, 8)), - first_webp_anim_frame_size(&animated_webp.bytes) - ); - - let static_webp = transform_image( - &apng, - &ImageOptions { - width: Some(8), - format: AssetExtension::Webp, - quality: "lossless".to_owned(), - animated: false, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/webp", static_webp.content_type); - assert!(!static_webp.bytes.windows(4).any(|chunk| chunk == b"ANIM")); - assert_eq!( - ImageDimensions { - width: 8, - height: 8, - pages: 1 - }, - probe_image_dims(&static_webp.bytes).unwrap() - ); - - let animated_gif = transform_image( - &apng, - &ImageOptions { - width: Some(8), - format: AssetExtension::Gif, - quality: "lossless".to_owned(), - animated: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/gif", animated_gif.content_type); - assert!( - animated_gif.bytes.starts_with(b"GIF89a") || animated_gif.bytes.starts_with(b"GIF87a") - ); - - let animated_png = transform_image( - &apng, - &ImageOptions { - width: Some(8), - format: AssetExtension::Apng, - quality: "lossless".to_owned(), - animated: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/apng", animated_png.content_type); - assert!(animated_png.bytes.starts_with(b"\x89PNG\r\n\x1a\n")); - let apng_chunks = parse_png_chunks(&animated_png.bytes).unwrap(); - assert_eq!(Some((8, 8)), png_ihdr_dimensions(apng_chunks[0].data)); - assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"acTL")); - assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"fcTL")); - assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"fdAT")); - assert_eq!( - Some(2), - apng_chunks - .iter() - .find(|chunk| chunk.kind == *b"acTL") - .and_then(|chunk| chunk.data.get(..4)) - .map(|bytes| u32::from_be_bytes(bytes.try_into().unwrap())) - ); - - let animated_png_alias = transform_image( - &apng, - &ImageOptions { - width: Some(8), - format: AssetExtension::Png, - quality: "lossless".to_owned(), - animated: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/apng", animated_png_alias.content_type); - let alias_chunks = parse_png_chunks(&animated_png_alias.bytes).unwrap(); - assert_eq!(Some((8, 8)), png_ihdr_dimensions(alias_chunks[0].data)); - assert!(alias_chunks.iter().any(|chunk| chunk.kind == *b"acTL")); - } - - #[test] - fn avif_direct_decode_applies_resize_and_crop() { - let fixture_b64 = "AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAAD5bWV0YQAAAAAAAAAvaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAFBpY3R1cmVIYW5kbGVyAAAAAA5waXRtAAAAAAABAAAAHmlsb2MAAAAARAAAAQABAAAAAQAAASEAAAFZAAAAKGlpbmYAAAAAAAEAAAAaaW5mZQIAAAAAAQAAYXYwMUNvbG9yAAAAAGppcHJwAAAAS2lwY28AAAAUaXNwZQAAAAAAAABAAAAAMAAAABBwaXhpAAAAAAMICAgAAAAMYXYxQ4EADAAAAAATY29scm5jbHgAAgACAAIAAAAAF2lwbWEAAAAAAAAAAQABBAECgwQAAAFhbWRhdAoGGBV/vbAIMs4CRgAABBBBQEqBANtxpEnkS8i7Ewu1Oa+E52+0gHxmN6DekBiIYovbIpo+I+L2MbaIuGgpmhiq3wmhtHx3Lyb9HWhe08jL3lTmL0L92z3pFGZiyNiXjoWSnt6Vs2YF9Ogt2S1YudcnVbcGESJSHNs+6UmubDO+hIB+aL08iAZr/qkVPsTgHY5xL3y7b0B4W8BuTdfXeVy/nJ8V2xmFc1fc4DXzEalW69hTvoJEKuitiwnHu32Gr1Qbjk88s36/tv1BQ2bbYX/QIFDJwLoME7YrHOzOB0zEmhjjdKZkNDwlG0u7YsB5EvaXAnkkgF6l5yaKb8tv2ZBYJO+kDNE7uK8kt5dEIlsrravn8byytjhCTzx5rRLwkj6obavPpIgh/z/z9mG1oxZ2zWugKXunGbw64JUJ+fUiTa2frsG0dGb02dKJ4rPXq9ZQY/B4G3nuZg=="; - let avif = base64::engine::general_purpose::STANDARD - .decode(fixture_b64) - .unwrap(); - - let resized = transform_image( - &avif, - &ImageOptions { - width: Some(32), - format: AssetExtension::Webp, - quality: "high".to_owned(), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/webp", resized.content_type); - assert_eq!( - ImageDimensions { - width: 32, - height: 24, - pages: 1 - }, - probe_image_dims(&resized.bytes).unwrap() - ); - - let cropped = transform_image( - &avif, - &ImageOptions { - width: Some(32), - height: Some(32), - format: AssetExtension::Png, - quality: "high".to_owned(), - cover_crop: true, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/png", cropped.content_type); - assert_eq!( - ImageDimensions { - width: 32, - height: 32, - pages: 1 - }, - probe_image_dims(&cropped.bytes).unwrap() - ); - } - - #[test] - fn transforms_png_to_webp() { - let png = synthetic_png(32, 24); - let out = transform_image( - &png, - &ImageOptions { - width: Some(16), - format: AssetExtension::Webp, - ..Default::default() - }, - ) - .unwrap(); - assert_eq!("image/webp", out.content_type); - assert!(out.bytes.starts_with(b"RIFF")); - } - - #[test] - fn transforms_static_png_with_animated_flag_does_not_pass_n_to_pngload() { - let png = synthetic_png(48, 48); - let out = transform_image( - &png, - &ImageOptions { - width: Some(32), - height: Some(32), - format: AssetExtension::Webp, - animated: true, - ..Default::default() - }, - ) - .expect("static-png + animated=true must transform without erroring"); - assert_eq!("image/webp", out.content_type); - assert!(out.bytes.starts_with(b"RIFF")); - } - - #[test] - fn source_supports_pages_matches_libvips_loader_list() { - assert!(source_supports_pages("image/webp")); - assert!(source_supports_pages("image/gif")); - assert!(source_supports_pages("image/apng")); - assert!(source_supports_pages("image/heif")); - assert!(source_supports_pages("image/avif")); - assert!(!source_supports_pages("image/png")); - assert!(!source_supports_pages("image/jpeg")); - assert!(!source_supports_pages("image/bmp")); - assert!(!source_supports_pages("application/octet-stream")); - } - - #[test] - fn metadata_json_includes_dimensions_and_placeholder() { - let png = synthetic_png(16, 16); - let meta = tokio::runtime::Builder::new_current_thread() - .build() - .unwrap() - .block_on(async { - let client = reqwest::Client::new(); - metadata_json_with_options(&png, "test.png", MetadataOptions::default(), &client) - .await - .unwrap() - }); - assert!(meta.contains("\"format\":\"png\"")); - assert!(meta.contains("\"width\":16")); - assert!(meta.contains("\"height\":16")); - assert!(meta.contains("\"placeholder\":\"")); - assert!(meta.contains("\"nsfw\":false")); - assert!(meta.contains("\"nsfw_probability\":0")); - } - - #[test] - fn metadata_json_returns_unavailable_when_nsfw_service_fails() { - let png = synthetic_png(16, 16); - let err = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - let client = reqwest::Client::new(); - metadata_json_with_options( - &png, - "test.png", - MetadataOptions { - placeholder: false, - nsfw: nsfw::Config { - endpoint: "http://127.0.0.1:9".to_owned(), - threshold: 0.85, - timeout_ms: 50, - connect_timeout_ms: 50, - }, - }, - &client, - ) - .await - .unwrap_err() - }); - assert_eq!(err, MediaError::NsfwScanUnavailable); - } - - #[test] - fn metadata_json_uses_null_dimensions_for_audio() { - let wav = synthetic_wav(); - let value = metadata_value(&wav, "test.wav"); - assert_eq!(value["content_type"], "audio/wav"); - assert_eq!(value.get("width"), Some(&serde_json::Value::Null)); - assert_eq!(value.get("height"), Some(&serde_json::Value::Null)); - assert_eq!(value["duration"], 1); - } - - #[test] - fn metadata_json_classifies_audio_only_mp4_as_audio() { - let mp4 = fixture_audio_only_mp4(); - assert_eq!("video/mp4", mime::sniff(&mp4).mime); - let value = metadata_value(&mp4, "renamed.mp4"); - assert_eq!(value["content_type"], "audio/mp4"); - assert_eq!(value["format"], "m4a"); - assert_eq!(value.get("width"), Some(&serde_json::Value::Null)); - assert_eq!(value.get("height"), Some(&serde_json::Value::Null)); - assert_eq!(value["duration"], 1); - } - - #[test] - fn metadata_json_treats_mp4_attached_picture_as_audio_cover_art() { - let mp4 = fixture_audio_mp4_with_attached_picture(); - let probe = probe_av_media(&mp4).unwrap(); - assert!(probe.has_audio); - assert!(!probe.has_video); - let value = metadata_value(&mp4, "renamed.mp4"); - assert_eq!(value["content_type"], "audio/mp4"); - assert_eq!(value["format"], "m4a"); - assert_eq!(value.get("width"), Some(&serde_json::Value::Null)); - assert_eq!(value.get("height"), Some(&serde_json::Value::Null)); - } - - #[test] - fn thumbhash_for_valid_image_is_non_empty() { - let png = synthetic_png(16, 16); - let hash = encode_thumbhash(&png).unwrap(); - assert!(!hash.is_empty()); - } - - #[test] - fn compute_frame_sample_timestamps_distributes_start_middle_end() { - use rand::SeedableRng as _; - let seed = [7u8; 32]; - let mut prng = rand_chacha::ChaCha8Rng::from_seed(seed); - let ts = compute_frame_sample_timestamps(Some(10.0), &mut prng); - for t in ts { - assert!((0.0..10.0).contains(&t), "ts {t} out of range"); - } - assert!( - ts[0] < ts[1], - "start {} should precede middle {}", - ts[0], - ts[1] - ); - assert!( - ts[1] < ts[2], - "middle {} should precede end {}", - ts[1], - ts[2] - ); - } - - #[test] - fn nsfw_frame_seed_is_deterministic_per_input() { - let a = b"hello world this is a video header blob".to_vec(); - let b = b"hello world this is a video header blob".to_vec(); - let c = b"hello world this is a different blob xx".to_vec(); - assert_eq!(nsfw_frame_seed(&a), nsfw_frame_seed(&b)); - assert_ne!(nsfw_frame_seed(&a), nsfw_frame_seed(&c)); - } - - #[test] - fn extract_video_frames_for_nsfw_returns_multiple_frames() { - let fixture = std::path::Path::new("tests/fixtures/big-buck-bunny-720p-10s.mp4"); - let alt = std::path::Path::new(".benchmark-cache/media/big-buck-bunny-720p-10s.mp4"); - let path = if fixture.exists() { - fixture - } else if alt.exists() { - alt - } else { - eprintln!("skipping: no video fixture available"); - return; - }; - let bytes = std::fs::read(path).expect("read fixture"); - let frames = extract_video_frames_for_nsfw(&bytes).expect("extract frames"); - assert!( - !frames.is_empty() && frames.len() <= 3, - "expected 1-3 frames, got {}", - frames.len() - ); - for f in &frames { - assert!(f.len() > 100, "JPEG frame too small ({} bytes)", f.len()); - assert_eq!(&f[..2], &[0xFF, 0xD8], "not a JPEG"); - } - } - - fn ffmpeg_gen_mp4(args: &[&str]) -> Option> { - let dir = tempfile::tempdir().ok()?; - let out = dir.path().join("fixture.mp4"); - let status = std::process::Command::new("ffmpeg") - .args(["-nostdin", "-loglevel", "error", "-y"]) - .args(args) - .arg(out.to_str()?) - .status() - .ok()?; - if !status.success() { - return None; - } - std::fs::read(&out).ok() - } - - fn ffmpeg_gen_rotated_mp4(display_rotation: &str, source_args: &[&str]) -> Option> { - let dir = tempfile::tempdir().ok()?; - let source = dir.path().join("source.mp4"); - let out = dir.path().join("rotated.mp4"); - let source_status = std::process::Command::new("ffmpeg") - .args(["-nostdin", "-loglevel", "error", "-y"]) - .args(source_args) - .arg(source.to_str()?) - .status() - .ok()?; - if !source_status.success() { - return None; - } - let rotate_status = std::process::Command::new("ffmpeg") - .args(["-nostdin", "-loglevel", "error", "-y", "-noautorotate"]) - .args(["-display_rotation", display_rotation]) - .args(["-i", source.to_str()?]) - .args(["-c", "copy", "-f", "mp4"]) - .arg(out.to_str()?) - .status() - .ok()?; - if !rotate_status.success() { - return None; - } - std::fs::read(&out).ok() - } - - fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { - if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" { - return None; - } - let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); - let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); - Some((width, height)) - } - - #[test] - fn video_thumbnail_corrects_display_geometry() { - let Some(plain) = ffmpeg_gen_mp4(&[ - "-f", - "lavfi", - "-i", - "testsrc=size=320x240:rate=10:duration=1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) else { - eprintln!("skipping: ffmpeg CLI not available"); - return; - }; - let thumb = extract_video_thumbnail(&plain, AssetExtension::Png).expect("plain thumbnail"); - assert_eq!( - png_dimensions(&thumb.bytes), - Some((320, 240)), - "square-pixel video should keep its coded dimensions" - ); - - let anamorphic = ffmpeg_gen_mp4(&[ - "-f", - "lavfi", - "-i", - "testsrc=size=1280x720:rate=10:duration=1", - "-vf", - "setsar=2/1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) - .expect("anamorphic fixture"); - let thumb = extract_video_thumbnail(&anamorphic, AssetExtension::Png) - .expect("anamorphic thumbnail"); - let (w, h) = png_dimensions(&thumb.bytes).expect("anamorphic png dimensions"); - assert_eq!(h, 720, "anamorphic height preserved"); - assert!( - (i64::from(w) - 2560).abs() <= 2, - "anamorphic width should expand to the ~2560 display width, got {w}" - ); - - let narrow = ffmpeg_gen_mp4(&[ - "-f", - "lavfi", - "-i", - "testsrc=size=1280x720:rate=10:duration=1", - "-vf", - "setsar=1/2", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) - .expect("narrow anamorphic fixture"); - let thumb = extract_video_thumbnail(&narrow, AssetExtension::Png) - .expect("narrow anamorphic thumbnail"); - assert_eq!( - png_dimensions(&thumb.bytes), - Some((1280, 1440)), - "sub-square pixel video should grow height to its display size" - ); - - let rotated = ffmpeg_gen_rotated_mp4( - "90", - &[ - "-f", - "lavfi", - "-i", - "testsrc=size=640x480:rate=10:duration=1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ], - ) - .expect("rotated fixture"); - let thumb = - extract_video_thumbnail(&rotated, AssetExtension::Png).expect("rotated thumbnail"); - assert_eq!( - png_dimensions(&thumb.bytes), - Some((480, 640)), - "rotation-metadata video should present in its display (portrait) orientation" - ); - - let rotated_counterclockwise = ffmpeg_gen_rotated_mp4( - "-90", - &[ - "-f", - "lavfi", - "-i", - "testsrc=size=640x480:rate=10:duration=1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ], - ) - .expect("counterclockwise rotated fixture"); - let thumb = extract_video_thumbnail(&rotated_counterclockwise, AssetExtension::Png) - .expect("counterclockwise rotated thumbnail"); - assert_eq!( - png_dimensions(&thumb.bytes), - Some((480, 640)), - "either quarter-turn direction should swap dimensions" - ); - - let rotated_anamorphic = ffmpeg_gen_rotated_mp4( - "90", - &[ - "-f", - "lavfi", - "-i", - "testsrc=size=320x180:rate=10:duration=1", - "-vf", - "setsar=2/1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ], - ) - .expect("rotated anamorphic fixture"); - let thumb = extract_video_thumbnail(&rotated_anamorphic, AssetExtension::Png) - .expect("rotated anamorphic thumbnail"); - assert_eq!( - png_dimensions(&thumb.bytes), - Some((180, 640)), - "SAR correction should happen in coded space before rotation" - ); - - let single = ffmpeg_gen_mp4(&[ - "-f", - "lavfi", - "-i", - "testsrc=size=200x150:rate=1:duration=1", - "-frames:v", - "1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) - .expect("single-frame fixture"); - let thumb = - extract_video_thumbnail(&single, AssetExtension::Png).expect("single-frame thumbnail"); - assert_eq!( - png_dimensions(&thumb.bytes), - Some((200, 150)), - "single-frame clip should still produce a thumbnail" - ); - } - - #[test] - fn video_metadata_placeholder_and_dimensions_are_display_corrected() { - let Some(rotated) = ffmpeg_gen_rotated_mp4( - "90", - &[ - "-f", - "lavfi", - "-i", - "testsrc=size=640x480:rate=10:duration=1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ], - ) else { - eprintln!("skipping: ffmpeg CLI not available"); - return; - }; - let meta = metadata_value(&rotated, "rotated.mp4"); - assert_eq!( - meta["width"].as_u64(), - Some(480), - "stored width is the display (portrait) width" - ); - assert_eq!( - meta["height"].as_u64(), - Some(640), - "stored height is the display (portrait) height" - ); - assert!( - meta["placeholder"].as_str().is_some_and(|s| !s.is_empty()), - "placeholder should be generated from the display-corrected frame" - ); - - let anamorphic = ffmpeg_gen_mp4(&[ - "-f", - "lavfi", - "-i", - "testsrc=size=1280x720:rate=10:duration=1", - "-vf", - "setsar=2/1", - "-pix_fmt", - "yuv420p", - "-f", - "mp4", - ]) - .expect("anamorphic fixture"); - let meta = metadata_value(&anamorphic, "anamorphic.mp4"); - assert_eq!( - meta["height"].as_u64(), - Some(720), - "stored height preserved" - ); - let w = meta["width"].as_u64().expect("stored width"); - assert!( - (w as i64 - 2560).abs() <= 2, - "stored width expands to the display width, got {w}" - ); - } -} diff --git a/fluxer_media_proxy/src/media_process/animated_transform.rs b/fluxer_media_proxy/src/media_process/animated_transform.rs new file mode 100644 index 000000000..5be0bc570 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/animated_transform.rs @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::image_probe::animated_probe_from_image; +use super::loaded_image::validate_dimensions; +use super::native_runtime::{clear_vips_error, vips_runtime}; +use super::{ + MediaError, native_animated_frame_limit, native_buffer_to_media_bytes, native_status_error, +}; +use crate::{ + image_transform::ImageOptions, + media_limits::MediaLimits, + metrics::transform::TransformMetrics, + mime, + native::{self, NativeStatus, VipsImageHandle, buffer::NativeBuffer}, +}; +use libc::{c_int, c_void, size_t}; +use std::ptr; + +pub(super) struct DecodedHEIFPrimaryStill { + pub(super) image: VipsImageHandle<'static>, + pub(super) hdr_tone_mapped: bool, +} + +pub(super) struct DecodedAnimation { + pub(super) image: VipsImageHandle<'static>, + pub(super) loop_count: Option, +} + +pub(super) fn try_decode_heif_primary_still( + input: &[u8], + media_limits: &MediaLimits, + metrics: &TransformMetrics, +) -> Result, MediaError> { + let mut raw = ptr::null_mut(); + let mut facts = native::FluxerHEIFPrimaryStillDecodeFacts::empty(); + let rc = unsafe { + native::fluxer_heif_decode_primary_still( + input.as_ptr().cast(), + input.len(), + 0, + &mut raw, + media_limits.image_pixels(), + c_int::try_from(media_limits.image_dimension()) + .map_err(|_| MediaError::InvalidImageDimensions)?, + &mut facts, + ) + }; + let image = unsafe { VipsImageHandle::from_raw_owned(raw) }; + match NativeStatus::from_code(rc) { + NativeStatus::Ok if image.is_some() => {} + NativeStatus::Ok | NativeStatus::Unsupported | NativeStatus::CodecFailure => { + clear_vips_error(); + metrics.record_heif_primary_still_decode_failure(); + return Ok(None); + } + status => { + clear_vips_error(); + metrics.record_heif_primary_still_decode_failure(); + return Err(native_status_error(status, MediaError::MediaDecodeFailed) + .expect("non-success native status must map to an error")); + } + } + metrics.record_heif_primary_still_decode(); + if facts.hdr_gain_map_detected != 0 { + metrics.record_heif_hdr_gain_map_detected(); + } + let image = image.ok_or(MediaError::MediaDecodeFailed)?; + Ok(Some(DecodedHEIFPrimaryStill { + image, + hdr_tone_mapped: facts.hdr_tone_mapped != 0, + })) +} + +pub(super) fn try_decode_apng( + input: &[u8], + animated: bool, + media_limits: &MediaLimits, +) -> Result, MediaError> { + let runtime = vips_runtime()?; + let mut raw = ptr::null_mut(); + let mut num_plays = 0u32; + let rc = unsafe { + native::fluxer_ffmpeg_decode_apng( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + 0, + &mut raw, + if animated { + native_animated_frame_limit(media_limits) + } else { + 1 + }, + media_limits.animated_total_pixels(), + if animated { 1 } else { 0 }, + &mut num_plays, + ) + }; + let image = unsafe { VipsImageHandle::from_raw_owned(raw) }; + let status = NativeStatus::from_code(rc); + match status { + NativeStatus::Ok if image.is_some() => { + let image = image.ok_or(MediaError::MediaDecodeFailed)?; + Ok(Some(DecodedAnimation { + image, + loop_count: Some(num_plays), + })) + } + NativeStatus::Ok | NativeStatus::Unsupported | NativeStatus::CodecFailure => { + clear_vips_error(); + if animated { + return Err(MediaError::MediaDecodeFailed); + } + Ok(None) + } + status => { + clear_vips_error(); + Err(native_status_error(status, MediaError::MediaDecodeFailed) + .expect("non-success native status must map to an error")) + } + } +} + +pub(super) fn decode_heif_sequence( + input: &[u8], + media_limits: &MediaLimits, +) -> Result, MediaError> { + let runtime = vips_runtime()?; + let mut raw = ptr::null_mut(); + let mut frame_count = 0; + let rc = unsafe { + native::fluxer_ffmpeg_decode_heif_sequence( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + 0, + &mut raw, + native_animated_frame_limit(media_limits), + media_limits.animated_total_pixels(), + &mut frame_count, + ) + }; + let image = unsafe { VipsImageHandle::from_raw_owned(raw) }; + match NativeStatus::from_code(rc) { + NativeStatus::Ok if image.is_some() => {} + NativeStatus::Ok | NativeStatus::Unsupported | NativeStatus::CodecFailure => { + clear_vips_error(); + return Ok(None); + } + status => { + clear_vips_error(); + return Err(native_status_error(status, MediaError::MediaDecodeFailed) + .expect("non-success native status must map to an error")); + } + } + let image = image.ok_or(MediaError::MediaDecodeFailed)?; + let frame_count = u32::try_from(frame_count).map_err(|_| MediaError::MediaDecodeFailed)?; + if frame_count < 2 { + return Ok(None); + } + let probe = animated_probe_from_image(&image)?.ok_or(MediaError::MediaDecodeFailed)?; + let pages = u32::try_from(probe.pages).map_err(|_| MediaError::MediaDecodeFailed)?; + if pages != frame_count { + return Err(MediaError::MediaDecodeFailed); + } + validate_dimensions(media_limits, probe.width, probe.height)?; + Ok(Some(DecodedAnimation { + image, + loop_count: None, + })) +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct GifResizeDims { + pub(super) width: c_int, + pub(super) height: c_int, +} + +pub(super) fn gif_resize_dims( + sniffed: mime::SniffInfo, + options: &ImageOptions, +) -> Option { + if sniffed.width == 0 || sniffed.height == 0 { + return None; + } + let src_w = sniffed.width; + let src_h = sniffed.height; + let scale = match (options.width, options.height) { + (Some(0), _) | (_, Some(0)) => return None, + (Some(w), Some(h)) => (w as f64 / src_w as f64).min(h as f64 / src_h as f64), + (Some(w), None) => w as f64 / src_w as f64, + (None, Some(h)) => h as f64 / src_h as f64, + (None, None) => return None, + } + .min(1.0); + let target_w = ((src_w as f64) * scale).round().max(1.0) as u32; + let target_h = ((src_h as f64) * scale).round().max(1.0) as u32; + if target_w == src_w && target_h == src_h { + return None; + } + Some(GifResizeDims { + width: target_w as c_int, + height: target_h as c_int, + }) +} + +pub(super) fn resize_animated_gif_with_ffmpeg( + input: &[u8], + dims: GifResizeDims, + options: &ImageOptions, + media_limits: &MediaLimits, +) -> Result, MediaError> { + let runtime = vips_runtime()?; + let mut out_ptr: *mut c_void = ptr::null_mut(); + let mut out_size: size_t = 0; + let mut out_capacity: size_t = 0; + let rc = unsafe { + native::fluxer_ffmpeg_resize_gif( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + dims.width, + dims.height, + options.deadline_ms.unwrap_or(0), + native_animated_frame_limit(media_limits), + // The GIF fast path resizes without re-timing, so the encode budget must never bind + // tighter than the decode budget. Capping it here silently dropped the tail of any + // animation longer than FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS. + native_animated_frame_limit(media_limits), + c_int::MAX, + media_limits.animated_total_pixels(), + media_limits.max_media_proxy_bytes(), + &mut out_ptr, + &mut out_size, + &mut out_capacity, + ) + }; + let output = + unsafe { NativeBuffer::from_av_owned_with_resident_bytes(out_ptr, out_size, out_capacity) }; + match NativeStatus::from_code(rc) { + NativeStatus::Ok => {} + NativeStatus::DeadlineExceeded => return Err(MediaError::RequestTimeout), + NativeStatus::WorkLimitExceeded | NativeStatus::OutputLimitExceeded => { + return Err(MediaError::StreamTooLong); + } + NativeStatus::AllocationFailed => return Err(MediaError::AllocationFailed), + NativeStatus::Unsupported + | NativeStatus::InvalidDimensions + | NativeStatus::CodecFailure => { + clear_vips_error(); + return Err(MediaError::MediaTransformFailed); + } + } + let out = match output { + Some(out) => out, + None => { + clear_vips_error(); + return Err(MediaError::MediaTransformFailed); + } + }; + native_buffer_to_media_bytes(out)?.try_into_vec() +} diff --git a/fluxer_media_proxy/src/media_process/apng.rs b/fluxer_media_proxy/src/media_process/apng.rs new file mode 100644 index 000000000..545defa45 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/apng.rs @@ -0,0 +1,549 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::encoding::{ + AnimLimits, frame_delays_ms, resolve_animation_loop_count, truncated_frame_count, +}; +use super::loaded_image::validate_dimensions; +use super::native_runtime::clear_vips_error; +use super::streaming_encoder::write_vips_image_to_vec; +use super::{MediaError, PNG_SIGNATURE, ensure_deadline_pending, native_status_error}; +use crate::{ + media_limits::MediaLimits, + native::{self, NativeStatus, VipsImageHandle}, +}; +use libc::c_int; +use std::{ffi::CString, ptr, sync::OnceLock}; + +const APNG_FRAME_PNG_SUFFIX: &str = ".png[strip,compression=9,filter=all]"; +const MAX_U32_GCD_STEPS: usize = 64; +const PNG_CRC_DEADLINE_CHUNK_BYTES: usize = 64 * 1024; +static PNG_CRC_TABLE: OnceLock<[u32; 256]> = OnceLock::new(); + +#[derive(Clone, Copy)] +struct PNGChunk<'a> { + kind: [u8; 4], + data: &'a [u8], +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PNGFrameCursorState { + BeforeImageData, + ReadingImageData, + Complete, +} + +struct PNGFrameCursor<'a> { + bytes: &'a [u8], + offset: usize, + chunks_remaining: usize, + ihdr: [u8; 13], + state: PNGFrameCursorState, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct APNGDelayFraction { + numerator: u16, + denominator: u16, +} + +impl<'a> PNGFrameCursor<'a> { + fn new(bytes: &'a [u8], expected_width: u32, expected_height: u32) -> Result { + if bytes.get(..PNG_SIGNATURE.len()) != Some(PNG_SIGNATURE.as_slice()) { + return Err(MediaError::MediaEncodeFailed); + } + let chunk_bytes = bytes + .len() + .checked_sub(PNG_SIGNATURE.len()) + .ok_or(MediaError::MediaEncodeFailed)?; + let mut cursor = Self { + bytes, + offset: PNG_SIGNATURE.len(), + chunks_remaining: chunk_bytes / 12, + ihdr: [0; 13], + state: PNGFrameCursorState::BeforeImageData, + }; + let first = cursor.next_chunk()?.ok_or(MediaError::MediaEncodeFailed)?; + if first.kind != *b"IHDR" { + return Err(MediaError::MediaEncodeFailed); + } + let ihdr: [u8; 13] = first + .data + .try_into() + .map_err(|_| MediaError::MediaEncodeFailed)?; + let width = u32::from_be_bytes( + ihdr[..4] + .try_into() + .map_err(|_| MediaError::MediaEncodeFailed)?, + ); + let height = u32::from_be_bytes( + ihdr[4..8] + .try_into() + .map_err(|_| MediaError::MediaEncodeFailed)?, + ); + if width != expected_width || height != expected_height { + return Err(MediaError::MediaEncodeFailed); + } + cursor.ihdr = ihdr; + Ok(cursor) + } + + fn ihdr(&self) -> &[u8; 13] { + &self.ihdr + } + + fn next_idat(&mut self) -> Result, MediaError> { + if self.state == PNGFrameCursorState::Complete { + return Ok(None); + } + loop { + let chunk = self.next_chunk()?.ok_or(MediaError::MediaEncodeFailed)?; + match &chunk.kind { + b"IHDR" => return Err(MediaError::MediaEncodeFailed), + b"IDAT" => { + self.state = PNGFrameCursorState::ReadingImageData; + return Ok(Some(chunk.data)); + } + b"IEND" => { + if self.state != PNGFrameCursorState::ReadingImageData { + return Err(MediaError::MediaEncodeFailed); + } + self.state = PNGFrameCursorState::Complete; + return Ok(None); + } + _ => {} + } + } + } + + fn next_chunk(&mut self) -> Result>, MediaError> { + if self.state == PNGFrameCursorState::Complete { + return Ok(None); + } + self.chunks_remaining = self + .chunks_remaining + .checked_sub(1) + .ok_or(MediaError::MediaEncodeFailed)?; + let length_end = self + .offset + .checked_add(4) + .ok_or(MediaError::MediaEncodeFailed)?; + let kind_end = length_end + .checked_add(4) + .ok_or(MediaError::MediaEncodeFailed)?; + let length_bytes: [u8; 4] = self + .bytes + .get(self.offset..length_end) + .ok_or(MediaError::MediaEncodeFailed)? + .try_into() + .map_err(|_| MediaError::MediaEncodeFailed)?; + let kind: [u8; 4] = self + .bytes + .get(length_end..kind_end) + .ok_or(MediaError::MediaEncodeFailed)? + .try_into() + .map_err(|_| MediaError::MediaEncodeFailed)?; + let data_len = usize::try_from(u32::from_be_bytes(length_bytes)) + .map_err(|_| MediaError::MediaEncodeFailed)?; + let data_end = kind_end + .checked_add(data_len) + .ok_or(MediaError::MediaEncodeFailed)?; + let chunk_end = data_end + .checked_add(4) + .ok_or(MediaError::MediaEncodeFailed)?; + let data = self + .bytes + .get(kind_end..data_end) + .ok_or(MediaError::MediaEncodeFailed)?; + if self.bytes.get(data_end..chunk_end).is_none() { + return Err(MediaError::MediaEncodeFailed); + } + self.offset = chunk_end; + if kind == *b"IEND" && (!data.is_empty() || chunk_end != self.bytes.len()) { + return Err(MediaError::MediaEncodeFailed); + } + Ok(Some(PNGChunk { kind, data })) + } +} + +fn update_png_crc32( + table: &[u32; 256], + mut crc: u32, + bytes: &[u8], + deadline: Option, +) -> Result { + for chunk in bytes.chunks(PNG_CRC_DEADLINE_CHUNK_BYTES) { + ensure_deadline_pending(deadline)?; + for byte in chunk.iter().copied() { + crc = table[((crc ^ byte as u32) & 0xff) as usize] ^ (crc >> 8); + } + } + Ok(crc) +} + +fn png_crc32_parts( + kind: &[u8; 4], + prefix: &[u8], + payload: &[u8], + deadline: Option, +) -> Result { + let table = PNG_CRC_TABLE.get_or_init(|| { + let mut table = [0u32; 256]; + for (slot, value) in table.iter_mut().zip(0u32..=255) { + let mut crc = value; + for _ in 0..8 { + let mask = 0u32.wrapping_sub(crc & 1); + crc = (crc >> 1) ^ (0xedb8_8320u32 & mask); + } + *slot = crc; + } + table + }); + let crc = update_png_crc32(table, 0xffff_ffffu32, kind, deadline)?; + let crc = update_png_crc32(table, crc, prefix, deadline)?; + let crc = update_png_crc32(table, crc, payload, deadline)?; + ensure_deadline_pending(deadline)?; + Ok(crc ^ 0xffff_ffffu32) +} + +pub(super) fn png_crc32( + kind: &[u8; 4], + payload: &[u8], + deadline: Option, +) -> Result { + png_crc32_parts(kind, &[], payload, deadline) +} + +fn append_be_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_be_bytes()); +} + +fn reserve_output( + media_limits: &MediaLimits, + out: &mut Vec, + additional: usize, +) -> Result<(), MediaError> { + let output_len = out + .len() + .checked_add(additional) + .ok_or(MediaError::StreamTooLong)?; + if output_len > media_limits.max_media_proxy_bytes() { + return Err(MediaError::StreamTooLong); + } + if output_len <= out.capacity() { + return Ok(()); + } + let next_capacity = out + .capacity() + .saturating_mul(2) + .max(output_len) + .min(media_limits.max_media_proxy_bytes()); + let additional_capacity = next_capacity + .checked_sub(out.len()) + .ok_or(MediaError::StreamTooLong)?; + out.try_reserve_exact(additional_capacity) + .map_err(|_| MediaError::AllocationFailed) +} + +fn append_png_chunk( + media_limits: &MediaLimits, + out: &mut Vec, + kind: &[u8; 4], + payload: &[u8], + deadline: Option, +) -> Result<(), MediaError> { + let payload_len = u32::try_from(payload.len()).map_err(|_| MediaError::StreamTooLong)?; + let additional = 12usize + .checked_add(payload.len()) + .ok_or(MediaError::StreamTooLong)?; + reserve_output(media_limits, out, additional)?; + append_be_u32(out, payload_len); + out.extend_from_slice(kind); + out.extend_from_slice(payload); + append_be_u32(out, png_crc32(kind, payload, deadline)?); + Ok(()) +} + +fn append_fdat_chunk( + media_limits: &MediaLimits, + out: &mut Vec, + sequence_number: u32, + idat: &[u8], + deadline: Option, +) -> Result<(), MediaError> { + let sequence = sequence_number.to_be_bytes(); + let payload_len = idat + .len() + .checked_add(sequence.len()) + .ok_or(MediaError::StreamTooLong)?; + let payload_len_u32 = u32::try_from(payload_len).map_err(|_| MediaError::StreamTooLong)?; + let additional = 12usize + .checked_add(payload_len) + .ok_or(MediaError::StreamTooLong)?; + reserve_output(media_limits, out, additional)?; + append_be_u32(out, payload_len_u32); + out.extend_from_slice(b"fdAT"); + out.extend_from_slice(&sequence); + out.extend_from_slice(idat); + append_be_u32(out, png_crc32_parts(b"fdAT", &sequence, idat, deadline)?); + Ok(()) +} + +fn gcd_u32(mut a: u32, mut b: u32) -> u32 { + assert!(a > 0, "validated APNG delay must be nonzero"); + for _ in 0..MAX_U32_GCD_STEPS { + if b == 0 { + return a; + } + let rem = a % b; + a = b; + b = rem; + } + panic!("u32 Euclidean algorithm exceeded its iteration bound") +} + +fn apng_delay_fraction(delay_ms: u32) -> APNGDelayFraction { + let divisor = gcd_u32(delay_ms, 1_000); + let num = delay_ms / divisor; + let den = 1_000 / divisor; + if num <= u16::MAX as u32 && den <= u16::MAX as u32 { + return APNGDelayFraction { + numerator: num as u16, + denominator: den as u16, + }; + } + APNGDelayFraction { + numerator: delay_ms.div_ceil(1_000).min(u16::MAX as u32) as u16, + denominator: 1, + } +} + +fn encode_png_strip( + media_limits: &MediaLimits, + image: &VipsImageHandle<'_>, + frame_index: usize, + width: c_int, + page_height: c_int, +) -> Result, MediaError> { + let top = c_int::try_from(frame_index) + .ok() + .and_then(|index| index.checked_mul(page_height)) + .ok_or(MediaError::InvalidImageDimensions)?; + let mut strip_raw = ptr::null_mut(); + let rc = unsafe { + native::fluxer_vips_extract_area(image.as_ptr(), &mut strip_raw, 0, top, width, page_height) + }; + let strip = unsafe { image.adopt_derived_raw(strip_raw) }; + if let Some(error) = native_status_error( + NativeStatus::from_code(rc), + MediaError::MediaTransformFailed, + ) { + clear_vips_error(); + return Err(error); + } + let strip = strip.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaTransformFailed + })?; + let mut rgba_raw = ptr::null_mut(); + let rc = unsafe { native::fluxer_vips_image_to_rgba(strip.as_ptr(), &mut rgba_raw) }; + let rgba_image = unsafe { strip.adopt_derived_raw(rgba_raw) }; + if let Some(error) = native_status_error( + NativeStatus::from_code(rc), + MediaError::MediaTransformFailed, + ) { + clear_vips_error(); + return Err(error); + } + let rgba_image = rgba_image.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaTransformFailed + })?; + let suffix = CString::new(APNG_FRAME_PNG_SUFFIX).expect("static string has no NUL"); + write_vips_image_to_vec( + &rgba_image, + &suffix, + media_limits.max_media_proxy_bytes(), + None, + ) +} + +pub(super) fn rewrite_actl_frame_count( + encoded: &mut [u8], + actl_offset: usize, + frame_count: usize, + deadline: Option, +) -> Result<(), MediaError> { + let frame_count = u32::try_from(frame_count).map_err(|_| MediaError::StreamTooLong)?; + if frame_count == 0 { + return Err(MediaError::MediaEncodeFailed); + } + let kind_start = actl_offset + .checked_add(4) + .ok_or(MediaError::MediaEncodeFailed)?; + let payload_start = kind_start + .checked_add(4) + .ok_or(MediaError::MediaEncodeFailed)?; + let payload_end = payload_start + .checked_add(8) + .ok_or(MediaError::MediaEncodeFailed)?; + let crc_end = payload_end + .checked_add(4) + .ok_or(MediaError::MediaEncodeFailed)?; + if encoded.get(kind_start..payload_start) != Some(b"acTL".as_slice()) { + return Err(MediaError::MediaEncodeFailed); + } + let mut actl = [0u8; 8]; + actl.copy_from_slice( + encoded + .get(payload_start..payload_end) + .ok_or(MediaError::MediaEncodeFailed)?, + ); + actl[..4].copy_from_slice(&frame_count.to_be_bytes()); + let crc = png_crc32(b"acTL", &actl, deadline)?; + encoded + .get_mut(payload_start..payload_end) + .ok_or(MediaError::MediaEncodeFailed)? + .copy_from_slice(&actl); + encoded + .get_mut(payload_end..crc_end) + .ok_or(MediaError::MediaEncodeFailed)? + .copy_from_slice(&crc.to_be_bytes()); + Ok(()) +} + +pub(super) fn encode_animated_apng( + image: &VipsImageHandle<'_>, + page_height: c_int, + limits: AnimLimits, + media_limits: &MediaLimits, + carried_loop_count: Option, +) -> Result, MediaError> { + let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; + let total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; + validate_dimensions(media_limits, width, page_height)?; + if total_height <= 0 || total_height % page_height != 0 { + return Err(MediaError::InvalidImageDimensions); + } + let page_count = total_height / page_height; + let n_pages = usize::try_from(page_count).map_err(|_| MediaError::InvalidImageDimensions)?; + if n_pages == 0 || page_count as u32 > media_limits.animated_frames() { + return Err(MediaError::InvalidImageDimensions); + } + let frame_pixels = usize::try_from(width) + .ok() + .and_then(|width| { + usize::try_from(page_height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .ok_or(MediaError::InvalidImageDimensions)?; + let animation_pixels = frame_pixels + .checked_mul(n_pages) + .ok_or(MediaError::InvalidImageDimensions)?; + if animation_pixels > media_limits.animated_total_pixels() { + return Err(MediaError::InvalidImageDimensions); + } + + let delays = frame_delays_ms(image, page_count)?; + let deadline = limits.deadline_unix_ms; + let flush_deadline = limits.flush_deadline_unix_ms; + let frame_count = truncated_frame_count(&delays, limits.max_frames, limits.max_duration_ms); + if frame_count == 0 { + return Err(MediaError::MediaEncodeFailed); + } + let num_plays = resolve_animation_loop_count(image, carried_loop_count); + let expected_width = width as u32; + let expected_height = page_height as u32; + + let mut expected_ihdr: Option<[u8; 13]> = None; + let mut out: Option> = None; + let mut actl_offset: Option = None; + let mut sequence_number = 0u32; + let mut encoded_frames = 0usize; + for frame_index in 0..frame_count { + // Overrunning the encode deadline shortens the animation instead of failing the request; + // the flush headroom is reserved so the frames already written still reach the client. + if ensure_deadline_pending(deadline).is_err() { + break; + } + let frame_png = encode_png_strip(media_limits, image, frame_index, width, page_height)?; + let mut frame = PNGFrameCursor::new(&frame_png, expected_width, expected_height)?; + let frame_ihdr = *frame.ihdr(); + if let Some(existing_ihdr) = expected_ihdr.as_ref() { + if existing_ihdr != &frame_ihdr { + return Err(MediaError::MediaEncodeFailed); + } + } else { + expected_ihdr = Some(frame_ihdr); + let estimated_len = frame_png + .len() + .checked_add(256) + .ok_or(MediaError::StreamTooLong)? + .min(media_limits.max_media_proxy_bytes()); + let mut encoded = Vec::new(); + encoded + .try_reserve_exact(estimated_len) + .map_err(|_| MediaError::AllocationFailed)?; + reserve_output(media_limits, &mut encoded, PNG_SIGNATURE.len())?; + encoded.extend_from_slice(PNG_SIGNATURE); + append_png_chunk( + media_limits, + &mut encoded, + b"IHDR", + &frame_ihdr, + flush_deadline, + )?; + let mut actl = [0u8; 8]; + actl[..4].copy_from_slice( + &u32::try_from(frame_count) + .map_err(|_| MediaError::StreamTooLong)? + .to_be_bytes(), + ); + actl[4..].copy_from_slice(&num_plays.to_be_bytes()); + actl_offset = Some(encoded.len()); + append_png_chunk(media_limits, &mut encoded, b"acTL", &actl, flush_deadline)?; + out = Some(encoded); + } + + let delay_ms = delays + .get(frame_index) + .copied() + .ok_or(MediaError::MediaEncodeFailed)?; + let delay_fraction = apng_delay_fraction(delay_ms); + let mut fctl = [0u8; 26]; + fctl[..4].copy_from_slice(&sequence_number.to_be_bytes()); + sequence_number = sequence_number + .checked_add(1) + .ok_or(MediaError::StreamTooLong)?; + fctl[4..8].copy_from_slice(&expected_width.to_be_bytes()); + fctl[8..12].copy_from_slice(&expected_height.to_be_bytes()); + fctl[20..22].copy_from_slice(&delay_fraction.numerator.to_be_bytes()); + fctl[22..24].copy_from_slice(&delay_fraction.denominator.to_be_bytes()); + let encoded = out.as_mut().ok_or(MediaError::MediaEncodeFailed)?; + append_png_chunk(media_limits, encoded, b"fcTL", &fctl, flush_deadline)?; + + while let Some(idat) = frame.next_idat()? { + if frame_index == 0 { + append_png_chunk(media_limits, encoded, b"IDAT", idat, flush_deadline)?; + } else { + append_fdat_chunk(media_limits, encoded, sequence_number, idat, flush_deadline)?; + sequence_number = sequence_number + .checked_add(1) + .ok_or(MediaError::StreamTooLong)?; + } + } + encoded_frames += 1; + } + let mut encoded = out.ok_or(MediaError::MediaEncodeFailed)?; + if encoded_frames < frame_count { + rewrite_actl_frame_count( + &mut encoded, + actl_offset.ok_or(MediaError::MediaEncodeFailed)?, + encoded_frames, + flush_deadline, + )?; + } + append_png_chunk(media_limits, &mut encoded, b"IEND", &[], flush_deadline)?; + ensure_deadline_pending(flush_deadline)?; + Ok(encoded) +} diff --git a/fluxer_media_proxy/src/media_process/av_metadata.rs b/fluxer_media_proxy/src/media_process/av_metadata.rs new file mode 100644 index 000000000..272e287c7 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/av_metadata.rs @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::encoding::{VipsEncodeRequest, anim_limits_from_options, encode_vips_image}; +use super::loaded_image::{resize_loaded_image, validate_dimensions_u32, validate_vips_image}; +use super::native_runtime::{clear_vips_error, vips_runtime}; +use super::placeholder::encode_thumbhash_image; +use super::{MediaError, native_status_error}; +use crate::{ + constants, + image_quality::ResolvedImageQuality, + image_transform::ImageOptions, + media_limits::MediaLimits, + native::{self, NativeStatus, VipsImageHandle, buffer::NativeBuffer}, + output_format::OutputFormat, + thumbhash, +}; +use libc::c_int; + +pub const NSFW_PREVIEW_MAX_DIMENSION: u32 = 512; + +#[derive(Clone, Copy, Debug, Default)] +pub struct AVProbe { + pub has_video: bool, + pub has_audio: bool, + pub duration_seconds: Option, +} + +pub struct AVMetadataFrame { + buffer: NativeBuffer, + pub display_width: u32, + pub display_height: u32, + width: u32, + height: u32, +} + +impl AVMetadataFrame { + pub fn encode_thumbhash( + &self, + media_limits: &MediaLimits, + deadline_ms: Option, + ) -> Result, MediaError> { + if self.width <= thumbhash::MAX_DIM && self.height <= thumbhash::MAX_DIM { + return thumbhash::encode_rgba(self.buffer.as_slice(), self.width, self.height) + .map_err(|_| MediaError::InvalidImageDimensions); + } + let image = self.borrow_as_vips_image(media_limits)?; + let resized = resize_loaded_image( + media_limits, + image, + &ImageOptions { + width: Some(thumbhash::MAX_DIM), + height: Some(thumbhash::MAX_DIM), + deadline_ms, + ..Default::default() + }, + )?; + encode_thumbhash_image(&resized, deadline_ms) + } + + pub fn encode_nsfw_jpeg( + &self, + media_limits: &MediaLimits, + deadline_ms: Option, + ) -> Result, MediaError> { + let image = self.borrow_as_vips_image(media_limits)?; + encode_vips_image( + &image, + VipsEncodeRequest { + format: OutputFormat::JPEG, + quality: ResolvedImageQuality::Low, + page_height: None, + effort_override: None, + animation: anim_limits_from_options(&ImageOptions { + deadline_ms, + ..Default::default() + }), + animation_loop_count: None, + media_limits, + full_canvas_animation: false, + }, + ) + } + + fn borrow_as_vips_image( + &self, + media_limits: &MediaLimits, + ) -> Result, MediaError> { + let buffer = self.buffer.as_slice(); + let width = c_int::try_from(self.width).map_err(|_| MediaError::InvalidImageDimensions)?; + let height = + c_int::try_from(self.height).map_err(|_| MediaError::InvalidImageDimensions)?; + let raw = unsafe { + native::fluxer_vips_image_new_from_memory( + buffer.as_ptr().cast(), + buffer.len(), + width, + height, + 4, + native::fluxer_vips_format_uchar, + ) + }; + let image = unsafe { VipsImageHandle::from_raw_borrowing(raw, buffer) } + .ok_or(MediaError::MediaTransformFailed)?; + validate_vips_image(media_limits, &image)?; + Ok(image) + } +} + +pub struct AVMetadata { + pub probe: AVProbe, + pub frame: Option, +} + +pub fn probe_av_metadata( + input: &[u8], + preview_max_dimension: u32, + media_limits: &MediaLimits, + deadline_ms: Option, +) -> Result { + if input.is_empty() { + return Err(MediaError::MediaDecodeFailed); + } + if input.len() > media_limits.max_media_proxy_bytes() { + return Err(MediaError::StreamTooLong); + } + if preview_max_dimension > NSFW_PREVIEW_MAX_DIMENSION { + return Err(MediaError::InvalidImageDimensions); + } + let runtime = vips_runtime()?; + let max_dimension = + c_int::try_from(preview_max_dimension).map_err(|_| MediaError::InvalidImageDimensions)?; + let mut output = native::FluxerAVMetadataOut::empty(); + let rc = unsafe { + native::fluxer_av_metadata( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + deadline_ms.unwrap_or(0), + constants::MAX_VIDEO_PACKETS_FOR_THUMBNAIL as c_int, + max_dimension, + max_dimension, + &mut output, + ) + }; + let had_frame_allocation = !output.rgba.is_null(); + let frame_buffer = unsafe { NativeBuffer::from_vips_owned(output.rgba, output.rgba_size) }; + if let Some(error) = + native_status_error(NativeStatus::from_code(rc), MediaError::MediaDecodeFailed) + { + clear_vips_error(); + return Err(error); + } + if output.has_video == 0 && output.has_audio == 0 { + return Err(MediaError::MediaDecodeFailed); + } + if output.has_video != 0 { + if output.frame_count < 0 { + return Err(MediaError::MediaDecodeFailed); + } + } else if output.frame_count != 0 { + return Err(MediaError::MediaDecodeFailed); + } + let probe = AVProbe { + has_video: output.has_video != 0, + has_audio: output.has_audio != 0, + duration_seconds: positive_finite_duration(output.duration_seconds), + }; + let frame = av_metadata_frame( + &output, + frame_buffer, + had_frame_allocation, + preview_max_dimension, + media_limits, + )?; + Ok(AVMetadata { probe, frame }) +} + +fn av_metadata_frame( + output: &native::FluxerAVMetadataOut, + buffer: Option, + had_frame_allocation: bool, + max_dimension: u32, + media_limits: &MediaLimits, +) -> Result, MediaError> { + let Some(buffer) = buffer else { + let empty = !had_frame_allocation + && output.rgba_size == 0 + && output.rgba_width == 0 + && output.rgba_height == 0 + && output.display_width == 0 + && output.display_height == 0; + return if empty { + Ok(None) + } else { + Err(MediaError::MediaDecodeFailed) + }; + }; + if output.rgba_width <= 0 || output.rgba_height <= 0 { + return Err(MediaError::MediaDecodeFailed); + } + if output.display_width <= 0 || output.display_height <= 0 { + return Err(MediaError::MediaDecodeFailed); + } + let width = output.rgba_width as u32; + let height = output.rgba_height as u32; + validate_dimensions_u32( + media_limits, + output.display_width as u32, + output.display_height as u32, + )?; + if width > max_dimension || height > max_dimension { + return Err(MediaError::InvalidImageDimensions); + } + let expected_size = width as usize * height as usize * 4; + if buffer.len() != expected_size { + return Err(MediaError::MediaDecodeFailed); + } + Ok(Some(AVMetadataFrame { + buffer, + display_width: output.display_width as u32, + display_height: output.display_height as u32, + width, + height, + })) +} + +fn positive_finite_duration(duration_seconds: f64) -> Option { + (duration_seconds.is_finite() && duration_seconds > 0.0).then_some(duration_seconds) +} diff --git a/fluxer_media_proxy/src/media_process/encoding.rs b/fluxer_media_proxy/src/media_process/encoding.rs new file mode 100644 index 000000000..ab4a1af23 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/encoding.rs @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::apng::encode_animated_apng; +use super::native_runtime::{clear_vips_error, vips_runtime}; +use super::streaming_encoder::write_vips_image_to_vec; +use super::transform_plan::output_suffix; +use super::{ + MediaError, native_animated_frame_limit, native_buffer_to_media_bytes, native_status_error, +}; +use crate::{ + constants, + image_quality::ResolvedImageQuality, + image_transform::{EncodeEffort, ImageOptions}, + media_limits::MediaLimits, + native::{ + self, NativeStatus, VipsImageHandle, buffer::NativeBuffer, delay_array::VipsDelayArray, + }, + output_format::OutputFormat, +}; +use libc::{c_int, c_void, size_t}; +use std::ptr; + +const ANIMATED_ENCODE_FLUSH_HEADROOM_MS: i64 = 3_000; +const GIF_PLAY_COUNT_MAX: u32 = u16::MAX as u32 + 1; +const WEBP_PLAY_COUNT_MAX: u32 = u16::MAX as u32; + +#[derive(Clone, Copy, Debug)] +pub(super) struct AnimLimits { + pub(super) deadline_unix_ms: Option, + pub(super) flush_deadline_unix_ms: Option, + pub(super) max_frames: u32, + pub(super) max_duration_ms: u32, +} + +pub(super) fn anim_limits_from_options(options: &ImageOptions) -> AnimLimits { + let encode_limits = options.animation.encode_limits(); + AnimLimits { + deadline_unix_ms: options.deadline_ms.map(|deadline| { + if deadline > ANIMATED_ENCODE_FLUSH_HEADROOM_MS { + deadline - ANIMATED_ENCODE_FLUSH_HEADROOM_MS + } else { + deadline + } + }), + flush_deadline_unix_ms: options.deadline_ms, + max_frames: encode_limits.max_frames().get(), + max_duration_ms: encode_limits.max_duration_ms().get(), + } +} + +fn native_optional_dimension(dimension: Option) -> Result { + match dimension { + None => Ok(0), + Some(0) => Err(MediaError::InvalidImageDimensions), + Some(value) => c_int::try_from(value).map_err(|_| MediaError::InvalidImageDimensions), + } +} + +pub(super) fn frame_delays_ms( + image: &VipsImageHandle<'_>, + page_count: c_int, +) -> Result, MediaError> { + let delays = VipsDelayArray::read(image, page_count).map_err(|status| { + if status == NativeStatus::AllocationFailed { + return MediaError::AllocationFailed; + } + MediaError::MediaEncodeFailed + })?; + delays + .as_slice() + .iter() + .copied() + .map(|delay| { + u32::try_from(delay) + .ok() + .filter(|delay| *delay > 0) + .ok_or(MediaError::MediaEncodeFailed) + }) + .collect() +} + +pub(super) fn truncated_frame_count( + delays: &[u32], + max_frames: u32, + max_duration_ms: u32, +) -> usize { + let mut timestamp_ms = 0u32; + let mut frames = 0usize; + for delay_ms in delays.iter().copied() { + if max_frames > 0 && frames as u64 >= max_frames as u64 { + break; + } + if max_duration_ms > 0 && timestamp_ms >= max_duration_ms { + break; + } + frames += 1; + timestamp_ms = timestamp_ms.saturating_add(delay_ms); + } + frames +} + +fn apply_gif_animation_loop_count( + image: &VipsImageHandle<'_>, + loop_count: u32, +) -> Result<(), MediaError> { + let loop_count = loop_count.min(GIF_PLAY_COUNT_MAX) as c_int; + let status = NativeStatus::from_code(unsafe { + native::fluxer_vips_set_animation_loop_count(image.as_ptr(), loop_count) + }); + if let Some(error) = native_status_error(status, MediaError::MediaEncodeFailed) { + clear_vips_error(); + return Err(error); + } + Ok(()) +} + +pub(super) struct VipsEncodeRequest<'limits> { + pub(super) format: OutputFormat, + pub(super) quality: ResolvedImageQuality, + pub(super) page_height: Option, + pub(super) effort_override: Option, + pub(super) animation: AnimLimits, + pub(super) animation_loop_count: Option, + pub(super) media_limits: &'limits MediaLimits, + pub(super) full_canvas_animation: bool, +} + +pub(super) fn encode_vips_image( + image: &VipsImageHandle<'_>, + request: VipsEncodeRequest<'_>, +) -> Result, MediaError> { + let VipsEncodeRequest { + format, + quality, + page_height, + effort_override, + animation, + animation_loop_count, + media_limits, + full_canvas_animation, + } = request; + if format == OutputFormat::WebP && page_height.is_some_and(|ph| ph > 0) { + return encode_animated_webp(AnimatedWebPEncodeRequest { + media_limits, + image, + quality, + effort_override, + loop_count: resolve_animation_loop_count(image, animation_loop_count), + limits: animation, + full_canvas_frames: full_canvas_animation, + }); + } + if format == OutputFormat::APNG + && let Some(ph) = page_height + && ph > 0 + { + return encode_animated_apng(image, ph, animation, media_limits, animation_loop_count); + } + if format == OutputFormat::GIF + && let Some(frame_height) = page_height + && frame_height > 0 + { + apply_gif_animation_loop_count( + image, + resolve_animation_loop_count(image, animation_loop_count), + )?; + let suffix = output_suffix(format, quality, Some(frame_height), effort_override)?; + return write_vips_image_to_vec( + image, + &suffix, + constants::MAX_MEDIA_PROXY_BYTES, + animation.deadline_unix_ms, + ); + } + let suffix = output_suffix(format, quality, page_height, effort_override)?; + write_vips_image_to_vec( + image, + &suffix, + constants::MAX_MEDIA_PROXY_BYTES, + animation.deadline_unix_ms, + ) +} + +pub(super) fn resolve_animation_loop_count( + image: &VipsImageHandle<'_>, + carried: Option, +) -> u32 { + if let Some(loop_count) = carried { + return loop_count; + } + let field = c"loop"; + let has_field = + unsafe { native::fluxer_vips_image_has_field(image.as_ptr(), field.as_ptr()) } != 0; + if !has_field { + return 0; + } + let mut loop_count: c_int = 0; + let rc = unsafe { + native::fluxer_vips_image_get_int(image.as_ptr(), field.as_ptr(), &mut loop_count) + }; + if rc != 0 { + clear_vips_error(); + return 0; + } + u32::try_from(loop_count).unwrap_or(0) +} + +struct AnimatedWebPEncodeRequest<'request, 'source> { + media_limits: &'request MediaLimits, + image: &'request VipsImageHandle<'source>, + quality: ResolvedImageQuality, + effort_override: Option, + loop_count: u32, + limits: AnimLimits, + full_canvas_frames: bool, +} + +fn webp_animation_limits(limits: AnimLimits) -> native::WebpAnimLimits { + native::WebpAnimLimits { + max_frames: limits.max_frames.min(c_int::MAX as u32) as c_int, + max_duration_ms: limits.max_duration_ms.min(c_int::MAX as u32) as c_int, + deadline_monotonic_ms: limits.deadline_unix_ms.unwrap_or(0), + } +} + +fn encode_animated_webp(request: AnimatedWebPEncodeRequest<'_, '_>) -> Result, MediaError> { + let AnimatedWebPEncodeRequest { + media_limits, + image, + quality, + effort_override, + loop_count, + limits, + full_canvas_frames, + } = request; + let q = quality.encoder_quality(); + let effort = effort_override + .map(EncodeEffort::get) + .unwrap_or_else(|| quality.default_effort(true)); + let c_limits = webp_animation_limits(limits); + let loop_count = loop_count.min(WEBP_PLAY_COUNT_MAX) as u16; + let runtime = vips_runtime()?; + let mut out_ptr: *mut c_void = ptr::null_mut(); + let mut out_size: size_t = 0; + let rc = unsafe { + native::fluxer_webp_encode_animated( + image.as_ptr(), + q as c_int, + if quality.is_lossless() { 1 } else { 0 }, + effort as c_int, + 90, + 1, + runtime.config().webp_thread_level(), + c_int::from(loop_count), + if full_canvas_frames { 1 } else { 0 }, + &c_limits, + media_limits.max_media_proxy_bytes(), + &mut out_ptr, + &mut out_size, + ) + }; + let output = unsafe { NativeBuffer::from_webp_owned(out_ptr, out_size) }; + match NativeStatus::from_code(rc) { + NativeStatus::Ok => {} + NativeStatus::DeadlineExceeded => { + clear_vips_error(); + return Err(MediaError::RequestTimeout); + } + NativeStatus::WorkLimitExceeded | NativeStatus::OutputLimitExceeded => { + clear_vips_error(); + return Err(MediaError::StreamTooLong); + } + NativeStatus::AllocationFailed => { + clear_vips_error(); + return Err(MediaError::AllocationFailed); + } + NativeStatus::Unsupported + | NativeStatus::InvalidDimensions + | NativeStatus::CodecFailure => { + clear_vips_error(); + return Err(MediaError::MediaEncodeFailed); + } + } + let out = match output { + Some(out) => out, + None => { + clear_vips_error(); + return Err(MediaError::MediaEncodeFailed); + } + }; + native_buffer_to_media_bytes(out)?.try_into_vec() +} + +pub(super) fn try_transform_animated_webp_direct( + input: &[u8], + options: &ImageOptions, + quality: ResolvedImageQuality, + effort_override: Option, + media_limits: &MediaLimits, +) -> Result>, MediaError> { + let effort = effort_override + .map(EncodeEffort::get) + .unwrap_or_else(|| quality.default_effort(true)); + let c_limits = webp_animation_limits(anim_limits_from_options(options)); + let runtime = vips_runtime()?; + let mut out_ptr: *mut c_void = ptr::null_mut(); + let mut out_size: size_t = 0; + let rc = unsafe { + native::fluxer_webp_transform_animated( + input.as_ptr().cast(), + input.len(), + native_optional_dimension(options.width)?, + native_optional_dimension(options.height)?, + quality.encoder_quality() as c_int, + if quality.is_lossless() { 1 } else { 0 }, + effort as c_int, + 90, + 1, + runtime.config().webp_thread_level(), + native_animated_frame_limit(media_limits), + media_limits.animated_total_pixels(), + &c_limits, + media_limits.max_media_proxy_bytes(), + &mut out_ptr, + &mut out_size, + ) + }; + let output = unsafe { NativeBuffer::from_webp_owned(out_ptr, out_size) }; + match NativeStatus::from_code(rc) { + NativeStatus::Ok => {} + NativeStatus::Unsupported => { + clear_vips_error(); + return Ok(None); + } + NativeStatus::DeadlineExceeded => { + clear_vips_error(); + return Err(MediaError::RequestTimeout); + } + NativeStatus::WorkLimitExceeded | NativeStatus::OutputLimitExceeded => { + clear_vips_error(); + return Err(MediaError::StreamTooLong); + } + NativeStatus::InvalidDimensions => { + clear_vips_error(); + return Err(MediaError::InvalidImageDimensions); + } + NativeStatus::AllocationFailed => { + clear_vips_error(); + return Err(MediaError::AllocationFailed); + } + NativeStatus::CodecFailure => { + clear_vips_error(); + return Err(MediaError::MediaTransformFailed); + } + } + let out = match output { + Some(out) => out, + None => { + clear_vips_error(); + return Err(MediaError::MediaTransformFailed); + } + }; + native_buffer_to_media_bytes(out)?.try_into_vec().map(Some) +} diff --git a/fluxer_media_proxy/src/media_process/image_probe.rs b/fluxer_media_proxy/src/media_process/image_probe.rs new file mode 100644 index 000000000..790ced846 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/image_probe.rs @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::loaded_image::{page_height, validate_animation_geometry, validate_vips_image}; +use super::native_runtime::{clear_vips_error, ensure_vips_init, vips_runtime}; +use super::{AnimatedProbe, ImageDimensions, MediaError, native_status_error}; +use crate::{ + media_limits::MediaLimits, + mime, + native::{self, NativeStatus, VipsImageHandle}, +}; +use libc::c_int; +use std::{ffi::CString, ptr}; + +pub(crate) fn probe_animated( + media_limits: &MediaLimits, + input: &[u8], +) -> Result, MediaError> { + let mut width = 0; + let mut height = 0; + let mut pages = 0; + let rc = unsafe { + native::fluxer_vips_probe_animated( + input.as_ptr().cast(), + input.len(), + &mut width, + &mut height, + &mut pages, + ) + }; + match native::NativeStatus::from_code(rc) { + native::NativeStatus::Ok => {} + native::NativeStatus::Unsupported | native::NativeStatus::CodecFailure => { + clear_vips_error(); + return Ok(None); + } + status => { + clear_vips_error(); + return Err(native_status_error(status, MediaError::MediaDecodeFailed) + .expect("non-success native status must map to an error")); + } + } + if pages <= 0 || width <= 0 || height <= 0 { + return Err(MediaError::InvalidImageDimensions); + } + validate_animation_geometry(media_limits, width as u32, height as u32, pages as u32)?; + Ok(Some(AnimatedProbe { + width, + height, + pages, + })) +} + +pub(crate) fn probe_image_dims( + media_limits: &MediaLimits, + input: &[u8], +) -> Result { + ensure_vips_init()?; + if let Some(probe) = probe_animated(media_limits, input)? { + return Ok(ImageDimensions { + width: probe.width as u32, + height: probe.height as u32, + pages: probe.pages as u32, + }); + } + if mime::sniff(input).mime == "image/bmp" + && let Some(image) = try_decode_bmp(input, media_limits)? + { + return probe_loaded_image_dims(media_limits, &image); + } + let image = load_image(input, "access=sequential")?; + probe_loaded_image_dims(media_limits, &image) +} + +fn probe_loaded_image_dims( + media_limits: &MediaLimits, + image: &native::VipsImageHandle<'_>, +) -> Result { + validate_vips_image(media_limits, image)?; + let mut width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) as u32 }; + let mut height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) as u32 }; + if unsafe { native::fluxer_vips_image_get_orientation_swap(image.as_ptr()) } != 0 { + std::mem::swap(&mut width, &mut height); + } + let pages = match page_count(image)? { + Some(pages) => u32::try_from(pages).map_err(|_| MediaError::InvalidImageDimensions)?, + None => 1, + }; + Ok(ImageDimensions { + width, + height, + pages, + }) +} + +pub(crate) fn load_image<'source>( + input: &'source [u8], + options: &str, +) -> Result, MediaError> { + let options = CString::new(options).map_err(|_| MediaError::MediaDecodeFailed)?; + let raw = unsafe { + native::fluxer_vips_image_new_from_buffer( + input.as_ptr().cast(), + input.len(), + options.as_ptr(), + ) + }; + unsafe { native::VipsImageHandle::from_raw_borrowing(raw, input) }.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaDecodeFailed + }) +} + +fn page_count(image: &native::VipsImageHandle<'_>) -> Result, MediaError> { + let Some(page_height) = page_height(image)? else { + return Ok(None); + }; + let height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; + Ok(Some(height / page_height)) +} + +pub(crate) fn animated_probe_from_image( + image: &native::VipsImageHandle<'_>, +) -> Result, MediaError> { + let Some(page_height) = page_height(image)? else { + return Ok(None); + }; + let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; + let pages = page_count(image)?.ok_or(MediaError::InvalidImageDimensions)?; + if width <= 0 || pages <= 0 { + return Err(MediaError::InvalidImageDimensions); + } + Ok(Some(AnimatedProbe { + width, + height: page_height, + pages, + })) +} + +pub(super) fn try_decode_bmp( + input: &[u8], + media_limits: &MediaLimits, +) -> Result>, MediaError> { + let runtime = vips_runtime()?; + let mut raw = ptr::null_mut(); + let rc = unsafe { + native::fluxer_ffmpeg_decode_bmp( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + 0, + &mut raw, + media_limits.image_pixels(), + ) + }; + let image = unsafe { VipsImageHandle::from_raw_owned(raw) }; + match NativeStatus::from_code(rc) { + NativeStatus::Ok if image.is_some() => {} + NativeStatus::Ok | NativeStatus::Unsupported | NativeStatus::CodecFailure => { + clear_vips_error(); + return Ok(None); + } + status => { + clear_vips_error(); + return Err(native_status_error(status, MediaError::MediaDecodeFailed) + .expect("non-success native status must map to an error")); + } + } + Ok(Some(image.ok_or(MediaError::MediaDecodeFailed)?)) +} diff --git a/fluxer_media_proxy/src/media_process/loaded_image.rs b/fluxer_media_proxy/src/media_process/loaded_image.rs new file mode 100644 index 000000000..e316625c7 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/loaded_image.rs @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::native_runtime::clear_vips_error; +use super::{MediaError, native_status_error}; +use crate::{ + image_transform::{ImageOptions, ResizeMode}, + media_limits::MediaLimits, + native, +}; +use libc::c_int; +use std::ptr; + +const VIPS_IMAGE_BANDS_MAX: c_int = 4; +const RESIZE_SCALE_IDENTITY_THRESHOLD: f64 = 0.999; + +pub(crate) fn page_height( + image: &native::VipsImageHandle<'_>, +) -> Result, MediaError> { + let field = c"page-height"; + let has_page_height = + unsafe { native::fluxer_vips_image_has_field(image.as_ptr(), field.as_ptr()) } != 0; + if !has_page_height { + return Ok(None); + } + let mut page_height = 0; + let rc = unsafe { + native::fluxer_vips_image_get_int(image.as_ptr(), field.as_ptr(), &mut page_height) + }; + if rc != 0 { + clear_vips_error(); + return Err(MediaError::InvalidImageDimensions); + } + if page_height <= 0 { + return Err(MediaError::InvalidImageDimensions); + } + let total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; + if total_height <= 0 || total_height % page_height != 0 { + return Err(MediaError::InvalidImageDimensions); + } + Ok(Some(page_height)) +} + +pub(crate) fn validate_dimensions_u32( + media_limits: &MediaLimits, + width: u32, + height: u32, +) -> Result<(), MediaError> { + let max_dimension = media_limits.image_dimension(); + if width == 0 || height == 0 || width > max_dimension || height > max_dimension { + return Err(MediaError::InvalidImageDimensions); + } + if width as usize * height as usize > media_limits.image_pixels() { + return Err(MediaError::InvalidImageDimensions); + } + Ok(()) +} + +pub(crate) fn validate_dimensions( + media_limits: &MediaLimits, + width: c_int, + height: c_int, +) -> Result<(), MediaError> { + if width <= 0 || height <= 0 { + return Err(MediaError::InvalidImageDimensions); + } + validate_dimensions_u32(media_limits, width as u32, height as u32) +} + +pub(super) fn validate_animation_geometry( + media_limits: &MediaLimits, + width: u32, + height: u32, + frames: u32, +) -> Result<(), MediaError> { + validate_dimensions_u32(media_limits, width, height)?; + if frames == 0 || frames > media_limits.animated_frames() { + return Err(MediaError::InvalidImageDimensions); + } + let frame_pixels = width as usize * height as usize; + if frames > 1 && frame_pixels > media_limits.animated_total_pixels() / frames as usize { + return Err(MediaError::InvalidImageDimensions); + } + Ok(()) +} + +pub(crate) fn validate_vips_image( + media_limits: &MediaLimits, + image: &native::VipsImageHandle<'_>, +) -> Result<(), MediaError> { + let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; + let height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; + match page_height(image)? { + Some(page_height) => { + validate_dimensions(media_limits, width, page_height)?; + let frames = height / page_height; + let width = usize::try_from(width).map_err(|_| MediaError::InvalidImageDimensions)?; + let page_height = + usize::try_from(page_height).map_err(|_| MediaError::InvalidImageDimensions)?; + let frames = u32::try_from(frames).map_err(|_| MediaError::InvalidImageDimensions)?; + if frames > media_limits.animated_frames() { + return Err(MediaError::InvalidImageDimensions); + } + let total_pixels = width + .checked_mul(page_height) + .and_then(|pixels| pixels.checked_mul(frames as usize)) + .ok_or(MediaError::InvalidImageDimensions)?; + if total_pixels > media_limits.animated_total_pixels() { + return Err(MediaError::InvalidImageDimensions); + } + } + None => validate_dimensions(media_limits, width, height)?, + } + let bands = unsafe { native::fluxer_vips_image_get_bands(image.as_ptr()) }; + if bands <= 0 || bands > VIPS_IMAGE_BANDS_MAX { + return Err(MediaError::InvalidImageDimensions); + } + let format = unsafe { native::fluxer_vips_image_get_format(image.as_ptr()) }; + let supported_format = unsafe { + format == native::fluxer_vips_format_uchar + || format == native::fluxer_vips_format_ushort + || format == native::fluxer_vips_format_float + }; + if !supported_format { + return Err(MediaError::MediaDecodeFailed); + } + Ok(()) +} + +pub(crate) fn normalize_vips_image_to_uchar<'source>( + media_limits: &MediaLimits, + image: native::VipsImageHandle<'source>, +) -> Result, MediaError> { + validate_vips_image(media_limits, &image)?; + let format = unsafe { native::fluxer_vips_image_get_format(image.as_ptr()) }; + if format == unsafe { native::fluxer_vips_format_uchar } { + return Ok(image); + } + let mut normalized_raw = ptr::null_mut(); + let rc = unsafe { native::fluxer_vips_image_to_rgba(image.as_ptr(), &mut normalized_raw) }; + let normalized = unsafe { image.adopt_derived_raw(normalized_raw) }; + if let Some(error) = native_status_error( + native::NativeStatus::from_code(rc), + MediaError::MediaTransformFailed, + ) { + clear_vips_error(); + return Err(error); + } + drop(image); + let normalized = normalized.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaTransformFailed + })?; + validate_vips_image(media_limits, &normalized)?; + let normalized_format = unsafe { native::fluxer_vips_image_get_format(normalized.as_ptr()) }; + if normalized_format != unsafe { native::fluxer_vips_format_uchar } { + return Err(MediaError::MediaTransformFailed); + } + Ok(normalized) +} + +pub(crate) fn resize_loaded_image<'source>( + media_limits: &MediaLimits, + image: native::VipsImageHandle<'source>, + options: &ImageOptions, +) -> Result, MediaError> { + let (Some(target_width), Some(target_height)) = (options.width, options.height) else { + return resize_loaded_image_fit_inside(media_limits, image, options); + }; + if options.animation.is_animated() || options.resize_mode == ResizeMode::Fit { + return resize_loaded_image_fit_inside(media_limits, image, options); + } + + let source_width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; + let source_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; + validate_dimensions(media_limits, source_width, source_height)?; + + let scale_width = target_width as f64 / source_width as f64; + let scale_height = target_height as f64 / source_height as f64; + let scale = scale_width.max(scale_height).min(1.0); + let current = resize_loaded_image_by_scale(media_limits, image, scale)?; + let scaled_width = unsafe { native::fluxer_vips_image_get_width(current.as_ptr()) }; + let scaled_height = unsafe { native::fluxer_vips_image_get_height(current.as_ptr()) }; + validate_dimensions(media_limits, scaled_width, scaled_height)?; + + let final_width = scaled_width.min(target_width as c_int); + let final_height = scaled_height.min(target_height as c_int); + if final_width == scaled_width && final_height == scaled_height { + return Ok(current); + } + + let had_page_height = page_height(¤t)?.is_some(); + let left = (scaled_width - final_width) / 2; + let top = (scaled_height - final_height) / 2; + let cropped = extract_vips_image_area(¤t, left, top, final_width, final_height)?; + drop(current); + if had_page_height { + unsafe { native::fluxer_vips_set_page_height(cropped.as_ptr(), final_height) }; + } + validate_vips_image(media_limits, &cropped)?; + Ok(cropped) +} + +fn resize_loaded_image_fit_inside<'source>( + media_limits: &MediaLimits, + image: native::VipsImageHandle<'source>, + options: &ImageOptions, +) -> Result, MediaError> { + if options.width.is_none() && options.height.is_none() { + return Ok(image); + } + let source_width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; + let total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; + let source_height = if options.animation.is_animated() { + page_height(&image)?.unwrap_or(total_height) + } else { + total_height + }; + validate_dimensions(media_limits, source_width, source_height)?; + + let scale = match (options.width, options.height) { + (Some(width), Some(height)) => { + (width as f64 / source_width as f64).min(height as f64 / source_height as f64) + } + (Some(width), None) => width as f64 / source_width as f64, + (None, Some(height)) => height as f64 / source_height as f64, + (None, None) => unreachable!("dimension presence is checked before the scale calculation"), + } + .min(1.0); + resize_loaded_image_by_scale(media_limits, image, scale) +} + +pub(crate) fn resize_loaded_image_by_scale<'source>( + media_limits: &MediaLimits, + image: native::VipsImageHandle<'source>, + scale: f64, +) -> Result, MediaError> { + assert!(scale.is_finite()); + assert!(scale > 0.0); + assert!(scale <= 1.0); + if scale >= RESIZE_SCALE_IDENTITY_THRESHOLD { + return Ok(image); + } + let old_page_height = page_height(&image)?; + if let Some(old_page_height) = old_page_height { + let old_total_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; + let page_count = old_total_height / old_page_height; + if page_count > 1 { + validate_vips_image(media_limits, &image)?; + return resize_loaded_animation_pages_by_scale( + media_limits, + image, + scale, + old_page_height, + page_count, + ); + } + } + let resized = resize_vips_image(&image, scale)?; + drop(image); + if old_page_height.is_some() { + let new_total_height = unsafe { native::fluxer_vips_image_get_height(resized.as_ptr()) }; + if new_total_height <= 0 { + return Err(MediaError::InvalidImageDimensions); + } + unsafe { native::fluxer_vips_set_page_height(resized.as_ptr(), new_total_height) }; + } + validate_vips_image(media_limits, &resized)?; + Ok(resized) +} + +fn resize_loaded_animation_pages_by_scale<'source>( + media_limits: &MediaLimits, + image: native::VipsImageHandle<'source>, + scale: f64, + old_page_height: c_int, + page_count: c_int, +) -> Result, MediaError> { + assert!(old_page_height > 0); + assert!(page_count > 1); + let source_width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; + validate_dimensions(media_limits, source_width, old_page_height)?; + let page_count_usize = + usize::try_from(page_count).map_err(|_| MediaError::InvalidImageDimensions)?; + let mut resized_pages = Vec::new(); + resized_pages + .try_reserve_exact(page_count_usize) + .map_err(|_| MediaError::AllocationFailed)?; + let mut resized_page_width = 0; + let mut resized_page_height = 0; + for page_index in 0..page_count { + let top = page_index + .checked_mul(old_page_height) + .ok_or(MediaError::InvalidImageDimensions)?; + let page = extract_vips_image_area(&image, 0, top, source_width, old_page_height)?; + let resized_page = resize_vips_image(&page, scale)?; + drop(page); + let page_width = unsafe { native::fluxer_vips_image_get_width(resized_page.as_ptr()) }; + let page_height = unsafe { native::fluxer_vips_image_get_height(resized_page.as_ptr()) }; + validate_dimensions(media_limits, page_width, page_height)?; + if page_index == 0 { + resized_page_width = page_width; + resized_page_height = page_height; + } else if page_width != resized_page_width || page_height != resized_page_height { + return Err(MediaError::InvalidImageDimensions); + } + resized_pages.push(resized_page); + } + assert_eq!(resized_pages.len(), page_count_usize); + let output_width = + u32::try_from(resized_page_width).map_err(|_| MediaError::InvalidImageDimensions)?; + let output_page_height = + u32::try_from(resized_page_height).map_err(|_| MediaError::InvalidImageDimensions)?; + let output_page_count = + u32::try_from(page_count).map_err(|_| MediaError::InvalidImageDimensions)?; + validate_animation_geometry( + media_limits, + output_width, + output_page_height, + output_page_count, + )?; + let expected_total_height = resized_page_height + .checked_mul(page_count) + .ok_or(MediaError::InvalidImageDimensions)?; + let mut page_pointers = Vec::new(); + page_pointers + .try_reserve_exact(page_count_usize) + .map_err(|_| MediaError::AllocationFailed)?; + page_pointers.extend(resized_pages.iter().map(native::VipsImageHandle::as_ptr)); + assert_eq!(page_pointers.len(), page_count_usize); + let max_pages = c_int::try_from(media_limits.animated_frames()) + .expect("validated animated frame limit must fit c_int"); + let mut joined_raw = ptr::null_mut(); + let rc = unsafe { + native::fluxer_vips_join_animation_pages( + image.as_ptr(), + page_pointers.as_mut_ptr(), + page_count, + max_pages, + media_limits.animated_total_pixels(), + &mut joined_raw, + ) + }; + let joined = unsafe { image.adopt_derived_raw(joined_raw) }; + if let Some(error) = native_status_error( + native::NativeStatus::from_code(rc), + MediaError::MediaTransformFailed, + ) { + clear_vips_error(); + return Err(error); + } + drop(resized_pages); + drop(image); + let joined = joined.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaTransformFailed + })?; + let joined_width = unsafe { native::fluxer_vips_image_get_width(joined.as_ptr()) }; + let joined_total_height = unsafe { native::fluxer_vips_image_get_height(joined.as_ptr()) }; + if joined_width != resized_page_width || joined_total_height != expected_total_height { + return Err(MediaError::InvalidImageDimensions); + } + if page_height(&joined)? != Some(resized_page_height) { + return Err(MediaError::InvalidImageDimensions); + } + validate_vips_image(media_limits, &joined)?; + Ok(joined) +} + +fn extract_vips_image_area<'source>( + image: &native::VipsImageHandle<'source>, + left: c_int, + top: c_int, + width: c_int, + height: c_int, +) -> Result, MediaError> { + let mut extracted_raw = ptr::null_mut(); + let rc = unsafe { + native::fluxer_vips_extract_area( + image.as_ptr(), + &mut extracted_raw, + left, + top, + width, + height, + ) + }; + let extracted = unsafe { image.adopt_derived_raw(extracted_raw) }; + if let Some(error) = native_status_error( + native::NativeStatus::from_code(rc), + MediaError::MediaTransformFailed, + ) { + clear_vips_error(); + return Err(error); + } + extracted.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaTransformFailed + }) +} + +fn resize_vips_image<'source>( + image: &native::VipsImageHandle<'source>, + scale: f64, +) -> Result, MediaError> { + assert!(scale.is_finite()); + assert!(scale > 0.0); + assert!(scale < RESIZE_SCALE_IDENTITY_THRESHOLD); + let mut resized_raw = ptr::null_mut(); + let rc = unsafe { native::fluxer_vips_resize(image.as_ptr(), &mut resized_raw, scale) }; + let resized = unsafe { image.adopt_derived_raw(resized_raw) }; + if let Some(error) = native_status_error( + native::NativeStatus::from_code(rc), + MediaError::MediaTransformFailed, + ) { + clear_vips_error(); + return Err(error); + } + resized.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaTransformFailed + }) +} diff --git a/fluxer_media_proxy/src/media_process/metadata.rs b/fluxer_media_proxy/src/media_process/metadata.rs new file mode 100644 index 000000000..fb16b860d --- /dev/null +++ b/fluxer_media_proxy/src/media_process/metadata.rs @@ -0,0 +1,302 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::MediaError; +use super::av_metadata::{AVMetadata, AVProbe, NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata}; +use super::image_probe::probe_image_dims; +use super::loaded_image::validate_dimensions_u32; +use super::nsfw_processing::{ + NSFWScanPreparation, NSFWScanSource, classify_nsfw_buffers, nsfw_scan_buffers, +}; +use super::placeholder::{encode_thumbhash, encoded_placeholder, optional_thumbhash}; +use crate::{ + constants, + media_limits::MediaLimits, + metrics::transform::TransformMetrics, + mime, + nsfw::{NSFWClassification, NSFWClient, NSFWPolicy}, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Debug)] +pub struct MetadataOptions { + pub placeholder: bool, + pub nsfw: NSFWPolicy, +} + +impl Default for MetadataOptions { + fn default() -> Self { + Self { + placeholder: true, + nsfw: NSFWPolicy::Disabled, + } + } +} + +#[derive(Serialize)] +struct MetadataResponse { + content_type: String, + size: usize, + content_hash: String, + format: String, + width: Option, + height: Option, + animated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + duration: Option, + #[serde(skip_serializing_if = "Option::is_none")] + placeholder: Option, + nsfw: bool, + nsfw_probability: f32, +} + +struct MetadataBlocking { + response: MetadataResponse, + nsfw_scan: Option, +} + +impl MetadataBlocking { + fn take_nsfw_scan(&mut self) -> Option { + self.nsfw_scan.take() + } +} + +struct MetadataBlockingRequest<'a> { + media_limits: &'a MediaLimits, + metrics: &'a TransformMetrics, + input: &'a [u8], + options: &'a MetadataOptions, +} + +fn metadata_content_type(sniffed_mime: &'static str, av_probe: Option<&AVProbe>) -> &'static str { + if sniffed_mime == "video/mp4" + && av_probe.is_some_and(|probe| probe.has_audio && !probe.has_video) + { + return "audio/mp4"; + } + sniffed_mime +} + +fn metadata_format(sniffed_mime: &str) -> String { + match sniffed_mime { + "audio/mpeg" => "mp3".to_owned(), + "video/quicktime" => "mov".to_owned(), + "video/x-matroska" => "mkv".to_owned(), + "image/svg+xml" => "svg".to_owned(), + "audio/mp4" => "m4a".to_owned(), + "image/avif-sequence" => "avif".to_owned(), + "image/apng" => "apng".to_owned(), + other => other + .rsplit_once('/') + .map(|(_, suffix)| suffix.strip_prefix("x-").unwrap_or(suffix)) + .unwrap_or("bin") + .to_owned(), + } +} + +fn validate_metadata_image_dimensions( + media_limits: &MediaLimits, + width: u32, + height: u32, + frames: u32, +) -> Result<(), MediaError> { + validate_dimensions_u32(media_limits, width, height)?; + let frame_count = frames.max(1); + if frame_count > media_limits.animated_frames() { + return Err(MediaError::InvalidImageDimensions); + } + if frame_count > 1 { + let w = width as usize; + let h = height as usize; + let per_frame = w.saturating_mul(h); + let max_total = media_limits.animated_total_pixels(); + let fc = frame_count as usize; + if per_frame > max_total / fc.max(1) { + return Err(MediaError::InvalidImageDimensions); + } + } + Ok(()) +} + +fn probe_av_metadata_without_requiring_a_frame( + input: &[u8], + media_limits: &MediaLimits, +) -> Result { + match probe_av_metadata(input, NSFW_PREVIEW_MAX_DIMENSION, media_limits, None) { + Ok(metadata) => Ok(metadata), + Err(error) => probe_av_metadata(input, 0, media_limits, None).map_err(|_| error), + } +} + +fn metadata_blocking(request: MetadataBlockingRequest<'_>) -> Result { + let MetadataBlockingRequest { + media_limits, + metrics, + input, + options, + } = request; + if input.len() > constants::MAX_MEDIA_PROXY_BYTES { + return Err(MediaError::StreamTooLong); + } + let sniffed = mime::sniff(input); + if !mime::is_supported_media_mime(sniffed.mime) { + return Err(MediaError::UnsupportedMediaType); + } + let initial_category = mime::category(sniffed.mime).ok_or(MediaError::UnsupportedMediaType)?; + let is_image = initial_category == mime::Category::Image; + let dims = if is_image { + Some(probe_image_dims(media_limits, input)?) + } else { + None + }; + let frames_count = dims.map(|d| d.pages).unwrap_or(sniffed.frames); + let mut width = dims.map(|d| d.width).unwrap_or(sniffed.width); + let mut height = dims.map(|d| d.height).unwrap_or(sniffed.height); + if is_image && (width > 0 || height > 0 || frames_count > 1) { + validate_metadata_image_dimensions(media_limits, width, height, frames_count)?; + } + + let av_metadata = if matches!( + initial_category, + mime::Category::Video | mime::Category::Audio + ) { + Some(probe_av_metadata_without_requiring_a_frame( + input, + media_limits, + )?) + } else { + None + }; + let av_probe = av_metadata.as_ref().map(|metadata| metadata.probe); + let content_type = metadata_content_type(sniffed.mime, av_probe.as_ref()); + let category = mime::category(content_type).ok_or(MediaError::UnsupportedMediaType)?; + if let Some(probe) = av_probe.as_ref() + && category == mime::Category::Audio + && !probe.has_audio + { + return Err(MediaError::MediaDecodeFailed); + } + + let av_frame = av_metadata + .as_ref() + .filter(|_| category == mime::Category::Video) + .and_then(|metadata| metadata.frame.as_ref()); + if let Some(frame) = av_frame { + width = frame.display_width; + height = frame.display_height; + } + + let animated = sniffed.animated || dims.is_some_and(|d| d.pages > 1); + let placeholder = if options.placeholder { + let hash = if is_image { + optional_thumbhash( + encode_thumbhash(media_limits, input, None), + "image_metadata", + ) + } else { + match av_frame { + Some(frame) => { + optional_thumbhash(frame.encode_thumbhash(media_limits, None), "video_metadata") + } + None => None, + } + }; + encoded_placeholder(hash) + } else { + None + }; + + let scan_eligible = is_image + || (category == mime::Category::Video + && av_probe.as_ref().is_some_and(|probe| probe.has_video)); + let nsfw_scan = if let Some(threshold) = options.nsfw.scan_threshold(scan_eligible) { + nsfw_scan_buffers(NSFWScanSource { + media_limits, + metrics, + threshold, + content_type: sniffed.mime, + animated, + frame_count: frames_count.max(sniffed.frames), + input, + duration_seconds: av_probe.as_ref().and_then(|probe| probe.duration_seconds), + deadline_ms: None, + })? + } else { + None + }; + + let duration = av_probe.as_ref().and_then(|probe| { + probe + .duration_seconds + .filter(|duration| duration.is_finite() && *duration > 0.0) + .map(|duration| duration.ceil() as u32) + }); + let (response_width, response_height) = if width > 0 && height > 0 { + (Some(width), Some(height)) + } else { + (None, None) + }; + + let format = metadata_format(content_type); + let content_hash = hex::encode(Sha256::digest(input)); + Ok(MetadataBlocking { + response: MetadataResponse { + content_type: content_type.to_owned(), + size: input.len(), + content_hash, + format, + width: response_width, + height: response_height, + animated, + duration, + placeholder, + nsfw: false, + nsfw_probability: 0.0, + }, + nsfw_scan, + }) +} + +fn metadata_finalize(prepared: MetadataBlocking, verdict: NSFWClassification) -> MetadataResponse { + let mut response = prepared.response; + response.nsfw = verdict.is_nsfw; + response.nsfw_probability = verdict.probability; + response +} + +pub async fn metadata_json_with_options( + input: &[u8], + _filename: &str, + options: MetadataOptions, + media_limits: &MediaLimits, + nsfw_client: &NSFWClient, + metrics: &TransformMetrics, +) -> Result { + let mut prepared = metadata_blocking(MetadataBlockingRequest { + media_limits, + metrics, + input, + options: &options, + })?; + let scan = prepared.take_nsfw_scan(); + let verdict = classify_nsfw_buffers(nsfw_client, scan).await?; + let response = metadata_finalize(prepared, verdict); + serde_json::to_string(&response).map_err(|_| MediaError::MediaEncodeFailed) +} + +pub async fn metadata_json( + input: &[u8], + filename: &str, + media_limits: &MediaLimits, + metrics: &TransformMetrics, +) -> Result { + metadata_json_with_options( + input, + filename, + MetadataOptions::default(), + media_limits, + &NSFWClient::disabled(), + metrics, + ) + .await +} diff --git a/fluxer_media_proxy/src/media_process/mod.rs b/fluxer_media_proxy/src/media_process/mod.rs new file mode 100644 index 000000000..6f3d4679a --- /dev/null +++ b/fluxer_media_proxy/src/media_process/mod.rs @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::{BudgetedBytes, ByteBudget}, + constants, metrics, + native::{NativeStatus, buffer::NativeBuffer}, +}; + +pub use crate::{ + image_quality::{ImageQuality, ResolvedImageQuality}, + image_transform::{AnimationLimits, AnimationMode, EncodeEffort, ImageOptions, ResizeMode}, + media_limits::MediaLimits, +}; +mod animated_transform; +mod apng; +mod av_metadata; +mod encoding; +mod image_probe; +mod loaded_image; +mod metadata; +pub(crate) mod native_runtime; +mod nsfw_processing; +mod placeholder; +mod streaming_encoder; +mod transform; +mod transform_plan; +mod video_thumbnail; + +#[cfg(test)] +mod tests; + +pub use av_metadata::{ + AVMetadata, AVMetadataFrame, AVProbe, NSFW_PREVIEW_MAX_DIMENSION, probe_av_metadata, +}; +pub use metadata::{MetadataOptions, metadata_json, metadata_json_with_options}; +pub use nsfw_processing::encode_static_image_for_nsfw; +pub use transform::transform_image; +pub use video_thumbnail::{ + VideoThumbnailOptions, extract_video_thumbnail, extract_video_thumbnail_with_options, +}; + +use native_runtime::ensure_vips_init; + +use bytes::Bytes; +use libc::{c_int, c_longlong}; +use std::ops::Deref; +use thiserror::Error; + +const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + +#[derive(Clone, Debug)] +pub struct ProcessedMedia { + pub bytes: Vec, + pub content_type: &'static str, +} + +#[derive(Debug)] +pub struct MediaBytes { + storage: MediaBytesStorage, +} + +#[derive(Debug)] +enum MediaBytesStorage { + Native(NativeBuffer), + Rust(Vec), +} + +impl MediaBytes { + fn from_native(buffer: NativeBuffer) -> Self { + Self { + storage: MediaBytesStorage::Native(buffer), + } + } + + pub fn resident_bytes(&self) -> usize { + match &self.storage { + MediaBytesStorage::Native(buffer) => buffer.resident_bytes(), + MediaBytesStorage::Rust(buffer) => buffer.capacity(), + } + } + + pub fn into_bytes(self) -> Bytes { + match self.storage { + MediaBytesStorage::Native(buffer) => Bytes::from_owner(buffer), + MediaBytesStorage::Rust(buffer) => Bytes::from(buffer), + } + } + + pub fn try_into_budgeted(self, budget: &ByteBudget) -> Option { + let reservation = budget.try_reserve(self.resident_bytes())?; + Some(BudgetedBytes::budgeted(self.into_bytes(), reservation)) + } + + pub fn try_into_vec(self) -> Result, MediaError> { + match self.storage { + MediaBytesStorage::Native(buffer) => buffer + .try_to_vec() + .map_err(|_| MediaError::AllocationFailed), + MediaBytesStorage::Rust(buffer) => Ok(buffer), + } + } +} + +impl From> for MediaBytes { + fn from(buffer: Vec) -> Self { + Self { + storage: MediaBytesStorage::Rust(buffer), + } + } +} + +impl AsRef<[u8]> for MediaBytes { + fn as_ref(&self) -> &[u8] { + self + } +} + +impl Deref for MediaBytes { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + match &self.storage { + MediaBytesStorage::Native(buffer) => buffer.as_slice(), + MediaBytesStorage::Rust(buffer) => buffer.as_slice(), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ImageDimensions { + pub width: u32, + pub height: u32, + pub pages: u32, +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum MediaError { + #[error("media allocation failed")] + AllocationFailed, + #[error("native media init failed")] + VipsInitFailed, + #[error("media decode failed")] + MediaDecodeFailed, + #[error("media encode failed")] + MediaEncodeFailed, + #[error("media transform failed")] + MediaTransformFailed, + #[error("invalid image dimensions")] + InvalidImageDimensions, + #[error("unsupported media type")] + UnsupportedMediaType, + #[error("unsupported output format")] + UnsupportedOutputFormat, + #[error("stream too long")] + StreamTooLong, + #[error("request timed out")] + RequestTimeout, + #[error("nsfw scan unavailable")] + NsfwScanUnavailable, +} + +#[derive(Clone, Copy, Debug)] +struct AnimatedProbe { + width: c_int, + height: c_int, + pages: c_int, +} + +pub fn warmup_vips() -> Result<(), MediaError> { + ensure_vips_init() +} + +fn native_optional_deadline(deadline_monotonic_ms: Option) -> c_longlong { + match deadline_monotonic_ms { + Some(deadline) if deadline > 0 => deadline, + _ => 0, + } +} + +fn ensure_deadline_pending(deadline_monotonic_ms: Option) -> Result<(), MediaError> { + match deadline_monotonic_ms { + Some(deadline) if deadline > 0 && metrics::now_ms() >= deadline => { + Err(MediaError::RequestTimeout) + } + _ => Ok(()), + } +} + +fn native_animated_frame_limit(media_limits: &MediaLimits) -> c_int { + media_limits.animated_frames().min(c_int::MAX as u32) as c_int +} + +fn native_status_error(status: NativeStatus, operation_failure: MediaError) -> Option { + match status { + NativeStatus::Ok => None, + NativeStatus::Unsupported | NativeStatus::CodecFailure => Some(operation_failure), + NativeStatus::DeadlineExceeded => Some(MediaError::RequestTimeout), + NativeStatus::WorkLimitExceeded | NativeStatus::OutputLimitExceeded => { + Some(MediaError::StreamTooLong) + } + NativeStatus::InvalidDimensions => Some(MediaError::InvalidImageDimensions), + NativeStatus::AllocationFailed => Some(MediaError::AllocationFailed), + } +} + +fn native_buffer_to_media_bytes(buffer: NativeBuffer) -> Result { + if buffer.len() > constants::MAX_MEDIA_PROXY_BYTES { + return Err(MediaError::StreamTooLong); + } + Ok(MediaBytes::from_native(buffer)) +} diff --git a/fluxer_media_proxy/src/media_process/native_runtime.rs b/fluxer_media_proxy/src/media_process/native_runtime.rs new file mode 100644 index 000000000..dc7b97741 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/native_runtime.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{MediaError, native_status_error}; +use crate::native; +use libc::c_int; +use std::{ + ffi::{CStr, CString}, + sync::OnceLock, +}; + +const VIPS_ERROR_MESSAGE_MAX_CHARS: usize = 512; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct NativeRuntimeConfig { + vips_threads_per_pipeline: c_int, + ffmpeg_decoder_threads: c_int, + webp_thread_level: c_int, +} + +impl NativeRuntimeConfig { + pub const fn single_pipeline() -> Self { + Self { + vips_threads_per_pipeline: 1, + ffmpeg_decoder_threads: 1, + webp_thread_level: 0, + } + } + + pub(crate) const fn ffmpeg_decoder_threads(self) -> c_int { + self.ffmpeg_decoder_threads + } + + pub(crate) const fn webp_thread_level(self) -> c_int { + self.webp_thread_level + } +} + +pub struct VipsRuntime { + config: NativeRuntimeConfig, +} + +impl VipsRuntime { + fn initialize(config: NativeRuntimeConfig) -> Result { + let argv0 = CString::new("fluxer-media-proxy").expect("static string has no NUL"); + let rc = unsafe { native::fluxer_vips_init(argv0.as_ptr()) }; + if let Some(error) = native_status_error( + native::NativeStatus::from_code(rc), + MediaError::VipsInitFailed, + ) { + clear_vips_error(); + return Err(error); + } + unsafe { native::fluxer_vips_tune_for_server(config.vips_threads_per_pipeline) }; + Ok(Self { config }) + } + + pub(crate) const fn config(&self) -> NativeRuntimeConfig { + self.config + } +} + +static VIPS_RUNTIME: OnceLock> = OnceLock::new(); + +pub(crate) fn vips_runtime() -> Result<&'static VipsRuntime, MediaError> { + VIPS_RUNTIME + .get_or_init(|| VipsRuntime::initialize(NativeRuntimeConfig::single_pipeline())) + .as_ref() + .map_err(|error| *error) +} + +pub fn ensure_vips_init() -> Result<(), MediaError> { + vips_runtime().map(|_| ()) +} + +pub(crate) fn clear_vips_error() { + unsafe { native::fluxer_vips_error_clear() }; +} + +pub(crate) fn last_vips_error() -> String { + let buffer = unsafe { native::fluxer_vips_error_buffer() }; + if buffer.is_null() { + return String::new(); + } + unsafe { CStr::from_ptr(buffer) } + .to_string_lossy() + .trim() + .chars() + .take(VIPS_ERROR_MESSAGE_MAX_CHARS) + .collect() +} diff --git a/fluxer_media_proxy/src/media_process/nsfw_processing/mod.rs b/fluxer_media_proxy/src/media_process/nsfw_processing/mod.rs new file mode 100644 index 000000000..5fc5058c1 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/nsfw_processing/mod.rs @@ -0,0 +1,424 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::encoding::{VipsEncodeRequest, anim_limits_from_options, encode_vips_image}; +use super::image_probe::load_image; +use super::loaded_image::{page_height, resize_loaded_image, validate_vips_image}; +use super::native_runtime::{clear_vips_error, ensure_vips_init, vips_runtime}; +use super::transform::transform_image; +use super::{MediaError, native_status_error}; +use crate::{ + image_quality::{ImageQuality, ResolvedImageQuality}, + image_transform::ImageOptions, + media_limits::MediaLimits, + media_type::MediaType, + metrics::transform::TransformMetrics, + mime, + native::{ + self, NativeStatus, + nsfw_frame_output::{NSFWFrameCopyError, NSFWFrameOutput}, + }, + nsfw::{NSFW_MAX_FRAME_BYTES, NSFWClassification, NSFWClient, NSFWScanRequest, NSFWThreshold}, + output_format::OutputFormat, +}; +use libc::c_int; +use std::ptr; + +const MAX_NSFW_SAMPLE_FRAMES: usize = 3; +const NSFW_FRAME_SEED_BYTES: usize = 4 * 1024; +const NSFW_FRAME_MAX_DIMENSION: u32 = 512; + +pub(super) struct NSFWScanSource<'a> { + pub(super) media_limits: &'a MediaLimits, + pub(super) metrics: &'a TransformMetrics, + pub(super) threshold: NSFWThreshold, + pub(super) content_type: &'a str, + pub(super) animated: bool, + pub(super) frame_count: u32, + pub(super) input: &'a [u8], + pub(super) duration_seconds: Option, + pub(super) deadline_ms: Option, +} + +pub(super) struct NSFWScanPreparation { + request: NSFWScanRequest, + stage: &'static str, +} + +fn prepared_scan( + threshold: NSFWThreshold, + frames: Vec>, + stage: &'static str, +) -> Result, MediaError> { + let request = NSFWScanRequest::new(threshold, frames).map_err(|err| { + tracing::warn!("nsfw {stage} scan request rejected: {err}"); + MediaError::NsfwScanUnavailable + })?; + Ok(Some(NSFWScanPreparation { request, stage })) +} + +pub(super) fn nsfw_scan_buffers( + source: NSFWScanSource<'_>, +) -> Result, MediaError> { + let category = mime::category(source.content_type); + if category == Some(mime::Category::Video) { + let frames = nsfw_video_scan_buffers(&source).map_err(|err| { + tracing::warn!("nsfw video frame extract failed: {err:?}"); + MediaError::NsfwScanUnavailable + })?; + return prepared_scan(source.threshold, frames, "video"); + } + if category != Some(mime::Category::Image) { + return Ok(None); + } + if source.animated { + match extract_animated_image_frames_for_nsfw(&source) { + Ok(frames) => return prepared_scan(source.threshold, frames, "animated"), + Err(err) => tracing::warn!( + "nsfw animated frame extract failed: {err:?} - falling back to static scan" + ), + } + } + let jpeg = encode_static_image_for_nsfw( + source.input, + source.media_limits, + source.metrics, + source.deadline_ms, + ) + .map_err(|err| { + tracing::warn!("nsfw static JPEG encode failed: {err:?}"); + MediaError::NsfwScanUnavailable + })?; + let mut frames = Vec::new(); + frames + .try_reserve_exact(1) + .map_err(|_| MediaError::AllocationFailed)?; + frames.push(jpeg); + prepared_scan(source.threshold, frames, "static") +} + +pub(super) async fn classify_nsfw_buffers( + client: &NSFWClient, + prepared: Option, +) -> Result { + let Some(NSFWScanPreparation { request, stage }) = prepared else { + return Ok(NSFWClassification::not_scanned()); + }; + client.check_buffers(request).await.map_err(|err| { + tracing::warn!("nsfw {stage} classify failed: {err}"); + MediaError::NsfwScanUnavailable + }) +} + +fn nsfw_video_scan_buffers(source: &NSFWScanSource<'_>) -> Result>, MediaError> { + extract_video_frames_for_nsfw(VideoNSFWFramesRequest { + media_limits: source.media_limits, + input: source.input, + duration_seconds: source.duration_seconds, + deadline_ms: source.deadline_ms, + }) +} + +fn extract_animated_image_frames_for_nsfw( + source: &NSFWScanSource<'_>, +) -> Result>, MediaError> { + let media_type = MediaType::from_mime(source.content_type); + if let Some(media_type @ (MediaType::APNG | MediaType::GIF | MediaType::WebP)) = media_type { + return extract_native_animated_frames_for_nsfw( + source.media_limits, + source.input, + source.frame_count, + media_type, + source.deadline_ms, + ); + } + extract_vips_animation_frames_for_nsfw(source.media_limits, source.input, source.deadline_ms) +} + +fn animated_nsfw_frame_indices(frame_count: u32) -> Result, MediaError> { + if frame_count == 0 { + return Err(MediaError::InvalidImageDimensions); + } + let candidates = if frame_count == 1 { + [0, 0, 0] + } else if frame_count == 2 { + [0, 1, 1] + } else { + [0, frame_count / 2, frame_count - 1] + }; + let mut indices = Vec::new(); + indices + .try_reserve_exact(MAX_NSFW_SAMPLE_FRAMES) + .map_err(|_| MediaError::AllocationFailed)?; + for index in candidates { + if !indices.contains(&index) { + indices.push(index); + } + } + Ok(indices) +} + +fn native_source_frame_limit(media_limits: &MediaLimits) -> c_int { + media_limits.animated_frames().min(c_int::MAX as u32) as c_int +} + +fn extract_native_animated_frames_for_nsfw( + media_limits: &MediaLimits, + input: &[u8], + frame_count: u32, + media_type: MediaType, + deadline_ms: Option, +) -> Result>, MediaError> { + if input.is_empty() || input.len() > media_limits.max_media_proxy_bytes() { + return Err(MediaError::StreamTooLong); + } + let runtime = vips_runtime()?; + let source_indices = animated_nsfw_frame_indices(frame_count)?; + let mut indices: Vec = Vec::new(); + indices + .try_reserve_exact(source_indices.len()) + .map_err(|_| MediaError::AllocationFailed)?; + for index in source_indices { + indices.push(c_int::try_from(index).map_err(|_| MediaError::InvalidImageDimensions)?); + } + let mut output = NSFWFrameOutput::new(indices.len()); + let max_frames = native_source_frame_limit(media_limits); + let max_total_pixels = media_limits.animated_total_pixels(); + let deadline = deadline_ms.unwrap_or(0); + let status_code = unsafe { + match media_type { + MediaType::APNG => native::fluxer_ffmpeg_extract_apng_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + deadline, + indices.as_ptr(), + indices.len(), + max_frames, + max_total_pixels, + NSFW_MAX_FRAME_BYTES, + output.as_mut_ptr(), + ), + MediaType::GIF => native::fluxer_ffmpeg_extract_gif_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + deadline, + indices.as_ptr(), + indices.len(), + max_frames, + max_total_pixels, + NSFW_MAX_FRAME_BYTES, + output.as_mut_ptr(), + ), + MediaType::WebP => native::fluxer_webp_extract_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + runtime.config().webp_thread_level(), + deadline, + indices.as_ptr(), + indices.len(), + max_frames, + max_total_pixels, + NSFW_MAX_FRAME_BYTES, + output.as_mut_ptr(), + ), + _ => return Err(MediaError::MediaDecodeFailed), + } + }; + copy_native_nsfw_frames(&output, status_code) +} + +fn copy_native_nsfw_frames( + output: &NSFWFrameOutput, + status_code: c_int, +) -> Result>, MediaError> { + if let Some(error) = native_status_error( + NativeStatus::from_code(status_code), + MediaError::MediaDecodeFailed, + ) { + return Err(error); + } + output.copy_frames().map_err(|err| match err { + NSFWFrameCopyError::AllocationFailed => MediaError::AllocationFailed, + NSFWFrameCopyError::InvalidOutput => MediaError::MediaDecodeFailed, + }) +} + +fn extract_vips_animation_frames_for_nsfw( + media_limits: &MediaLimits, + input: &[u8], + deadline_ms: Option, +) -> Result>, MediaError> { + ensure_vips_init()?; + let loaded = load_image(input, "n=-1,access=sequential,fail=true")?; + validate_vips_image(media_limits, &loaded)?; + let total_height = unsafe { native::fluxer_vips_image_get_height(loaded.as_ptr()) }; + let width = unsafe { native::fluxer_vips_image_get_width(loaded.as_ptr()) }; + if total_height <= 0 || width <= 0 { + return Err(MediaError::InvalidImageDimensions); + } + let page_h = page_height(&loaded)?.ok_or(MediaError::InvalidImageDimensions)?; + let n_pages = + u32::try_from(total_height / page_h).map_err(|_| MediaError::InvalidImageDimensions)?; + let indices = animated_nsfw_frame_indices(n_pages)?; + let mut out: Vec> = Vec::new(); + out.try_reserve_exact(indices.len()) + .map_err(|_| MediaError::AllocationFailed)?; + for idx in indices { + let index = c_int::try_from(idx).map_err(|_| MediaError::InvalidImageDimensions)?; + let top = index + .checked_mul(page_h) + .ok_or(MediaError::InvalidImageDimensions)?; + let mut sub_raw = ptr::null_mut(); + let rc = unsafe { + native::fluxer_vips_extract_area(loaded.as_ptr(), &mut sub_raw, 0, top, width, page_h) + }; + let sub = unsafe { loaded.adopt_derived_raw(sub_raw) }; + if let Some(error) = native_status_error( + NativeStatus::from_code(rc), + MediaError::MediaTransformFailed, + ) { + clear_vips_error(); + return Err(error); + } + let sub = sub.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaTransformFailed + })?; + let resized = resize_loaded_image( + media_limits, + sub, + &ImageOptions { + width: Some(NSFW_FRAME_MAX_DIMENSION), + height: Some(NSFW_FRAME_MAX_DIMENSION), + deadline_ms, + ..Default::default() + }, + )?; + out.push(encode_vips_image( + &resized, + VipsEncodeRequest { + format: OutputFormat::JPEG, + quality: ResolvedImageQuality::Low, + page_height: None, + effort_override: None, + animation: anim_limits_from_options(&ImageOptions { + deadline_ms, + ..Default::default() + }), + animation_loop_count: None, + media_limits, + full_canvas_animation: false, + }, + )?); + } + if out.is_empty() { + return Err(MediaError::MediaDecodeFailed); + } + Ok(out) +} + +pub(super) fn compute_frame_sample_timestamps( + duration_seconds: Option, + prng: &mut rand_chacha::ChaCha8Rng, +) -> [f64; MAX_NSFW_SAMPLE_FRAMES] { + const DEFAULT_SAMPLING_WINDOW_SECONDS: f64 = 1.0; + + use rand::RngExt; + let valid: Option = duration_seconds.filter(|d| d.is_finite() && *d > 0.0); + let sampling_window_seconds = valid.unwrap_or(DEFAULT_SAMPLING_WINDOW_SECONDS); + + let clamp = |v: f64| -> f64 { + if !v.is_finite() { + return 0.0; + } + match valid { + Some(max_v) => v.clamp(0.0, max_v), + None => v.max(0.0), + } + }; + + let start_base = clamp((sampling_window_seconds * 0.1 + 0.5).clamp(1.0, 2.0)); + let middle_base = clamp(sampling_window_seconds / 2.0); + let end_candidate = if sampling_window_seconds > 2.0 { + sampling_window_seconds - 1.0 + } else { + sampling_window_seconds * 0.95 + }; + let min_end = start_base + 0.5; + let end_base = clamp(end_candidate.max(min_end)); + + let mut jitter = |v: f64| -> f64 { + let radius = (v.abs() * 0.1).max(0.05); + let r: f64 = prng.random(); + clamp(v + (r * 2.0 - 1.0) * radius) + }; + + [jitter(start_base), jitter(middle_base), jitter(end_base)] +} + +pub(super) fn nsfw_frame_seed(input: &[u8]) -> u64 { + let take = input.len().min(NSFW_FRAME_SEED_BYTES); + wyhash::wyhash(&input[..take], 0) +} + +pub(super) struct VideoNSFWFramesRequest<'a> { + pub(super) media_limits: &'a MediaLimits, + pub(super) input: &'a [u8], + pub(super) duration_seconds: Option, + pub(super) deadline_ms: Option, +} + +pub(super) fn extract_video_frames_for_nsfw( + request: VideoNSFWFramesRequest<'_>, +) -> Result>, MediaError> { + use rand::SeedableRng as _; + let VideoNSFWFramesRequest { + media_limits, + input, + duration_seconds, + deadline_ms, + } = request; + if input.is_empty() || input.len() > media_limits.max_media_proxy_bytes() { + return Err(MediaError::StreamTooLong); + } + let runtime = vips_runtime()?; + let seed = nsfw_frame_seed(input); + let mut prng = rand_chacha::ChaCha8Rng::seed_from_u64(seed); + let timestamps = compute_frame_sample_timestamps(duration_seconds, &mut prng); + + let mut output = NSFWFrameOutput::new(timestamps.len()); + let status_code = unsafe { + native::fluxer_av_extract_frames_for_nsfw( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + deadline_ms.unwrap_or(0), + timestamps.as_ptr(), + timestamps.len(), + NSFW_MAX_FRAME_BYTES, + output.as_mut_ptr(), + ) + }; + copy_native_nsfw_frames(&output, status_code) +} + +pub fn encode_static_image_for_nsfw( + input: &[u8], + media_limits: &MediaLimits, + metrics: &TransformMetrics, + deadline_ms: Option, +) -> Result, MediaError> { + let options = ImageOptions { + width: Some(NSFW_FRAME_MAX_DIMENSION), + height: Some(NSFW_FRAME_MAX_DIMENSION), + format: OutputFormat::JPEG, + quality: ImageQuality::Low, + deadline_ms, + ..Default::default() + }; + transform_image(input, &options, media_limits, metrics).map(|media| media.bytes) +} + +#[cfg(test)] +mod tests; diff --git a/fluxer_media_proxy/src/media_process/nsfw_processing/tests.rs b/fluxer_media_proxy/src/media_process/nsfw_processing/tests.rs new file mode 100644 index 000000000..aa1b5ef5e --- /dev/null +++ b/fluxer_media_proxy/src/media_process/nsfw_processing/tests.rs @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::*; +use crate::test_fixtures::{animated_gif_fixture, ffmpeg_gen_mp4}; +use base64::{Engine as _, engine::general_purpose}; + +const APNG_FIXTURE_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAAAAEABQAAaBqIGAAAAK1JREFUeJxjqGf4hxUxIKF/DH+BiOH/fxAiU4Mcgx0aYkBCtgy2QMRgZwdCg1yDCQMvEL1oMACix00mQCTFwANE/gx8QHSrUwuI0DXsbpkCVJ3IwBDEwADUsLprLkTD2ZoeoOp8OQZgaIEQxG5IiNqCXQARQw5pqJOooCFPj+Fcn96x9gxTBpS49NBk2DwlhBoaaOkHZUOGqYvDnrWJt6kRF6xADUAE1ABEuCIOAPEY5L3Pr8FWAAAAGmZjVEwAAAABAAAAAQAAAAEAAAAAAAAAAAABAAUAAMpQnTkAAAAQZmRBVAAAAAJ4nGOoZ/gHAAJ/AX511aUxAAAAAElFTkSuQmCC"; + +fn limits() -> MediaLimits { + MediaLimits::default_from_config() +} + +fn reconciled_frame_count(input: &[u8], media_limits: &MediaLimits) -> u32 { + super::super::image_probe::probe_image_dims(media_limits, input) + .expect("image dimensions") + .pages + .max(mime::sniff(input).frames) +} + +fn assert_bounded_jpeg(frame: &[u8], media_limits: &MediaLimits) -> (u32, u32) { + assert_eq!(&[0xFF, 0xD8], &frame[..2], "extracted frame is not a JPEG"); + assert!( + frame.len() <= NSFW_MAX_FRAME_BYTES, + "frame of {} bytes exceeds the classifier boundary", + frame.len() + ); + let dims = + super::super::image_probe::probe_image_dims(media_limits, frame).expect("frame dimensions"); + assert!( + dims.width.max(dims.height) <= NSFW_FRAME_MAX_DIMENSION, + "frame {}x{} exceeds the {NSFW_FRAME_MAX_DIMENSION} px cap", + dims.width, + dims.height + ); + (dims.width, dims.height) +} + +#[test] +fn video_nsfw_frames_are_downscaled_to_the_frame_cap() { + let Some(mp4) = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=1280x720:rate=10:duration=4", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]) else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + let media_limits = limits(); + let duration = super::super::probe_av_metadata(&mp4, 0, &media_limits, None) + .expect("probe generated mp4") + .probe + .duration_seconds; + let frames = extract_video_frames_for_nsfw(VideoNSFWFramesRequest { + media_limits: &media_limits, + input: &mp4, + duration_seconds: duration, + deadline_ms: None, + }) + .expect("extract nsfw frames"); + assert_eq!(MAX_NSFW_SAMPLE_FRAMES, frames.len()); + for frame in &frames { + let (width, height) = assert_bounded_jpeg(frame, &media_limits); + assert_eq!( + NSFW_FRAME_MAX_DIMENSION, + width.max(height), + "a 1280x720 source must be fitted to the cap, got {width}x{height}" + ); + } +} + +#[test] +fn the_video_scan_never_spends_a_slot_on_the_container_first_frame() { + let Some(mp4) = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=320x240:rate=10:duration=4", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]) else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + let media_limits = limits(); + let metrics = TransformMetrics::new(); + let metadata = super::super::probe_av_metadata( + &mp4, + super::super::NSFW_PREVIEW_MAX_DIMENSION, + &media_limits, + None, + ) + .expect("probe generated mp4"); + let preview = metadata + .frame + .as_ref() + .expect("the generated mp4 decodes a preview frame") + .encode_nsfw_jpeg(&media_limits, None) + .expect("encode the preview frame"); + let frames = nsfw_video_scan_buffers(&NSFWScanSource { + media_limits: &media_limits, + metrics: &metrics, + threshold: NSFWThreshold::new(0.85).expect("0.85 is a valid threshold"), + content_type: "video/mp4", + animated: false, + frame_count: 0, + input: &mp4, + duration_seconds: metadata.probe.duration_seconds, + deadline_ms: None, + }) + .expect("video nsfw frames"); + assert_eq!( + MAX_NSFW_SAMPLE_FRAMES, + frames.len(), + "every corroborating slot must hold a sampled frame" + ); + assert!( + !frames.contains(&preview), + "the t=0 preview frame must not take one of the sampled slots" + ); + for frame in &frames { + assert_bounded_jpeg(frame, &media_limits); + } +} + +#[test] +fn animated_gif_and_apng_frames_extract_through_the_native_decoders() { + let media_limits = limits(); + let gif = animated_gif_fixture(); + let gif_pages = reconciled_frame_count(&gif, &media_limits); + assert_eq!(2, gif_pages); + let gif_frames = extract_native_animated_frames_for_nsfw( + &media_limits, + &gif, + gif_pages, + MediaType::GIF, + None, + ) + .expect("native gif nsfw frames"); + assert_eq!(2, gif_frames.len()); + for frame in &gif_frames { + assert_bounded_jpeg(frame, &media_limits); + } + + let apng = general_purpose::STANDARD + .decode(APNG_FIXTURE_BASE64) + .expect("apng fixture decodes"); + assert_eq!( + 1, + super::super::image_probe::probe_image_dims(&media_limits, &apng) + .expect("apng dimensions") + .pages, + "this libvips build reports a single page for an APNG" + ); + let apng_pages = reconciled_frame_count(&apng, &media_limits); + assert_eq!(2, apng_pages); + let apng_frames = extract_native_animated_frames_for_nsfw( + &media_limits, + &apng, + apng_pages, + MediaType::APNG, + None, + ) + .expect("native apng nsfw frames"); + assert_eq!(2, apng_frames.len()); + for frame in &apng_frames { + assert_bounded_jpeg(frame, &media_limits); + } +} + +#[test] +fn a_frame_count_that_disagrees_with_the_container_is_rejected() { + let media_limits = limits(); + let gif = animated_gif_fixture(); + assert_eq!( + Err(MediaError::MediaDecodeFailed), + extract_native_animated_frames_for_nsfw(&media_limits, &gif, 3, MediaType::GIF, None) + ); +} + +#[test] +fn frame_indices_collapse_to_start_middle_and_end() { + assert_eq!( + Err(MediaError::InvalidImageDimensions), + animated_nsfw_frame_indices(0) + ); + assert_eq!(vec![0], animated_nsfw_frame_indices(1).unwrap()); + assert_eq!(vec![0, 1], animated_nsfw_frame_indices(2).unwrap()); + assert_eq!(vec![0, 1, 2], animated_nsfw_frame_indices(3).unwrap()); + assert_eq!(vec![0, 4, 8], animated_nsfw_frame_indices(9).unwrap()); +} + +#[test] +fn a_sample_beyond_the_packet_budget_keeps_the_frames_that_decoded() { + let Some(mp4) = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=64x64:rate=60:duration=12", + "-pix_fmt", + "yuv420p", + "-g", + "10000", + "-f", + "mp4", + ]) else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + let media_limits = limits(); + let frames = extract_video_frames_for_nsfw(VideoNSFWFramesRequest { + media_limits: &media_limits, + input: &mp4, + duration_seconds: Some(12.0), + deadline_ms: None, + }) + .expect("a single keyframe video still scans on the samples that decoded"); + assert_eq!(MAX_NSFW_SAMPLE_FRAMES, frames.len()); + for frame in &frames { + assert_bounded_jpeg(frame, &media_limits); + } +} diff --git a/fluxer_media_proxy/src/media_process/placeholder.rs b/fluxer_media_proxy/src/media_process/placeholder.rs new file mode 100644 index 000000000..b312ec4b8 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/placeholder.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::native_runtime::clear_vips_error; +use super::transform::{StaticThumbnailRequest, load_static_thumbnail}; +use super::{MediaError, ensure_deadline_pending, native_optional_deadline, native_status_error}; +use crate::{ + media_limits::MediaLimits, + native::{self, NativeStatus, VipsImageHandle, buffer::NativeBuffer}, + thumbhash, +}; +use base64::{Engine as _, engine::general_purpose}; +use libc::{c_void, size_t}; +use std::ptr; + +pub(super) fn encode_thumbhash( + media_limits: &MediaLimits, + input: &[u8], + deadline_ms: Option, +) -> Result, MediaError> { + let image = load_static_thumbnail(StaticThumbnailRequest { + media_limits, + input, + width: thumbhash::MAX_DIM, + height: thumbhash::MAX_DIM, + deadline_ms, + })?; + encode_thumbhash_image(&image, deadline_ms) +} + +pub(super) fn encode_thumbhash_image( + image: &VipsImageHandle<'_>, + deadline_ms: Option, +) -> Result, MediaError> { + ensure_deadline_pending(deadline_ms)?; + let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) as u32 }; + let height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) as u32 }; + if width == 0 || height == 0 || width > thumbhash::MAX_DIM || height > thumbhash::MAX_DIM { + return Err(MediaError::InvalidImageDimensions); + } + let mut rgba_ptr: *mut c_void = ptr::null_mut(); + let mut rgba_size: size_t = 0; + let status = NativeStatus::from_code(unsafe { + native::fluxer_vips_extract_rgba( + image.as_ptr(), + native_optional_deadline(deadline_ms), + &mut rgba_ptr, + &mut rgba_size, + ) + }); + let rgba = unsafe { NativeBuffer::from_vips_owned(rgba_ptr, rgba_size) }; + if let Some(error) = native_status_error(status, MediaError::MediaTransformFailed) { + clear_vips_error(); + return Err(error); + } + let rgba = rgba.ok_or(MediaError::MediaTransformFailed)?; + thumbhash::encode_rgba(rgba.as_slice(), width, height) + .map_err(|_| MediaError::InvalidImageDimensions) +} + +pub(super) fn optional_thumbhash( + result: Result, MediaError>, + source: &'static str, +) -> Option> { + match result { + Ok(hash) => Some(hash), + Err(error) => { + tracing::warn!("optional {source} placeholder generation failed: {error:?}"); + None + } + } +} + +pub(super) fn encoded_placeholder(hash: Option>) -> Option { + hash.map(|hash| general_purpose::STANDARD.encode(hash)) +} diff --git a/fluxer_media_proxy/src/media_process/streaming_encoder.rs b/fluxer_media_proxy/src/media_process/streaming_encoder.rs new file mode 100644 index 000000000..e8d563157 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/streaming_encoder.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::native_runtime::clear_vips_error; +use super::{MediaError, ensure_deadline_pending, native_optional_deadline, native_status_error}; +use crate::native; +use libc::{c_int, c_void, size_t}; +use std::{ffi::CStr, slice}; + +const STREAMING_WRITE_INITIAL_CAPACITY: usize = 16 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum StreamingWriteFailure { + DeadlineExceeded, + OutputLimitExceeded, + LengthOverflow, + InvalidBytesPointer, + AllocationFailed, +} + +struct StreamingWriteContext { + out: Vec, + cap: usize, + deadline: Option, + failure: Option, +} + +impl StreamingWriteContext { + fn reject(&mut self, failure: StreamingWriteFailure) -> c_int { + if self.failure.is_none() { + self.failure = Some(failure); + } + -1 + } + + fn media_error(&self, native_status: native::NativeStatus) -> Option { + match self.failure { + Some(StreamingWriteFailure::DeadlineExceeded) => Some(MediaError::RequestTimeout), + Some( + StreamingWriteFailure::OutputLimitExceeded | StreamingWriteFailure::LengthOverflow, + ) => Some(MediaError::StreamTooLong), + Some(StreamingWriteFailure::InvalidBytesPointer) => Some(MediaError::MediaEncodeFailed), + Some(StreamingWriteFailure::AllocationFailed) => Some(MediaError::AllocationFailed), + None => native_status_error(native_status, MediaError::MediaEncodeFailed) + .or_else(|| self.out.is_empty().then_some(MediaError::MediaEncodeFailed)), + } + } +} + +unsafe extern "C" fn streaming_write_callback( + user_data: *mut c_void, + bytes: *const c_void, + len: size_t, +) -> c_int { + if user_data.is_null() { + return -1; + } + let context = unsafe { &mut *(user_data as *mut StreamingWriteContext) }; + if ensure_deadline_pending(context.deadline).is_err() { + return context.reject(StreamingWriteFailure::DeadlineExceeded); + } + if context.failure.is_some() { + return -1; + } + if len == 0 { + return 0; + } + if bytes.is_null() { + return context.reject(StreamingWriteFailure::InvalidBytesPointer); + } + if len > isize::MAX as usize { + return context.reject(StreamingWriteFailure::LengthOverflow); + } + let Some(next_len) = context.out.len().checked_add(len) else { + return context.reject(StreamingWriteFailure::LengthOverflow); + }; + if next_len > isize::MAX as usize { + return context.reject(StreamingWriteFailure::LengthOverflow); + } + if next_len > context.cap { + return context.reject(StreamingWriteFailure::OutputLimitExceeded); + } + if next_len > context.out.capacity() { + let next_capacity = context + .out + .capacity() + .saturating_mul(2) + .max(next_len) + .min(context.cap); + let additional = next_capacity - context.out.len(); + if context.out.try_reserve_exact(additional).is_err() { + return context.reject(StreamingWriteFailure::AllocationFailed); + } + } + let chunk = unsafe { slice::from_raw_parts(bytes.cast::(), len) }; + context.out.extend_from_slice(chunk); + 0 +} + +pub(super) fn write_vips_image_to_vec( + image: &native::VipsImageHandle<'_>, + suffix: &CStr, + max_bytes: usize, + deadline: Option, +) -> Result, MediaError> { + assert!( + max_bytes > 0, + "streaming encode byte limit must be positive" + ); + ensure_deadline_pending(deadline)?; + let mut out = Vec::new(); + out.try_reserve_exact(max_bytes.min(STREAMING_WRITE_INITIAL_CAPACITY)) + .map_err(|_| MediaError::AllocationFailed)?; + let mut context = StreamingWriteContext { + out, + cap: max_bytes, + deadline, + failure: None, + }; + let status = native::NativeStatus::from_code(unsafe { + native::fluxer_vips_image_write_to_callback( + image.as_ptr(), + suffix.as_ptr(), + native_optional_deadline(deadline), + Some(streaming_write_callback), + (&mut context as *mut StreamingWriteContext).cast(), + ) + }); + ensure_deadline_pending(deadline)?; + if let Some(error) = context.media_error(status) { + clear_vips_error(); + return Err(error); + } + Ok(context.out) +} diff --git a/fluxer_media_proxy/src/media_process/tests/animated_apng.rs b/fluxer_media_proxy/src/media_process/tests/animated_apng.rs new file mode 100644 index 000000000..e80215dff --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/animated_apng.rs @@ -0,0 +1,327 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::apng::{png_crc32, rewrite_actl_frame_count}; +use super::super::image_probe::probe_image_dims; +use super::super::{ + AnimationLimits, AnimationMode, ImageDimensions, ImageOptions, ImageQuality, MediaError, +}; +use super::fixtures::{ + animated_mode, parse_png_chunks, png_ihdr_dimensions, test_media_limits, transform_image, +}; +use crate::{ + metrics::now_ms, + output_format::OutputFormat, + test_fixtures::{first_webp_anim_frame_size, gif_loop_count, webp_animation_loop_count}, +}; +use base64::Engine as _; + +#[test] +fn animated_apng_input_transforms_through_ffmpeg_decode_path() { + let fixture_b64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAAAAEABQAAaBqIGAAAAK1JREFUeJxjqGf4hxUxIKF/DH+BiOH/fxAiU4Mcgx0aYkBCtgy2QMRgZwdCg1yDCQMvEL1oMACix00mQCTFwANE/gx8QHSrUwuI0DXsbpkCVJ3IwBDEwADUsLprLkTD2ZoeoOp8OQZgaIEQxG5IiNqCXQARQw5pqJOooCFPj+Fcn96x9gxTBpS49NBk2DwlhBoaaOkHZUOGqYvDnrWJt6kRF6xADUAE1ABEuCIOAPEY5L3Pr8FWAAAAGmZjVEwAAAABAAAAAQAAAAEAAAAAAAAAAAABAAUAAMpQnTkAAAAQZmRBVAAAAAJ4nGOoZ/gHAAJ/AX511aUxAAAAAElFTkSuQmCC"; + let apng = base64::engine::general_purpose::STANDARD + .decode(fixture_b64) + .unwrap(); + + let animated_webp = transform_image( + &apng, + &ImageOptions { + width: Some(8), + format: OutputFormat::WebP, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/webp", animated_webp.content_type); + assert!(animated_webp.bytes.windows(4).any(|chunk| chunk == b"ANIM")); + assert_eq!( + Some((8, 8)), + first_webp_anim_frame_size(&animated_webp.bytes) + ); + + let static_webp = transform_image( + &apng, + &ImageOptions { + width: Some(8), + format: OutputFormat::WebP, + quality: ImageQuality::Lossless, + animation: AnimationMode::Static, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/webp", static_webp.content_type); + assert!(!static_webp.bytes.windows(4).any(|chunk| chunk == b"ANIM")); + assert_eq!( + ImageDimensions { + width: 8, + height: 8, + pages: 1 + }, + probe_image_dims(&test_media_limits(), &static_webp.bytes).unwrap() + ); + + let animated_gif = transform_image( + &apng, + &ImageOptions { + width: Some(8), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/gif", animated_gif.content_type); + assert!(animated_gif.bytes.starts_with(b"GIF89a") || animated_gif.bytes.starts_with(b"GIF87a")); + + let animated_png = transform_image( + &apng, + &ImageOptions { + width: Some(8), + format: OutputFormat::APNG, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/apng", animated_png.content_type); + assert!(animated_png.bytes.starts_with(b"\x89PNG\r\n\x1a\n")); + let apng_chunks = parse_png_chunks(&animated_png.bytes).unwrap(); + assert_eq!(Some((8, 8)), png_ihdr_dimensions(apng_chunks[0].data)); + assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"acTL")); + assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"fcTL")); + assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"fdAT")); + assert_eq!( + Some(2), + apng_chunks + .iter() + .find(|chunk| chunk.kind == *b"acTL") + .and_then(|chunk| chunk.data.get(..4)) + .map(|bytes| u32::from_be_bytes(bytes.try_into().unwrap())) + ); + + let animated_png_alias = transform_image( + &apng, + &ImageOptions { + width: Some(8), + format: OutputFormat::PNG, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/apng", animated_png_alias.content_type); + let alias_chunks = parse_png_chunks(&animated_png_alias.bytes).unwrap(); + assert_eq!(Some((8, 8)), png_ihdr_dimensions(alias_chunks[0].data)); + assert!(alias_chunks.iter().any(|chunk| chunk.kind == *b"acTL")); +} + +fn apng_num_plays(bytes: &[u8]) -> Option { + parse_png_chunks(bytes) + .ok()? + .iter() + .find(|chunk| chunk.kind == *b"acTL") + .and_then(|chunk| chunk.data.get(4..8)) + .and_then(|plays| plays.try_into().ok()) + .map(u32::from_be_bytes) +} + +fn patch_apng_num_plays(bytes: &[u8], num_plays: u32) -> Vec { + let mut out = bytes.to_vec(); + let position = out + .windows(4) + .position(|window| window == b"acTL") + .expect("fixture carries an acTL chunk"); + let payload_start = position + 4; + out[payload_start + 4..payload_start + 8].copy_from_slice(&num_plays.to_be_bytes()); + let payload: [u8; 8] = out[payload_start..payload_start + 8] + .try_into() + .expect("acTL payload is eight bytes"); + let crc = png_crc32(b"acTL", &payload, None).expect("crc without a deadline"); + out[payload_start + 8..payload_start + 12].copy_from_slice(&crc.to_be_bytes()); + out +} + +#[test] +fn animated_apng_transform_carries_the_source_num_plays() { + let fixture_b64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAAAAEABQAAaBqIGAAAAK1JREFUeJxjqGf4hxUxIKF/DH+BiOH/fxAiU4Mcgx0aYkBCtgy2QMRgZwdCg1yDCQMvEL1oMACix00mQCTFwANE/gx8QHSrUwuI0DXsbpkCVJ3IwBDEwADUsLprLkTD2ZoeoOp8OQZgaIEQxG5IiNqCXQARQw5pqJOooCFPj+Fcn96x9gxTBpS49NBk2DwlhBoaaOkHZUOGqYvDnrWJt6kRF6xADUAE1ABEuCIOAPEY5L3Pr8FWAAAAGmZjVEwAAAABAAAAAQAAAAEAAAAAAAAAAAABAAUAAMpQnTkAAAAQZmRBVAAAAAJ4nGOoZ/gHAAJ/AX511aUxAAAAAElFTkSuQmCC"; + let apng = base64::engine::general_purpose::STANDARD + .decode(fixture_b64) + .unwrap(); + assert_eq!(Some(0), apng_num_plays(&apng)); + + let looped = patch_apng_num_plays(&apng, 7); + assert_eq!(Some(7), apng_num_plays(&looped)); + + let animated_png = transform_image( + &looped, + &ImageOptions { + width: Some(8), + format: OutputFormat::APNG, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/apng", animated_png.content_type); + assert_eq!(Some(7), apng_num_plays(&animated_png.bytes)); +} + +#[test] +fn animated_apng_encode_truncates_at_the_frame_cap_without_erroring() { + let fixture_b64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAAAAEABQAAaBqIGAAAAK1JREFUeJxjqGf4hxUxIKF/DH+BiOH/fxAiU4Mcgx0aYkBCtgy2QMRgZwdCg1yDCQMvEL1oMACix00mQCTFwANE/gx8QHSrUwuI0DXsbpkCVJ3IwBDEwADUsLprLkTD2ZoeoOp8OQZgaIEQxG5IiNqCXQARQw5pqJOooCFPj+Fcn96x9gxTBpS49NBk2DwlhBoaaOkHZUOGqYvDnrWJt6kRF6xADUAE1ABEuCIOAPEY5L3Pr8FWAAAAGmZjVEwAAAABAAAAAQAAAAEAAAAAAAAAAAABAAUAAMpQnTkAAAAQZmRBVAAAAAJ4nGOoZ/gHAAJ/AX511aUxAAAAAElFTkSuQmCC"; + let apng = base64::engine::general_purpose::STANDARD + .decode(fixture_b64) + .unwrap(); + + let capped = transform_image( + &apng, + &ImageOptions { + width: Some(8), + format: OutputFormat::APNG, + quality: ImageQuality::Lossless, + animation: AnimationMode::Animated( + AnimationLimits::new(1, 30_000).expect("valid animation limits"), + ), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/apng", capped.content_type); + let chunks = parse_png_chunks(&capped.bytes).unwrap(); + assert_eq!( + Some(1), + chunks + .iter() + .find(|chunk| chunk.kind == *b"acTL") + .and_then(|chunk| chunk.data.get(..4)) + .and_then(|frames| frames.try_into().ok()) + .map(u32::from_be_bytes) + ); + assert!(!chunks.iter().any(|chunk| chunk.kind == *b"fdAT")); +} + +#[test] +fn apng_with_saturated_num_plays_clamps_per_format_instead_of_failing() { + let fixture_b64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAAAAEABQAAaBqIGAAAAK1JREFUeJxjqGf4hxUxIKF/DH+BiOH/fxAiU4Mcgx0aYkBCtgy2QMRgZwdCg1yDCQMvEL1oMACix00mQCTFwANE/gx8QHSrUwuI0DXsbpkCVJ3IwBDEwADUsLprLkTD2ZoeoOp8OQZgaIEQxG5IiNqCXQARQw5pqJOooCFPj+Fcn96x9gxTBpS49NBk2DwlhBoaaOkHZUOGqYvDnrWJt6kRF6xADUAE1ABEuCIOAPEY5L3Pr8FWAAAAGmZjVEwAAAABAAAAAQAAAAEAAAAAAAAAAAABAAUAAMpQnTkAAAAQZmRBVAAAAAJ4nGOoZ/gHAAJ/AX511aUxAAAAAElFTkSuQmCC"; + let apng = base64::engine::general_purpose::STANDARD + .decode(fixture_b64) + .unwrap(); + let saturated = patch_apng_num_plays(&apng, u32::MAX); + assert_eq!(Some(u32::MAX), apng_num_plays(&saturated)); + + let animated_webp = transform_image( + &saturated, + &ImageOptions { + width: Some(8), + format: OutputFormat::WebP, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/webp", animated_webp.content_type); + assert_eq!( + Some(u16::MAX), + webp_animation_loop_count(&animated_webp.bytes) + ); + + let animated_gif = transform_image( + &saturated, + &ImageOptions { + width: Some(8), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/gif", animated_gif.content_type); + assert_eq!(Some(u16::MAX), gif_loop_count(&animated_gif.bytes)); + + let animated_png = transform_image( + &saturated, + &ImageOptions { + width: Some(8), + format: OutputFormat::APNG, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/apng", animated_png.content_type); + assert_eq!(Some(u32::MAX), apng_num_plays(&animated_png.bytes)); +} + +#[test] +fn animated_apng_encode_stops_at_the_deadline_instead_of_timing_out() { + let fixture_b64 = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAACGFjVEwAAAACAAAAAPONk3AAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAAAAEABQAAaBqIGAAAAK1JREFUeJxjqGf4hxUxIKF/DH+BiOH/fxAiU4Mcgx0aYkBCtgy2QMRgZwdCg1yDCQMvEL1oMACix00mQCTFwANE/gx8QHSrUwuI0DXsbpkCVJ3IwBDEwADUsLprLkTD2ZoeoOp8OQZgaIEQxG5IiNqCXQARQw5pqJOooCFPj+Fcn96x9gxTBpS49NBk2DwlhBoaaOkHZUOGqYvDnrWJt6kRF6xADUAE1ABEuCIOAPEY5L3Pr8FWAAAAGmZjVEwAAAABAAAAAQAAAAEAAAAAAAAAAAABAAUAAMpQnTkAAAAQZmRBVAAAAAJ4nGOoZ/gHAAJ/AX511aUxAAAAAElFTkSuQmCC"; + let apng = base64::engine::general_purpose::STANDARD + .decode(fixture_b64) + .unwrap(); + let options = |deadline_ms: Option| ImageOptions { + width: Some(8), + format: OutputFormat::APNG, + quality: ImageQuality::Lossless, + animation: animated_mode(), + deadline_ms, + ..Default::default() + }; + assert_eq!( + "image/apng", + transform_image(&apng, &options(None)).unwrap().content_type + ); + + let expired_encode_deadline = now_ms() + 2_000; + assert!(matches!( + transform_image(&apng, &options(Some(expired_encode_deadline))), + Err(MediaError::MediaEncodeFailed) + )); +} + +#[test] +fn rewriting_the_actl_frame_count_keeps_the_chunk_crc_valid() { + let mut encoded = vec![0u8; 33]; + let actl_offset = encoded.len(); + let mut payload = [0u8; 8]; + payload[..4].copy_from_slice(&7u32.to_be_bytes()); + payload[4..].copy_from_slice(&3u32.to_be_bytes()); + encoded.extend_from_slice(&8u32.to_be_bytes()); + encoded.extend_from_slice(b"acTL"); + encoded.extend_from_slice(&payload); + encoded.extend_from_slice( + &png_crc32(b"acTL", &payload, None) + .expect("crc without a deadline") + .to_be_bytes(), + ); + + rewrite_actl_frame_count(&mut encoded, actl_offset, 2, None) + .expect("acTL frame count is rewritten"); + + let payload_start = actl_offset + 8; + let payload_end = payload_start + 8; + assert_eq!( + 2, + u32::from_be_bytes( + encoded[payload_start..payload_start + 4] + .try_into() + .unwrap() + ) + ); + assert_eq!( + 3, + u32::from_be_bytes(encoded[payload_start + 4..payload_end].try_into().unwrap()) + ); + assert_eq!( + png_crc32(b"acTL", &encoded[payload_start..payload_end], None).unwrap(), + u32::from_be_bytes(encoded[payload_end..payload_end + 4].try_into().unwrap()) + ); +} diff --git a/fluxer_media_proxy/src/media_process/tests/animated_gif.rs b/fluxer_media_proxy/src/media_process/tests/animated_gif.rs new file mode 100644 index 000000000..1897e36e9 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/animated_gif.rs @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::animated_transform::gif_resize_dims; +use super::super::{ + AnimationLimits, AnimationMode, ImageOptions, ImageQuality, MediaError, ResizeMode, +}; +use super::fixtures::{animated_mode, parse_png_chunks, png_ihdr_dimensions, transform_image}; +use crate::{ + mime, + output_format::OutputFormat, + test_fixtures::{ + animated_gif_fixture, animated_gif_frames, first_webp_anim_frame_size, gif_frame_delays_cs, + gif_loop_count, webp_canvas_size, + }, +}; + +#[test] +fn animated_gif_encodes_to_animated_webp_with_alpha() { + let gif = animated_gif_fixture(); + + let animated_webp = transform_image( + &gif, + &ImageOptions { + width: Some(32), + height: Some(32), + format: OutputFormat::WebP, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/webp", animated_webp.content_type); + assert!(animated_webp.bytes.starts_with(b"RIFF")); + assert_eq!(&animated_webp.bytes[8..12], b"WEBP"); + assert!(animated_webp.bytes.windows(4).any(|chunk| chunk == b"ANIM")); + assert!( + animated_webp.bytes.windows(4).any(|chunk| chunk == b"ALPH") + || animated_webp.bytes.windows(4).any(|chunk| chunk == b"VP8L") + ); + let (canvas_width, canvas_height, feature_flags) = + webp_canvas_size(&animated_webp.bytes).unwrap(); + assert_eq!((32, 32), (canvas_width, canvas_height)); + assert_ne!(0, feature_flags & 0x02); + assert_ne!(0, feature_flags & 0x10); + assert_eq!( + Some((32, 32)), + first_webp_anim_frame_size(&animated_webp.bytes) + ); + + let static_webp = transform_image( + &gif, + &ImageOptions { + width: Some(32), + height: Some(32), + format: OutputFormat::WebP, + quality: ImageQuality::Lossless, + animation: AnimationMode::Static, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/webp", static_webp.content_type); + assert!(static_webp.bytes.starts_with(b"RIFF")); + assert_eq!(&static_webp.bytes[8..12], b"WEBP"); + + let animated_gif = transform_image( + &gif, + &ImageOptions { + width: Some(32), + height: Some(32), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/gif", animated_gif.content_type); + assert!(animated_gif.bytes.starts_with(b"GIF89a") || animated_gif.bytes.starts_with(b"GIF87a")); + + let animated_png = transform_image( + &gif, + &ImageOptions { + width: Some(32), + height: Some(32), + format: OutputFormat::APNG, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/apng", animated_png.content_type); + let apng_chunks = parse_png_chunks(&animated_png.bytes).unwrap(); + assert_eq!(Some((32, 32)), png_ihdr_dimensions(apng_chunks[0].data)); + assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"acTL")); + assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"fcTL")); + assert!(apng_chunks.iter().any(|chunk| chunk.kind == *b"fdAT")); +} + +#[test] +fn animated_gif_resize_preserves_last_frame_delay() { + let gif = animated_gif_fixture(); + assert_eq!(vec![20, 20], gif_frame_delays_cs(&gif)); + + let resized = transform_image( + &gif, + &ImageOptions { + width: Some(16), + height: Some(16), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!("image/gif", resized.content_type); + assert_eq!(vec![20, 20], gif_frame_delays_cs(&resized.bytes)); +} + +#[test] +fn animated_gif_without_a_resize_passes_through_byte_identically() { + let gif = animated_gif_fixture(); + let passed_through = transform_image( + &gif, + &ImageOptions { + format: OutputFormat::GIF, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!("image/gif", passed_through.content_type); + assert_eq!(gif, passed_through.bytes); +} + +#[test] +fn animated_gif_with_trailing_bytes_after_the_trailer_passes_through_byte_identically() { + let mut gif = animated_gif_frames(3, 20); + gif.extend_from_slice(b"trailing garbage"); + let passed_through = transform_image( + &gif, + &ImageOptions { + format: OutputFormat::GIF, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("a gif with trailing bytes after the trailer still passes through"); + + assert_eq!("image/gif", passed_through.content_type); + assert_eq!(gif, passed_through.bytes); +} + +#[test] +fn animated_gif_over_the_frame_cap_passes_through_byte_identically() { + let gif = animated_gif_frames(5, 20); + let passed_through = transform_image( + &gif, + &ImageOptions { + format: OutputFormat::GIF, + animation: AnimationMode::Animated( + AnimationLimits::new(3, 30_000).expect("valid animation limits"), + ), + ..Default::default() + }, + ) + .expect("a gif over the frame cap still passes through"); + + assert_eq!("image/gif", passed_through.content_type); + assert_eq!(gif, passed_through.bytes); +} + +#[test] +fn animated_gif_over_the_duration_cap_passes_through_byte_identically() { + let gif = animated_gif_frames(5, 20); + let passed_through = transform_image( + &gif, + &ImageOptions { + format: OutputFormat::GIF, + animation: AnimationMode::Animated( + AnimationLimits::new(4_096, 400).expect("valid animation limits"), + ), + ..Default::default() + }, + ) + .expect("a gif over the duration cap still passes through"); + + assert_eq!("image/gif", passed_through.content_type); + assert_eq!(gif, passed_through.bytes); +} + +fn resized_animated_gif_with_encode_limits( + input: &[u8], + max_frames: u32, + max_duration_ms: u32, +) -> Vec { + transform_image( + input, + &ImageOptions { + width: Some(8), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: AnimationMode::Animated( + AnimationLimits::new(max_frames, max_duration_ms).expect("valid animation limits"), + ), + ..Default::default() + }, + ) + .expect("animated gif resize ignores the encode caps") + .bytes +} + +fn assert_animated_gif(bytes: &[u8], frames: usize) { + assert_eq!(b"GIF89a", &bytes[..6]); + assert_eq!(Some(&0x3b), bytes.last()); + assert_eq!(frames, gif_frame_delays_cs(bytes).len()); +} + +#[test] +fn animated_gif_resize_keeps_every_frame_past_the_encode_caps() { + // A resize is not a re-time. The old proxy handed the ffmpeg resizer only the decode caps, so + // a long GIF came back whole; binding the encode budget here cut the tail off anything past + // FLUXER_MEDIA_PROXY_MAX_ENCODE_DURATION_MS while the same GIF passed through untouched when + // no resize was asked for. + let gif = animated_gif_frames(5, 20); + assert_eq!(vec![20u16; 5], gif_frame_delays_cs(&gif)); + + let uncapped = resized_animated_gif_with_encode_limits(&gif, 4_096, 30_000); + assert_animated_gif(&uncapped, 5); + assert_eq!(vec![20u16; 5], gif_frame_delays_cs(&uncapped)); + + for (max_frames, max_duration_ms) in [(3, 30_000), (1, 30_000), (4_096, 400), (1, 1)] { + let capped = resized_animated_gif_with_encode_limits(&gif, max_frames, max_duration_ms); + assert_animated_gif(&capped, 5); + assert_eq!(uncapped, capped); + } +} + +fn cover_cropped_animated_gif_with_encode_limits( + input: &[u8], + max_frames: u32, + max_duration_ms: u32, +) -> Vec { + transform_image( + input, + &ImageOptions { + width: Some(8), + height: Some(8), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + resize_mode: ResizeMode::Cover, + animation: AnimationMode::Animated( + AnimationLimits::new(max_frames, max_duration_ms).expect("valid animation limits"), + ), + ..Default::default() + }, + ) + .expect("the generic animated encode writes every frame without failing") + .bytes +} + +#[test] +fn generic_animated_gif_encode_output_is_unchanged_by_the_encode_caps() { + let gif = animated_gif_frames(5, 20); + assert_eq!(vec![20u16; 5], gif_frame_delays_cs(&gif)); + + let uncapped = cover_cropped_animated_gif_with_encode_limits(&gif, 4_096, 30_000); + assert_animated_gif(&uncapped, 5); + assert_eq!( + uncapped, + cover_cropped_animated_gif_with_encode_limits(&gif, 2, 30_000) + ); + assert_eq!( + uncapped, + cover_cropped_animated_gif_with_encode_limits(&gif, 4_096, 400) + ); +} + +#[test] +fn vips_gif_encode_writes_every_source_frame_past_the_encode_caps() { + let gif = animated_gif_frames(5, 20); + assert_eq!(vec![20u16; 5], gif_frame_delays_cs(&gif)); + + for (max_frames, max_duration_ms) in [(4_096, 30_000), (2, 30_000), (4_096, 400), (1, 1)] { + assert_animated_gif( + &cover_cropped_animated_gif_with_encode_limits(&gif, max_frames, max_duration_ms), + 5, + ); + } +} + +#[test] +fn a_zero_delay_source_resizes_to_the_hundred_millisecond_browser_default() { + // A GIF that declares a 0 cs delay is not a 20 ms animation: authoring tools emit 0 and + // every renderer shows it at 100 ms. Clamping it to the fast-frame minimum instead played + // the resized animation five times faster than the original. + let gif = animated_gif_frames(5, 0); + assert_eq!(vec![0u16; 5], gif_frame_delays_cs(&gif)); + + let resized = transform_image( + &gif, + &ImageOptions { + width: Some(8), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(vec![10u16; 5], gif_frame_delays_cs(&resized.bytes)); +} + +#[test] +fn a_sub_minimum_nonzero_delay_still_clamps_to_the_fast_frame_minimum() { + // A declared-but-tiny delay keeps the 20 ms floor; only "no delay at all" means 100 ms. + let gif = animated_gif_frames(5, 1); + let resized = transform_image( + &gif, + &ImageOptions { + width: Some(8), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(vec![2u16; 5], gif_frame_delays_cs(&resized.bytes)); +} + +#[test] +fn animated_gif_resize_still_errors_on_an_expired_deadline() { + let gif = animated_gif_frames(5, 20); + assert!(matches!( + transform_image( + &gif, + &ImageOptions { + width: Some(8), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: animated_mode(), + deadline_ms: Some(1), + ..Default::default() + }, + ), + Err(MediaError::RequestTimeout) + )); +} + +#[test] +fn animated_gif_resize_preserves_the_source_loop_count() { + let gif = animated_gif_fixture(); + assert_eq!(Some(0), gif_loop_count(&gif)); + + let resized = transform_image( + &gif, + &ImageOptions { + width: Some(16), + height: Some(16), + format: OutputFormat::GIF, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!("image/gif", resized.content_type); + assert_eq!(gif_loop_count(&gif), gif_loop_count(&resized.bytes)); +} + +#[test] +fn animated_gif_two_bounds_fit_inside_without_distortion() { + let dims = gif_resize_dims( + mime::SniffInfo { + mime: "image/gif", + animated: true, + width: 320, + height: 240, + ..Default::default() + }, + &ImageOptions { + width: Some(240), + height: Some(240), + format: OutputFormat::GIF, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("4:3 GIF should be reduced to fit in a 240px square"); + + assert_eq!(240, dims.width); + assert_eq!(180, dims.height); +} diff --git a/fluxer_media_proxy/src/media_process/tests/animated_webp.rs b/fluxer_media_proxy/src/media_process/tests/animated_webp.rs new file mode 100644 index 000000000..fdfd606db --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/animated_webp.rs @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::encoding::try_transform_animated_webp_direct; +use super::super::image_probe::probe_image_dims; +use super::super::native_runtime::ensure_vips_init; +use super::super::{ + AnimationLimits, AnimationMode, ImageOptions, ImageQuality, MediaError, ResolvedImageQuality, +}; +use super::fixtures::{animated_mode, assert_animated_webp, test_media_limits, transform_image}; +use crate::{ + mime, + native::{self, NativeStatus}, + output_format::OutputFormat, + test_fixtures::{ + animated_gif_fixture, animated_gif_frames, gif_frame_delays_cs, gif_loop_count, + webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads, webp_with_metadata_chunk, + }, +}; +use libc::c_int; + +fn webp_direct_transform(input: &[u8]) -> Option> { + ensure_vips_init().expect("libvips initialises"); + try_transform_animated_webp_direct( + input, + &ImageOptions { + width: Some(16), + format: OutputFormat::WebP, + animation: animated_mode(), + ..Default::default() + }, + ResolvedImageQuality::High, + None, + &test_media_limits(), + ) + .expect("direct animated webp transform succeeds or falls through") +} + +fn animated_webp_source() -> Vec { + transform_image( + &animated_gif_fixture(), + &ImageOptions { + width: Some(32), + height: Some(32), + format: OutputFormat::WebP, + quality: ImageQuality::Lossless, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("animated gif encodes to animated webp") + .bytes +} + +#[test] +fn animated_webp_transforms_directly_and_falls_through_on_embedded_metadata() { + let source = animated_webp_source(); + assert!(mime::sniff(&source).animated); + + let direct = transform_image( + &source, + &ImageOptions { + width: Some(16), + format: OutputFormat::WebP, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("animated webp transforms through the direct path"); + assert_eq!("image/webp", direct.content_type); + assert!(direct.bytes.starts_with(b"RIFF")); + assert_eq!(&direct.bytes[8..12], b"WEBP"); + assert!(direct.bytes.windows(4).any(|chunk| chunk == b"ANIM")); + let (canvas_width, canvas_height, feature_flags) = + webp_canvas_size(&direct.bytes).expect("direct output carries a VP8X canvas"); + assert_eq!((16, 16), (canvas_width, canvas_height)); + assert_ne!(0, feature_flags & 0x02); + let source_frames = webp_chunk_payloads(&source, b"ANMF").len(); + assert!(source_frames > 1); + assert_eq!( + source_frames, + webp_chunk_payloads(&direct.bytes, b"ANMF").len() + ); + + for (fourcc, flag) in [(b"ICCP", 0x20u8), (b"EXIF", 0x08), (b"XMP ", 0x04)] { + let tagged = webp_with_metadata_chunk(&source, fourcc, flag) + .expect("animated webp accepts an embedded metadata chunk"); + assert_eq!( + None, + webp_direct_transform(&tagged), + "chunk {}", + String::from_utf8_lossy(fourcc) + ); + let fallback = transform_image( + &tagged, + &ImageOptions { + width: Some(16), + format: OutputFormat::WebP, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("tagged animated webp falls through to the generic path"); + assert_eq!("image/webp", fallback.content_type); + assert!(fallback.bytes.windows(4).any(|chunk| chunk == b"ANIM")); + } + assert!( + webp_direct_transform(&source).is_some(), + "untagged source stays on the direct path" + ); +} + +#[test] +fn animated_webp_encode_carries_the_source_loop_count() { + let mut gif = animated_gif_fixture(); + let netscape = gif + .windows(11) + .position(|window| window == b"NETSCAPE2.0") + .expect("fixture carries a NETSCAPE application extension"); + gif[netscape + 13] = 3; + gif[netscape + 14] = 0; + assert_eq!(Some(3), gif_loop_count(&gif)); + + let out = transform_image( + &gif, + &ImageOptions { + width: Some(16), + format: OutputFormat::WebP, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(Some(4), webp_animation_loop_count(&out.bytes)); +} + +#[test] +fn animated_webp_nsfw_frames_extract_as_bounded_jpegs() { + let source = animated_webp_source(); + let indices: [c_int; 2] = [0, 1]; + let mut frames = native::nsfw_frame_output::NSFWFrameOutput::new(indices.len()); + let status = NativeStatus::from_code(unsafe { + native::fluxer_webp_extract_frames_for_nsfw( + source.as_ptr().cast(), + source.len(), + 0, + 0, + indices.as_ptr(), + indices.len(), + 16, + test_media_limits().animated_total_pixels(), + 1_048_576, + frames.as_mut_ptr(), + ) + }); + assert_eq!(NativeStatus::Ok, status); + let extracted = frames.copy_frames().expect("frames copy out"); + assert_eq!(2, extracted.len()); + for frame in &extracted { + assert_eq!(&[0xFF, 0xD8], &frame[..2]); + let dims = probe_image_dims(&test_media_limits(), frame).unwrap(); + assert!(dims.width <= 512 && dims.height <= 512); + } +} + +#[test] +fn zero_delay_animation_frames_are_clamped_instead_of_rejected() { + let mut gif = animated_gif_fixture(); + let mut offset = 0usize; + let mut patched = 0usize; + while offset + 8 <= gif.len() { + if gif[offset] == 0x21 && gif[offset + 1] == 0xF9 && gif[offset + 2] == 0x04 { + gif[offset + 4] = 0; + gif[offset + 5] = 0; + patched += 1; + } + offset += 1; + } + assert_eq!(2, patched); + assert!(gif_frame_delays_cs(&gif).iter().all(|delay| *delay == 0)); + + for format in [OutputFormat::WebP, OutputFormat::APNG] { + let out = transform_image( + &gif, + &ImageOptions { + width: Some(16), + format, + animation: animated_mode(), + ..Default::default() + }, + ) + .unwrap_or_else(|err| panic!("zero-delay {format:?} encode failed: {err:?}")); + assert_eq!(format.mime(), out.content_type); + } +} + +fn animated_webp_with_encode_limits( + input: &[u8], + max_frames: u32, + max_duration_ms: u32, +) -> Vec { + transform_image( + input, + &ImageOptions { + width: Some(16), + format: OutputFormat::WebP, + animation: AnimationMode::Animated( + AnimationLimits::new(max_frames, max_duration_ms).expect("valid animation limits"), + ), + ..Default::default() + }, + ) + .expect("animated webp encode truncates instead of failing") + .bytes +} + +fn direct_animated_webp_with_encode_limits( + input: &[u8], + max_frames: u32, + max_duration_ms: u32, +) -> Vec { + ensure_vips_init().expect("libvips initialises"); + try_transform_animated_webp_direct( + input, + &ImageOptions { + width: Some(16), + format: OutputFormat::WebP, + animation: AnimationMode::Animated( + AnimationLimits::new(max_frames, max_duration_ms).expect("valid animation limits"), + ), + ..Default::default() + }, + ResolvedImageQuality::High, + None, + &test_media_limits(), + ) + .expect("direct animated webp transform truncates instead of failing") + .expect("direct animated webp transform stays on the direct path") +} + +#[test] +fn animated_webp_encode_truncates_at_the_frame_cap_without_erroring() { + let gif = animated_gif_frames(4, 20); + assert_eq!(vec![20u16; 4], gif_frame_delays_cs(&gif)); + + assert_animated_webp(&animated_webp_with_encode_limits(&gif, 4_096, 30_000), 4); + assert_animated_webp(&animated_webp_with_encode_limits(&gif, 3, 30_000), 3); + assert_animated_webp(&animated_webp_with_encode_limits(&gif, 2, 30_000), 2); +} + +#[test] +fn animated_webp_encode_truncates_at_the_duration_cap_without_erroring() { + let gif = animated_gif_frames(4, 20); + + assert_animated_webp(&animated_webp_with_encode_limits(&gif, 4_096, 800), 4); + assert_animated_webp(&animated_webp_with_encode_limits(&gif, 4_096, 600), 3); + assert_animated_webp(&animated_webp_with_encode_limits(&gif, 4_096, 400), 2); +} + +#[test] +fn animated_webp_direct_transform_truncates_at_the_encode_caps() { + let source = animated_webp_with_encode_limits(&animated_gif_frames(4, 20), 4_096, 30_000); + assert_animated_webp(&source, 4); + + assert_animated_webp( + &direct_animated_webp_with_encode_limits(&source, 4_096, 30_000), + 4, + ); + assert_animated_webp( + &direct_animated_webp_with_encode_limits(&source, 2, 30_000), + 2, + ); + assert_animated_webp( + &direct_animated_webp_with_encode_limits(&source, 4_096, 400), + 2, + ); +} + +#[test] +fn animated_webp_encode_still_errors_on_an_expired_deadline() { + let expired = ImageOptions { + width: Some(16), + format: OutputFormat::WebP, + animation: animated_mode(), + deadline_ms: Some(1), + ..Default::default() + }; + let gif = animated_gif_frames(4, 20); + assert!(matches!( + transform_image(&gif, &expired), + Err(MediaError::RequestTimeout) + )); + + let source = animated_webp_with_encode_limits(&gif, 4_096, 30_000); + ensure_vips_init().expect("libvips initialises"); + assert!(matches!( + try_transform_animated_webp_direct( + &source, + &expired, + ResolvedImageQuality::High, + None, + &test_media_limits(), + ), + Err(MediaError::RequestTimeout) + )); +} diff --git a/fluxer_media_proxy/src/media_process/tests/encoding.rs b/fluxer_media_proxy/src/media_process/tests/encoding.rs new file mode 100644 index 000000000..0b8fed533 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/encoding.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::encoding::anim_limits_from_options; +use super::super::transform_plan::{AutoQualitySource, output_suffix, resolve_auto_quality}; +use super::super::{AnimatedProbe, EncodeEffort, ImageOptions, ImageQuality, ResolvedImageQuality}; +use super::fixtures::effort; +use crate::{mime, output_format::OutputFormat}; + +#[test] +fn webp_effort_override_is_clamped_to_the_encoder_maximum() { + for requested in [7u8, 8, 9, 200] { + let suffix = webp_suffix(effort(requested)); + assert!( + suffix.contains("effort=6"), + "effort={requested} produced {suffix}, but libvips webpsave rejects effort above 6 \ + and silently falls back to its own default" + ); + } + assert!(webp_suffix(effort(5)).contains("effort=5")); +} + +fn webp_suffix(requested: EncodeEffort) -> String { + output_suffix( + OutputFormat::WebP, + ResolvedImageQuality::High, + None, + Some(requested), + ) + .expect("webp output suffix") + .into_string() + .expect("utf8 output suffix") +} + +#[test] +fn animated_webp_default_effort_matches_fast_tier() { + assert_eq!(2, ResolvedImageQuality::Low.default_effort(true)); + assert_eq!(2, ResolvedImageQuality::High.default_effort(true)); + assert_eq!(4, ResolvedImageQuality::High.default_effort(false)); +} + +#[test] +fn auto_animated_palette_quality_matches_v1_thresholds() { + let gif_header = b"GIF89a\x01\x00\x01\x00"; + let small_probe = AnimatedProbe { + width: 300, + height: 225, + pages: 100, + }; + let large_probe = AnimatedProbe { + width: 480, + height: 480, + pages: 240, + }; + let resolved = |quality, probe, requested| { + resolve_auto_quality( + AutoQualitySource { + format: OutputFormat::WebP, + animated: true, + sniffed_mime: mime::sniff(gif_header).mime, + input: gif_header, + quality, + probe: Some(probe), + }, + requested, + ) + }; + + assert_eq!( + ResolvedImageQuality::Lossless, + resolved(ImageQuality::Auto, small_probe, None).quality + ); + assert_eq!( + ResolvedImageQuality::High, + resolved(ImageQuality::Auto, large_probe, None).quality + ); + assert_eq!( + ResolvedImageQuality::High, + resolved(ImageQuality::High, small_probe, None).quality + ); + assert_eq!( + ResolvedImageQuality::Low, + resolved(ImageQuality::Low, small_probe, None).quality + ); + assert_eq!( + None, + resolved(ImageQuality::Auto, small_probe, None).effort_override + ); + assert_eq!( + Some(EncodeEffort::minimum()), + resolved(ImageQuality::Auto, large_probe, None).effort_override + ); + assert_eq!( + Some(effort(2)), + resolved(ImageQuality::Auto, large_probe, Some(effort(2))).effort_override + ); +} + +#[test] +fn animated_encode_deadline_keeps_flush_headroom() { + assert_eq!( + Some(17_000), + anim_limits_from_options(&ImageOptions { + deadline_ms: Some(20_000), + ..Default::default() + }) + .deadline_unix_ms + ); + assert_eq!( + Some(1_500), + anim_limits_from_options(&ImageOptions { + deadline_ms: Some(1_500), + ..Default::default() + }) + .deadline_unix_ms + ); + assert_eq!( + None, + anim_limits_from_options(&ImageOptions::default()).deadline_unix_ms + ); + assert_eq!( + Some(20_000), + anim_limits_from_options(&ImageOptions { + deadline_ms: Some(20_000), + ..Default::default() + }) + .flush_deadline_unix_ms + ); +} diff --git a/fluxer_media_proxy/src/media_process/tests/fixtures.rs b/fluxer_media_proxy/src/media_process/tests/fixtures.rs new file mode 100644 index 000000000..1a25d01d9 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/fixtures.rs @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::native_runtime::ensure_vips_init; +use super::super::{ + AnimationLimits, AnimationMode, EncodeEffort, ImageOptions, MediaError, MediaLimits, + MetadataOptions, PNG_SIGNATURE, ProcessedMedia, metadata_json_with_options, +}; +use crate::{ + constants, + metrics::transform::TransformMetrics, + native::{self, NativeStatus, VipsImageHandle, buffer::NativeBuffer}, + nsfw::NSFWClient, + test_fixtures::webp_chunk_payloads, +}; +use libc::{c_void, size_t}; +use std::{ffi::CString, ptr}; + +#[derive(Clone, Copy, Debug)] +pub(super) struct PngChunk<'a> { + pub(super) kind: [u8; 4], + pub(super) data: &'a [u8], +} + +pub(super) fn parse_png_chunks(bytes: &[u8]) -> Result>, MediaError> { + if bytes.len() < PNG_SIGNATURE.len() || &bytes[..PNG_SIGNATURE.len()] != PNG_SIGNATURE { + return Err(MediaError::MediaEncodeFailed); + } + let mut chunks = Vec::new(); + let mut offset = PNG_SIGNATURE.len(); + while offset + 12 <= bytes.len() { + let len = u32::from_be_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) as usize; + let kind: [u8; 4] = bytes[offset + 4..offset + 8] + .try_into() + .map_err(|_| MediaError::MediaEncodeFailed)?; + let data_start = offset + 8; + let data_end = data_start + .checked_add(len) + .ok_or(MediaError::MediaEncodeFailed)?; + let crc_end = data_end + .checked_add(4) + .ok_or(MediaError::MediaEncodeFailed)?; + if crc_end > bytes.len() { + return Err(MediaError::MediaEncodeFailed); + } + chunks.push(PngChunk { + kind, + data: &bytes[data_start..data_end], + }); + offset = crc_end; + if kind == *b"IEND" { + return Ok(chunks); + } + } + Err(MediaError::MediaEncodeFailed) +} + +pub(super) fn png_ihdr_dimensions(ihdr: &[u8]) -> Option<(u32, u32)> { + if ihdr.len() != 13 { + return None; + } + Some(( + u32::from_be_bytes(ihdr[0..4].try_into().ok()?), + u32::from_be_bytes(ihdr[4..8].try_into().ok()?), + )) +} + +pub(super) fn test_media_limits() -> MediaLimits { + MediaLimits::default_from_config() +} + +pub(super) fn test_animation_limits() -> AnimationLimits { + AnimationLimits::new(constants::MAX_ANIMATED_FRAMES_DEFAULT, 30_000) + .expect("valid animation limits") +} + +pub(super) fn animated_mode() -> AnimationMode { + AnimationMode::Animated(test_animation_limits()) +} + +pub(super) fn effort(value: u8) -> EncodeEffort { + EncodeEffort::parse_lenient(&value.to_string()).expect("valid encode effort") +} + +pub(super) fn transform_image( + input: &[u8], + options: &ImageOptions, +) -> Result { + super::super::transform_image( + input, + options, + &test_media_limits(), + &TransformMetrics::new(), + ) +} + +pub(super) fn metadata_value(input: &[u8], filename: &str) -> serde_json::Value { + let meta = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(async { + metadata_json_with_options( + input, + filename, + MetadataOptions::default(), + &test_media_limits(), + &NSFWClient::disabled(), + &TransformMetrics::new(), + ) + .await + .unwrap() + }); + serde_json::from_str(&meta).unwrap() +} + +pub(super) fn assert_animated_webp(bytes: &[u8], frames: usize) { + assert!(bytes.starts_with(b"RIFF")); + assert_eq!(b"WEBP", &bytes[8..12]); + assert_eq!(1, webp_chunk_payloads(bytes, b"ANIM").len()); + assert_eq!(frames, webp_chunk_payloads(bytes, b"ANMF").len()); +} + +pub(super) fn decode_rgba(bytes: &[u8]) -> (u32, u32, Vec) { + ensure_vips_init().expect("libvips initialises"); + let options = CString::new("access=sequential").expect("static string has no NUL"); + let raw = unsafe { + native::fluxer_vips_image_new_from_buffer( + bytes.as_ptr().cast(), + bytes.len(), + options.as_ptr(), + ) + }; + let image = unsafe { VipsImageHandle::from_raw_borrowing(raw, bytes) }.expect("decoded image"); + let width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) as u32 }; + let height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) as u32 }; + let mut rgba_ptr: *mut c_void = ptr::null_mut(); + let mut rgba_size: size_t = 0; + let status = NativeStatus::from_code(unsafe { + native::fluxer_vips_extract_rgba(image.as_ptr(), 0, &mut rgba_ptr, &mut rgba_size) + }); + assert_eq!(NativeStatus::Ok, status); + let rgba = unsafe { NativeBuffer::from_vips_owned(rgba_ptr, rgba_size) }.expect("rgba pixels"); + (width, height, rgba.as_slice().to_vec()) +} diff --git a/fluxer_media_proxy/src/media_process/tests/heif.rs b/fluxer_media_proxy/src/media_process/tests/heif.rs new file mode 100644 index 000000000..f3dc49af9 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/heif.rs @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::image_probe::probe_image_dims; +use super::super::{ImageDimensions, ImageOptions, ImageQuality, ResizeMode}; +use super::fixtures::{ + animated_mode, assert_animated_webp, decode_rgba, parse_png_chunks, test_media_limits, + transform_image, +}; +use crate::{ + metrics::transform::TransformMetrics, mime, output_format::OutputFormat, + test_fixtures::webp_chunk_payloads, +}; +use base64::Engine as _; + +#[test] +fn animated_heic_sequence_decodes_every_frame_and_encodes_an_animation() { + let sequence_b64 = "AAAAKGZ0eXBoZXZjAAAAAG1pZjFoZWljbWlhZm1zZjFpc29taGV2YwAAAtdtb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAAKAAAABAABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAACY3RyYWsAAABcdGtoZAAAAAcAAAAAAAAAAAAAAAEAAAAAAAAABAAAAAAAAAAAAAAAAAEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAQAAAAEAAAAAAAf9tZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAAAAKAAAABFXLAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAG2bWluZgAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAXZzdGJsAAAA7nN0c2QAAAAAAAAAAQAAAN5odmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABBEhFVkMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGP//AAAAeGh2Y0MBAWAAAAAAAAAAAAAe8AD8/fj4AAAPA2AAAQAYQAEMAf//AWAAAAMAkAAAAwAAAwAeloCQYQABACtCAQEBYAAAAwCQAAADAAADAB6gIIEFllqSSmubgIaDAgAAAwAUAAADAAIQYgABAAdEAcFytCJAAAAAEGNjc3QAAAAAwAAAAAAAABhzdHRzAAAAAAAAAAEAAAAEAAAAAQAAABxzdHNjAAAAAAAAAAEAAAABAAAABAAAAAEAAAAkc3RzegAAAAAAAAAAAAAABAAAAB4AAAAUAAAAFgAAABcAAAAUc3RjbwAAAAAAAAABAAAEgwAAABRzdHNzAAAAAAAAAAEAAAABAAAAFHZtaGQAAAABAAAAAAAAAAAAAAFWbWV0YQAAAAAAAAAhaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAAAAAAAiaWxvYwAAAABEQAABAAEAAAAABF0AAQAAAAAAAAAeAAAAI2lpbmYAAAAAAAEAAAAVaW5mZQIAAAAAAQAAaHZjMQAAAAAOcGl0bQAAAAAAAQAAANZpcHJwAAAAt2lwY28AAAB4aHZjQwEDcAAAAAAAAAAAAB7wAPz9+PgAAA8DYAABABhAAQwB//8DcAAAAwCQAAADAAADAB66AkBhAAEAK0IBAQNwAAADAJAAAAMAAAMAHqAggQWW6kkprm4CGgwIAAADAMgAAAMACEBiAAEAB0QBwXKwIkAAAAATY29scm5jbHgAAQANAAaAAAAAFGlzcGUAAAAAAAAAQAAAAEAAAAAQcGl4aQAAAAADCAgIAAAAF2lwbWEAAAAAAAAAAQABBIECAwQAAAAmbWRhdAAAABooAa8J4CE5dV///sFn+9+qu//6bdHQcw9fgAAAAGdtZGF0AAAAGigBrwzgKGIep//+cn78j8uGP/9E0AcnMQj4AAAAEAIB0Al+KTYKhIVYOmGcqmIAAAASAgHQEf/UpKYKhITEg+YZQKpiAAAAEwIB0Bif/cpJGCwSFRIERhlAqmI="; + let heic = base64::engine::general_purpose::STANDARD + .decode(sequence_b64) + .expect("fixture decodes"); + let sniffed = mime::sniff(&heic); + assert_eq!("image/heic", sniffed.mime); + assert!(!sniffed.animated); + + let animated = transform_image( + &heic, + &ImageOptions { + format: OutputFormat::WebP, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("animated heic transforms"); + assert_eq!("image/webp", animated.content_type); + assert_animated_webp(&animated.bytes, 4); + assert_eq!( + ImageDimensions { + width: 64, + height: 64, + pages: 4 + }, + probe_image_dims(&test_media_limits(), &animated.bytes).expect("probes webp") + ); + + let mut heif = heic.clone(); + let ftyp_len = u32::from_be_bytes(heif[0..4].try_into().expect("ftyp length")) as usize; + for brand in heif[8..ftyp_len].chunks_exact_mut(4) { + if brand == b"heic" || brand == b"hevc" { + brand.copy_from_slice(b"msf1"); + } + } + assert_eq!("image/heif", mime::sniff(&heif).mime); + let animated_heif = transform_image( + &heif, + &ImageOptions { + format: OutputFormat::WebP, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("animated heif transforms"); + assert_animated_webp(&animated_heif.bytes, 4); +} + +#[test] +fn still_heic_decodes_as_a_single_frame_when_animation_is_requested() { + let still_b64 = "AAAAHGZ0eXBoZWljAAAAAG1pZjFoZWljbWlhZgAAAVZtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAAAAAAACJpbG9jAAAAAERAAAEAAQAAAAABegABAAAAAAAAAB4AAAAjaWluZgAAAAAAAQAAABVpbmZlAgAAAAABAABodmMxAAAAAA5waXRtAAAAAAABAAAA1mlwcnAAAAC3aXBjbwAAAHhodmNDAQNwAAAAAAAAAAAAHvAA/P34+AAADwNgAAEAGEABDAH//wNwAAADAJAAAAMAAAMAHroCQGEAAQArQgEBA3AAAAMAkAAAAwAAAwAeoCCBBZbqSSmubgIaDAgAAAMAyAAAAwAIQGIAAQAHRAHBcrAiQAAAABNjb2xybmNseAABAA0ABoAAAAAUaXNwZQAAAAAAAABAAAAAQAAAABBwaXhpAAAAAAMICAgAAAAXaXBtYQAAAAAAAAABAAEEgQIDBAAAACZtZGF0AAAAGigBrwngITl1X//+wWf736q7//pt0dBzD1+A"; + let heic = base64::engine::general_purpose::STANDARD + .decode(still_b64) + .expect("fixture decodes"); + assert_eq!("image/heic", mime::sniff(&heic).mime); + + let still = transform_image( + &heic, + &ImageOptions { + format: OutputFormat::WebP, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("still heic transforms"); + assert_eq!("image/webp", still.content_type); + assert!(webp_chunk_payloads(&still.bytes, b"ANIM").is_empty()); + assert_eq!( + ImageDimensions { + width: 64, + height: 64, + pages: 1 + }, + probe_image_dims(&test_media_limits(), &still.bytes).expect("probes webp") + ); +} + +#[test] +fn avif_direct_decode_applies_resize_and_crop() { + let fixture_b64 = "AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAAD5bWV0YQAAAAAAAAAvaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAFBpY3R1cmVIYW5kbGVyAAAAAA5waXRtAAAAAAABAAAAHmlsb2MAAAAARAAAAQABAAAAAQAAASEAAAFZAAAAKGlpbmYAAAAAAAEAAAAaaW5mZQIAAAAAAQAAYXYwMUNvbG9yAAAAAGppcHJwAAAAS2lwY28AAAAUaXNwZQAAAAAAAABAAAAAMAAAABBwaXhpAAAAAAMICAgAAAAMYXYxQ4EADAAAAAATY29scm5jbHgAAgACAAIAAAAAF2lwbWEAAAAAAAAAAQABBAECgwQAAAFhbWRhdAoGGBV/vbAIMs4CRgAABBBBQEqBANtxpEnkS8i7Ewu1Oa+E52+0gHxmN6DekBiIYovbIpo+I+L2MbaIuGgpmhiq3wmhtHx3Lyb9HWhe08jL3lTmL0L92z3pFGZiyNiXjoWSnt6Vs2YF9Ogt2S1YudcnVbcGESJSHNs+6UmubDO+hIB+aL08iAZr/qkVPsTgHY5xL3y7b0B4W8BuTdfXeVy/nJ8V2xmFc1fc4DXzEalW69hTvoJEKuitiwnHu32Gr1Qbjk88s36/tv1BQ2bbYX/QIFDJwLoME7YrHOzOB0zEmhjjdKZkNDwlG0u7YsB5EvaXAnkkgF6l5yaKb8tv2ZBYJO+kDNE7uK8kt5dEIlsrravn8byytjhCTzx5rRLwkj6obavPpIgh/z/z9mG1oxZ2zWugKXunGbw64JUJ+fUiTa2frsG0dGb02dKJ4rPXq9ZQY/B4G3nuZg=="; + let avif = base64::engine::general_purpose::STANDARD + .decode(fixture_b64) + .unwrap(); + + let resized = transform_image( + &avif, + &ImageOptions { + width: Some(32), + format: OutputFormat::WebP, + quality: ImageQuality::High, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/webp", resized.content_type); + assert_eq!( + ImageDimensions { + width: 32, + height: 24, + pages: 1 + }, + probe_image_dims(&test_media_limits(), &resized.bytes).unwrap() + ); + + let cropped = transform_image( + &avif, + &ImageOptions { + width: Some(32), + height: Some(32), + format: OutputFormat::PNG, + quality: ImageQuality::High, + resize_mode: ResizeMode::Cover, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/png", cropped.content_type); + assert_eq!( + ImageDimensions { + width: 32, + height: 32, + pages: 1 + }, + probe_image_dims(&test_media_limits(), &cropped.bytes).unwrap() + ); +} + +#[test] +fn hdr_pq_avif_tone_maps_to_sdr_pixels() { + let hdr_b64 = "AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAAD5bWV0YQAAAAAAAAAvaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAFBpY3R1cmVIYW5kbGVyAAAAAA5waXRtAAAAAAABAAAAHmlsb2MAAAAARAAAAQABAAAAAQAAASEAAAAkAAAAKGlpbmYAAAAAAAEAAAAaaW5mZQIAAAAAAQAAYXYwMUNvbG9yAAAAAGppcHJwAAAAS2lwY28AAAAUaXNwZQAAAAAAAABAAAAAMAAAABBwaXhpAAAAAAMKCgoAAAAMYXYxQ4EATAAAAAATY29scm5jbHgACQAQAAkAAAAAF2lwbWEAAAAAAAAAAQABBAECgwQAAAAsbWRhdAoOAgAABVV/vGr5UJEAkEAyEhAAhgAIIAAAABAABWqmwW/2MA=="; + let avif = base64::engine::general_purpose::STANDARD + .decode(hdr_b64) + .expect("fixture decodes"); + assert_eq!("image/avif", mime::sniff(&avif).mime); + let metrics = TransformMetrics::new(); + let png = super::super::transform_image( + &avif, + &ImageOptions { + format: OutputFormat::PNG, + quality: ImageQuality::Lossless, + ..Default::default() + }, + &test_media_limits(), + &metrics, + ) + .expect("hdr avif transforms"); + assert_eq!("image/png", png.content_type); + assert_eq!(1, metrics.hdr_tone_map_count()); + let chunks = parse_png_chunks(&png.bytes).expect("valid png"); + let ihdr = chunks.first().expect("png has chunks"); + assert_eq!(*b"IHDR", ihdr.kind); + assert_eq!( + 8, ihdr.data[8], + "tone mapped output must be 8 bit per channel" + ); + let (width, height, rgba) = decode_rgba(&png.bytes); + assert_eq!((64, 48), (width, height)); + let centre = ((height as usize / 2) * width as usize + width as usize / 2) * 4; + let expected = [166u8, 160, 165, 255]; + for (channel, value) in expected.iter().enumerate() { + assert!( + rgba[centre + channel].abs_diff(*value) <= 3, + "channel {channel} tone mapped to {} instead of {value}", + rgba[centre + channel] + ); + } + assert!( + rgba[centre] >= 150, + "a PQ mid grey read as plain sRGB would land near 128, got {}", + rgba[centre] + ); + for pixel in rgba.chunks_exact(4) { + assert_eq!(255, pixel[3], "tone mapped output must stay opaque"); + for channel in 0..3 { + assert!( + pixel[channel].abs_diff(expected[channel]) <= 8, + "flat source produced {pixel:?}" + ); + } + } +} + +#[test] +fn hdr_pq_avif_tone_maps_even_when_the_colour_signal_is_one_libheif_cannot_model() { + let hdr_icc_b64 = "AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAAGObWV0YQAAAAAAAAAvaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAFBpY3R1cmVIYW5kbGVyAAAAAA5waXRtAAAAAAABAAAAHmlsb2MAAAAARAAAAQABAAAAAQAAAbYAAAAkAAAAKGlpbmYAAAAAAAEAAAAaaW5mZQIAAAAAAQAAYXYwMUNvbG9yAAAAAP9pcHJwAAAA32lwY28AAAAUaXNwZQAAAAAAAABAAAAAMAAAABBwaXhpAAAAAAMKCgoAAAAMYXYxQ4EATAAAAAATY29scm5jbHgACQAQAAkAAAAAlGNvbHJwcm9mAAAAiG5vbmUEMAAAbW50clJHQiBYWVogAAAAAAAAAAAAAAAAYWNzcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABhpcG1hAAAAAAAAAAEAAQUBAoMEBQAAACxtZGF0Cg4CAAAFVX+8avlQkQCQQDISEACGAAggAAAAEAAFaqbBb/Yw"; + let avif = base64::engine::general_purpose::STANDARD + .decode(hdr_icc_b64) + .expect("fixture decodes"); + assert_eq!("image/avif", mime::sniff(&avif).mime); + let nclx = avif + .windows(8) + .position(|window| window == b"colrnclx") + .expect("fixture carries an nclx colour box"); + assert!( + avif.windows(8).any(|window| window == b"colrprof"), + "fixture carries an icc colour box" + ); + let mut cases = vec![("an icc profile", avif.clone())]; + for (label, offset) in [ + ("unspecified primaries", nclx + 8), + ("unspecified matrix coefficients", nclx + 12), + ] { + let mut mutated = avif.clone(); + mutated[offset..offset + 2].copy_from_slice(&2u16.to_be_bytes()); + cases.push((label, mutated)); + } + for (label, bytes) in cases { + let metrics = TransformMetrics::new(); + let png = super::super::transform_image( + &bytes, + &ImageOptions { + format: OutputFormat::PNG, + quality: ImageQuality::Lossless, + ..Default::default() + }, + &test_media_limits(), + &metrics, + ) + .unwrap_or_else(|error| panic!("hdr avif with {label} transforms: {error:?}")); + assert_eq!(1, metrics.hdr_tone_map_count(), "hdr avif with {label}"); + let (width, height, rgba) = decode_rgba(&png.bytes); + assert_eq!((64, 48), (width, height), "hdr avif with {label}"); + let centre = ((height as usize / 2) * width as usize + width as usize / 2) * 4; + assert!( + rgba[centre] >= 150, + "hdr avif with {label} read its PQ samples as plain sRGB and landed at {}", + rgba[centre] + ); + } +} diff --git a/fluxer_media_proxy/src/media_process/tests/metadata.rs b/fluxer_media_proxy/src/media_process/tests/metadata.rs new file mode 100644 index 000000000..8af34c534 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/metadata.rs @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::placeholder::{encode_thumbhash, optional_thumbhash}; +use super::super::{ + MediaError, MetadataOptions, NSFW_PREVIEW_MAX_DIMENSION, metadata_json_with_options, + probe_av_metadata, +}; +use super::fixtures::{metadata_value, test_media_limits}; +use crate::{ + metrics::transform::TransformMetrics, + mime, + nsfw::{NSFWClient, NSFWPolicy}, + test_fixtures::{ + fixture_audio_mp3_with_png_cover_art, fixture_audio_mp4_with_attached_picture, + fixture_audio_only_mp4, fixture_h264_mp4, fixture_mkv_with_png_video_stream, + fixture_mp4_with_undecodable_video, synthetic_png, synthetic_wav, + }, +}; +use std::sync::Arc; + +#[test] +fn metadata_json_includes_dimensions_and_placeholder() { + let png = synthetic_png(16, 16); + let meta = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(async { + metadata_json_with_options( + &png, + "test.png", + MetadataOptions::default(), + &test_media_limits(), + &NSFWClient::disabled(), + &TransformMetrics::new(), + ) + .await + .unwrap() + }); + assert!(meta.contains("\"format\":\"png\"")); + assert!(meta.contains("\"width\":16")); + assert!(meta.contains("\"height\":16")); + assert!(meta.contains("\"placeholder\":\"")); + assert!(meta.contains("\"nsfw\":false")); + assert!(meta.contains("\"nsfw_probability\":0")); +} + +#[test] +fn metadata_json_returns_unavailable_when_nsfw_service_fails() { + let png = synthetic_png(16, 16); + let err = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let client = NSFWClient::new( + "http://127.0.0.1:9", + 0.85, + Arc::new(crate::metrics::nsfw::NSFWMetrics::new()), + ) + .expect("nsfw client builds"); + metadata_json_with_options( + &png, + "test.png", + MetadataOptions { + placeholder: false, + nsfw: NSFWPolicy::enabled(0.85).expect("valid threshold"), + }, + &test_media_limits(), + &client, + &TransformMetrics::new(), + ) + .await + .unwrap_err() + }); + assert_eq!(err, MediaError::NsfwScanUnavailable); +} + +#[test] +fn metadata_json_uses_null_dimensions_for_audio() { + let wav = synthetic_wav(); + let value = metadata_value(&wav, "test.wav"); + assert_eq!(value["content_type"], "audio/wav"); + assert_eq!(value.get("width"), Some(&serde_json::Value::Null)); + assert_eq!(value.get("height"), Some(&serde_json::Value::Null)); + assert_eq!(value["duration"], 1); +} + +#[test] +fn metadata_json_classifies_audio_only_mp4_as_audio() { + let mp4 = fixture_audio_only_mp4(); + assert_eq!("video/mp4", mime::sniff(&mp4).mime); + let value = metadata_value(&mp4, "renamed.mp4"); + assert_eq!(value["content_type"], "audio/mp4"); + assert_eq!(value["format"], "m4a"); + assert_eq!(value.get("width"), Some(&serde_json::Value::Null)); + assert_eq!(value.get("height"), Some(&serde_json::Value::Null)); + assert_eq!(value["duration"], 1); +} + +#[test] +fn metadata_json_treats_mp4_attached_picture_as_audio_cover_art() { + let mp4 = fixture_audio_mp4_with_attached_picture(); + let probe = probe_av_metadata(&mp4, NSFW_PREVIEW_MAX_DIMENSION, &test_media_limits(), None) + .expect("audio mp4 probes") + .probe; + assert!(probe.has_audio); + assert!(!probe.has_video); + let value = metadata_value(&mp4, "renamed.mp4"); + assert_eq!(value["content_type"], "audio/mp4"); + assert_eq!(value["format"], "m4a"); + assert_eq!(value.get("width"), Some(&serde_json::Value::Null)); + assert_eq!(value.get("height"), Some(&serde_json::Value::Null)); +} + +#[test] +fn metadata_json_accepts_audio_carrying_png_cover_art() { + let mp3 = fixture_audio_mp3_with_png_cover_art(); + assert_eq!("audio/mpeg", mime::sniff(&mp3).mime); + let probe = probe_av_metadata(&mp3, NSFW_PREVIEW_MAX_DIMENSION, &test_media_limits(), None) + .expect("audio with png cover art probes") + .probe; + assert!(probe.has_audio); + assert!(!probe.has_video); + let value = metadata_value(&mp3, "cover.mp3"); + assert_eq!(value["content_type"], "audio/mpeg"); + assert_eq!(value["format"], "mp3"); + assert_eq!(value["size"], mp3.len()); + assert_eq!(value.get("width"), Some(&serde_json::Value::Null)); + assert_eq!(value.get("height"), Some(&serde_json::Value::Null)); + assert_eq!(value["animated"], false); + assert_eq!(value["duration"], 1); + assert_eq!(None, value.get("placeholder")); +} + +#[test] +fn metadata_json_degrades_when_the_video_frame_fails_to_decode() { + let intact = fixture_h264_mp4(); + let intact_value = metadata_value(&intact, "intact.mp4"); + assert_eq!(intact_value["width"], 16); + assert_eq!(intact_value["height"], 16); + + let broken = fixture_mp4_with_undecodable_video(); + assert_eq!( + Some(MediaError::MediaDecodeFailed), + probe_av_metadata( + &broken, + NSFW_PREVIEW_MAX_DIMENSION, + &test_media_limits(), + None + ) + .err() + ); + let value = metadata_value(&broken, "broken.mp4"); + assert_eq!(value["content_type"], "video/mp4"); + assert_eq!(value["format"], "mp4"); + assert_eq!(value.get("width"), Some(&serde_json::Value::Null)); + assert_eq!(value.get("height"), Some(&serde_json::Value::Null)); + assert_eq!(None, value.get("placeholder")); +} + +#[test] +fn metadata_json_still_rejects_a_playable_video_stream_outside_the_codec_allowlist() { + let mkv = fixture_mkv_with_png_video_stream(); + assert_eq!("video/x-matroska", mime::sniff(&mkv).mime); + assert_eq!( + Some(MediaError::MediaDecodeFailed), + probe_av_metadata(&mkv, NSFW_PREVIEW_MAX_DIMENSION, &test_media_limits(), None).err() + ); + let err = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap() + .block_on(async { + metadata_json_with_options( + &mkv, + "png.mkv", + MetadataOptions::default(), + &test_media_limits(), + &NSFWClient::disabled(), + &TransformMetrics::new(), + ) + .await + .unwrap_err() + }); + assert_eq!(MediaError::MediaDecodeFailed, err); +} + +#[test] +fn metadata_keeps_the_placeholder_for_every_aspect_ratio() { + for (width, height, filename) in [ + (64, 64, "square.png"), + (800, 50, "wide.png"), + (1000, 100, "banner.png"), + (100, 1000, "tall.png"), + ] { + let value = metadata_value(&synthetic_png(width, height), filename); + assert_eq!(width, value["width"]); + assert_eq!(height, value["height"]); + assert!( + value["placeholder"] + .as_str() + .is_some_and(|placeholder| !placeholder.is_empty()), + "{filename} should keep its placeholder" + ); + } +} + +#[test] +fn thumbhash_for_valid_image_is_non_empty() { + let png = synthetic_png(16, 16); + let hash = encode_thumbhash(&test_media_limits(), &png, None).unwrap(); + assert!(!hash.is_empty()); +} + +#[test] +fn metadata_omits_the_placeholder_when_thumbhash_allocation_fails() { + assert_eq!( + None, + optional_thumbhash(Err(MediaError::AllocationFailed), "image_metadata") + ); + assert_eq!( + None, + optional_thumbhash(Err(MediaError::MediaTransformFailed), "video_metadata") + ); +} + +#[test] +fn metadata_succeeds_without_a_placeholder_when_thumbhash_generation_fails() { + let mut png = synthetic_png(64, 64); + let idat = png + .windows(4) + .position(|window| window == b"IDAT") + .expect("synthetic png has an IDAT chunk"); + png.truncate(idat + 8); + let value = metadata_value(&png, "truncated.png"); + assert_eq!("image/png", value["content_type"]); + assert_eq!(64, value["width"]); + assert_eq!(64, value["height"]); + assert_eq!(None, value.get("placeholder")); +} diff --git a/fluxer_media_proxy/src/media_process/tests/mod.rs b/fluxer_media_proxy/src/media_process/tests/mod.rs new file mode 100644 index 000000000..37049f0cd --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/mod.rs @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod animated_apng; +mod animated_gif; +mod animated_webp; +mod encoding; +mod fixtures; +mod heif; +mod metadata; +mod nsfw; +mod transform; +mod video; diff --git a/fluxer_media_proxy/src/media_process/tests/nsfw.rs b/fluxer_media_proxy/src/media_process/tests/nsfw.rs new file mode 100644 index 000000000..e74947c7c --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/nsfw.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::nsfw_processing::{ + VideoNSFWFramesRequest, compute_frame_sample_timestamps, extract_video_frames_for_nsfw, + nsfw_frame_seed, +}; +use super::super::probe_av_metadata; +use super::fixtures::test_media_limits; + +#[test] +fn compute_frame_sample_timestamps_distributes_start_middle_end() { + use rand::SeedableRng as _; + let seed = [7u8; 32]; + let mut prng = rand_chacha::ChaCha8Rng::from_seed(seed); + let ts = compute_frame_sample_timestamps(Some(10.0), &mut prng); + for t in ts { + assert!((0.0..10.0).contains(&t), "ts {t} out of range"); + } + assert!( + ts[0] < ts[1], + "start {} should precede middle {}", + ts[0], + ts[1] + ); + assert!( + ts[1] < ts[2], + "middle {} should precede end {}", + ts[1], + ts[2] + ); +} + +#[test] +fn nsfw_frame_seed_is_deterministic_per_input() { + let a = b"hello world this is a video header blob".to_vec(); + let b = b"hello world this is a video header blob".to_vec(); + let c = b"hello world this is a different blob xx".to_vec(); + assert_eq!(nsfw_frame_seed(&a), nsfw_frame_seed(&b)); + assert_ne!(nsfw_frame_seed(&a), nsfw_frame_seed(&c)); +} + +#[test] +fn extract_video_frames_for_nsfw_returns_multiple_frames() { + let fixture = std::path::Path::new("tests/fixtures/big-buck-bunny-720p-10s.mp4"); + let alt = std::path::Path::new(".benchmark-cache/media/big-buck-bunny-720p-10s.mp4"); + let path = if fixture.exists() { + fixture + } else if alt.exists() { + alt + } else { + eprintln!("skipping: no video fixture available"); + return; + }; + let bytes = std::fs::read(path).expect("read fixture"); + let limits = test_media_limits(); + let duration = probe_av_metadata(&bytes, 0, &limits, None) + .expect("probe fixture") + .probe + .duration_seconds; + let frames = extract_video_frames_for_nsfw(VideoNSFWFramesRequest { + media_limits: &limits, + input: &bytes, + duration_seconds: duration, + deadline_ms: None, + }) + .expect("extract frames"); + assert!( + !frames.is_empty() && frames.len() <= 3, + "expected 1-3 frames, got {}", + frames.len() + ); + for f in &frames { + assert!(f.len() > 100, "JPEG frame too small ({} bytes)", f.len()); + assert_eq!(&f[..2], &[0xFF, 0xD8], "not a JPEG"); + } +} diff --git a/fluxer_media_proxy/src/media_process/tests/transform.rs b/fluxer_media_proxy/src/media_process/tests/transform.rs new file mode 100644 index 000000000..b141c13ef --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/transform.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::ImageOptions; +use super::super::image_probe::load_image; +use super::super::native_runtime::ensure_vips_init; +use super::super::transform::source_supports_pages; +use super::fixtures::{animated_mode, metadata_value, transform_image}; +use crate::{ + mime, native, + output_format::OutputFormat, + test_fixtures::{synthetic_bmp, synthetic_png}, +}; + +#[test] +fn transforms_png_to_webp() { + let png = synthetic_png(32, 24); + let out = transform_image( + &png, + &ImageOptions { + width: Some(16), + format: OutputFormat::WebP, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!("image/webp", out.content_type); + assert!(out.bytes.starts_with(b"RIFF")); +} + +#[test] +fn transforms_static_png_with_animated_flag_does_not_pass_n_to_pngload() { + let png = synthetic_png(48, 48); + let out = transform_image( + &png, + &ImageOptions { + width: Some(32), + height: Some(32), + format: OutputFormat::WebP, + animation: animated_mode(), + ..Default::default() + }, + ) + .expect("static-png + animated=true must transform without erroring"); + assert_eq!("image/webp", out.content_type); + assert!(out.bytes.starts_with(b"RIFF")); +} + +#[test] +fn transforms_a_24_bit_bmp_to_webp_without_unblocking_imagemagick() { + ensure_vips_init().expect("vips must initialise"); + let bmp = synthetic_bmp(4, 4); + assert_eq!("image/bmp", mime::sniff(&bmp).mime); + assert!(mime::is_supported_media_mime("image/bmp")); + assert!( + load_image(&bmp, "access=sequential,fail=true").is_err(), + "the libvips loader allowlist must keep ImageMagick blocked for bmp" + ); + let out = transform_image( + &bmp, + &ImageOptions { + format: OutputFormat::WebP, + ..Default::default() + }, + ) + .expect("a 24-bit bmp must transform end to end"); + assert_eq!("image/webp", out.content_type); + assert!(out.bytes.starts_with(b"RIFF")); + assert_eq!( + Some(b"WEBP"), + out.bytes.get(8..12).map(|tag| tag.try_into().unwrap()) + ); + let decoded = + load_image(&out.bytes, "access=sequential,fail=true").expect("output must be webp"); + assert_eq!(4, unsafe { + native::fluxer_vips_image_get_width(decoded.as_ptr()) + }); + assert_eq!(4, unsafe { + native::fluxer_vips_image_get_height(decoded.as_ptr()) + }); +} + +#[test] +fn reports_bmp_metadata_dimensions_and_placeholder() { + let bmp = synthetic_bmp(64, 48); + let meta = metadata_value(&bmp, "photo.bmp"); + assert_eq!("image/bmp", meta["content_type"]); + assert_eq!("bmp", meta["format"]); + assert_eq!(64, meta["width"]); + assert_eq!(48, meta["height"]); + assert_eq!(false, meta["animated"]); + assert!(meta["placeholder"].as_str().is_some_and(|p| !p.is_empty())); +} + +#[test] +fn source_supports_pages_matches_libvips_loader_list() { + assert!(source_supports_pages("image/webp")); + assert!(source_supports_pages("image/gif")); + assert!(source_supports_pages("image/apng")); + assert!(source_supports_pages("image/heif")); + assert!(source_supports_pages("image/avif")); + assert!(!source_supports_pages("image/png")); + assert!(!source_supports_pages("image/jpeg")); + assert!(!source_supports_pages("image/bmp")); + assert!(!source_supports_pages("application/octet-stream")); +} diff --git a/fluxer_media_proxy/src/media_process/tests/video.rs b/fluxer_media_proxy/src/media_process/tests/video.rs new file mode 100644 index 000000000..6c78dcf8d --- /dev/null +++ b/fluxer_media_proxy/src/media_process/tests/video.rs @@ -0,0 +1,374 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::super::{MediaError, extract_video_thumbnail}; +use super::fixtures::{decode_rgba, metadata_value, test_media_limits}; +use crate::{ + output_format::OutputFormat, + test_fixtures::{ + ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4, png_dimensions, + }, +}; + +fn color_tagged_video(primaries: &str, transfer: &str, matrix: &str) -> Option> { + let params = + format!("setparams=color_primaries={primaries}:color_trc={transfer}:colorspace={matrix}"); + ffmpeg_gen_media( + "fixture.mkv", + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=320x240:rate=10:duration=1", + "-vf", + ¶ms, + "-pix_fmt", + "yuv420p", + "-c:v", + "mpeg4", + "-f", + "matroska", + ], + ) +} + +#[test] +fn video_thumbnail_accepts_smpte170m_sd_primaries() { + let Some(video) = color_tagged_video("smpte170m", "smpte170m", "smpte170m") else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + let thumb = extract_video_thumbnail(&video, OutputFormat::PNG, &test_media_limits()) + .expect("ntsc sd video should thumbnail"); + assert_eq!(png_dimensions(&thumb.bytes), Some((320, 240))); +} + +#[test] +fn video_thumbnail_accepts_bt470bg_pal_primaries() { + let Some(video) = color_tagged_video("bt470bg", "bt470bg", "bt470bg") else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + let thumb = extract_video_thumbnail(&video, OutputFormat::PNG, &test_media_limits()) + .expect("pal sd video should thumbnail"); + assert_eq!(png_dimensions(&thumb.bytes), Some((320, 240))); + + let film = color_tagged_video("film", "bt470m", "bt470bg").expect("film fixture"); + let thumb = extract_video_thumbnail(&film, OutputFormat::PNG, &test_media_limits()) + .expect("film primaries should thumbnail"); + assert_eq!(png_dimensions(&thumb.bytes), Some((320, 240))); +} + +#[test] +fn video_thumbnail_accepts_every_widened_sdr_transfer() { + let transfers = ["smpte240m", "linear", "iec61966-2-1", "bt470m", "bt470bg"]; + let Some(first) = color_tagged_video("bt709", transfers[0], "bt709") else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + for (index, transfer) in transfers.iter().enumerate() { + let video = if index == 0 { + first.clone() + } else { + color_tagged_video("bt709", transfer, "bt709") + .unwrap_or_else(|| panic!("{transfer} fixture")) + }; + let thumb = extract_video_thumbnail(&video, OutputFormat::PNG, &test_media_limits()) + .unwrap_or_else(|error| panic!("{transfer} should thumbnail: {error:?}")); + assert_eq!(png_dimensions(&thumb.bytes), Some((320, 240)), "{transfer}"); + } +} + +#[test] +fn video_thumbnail_still_rejects_pq_bt2020_hdr() { + let Some(hdr) = color_tagged_video("bt2020", "smpte2084", "bt2020nc") else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + assert_eq!( + Some(MediaError::MediaDecodeFailed), + extract_video_thumbnail(&hdr, OutputFormat::PNG, &test_media_limits()).err() + ); + + let pq_only = color_tagged_video("bt709", "smpte2084", "bt709").expect("pq fixture"); + assert_eq!( + Some(MediaError::MediaDecodeFailed), + extract_video_thumbnail(&pq_only, OutputFormat::PNG, &test_media_limits()).err() + ); + + let hlg = color_tagged_video("bt2020", "arib-std-b67", "bt709").expect("hlg fixture"); + assert_eq!( + Some(MediaError::MediaDecodeFailed), + extract_video_thumbnail(&hlg, OutputFormat::PNG, &test_media_limits()).err() + ); + + let wide_primaries = color_tagged_video("bt2020", "bt709", "bt709").expect("bt2020 fixture"); + assert_eq!( + Some(MediaError::MediaDecodeFailed), + extract_video_thumbnail(&wide_primaries, OutputFormat::PNG, &test_media_limits()).err() + ); +} + +#[test] +fn video_thumbnail_corrects_display_geometry() { + let Some(plain) = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=320x240:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]) else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + let thumb = extract_video_thumbnail(&plain, OutputFormat::PNG, &test_media_limits()) + .expect("plain thumbnail"); + assert_eq!( + png_dimensions(&thumb.bytes), + Some((320, 240)), + "square-pixel video should keep its coded dimensions" + ); + + let anamorphic = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=1280x720:rate=10:duration=1", + "-vf", + "setsar=2/1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]) + .expect("anamorphic fixture"); + let thumb = extract_video_thumbnail(&anamorphic, OutputFormat::PNG, &test_media_limits()) + .expect("anamorphic thumbnail"); + let (w, h) = png_dimensions(&thumb.bytes).expect("anamorphic png dimensions"); + assert_eq!(h, 720, "anamorphic height preserved"); + assert!( + (i64::from(w) - 2560).abs() <= 2, + "anamorphic width should expand to the ~2560 display width, got {w}" + ); + + let narrow = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=1280x720:rate=10:duration=1", + "-vf", + "setsar=1/2", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]) + .expect("narrow anamorphic fixture"); + let thumb = extract_video_thumbnail(&narrow, OutputFormat::PNG, &test_media_limits()) + .expect("narrow anamorphic thumbnail"); + assert_eq!( + png_dimensions(&thumb.bytes), + Some((640, 720)), + "sub-square pixel video should shrink width to its display size" + ); + + let rotated = ffmpeg_gen_rotated_mp4( + "90", + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=640x480:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ], + ) + .expect("rotated fixture"); + let thumb = extract_video_thumbnail(&rotated, OutputFormat::PNG, &test_media_limits()) + .expect("rotated thumbnail"); + assert_eq!( + png_dimensions(&thumb.bytes), + Some((480, 640)), + "rotation-metadata video should present in its display (portrait) orientation" + ); + + let rotated_counterclockwise = ffmpeg_gen_rotated_mp4( + "-90", + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=640x480:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ], + ) + .expect("counterclockwise rotated fixture"); + let thumb = extract_video_thumbnail( + &rotated_counterclockwise, + OutputFormat::PNG, + &test_media_limits(), + ) + .expect("counterclockwise rotated thumbnail"); + assert_eq!( + png_dimensions(&thumb.bytes), + Some((480, 640)), + "either quarter-turn direction should swap dimensions" + ); + + let rotated_anamorphic = ffmpeg_gen_rotated_mp4( + "90", + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=320x180:rate=10:duration=1", + "-vf", + "setsar=2/1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ], + ) + .expect("rotated anamorphic fixture"); + let thumb = + extract_video_thumbnail(&rotated_anamorphic, OutputFormat::PNG, &test_media_limits()) + .expect("rotated anamorphic thumbnail"); + assert_eq!( + png_dimensions(&thumb.bytes), + Some((180, 640)), + "SAR correction should happen in coded space before rotation" + ); + + let single = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=200x150:rate=1:duration=1", + "-frames:v", + "1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]) + .expect("single-frame fixture"); + let thumb = extract_video_thumbnail(&single, OutputFormat::PNG, &test_media_limits()) + .expect("single-frame thumbnail"); + assert_eq!( + png_dimensions(&thumb.bytes), + Some((200, 150)), + "single-frame clip should still produce a thumbnail" + ); +} + +#[test] +fn mirrored_display_matrix_flips_the_video_frame_horizontally() { + let source_args = [ + "-f", + "lavfi", + "-i", + "testsrc=size=64x48:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]; + let Some(plain) = ffmpeg_gen_mp4(&source_args) else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + let mirrored = ffmpeg_mirror_mp4(&plain).expect("mirrored fixture"); + let plain_thumb = extract_video_thumbnail(&plain, OutputFormat::PNG, &test_media_limits()) + .expect("plain thumbnail"); + let mirrored_thumb = + extract_video_thumbnail(&mirrored, OutputFormat::PNG, &test_media_limits()) + .expect("mirrored thumbnail"); + let (width, height, plain_rgba) = decode_rgba(&plain_thumb.bytes); + let (mirrored_width, mirrored_height, mirrored_rgba) = decode_rgba(&mirrored_thumb.bytes); + assert_eq!((64, 48), (width, height)); + assert_eq!((width, height), (mirrored_width, mirrored_height)); + assert_ne!( + plain_rgba, mirrored_rgba, + "the mirrored source must not decode to the same pixels" + ); + let row_stride = width as usize * 4; + for y in 0..height as usize { + for x in 0..width as usize { + let source = y * row_stride + x * 4; + let mirror = y * row_stride + (width as usize - 1 - x) * 4; + assert_eq!( + plain_rgba[source..source + 4], + mirrored_rgba[mirror..mirror + 4], + "pixel ({x}, {y}) is not the horizontal mirror of the source" + ); + } + } +} + +#[test] +fn video_metadata_placeholder_and_dimensions_are_display_corrected() { + let Some(rotated) = ffmpeg_gen_rotated_mp4( + "90", + &[ + "-f", + "lavfi", + "-i", + "testsrc=size=640x480:rate=10:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ], + ) else { + eprintln!("skipping: ffmpeg CLI not available"); + return; + }; + let meta = metadata_value(&rotated, "rotated.mp4"); + assert_eq!( + meta["width"].as_u64(), + Some(480), + "stored width is the display (portrait) width" + ); + assert_eq!( + meta["height"].as_u64(), + Some(640), + "stored height is the display (portrait) height" + ); + assert!( + meta["placeholder"].as_str().is_some_and(|s| !s.is_empty()), + "placeholder should be generated from the display-corrected frame" + ); + + let anamorphic = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=1280x720:rate=10:duration=1", + "-vf", + "setsar=2/1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]) + .expect("anamorphic fixture"); + let meta = metadata_value(&anamorphic, "anamorphic.mp4"); + assert_eq!( + meta["height"].as_u64(), + Some(720), + "stored height preserved" + ); + let w = meta["width"].as_u64().expect("stored width"); + assert!( + (w as i64 - 2560).abs() <= 2, + "stored width expands to the display width, got {w}" + ); +} diff --git a/fluxer_media_proxy/src/media_process/transform.rs b/fluxer_media_proxy/src/media_process/transform.rs new file mode 100644 index 000000000..025c858c5 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/transform.rs @@ -0,0 +1,443 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::animated_transform::{ + DecodedAnimation, decode_heif_sequence, gif_resize_dims, resize_animated_gif_with_ffmpeg, + try_decode_apng, try_decode_heif_primary_still, +}; +use super::encoding::{ + VipsEncodeRequest, anim_limits_from_options, encode_vips_image, + try_transform_animated_webp_direct, +}; +use super::image_probe::{animated_probe_from_image, load_image, probe_animated, try_decode_bmp}; +use super::loaded_image::{ + normalize_vips_image_to_uchar, page_height, resize_loaded_image, resize_loaded_image_by_scale, + validate_dimensions_u32, validate_vips_image, +}; +use super::native_runtime::{clear_vips_error, ensure_vips_init, last_vips_error}; +use super::transform_plan::{ + AutoQualitySource, output_is_sdr, resolve_auto_quality, should_transform_animated_webp_direct, +}; +use super::{ + AnimatedProbe, MediaError, ProcessedMedia, ensure_deadline_pending, + native_animated_frame_limit, native_optional_deadline, native_status_error, +}; +use crate::{ + constants, + image_transform::ImageOptions, + media_limits::MediaLimits, + metrics::transform::TransformMetrics, + mime, + native::{self, NativeStatus, VipsImageHandle}, + output_format::OutputFormat, +}; +use libc::c_int; +use std::ptr; + +pub(super) fn source_supports_pages(mime: &str) -> bool { + matches!( + mime, + "image/webp" | "image/gif" | "image/apng" | "image/heic" | "image/heif" | "image/avif" + ) +} + +fn should_use_resize_path(options: &ImageOptions, probe: Option) -> bool { + if options.width.is_none() && options.height.is_none() { + return false; + } + if options.is_animated() { + return true; + } + match probe { + Some(p) => p.pages <= 1, + None => true, + } +} + +fn effective_transform_format( + sniffed_mime: &str, + requested: OutputFormat, + animated: bool, +) -> OutputFormat { + if animated && sniffed_mime == "image/apng" && requested == OutputFormat::PNG { + OutputFormat::APNG + } else { + requested + } +} + +fn is_heif_source(mime: &str) -> bool { + matches!(mime, "image/avif" | "image/heic" | "image/heif") +} + +fn heif_source_may_be_a_sequence(sniffed: mime::SniffInfo) -> bool { + match sniffed.mime { + "image/avif" => sniffed.animated, + "image/heic" | "image/heif" => true, + _ => false, + } +} + +pub fn transform_image( + input: &[u8], + options: &ImageOptions, + media_limits: &MediaLimits, + metrics: &TransformMetrics, +) -> Result { + if input.len() > constants::MAX_MEDIA_PROXY_BYTES { + return Err(MediaError::StreamTooLong); + } + if let Some(width) = options.width + && (width == 0 || width > media_limits.image_dimension()) + { + return Err(MediaError::InvalidImageDimensions); + } + if let Some(height) = options.height + && (height == 0 || height > media_limits.image_dimension()) + { + return Err(MediaError::InvalidImageDimensions); + } + ensure_vips_init()?; + let sniffed = mime::sniff(input); + let animated = options.is_animated(); + let format = effective_transform_format(sniffed.mime, options.format, animated); + let animated_avif = sniffed.mime == "image/avif" && sniffed.animated; + let full_canvas_animation = animated + && format == OutputFormat::WebP + && (matches!(sniffed.mime, "image/gif" | "image/apng") || animated_avif); + if animated + && format == OutputFormat::GIF + && !options.wants_cover_crop() + && sniffed.mime == "image/gif" + { + let bytes = if let Some(dims) = gif_resize_dims(sniffed, options) { + resize_animated_gif_with_ffmpeg(input, dims, options, media_limits)? + } else { + input.to_vec() + }; + return Ok(ProcessedMedia { + bytes, + content_type: "image/gif", + }); + } + if should_transform_animated_webp_direct(sniffed, options, format) { + validate_dimensions_u32(media_limits, sniffed.width, sniffed.height)?; + let resolved = resolve_auto_quality( + AutoQualitySource { + format, + animated, + sniffed_mime: sniffed.mime, + input, + quality: options.quality, + probe: None, + }, + options.effort_override, + ); + let direct = try_transform_animated_webp_direct( + input, + options, + resolved.quality, + resolved.effort_override, + media_limits, + )?; + if let Some(bytes) = direct { + return Ok(ProcessedMedia { + bytes, + content_type: format.mime(), + }); + } + } + let heif_source = is_heif_source(sniffed.mime); + let decoded_animation = if sniffed.mime == "image/apng" { + try_decode_apng(input, animated, media_limits)? + } else if animated && heif_source_may_be_a_sequence(sniffed) { + decode_heif_sequence(input, media_limits)? + } else { + None + }; + let animated_probe = if let Some(decoded) = decoded_animation.as_ref() { + animated_probe_from_image(&decoded.image)? + } else if animated && !heif_source { + probe_animated(media_limits, input)? + } else { + None + }; + let resolved_quality = resolve_auto_quality( + AutoQualitySource { + format, + animated, + sniffed_mime: sniffed.mime, + input, + quality: options.quality, + probe: animated_probe, + }, + options.effort_override, + ); + let requires_heif_sdr_decode = heif_source && output_is_sdr(format); + let use_heif_primary_still_path = heif_source + && !animated_avif + && decoded_animation.is_none() + && (animated || requires_heif_sdr_decode); + if use_heif_primary_still_path + && let Some(decoded) = try_decode_heif_primary_still(input, media_limits, metrics)? + { + let mut image = decoded.image; + validate_vips_image(media_limits, &image)?; + if decoded.hdr_tone_mapped { + metrics.record_hdr_tone_map(); + } + image = resize_loaded_image(media_limits, image, options)?; + let page_height = if animated { page_height(&image)? } else { None }; + let bytes = encode_vips_image( + &image, + VipsEncodeRequest { + format, + quality: resolved_quality.quality, + page_height, + effort_override: resolved_quality.effort_override, + animation: anim_limits_from_options(options), + animation_loop_count: None, + media_limits, + full_canvas_animation, + }, + )?; + return Ok(ProcessedMedia { + bytes, + content_type: format.mime(), + }); + } + + if let Some(decoded) = decoded_animation { + let DecodedAnimation { image, loop_count } = decoded; + validate_vips_image(media_limits, &image)?; + let image = resize_loaded_image(media_limits, image, options)?; + let page_height = if animated { page_height(&image)? } else { None }; + let bytes = encode_vips_image( + &image, + VipsEncodeRequest { + format, + quality: resolved_quality.quality, + page_height, + effort_override: resolved_quality.effort_override, + animation: anim_limits_from_options(options), + animation_loop_count: loop_count, + media_limits, + full_canvas_animation, + }, + )?; + return Ok(ProcessedMedia { + bytes, + content_type: format.mime(), + }); + } + + if sniffed.mime == "image/bmp" + && let Some(image) = try_decode_bmp(input, media_limits)? + { + validate_vips_image(media_limits, &image)?; + let image = resize_loaded_image(media_limits, image, options)?; + let page_height = if animated { page_height(&image)? } else { None }; + let bytes = encode_vips_image( + &image, + VipsEncodeRequest { + format, + quality: resolved_quality.quality, + page_height, + effort_override: resolved_quality.effort_override, + animation: anim_limits_from_options(options), + animation_loop_count: None, + media_limits, + full_canvas_animation, + }, + )?; + return Ok(ProcessedMedia { + bytes, + content_type: format.mime(), + }); + } + + if should_use_resize_path(options, animated_probe) { + let mut raw = ptr::null_mut(); + let crop = + if options.wants_cover_crop() && options.width.is_some() && options.height.is_some() { + native::THUMB_CROP_CENTRE + } else { + native::THUMB_CROP_NONE + }; + let n_pages: c_int = if animated && source_supports_pages(sniffed.mime) { + -1 + } else { + 1 + }; + let (max_pages, max_total_pixels) = if n_pages == -1 { + ( + native_animated_frame_limit(media_limits), + media_limits.animated_total_pixels(), + ) + } else { + (1, media_limits.image_pixels()) + }; + let rc = unsafe { + native::fluxer_vips_thumbnail_buffer_ex( + input.as_ptr().cast(), + input.len(), + 0, + &mut raw, + options.width.unwrap_or(0) as c_int, + options.height.unwrap_or(0) as c_int, + n_pages, + crop, + max_pages, + max_total_pixels, + ) + }; + if rc != 0 || raw.is_null() { + let err = last_vips_error(); + clear_vips_error(); + tracing::error!( + target: "fluxer_media_proxy::transform_debug", + stage = "thumbnail_buffer_ex", + sniffed_mime = %sniffed.mime, + animated = animated, + w = options.width.unwrap_or(0), + h = options.height.unwrap_or(0), + pages = animated_probe.map(|p| p.pages).unwrap_or(0), + vips_err = %err, + "transform failed" + ); + return Err(MediaError::MediaTransformFailed); + } + let image = unsafe { VipsImageHandle::from_raw_borrowing(raw, input) } + .ok_or(MediaError::MediaTransformFailed)?; + let image = normalize_vips_image_to_uchar(media_limits, image)?; + let page_height = if animated { page_height(&image)? } else { None }; + let bytes = encode_vips_image( + &image, + VipsEncodeRequest { + format, + quality: resolved_quality.quality, + page_height, + effort_override: resolved_quality.effort_override, + animation: anim_limits_from_options(options), + animation_loop_count: None, + media_limits, + full_canvas_animation, + }, + )?; + return Ok(ProcessedMedia { + bytes, + content_type: format.mime(), + }); + } + + let loader_options = if animated && source_supports_pages(sniffed.mime) { + if sniffed.mime == "image/jpeg" { + "n=-1,access=sequential" + } else { + "n=-1,access=sequential,fail=true" + } + } else if sniffed.mime == "image/jpeg" { + "access=sequential" + } else { + "access=sequential,fail=true" + }; + let loaded = load_image(input, loader_options)?; + let loaded = normalize_vips_image_to_uchar(media_limits, loaded)?; + let mut oriented_raw = ptr::null_mut(); + let status = NativeStatus::from_code(unsafe { + native::fluxer_vips_autorot(loaded.as_ptr(), 0, &mut oriented_raw) + }); + if let Some(error) = native_status_error(status, MediaError::MediaTransformFailed) { + clear_vips_error(); + return Err(error); + } + let base = unsafe { loaded.adopt_derived_raw(oriented_raw) } + .ok_or(MediaError::MediaTransformFailed)?; + validate_vips_image(media_limits, &base)?; + let page_height = if animated { page_height(&base)? } else { None }; + let bytes = encode_vips_image( + &base, + VipsEncodeRequest { + format, + quality: resolved_quality.quality, + page_height, + effort_override: resolved_quality.effort_override, + animation: anim_limits_from_options(options), + animation_loop_count: None, + media_limits, + full_canvas_animation, + }, + )?; + Ok(ProcessedMedia { + bytes, + content_type: format.mime(), + }) +} + +pub(super) struct StaticThumbnailRequest<'a> { + pub(super) media_limits: &'a MediaLimits, + pub(super) input: &'a [u8], + pub(super) width: u32, + pub(super) height: u32, + pub(super) deadline_ms: Option, +} + +pub(super) fn load_static_thumbnail<'source>( + request: StaticThumbnailRequest<'source>, +) -> Result, MediaError> { + let StaticThumbnailRequest { + media_limits, + input, + width, + height, + deadline_ms, + } = request; + ensure_vips_init()?; + ensure_deadline_pending(deadline_ms)?; + if mime::sniff(input).mime == "image/bmp" + && let Some(image) = try_decode_bmp(input, media_limits)? + { + let image = normalize_vips_image_to_uchar(media_limits, image)?; + let scale = fit_inside_scale(&image, width, height); + return resize_loaded_image_by_scale(media_limits, image, scale); + } + let mut raw = ptr::null_mut(); + let rc = unsafe { + native::fluxer_vips_thumbnail_buffer_ex( + input.as_ptr().cast(), + input.len(), + native_optional_deadline(deadline_ms), + &mut raw, + c_int::try_from(width).map_err(|_| MediaError::InvalidImageDimensions)?, + c_int::try_from(height).map_err(|_| MediaError::InvalidImageDimensions)?, + 1, + native::THUMB_CROP_NONE, + 1, + media_limits.image_pixels(), + ) + }; + let image = unsafe { VipsImageHandle::from_raw_borrowing(raw, input) }; + ensure_deadline_pending(deadline_ms)?; + if let Some(error) = native_status_error( + NativeStatus::from_code(rc), + MediaError::MediaTransformFailed, + ) { + clear_vips_error(); + return Err(error); + } + let image = image.ok_or_else(|| { + clear_vips_error(); + MediaError::MediaTransformFailed + })?; + normalize_vips_image_to_uchar(media_limits, image) +} + +fn fit_inside_scale(image: &VipsImageHandle<'_>, width: u32, height: u32) -> f64 { + let image_width = unsafe { native::fluxer_vips_image_get_width(image.as_ptr()) }; + let image_height = unsafe { native::fluxer_vips_image_get_height(image.as_ptr()) }; + if image_width <= 0 || image_height <= 0 { + return 1.0; + } + (f64::from(width) / f64::from(image_width)) + .min(f64::from(height) / f64::from(image_height)) + .min(1.0) +} diff --git a/fluxer_media_proxy/src/media_process/transform_plan.rs b/fluxer_media_proxy/src/media_process/transform_plan.rs new file mode 100644 index 000000000..583bb5b11 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/transform_plan.rs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{AnimatedProbe, MediaError}; +use crate::{ + image_quality::{ImageQuality, ResolvedImageQuality}, + image_transform::{EncodeEffort, ImageOptions}, + mime, + output_format::OutputFormat, +}; +use libc::c_int; +use std::ffi::CString; + +const AUTO_LOSSLESS_PALETTE_ANIMATION_MAX_BYTES: usize = 4 * 1024 * 1024; +const AUTO_LOSSLESS_PALETTE_ANIMATION_MAX_PIXELS: usize = 16 * 1024 * 1024; +const VIPS_WEBP_MAX_EFFORT: u8 = 6; + +pub(super) fn output_suffix( + format: OutputFormat, + quality: ResolvedImageQuality, + page_height: Option, + effort_override: Option, +) -> Result { + let q = quality.encoder_quality(); + let animated = page_height.is_some(); + let lossless = if quality.is_lossless() { + "true" + } else { + "false" + }; + let effort = effort_override + .map(|effort| effort.get().min(VIPS_WEBP_MAX_EFFORT)) + .unwrap_or_else(|| quality.default_effort(animated)); + let suffix = match format { + OutputFormat::JPEG => format!(".jpg[Q={q},strip,interlace=true,optimize_coding=true]"), + OutputFormat::WebP => { + let effort = effort.min(VIPS_WEBP_MAX_EFFORT); + match page_height { + Some(ph) => format!( + ".webp[Q={q},lossless={lossless},strip,effort={effort},smart_subsample=true,alpha_q=90,page-height={ph}]" + ), + None => format!( + ".webp[Q={q},lossless={lossless},strip,effort={effort},smart_subsample=true,alpha_q=90]" + ), + } + } + OutputFormat::PNG | OutputFormat::APNG => match page_height { + Some(ph) if format == OutputFormat::APNG => { + format!(".png[strip,compression=9,filter=all,page-height={ph}]") + } + _ => ".png[strip,compression=9,filter=all]".to_owned(), + }, + OutputFormat::GIF => match page_height { + Some(ph) => { + format!(".gif[strip,dither=1.0,effort=7,interframe_maxerror=8.0,page-height={ph}]") + } + None => ".gif[strip,dither=1.0,effort=7]".to_owned(), + }, + }; + CString::new(suffix).map_err(|_| MediaError::MediaEncodeFailed) +} + +pub(super) fn output_is_sdr(format: OutputFormat) -> bool { + matches!( + format, + OutputFormat::JPEG + | OutputFormat::WebP + | OutputFormat::PNG + | OutputFormat::GIF + | OutputFormat::APNG + ) +} + +pub(super) fn should_transform_animated_webp_direct( + sniffed: mime::SniffInfo, + options: &ImageOptions, + format: OutputFormat, +) -> bool { + sniffed.mime == "image/webp" + && sniffed.animated + && options.is_animated() + && !options.wants_cover_crop() + && format == OutputFormat::WebP +} + +#[derive(Clone, Copy)] +pub(super) struct AutoQualitySource<'a> { + pub(super) format: OutputFormat, + pub(super) animated: bool, + pub(super) sniffed_mime: &'a str, + pub(super) input: &'a [u8], + pub(super) quality: ImageQuality, + pub(super) probe: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct ResolvedAutoQuality { + pub(super) quality: ResolvedImageQuality, + pub(super) effort_override: Option, +} + +pub(super) fn resolve_auto_quality( + source: AutoQualitySource<'_>, + requested_override: Option, +) -> ResolvedAutoQuality { + if !source.quality.is_auto() { + return ResolvedAutoQuality { + quality: source.quality.resolve_static(), + effort_override: requested_override, + }; + } + let palette_animation = source.animated + && source.format == OutputFormat::WebP + && matches!(source.sniffed_mime, "image/gif" | "image/apng"); + let within_pixel_limit = source.probe.is_none_or(|probe| { + animated_probe_pixels(probe) + .is_some_and(|pixels| pixels <= AUTO_LOSSLESS_PALETTE_ANIMATION_MAX_PIXELS) + }); + let lossless = palette_animation + && source.input.len() <= AUTO_LOSSLESS_PALETTE_ANIMATION_MAX_BYTES + && within_pixel_limit; + let quality = if lossless { + ResolvedImageQuality::Lossless + } else { + source.quality.resolve_static() + }; + let effort_override = if requested_override.is_some() || !palette_animation || lossless { + requested_override + } else { + Some(EncodeEffort::minimum()) + }; + ResolvedAutoQuality { + quality, + effort_override, + } +} + +fn animated_probe_pixels(probe: AnimatedProbe) -> Option { + if probe.width <= 0 || probe.height <= 0 || probe.pages <= 0 { + return None; + } + (probe.width as usize) + .checked_mul(probe.height as usize)? + .checked_mul(probe.pages as usize) +} diff --git a/fluxer_media_proxy/src/media_process/video_thumbnail.rs b/fluxer_media_proxy/src/media_process/video_thumbnail.rs new file mode 100644 index 000000000..3adb04b60 --- /dev/null +++ b/fluxer_media_proxy/src/media_process/video_thumbnail.rs @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::native_runtime::{clear_vips_error, vips_runtime}; +use super::transform_plan::output_suffix; +use super::{MediaError, ProcessedMedia, native_buffer_to_media_bytes, native_status_error}; +use crate::{ + constants, + image_quality::ResolvedImageQuality, + media_limits::MediaLimits, + native::{self, NativeStatus, buffer::NativeBuffer}, + output_format::OutputFormat, +}; +use libc::{c_int, c_void, size_t}; +use std::ptr; + +#[derive(Clone, Copy, Debug)] +pub struct VideoThumbnailOptions { + pub format: OutputFormat, + pub width: Option, + pub height: Option, + pub quality: ResolvedImageQuality, + pub deadline_ms: Option, +} + +pub fn extract_video_thumbnail( + input: &[u8], + format: OutputFormat, + media_limits: &MediaLimits, +) -> Result { + extract_video_thumbnail_with_options( + input, + VideoThumbnailOptions { + format, + width: None, + height: None, + quality: ResolvedImageQuality::High, + deadline_ms: None, + }, + media_limits, + ) +} + +pub fn extract_video_thumbnail_with_options( + input: &[u8], + options: VideoThumbnailOptions, + media_limits: &MediaLimits, +) -> Result { + if input.len() > media_limits.max_media_proxy_bytes() { + return Err(MediaError::StreamTooLong); + } + let VideoThumbnailOptions { + format, + width, + height, + quality, + deadline_ms, + } = options; + let runtime = vips_runtime()?; + let (max_width, max_height) = native_thumbnail_bounds(width, height, media_limits)?; + let suffix = output_suffix(format, quality, None, None)?; + let mut out_ptr: *mut c_void = ptr::null_mut(); + let mut out_size: size_t = 0; + let mut out_capacity: size_t = 0; + let status = NativeStatus::from_code(unsafe { + native::fluxer_ffmpeg_video_thumbnail_ex( + input.as_ptr().cast(), + input.len(), + runtime.config().ffmpeg_decoder_threads(), + deadline_ms.unwrap_or(0), + suffix.as_ptr(), + constants::MAX_VIDEO_PACKETS_FOR_THUMBNAIL as c_int, + max_width, + max_height, + media_limits.max_media_proxy_bytes(), + ptr::null_mut(), + ptr::null_mut(), + &mut out_ptr, + &mut out_size, + &mut out_capacity, + ) + }); + let output = unsafe { + NativeBuffer::from_vips_owned_with_resident_bytes(out_ptr, out_size, out_capacity) + }; + if let Some(error) = native_status_error(status, MediaError::MediaDecodeFailed) { + clear_vips_error(); + return Err(error); + } + let output = match output { + Some(output) => output, + None => { + clear_vips_error(); + return Err(MediaError::MediaDecodeFailed); + } + }; + let bytes = native_buffer_to_media_bytes(output)?.try_into_vec()?; + Ok(ProcessedMedia { + bytes, + content_type: format.mime(), + }) +} + +fn native_thumbnail_bounds( + width: Option, + height: Option, + media_limits: &MediaLimits, +) -> Result<(c_int, c_int), MediaError> { + if width.is_none() && height.is_none() { + return Ok((0, 0)); + } + let dimension_limit = media_limits.image_dimension(); + let max_width = width.unwrap_or(dimension_limit); + let max_height = height.unwrap_or(dimension_limit); + if max_width == 0 + || max_height == 0 + || max_width > dimension_limit + || max_height > dimension_limit + { + return Err(MediaError::InvalidImageDimensions); + } + Ok(( + c_int::try_from(max_width).map_err(|_| MediaError::InvalidImageDimensions)?, + c_int::try_from(max_height).map_err(|_| MediaError::InvalidImageDimensions)?, + )) +} diff --git a/fluxer_media_proxy/src/media_type.rs b/fluxer_media_proxy/src/media_type.rs new file mode 100644 index 000000000..5e9bcaa81 --- /dev/null +++ b/fluxer_media_proxy/src/media_type.rs @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::constants::AssetExtension; + +struct MediaTypeSpec { + media_type: MediaType, + mime: &'static str, + mime_aliases: &'static [&'static str], + canonical_extension: &'static str, + extension_aliases: &'static [&'static str], +} + +macro_rules! define_media_types { + ( + assets { + $( + $asset_variant:ident => ( + AssetExtension::$asset_extension_variant:ident, + $asset_mime:literal, + [$($asset_mime_alias:literal),* $(,)?], + $asset_extension:literal, + [$($asset_extension_alias:literal),* $(,)?] + ) + ),+ $(,)? + } + media { + $( + $media_variant:ident => ( + $media_mime:literal, + [$($media_mime_alias:literal),* $(,)?], + $media_extension:literal, + [$($media_extension_alias:literal),* $(,)?] + ) + ),+ $(,)? + } + ) => { + #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] + #[expect(clippy::upper_case_acronyms)] + pub enum MediaType { + $($asset_variant,)+ + $($media_variant,)+ + } + + const MEDIA_TYPE_SPECS: &[MediaTypeSpec] = &[ + $( + MediaTypeSpec { + media_type: MediaType::$asset_variant, + mime: $asset_mime, + mime_aliases: &[$($asset_mime_alias),*], + canonical_extension: $asset_extension, + extension_aliases: &[$($asset_extension_alias),*], + }, + )+ + $( + MediaTypeSpec { + media_type: MediaType::$media_variant, + mime: $media_mime, + mime_aliases: &[$($media_mime_alias),*], + canonical_extension: $media_extension, + extension_aliases: &[$($media_extension_alias),*], + }, + )+ + ]; + + impl MediaType { + pub fn from_mime(mime: &str) -> Option { + MEDIA_TYPE_SPECS + .iter() + .find(|spec| { + spec.mime.eq_ignore_ascii_case(mime) + || spec + .mime_aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(mime)) + }) + .map(|spec| spec.media_type) + } + + pub fn from_extension(extension: &str) -> Option { + MEDIA_TYPE_SPECS + .iter() + .find(|spec| { + spec.canonical_extension.eq_ignore_ascii_case(extension) + || spec + .extension_aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(extension)) + }) + .map(|spec| spec.media_type) + } + + pub fn mime(self) -> &'static str { + self.spec().mime + } + + fn spec(self) -> &'static MediaTypeSpec { + MEDIA_TYPE_SPECS + .iter() + .find(|spec| spec.media_type == self) + .expect("every media type must have one specification") + } + } + + impl From for MediaType { + fn from(value: AssetExtension) -> Self { + match value { + $(AssetExtension::$asset_extension_variant => Self::$asset_variant,)+ + } + } + } + }; +} + +define_media_types!( + assets { + PNG => (AssetExtension::Png, "image/png", [], "png", []), + JPEG => (AssetExtension::Jpeg, "image/jpeg", [], "jpeg", ["jpg"]), + WebP => (AssetExtension::Webp, "image/webp", [], "webp", []), + GIF => (AssetExtension::Gif, "image/gif", [], "gif", []), + APNG => (AssetExtension::Apng, "image/apng", [], "apng", []), + AVIF => (AssetExtension::Avif, "image/avif", ["image/avif-sequence"], "avif", []), + HEIC => (AssetExtension::Heic, "image/heic", ["image/heic-sequence"], "heic", []), + HEIF => (AssetExtension::Heif, "image/heif", ["image/heif-sequence"], "heif", []), + JXL => (AssetExtension::Jxl, "image/jxl", [], "jxl", []), + SVG => (AssetExtension::Svg, "image/svg+xml", [], "svg", []), + } + media { + TIFF => ("image/tiff", [], "tiff", ["tif"]), + BMP => ("image/bmp", [], "bmp", []), + MP4Video => ("video/mp4", [], "mp4", ["m4v"]), + WebMVideo => ("video/webm", [], "webm", []), + QuickTimeVideo => ("video/quicktime", [], "mov", []), + ThreeGPPVideo => ("video/3gpp", [], "3gp", []), + MatroskaVideo => ("video/x-matroska", [], "mkv", []), + AVIVideo => ("video/x-msvideo", [], "avi", []), + FLVVideo => ("video/x-flv", [], "flv", []), + OGGVideo => ("video/ogg", [], "ogv", []), + MPEGTSVideo => ("video/mp2t", [], "ts", []), + MPEGVideo => ("video/mpeg", [], "mpeg", ["mpg"]), + WMVVideo => ("video/x-ms-wmv", [], "wmv", []), + MPEGAudio => ("audio/mpeg", [], "mp3", []), + WAVAudio => ("audio/wav", [], "wav", []), + FLACAudio => ("audio/flac", [], "flac", []), + OGGAudio => ("audio/ogg", [], "ogg", ["oga", "opus"]), + AACAudio => ("audio/aac", [], "aac", []), + MP4Audio => ("audio/mp4", [], "m4a", ["m4b"]), + WebMAudio => ("audio/webm", [], "weba", []), + AIFFAudio => ("audio/aiff", [], "aiff", ["aif"]), + } +); + +#[cfg(test)] +mod tests { + use super::*; + + const ASSET_EXTENSIONS: [AssetExtension; 10] = [ + AssetExtension::Png, + AssetExtension::Jpeg, + AssetExtension::Webp, + AssetExtension::Gif, + AssetExtension::Apng, + AssetExtension::Avif, + AssetExtension::Heic, + AssetExtension::Heif, + AssetExtension::Jxl, + AssetExtension::Svg, + ]; + + #[test] + fn registry_agrees_with_the_frozen_asset_extension_table() { + for extension in ASSET_EXTENSIONS { + let media_type = MediaType::from(extension); + let spec = MEDIA_TYPE_SPECS + .iter() + .find(|spec| spec.media_type == media_type) + .expect("every asset extension has a specification"); + assert_eq!(extension.mime(), media_type.mime()); + assert_eq!(extension.name(), spec.canonical_extension); + assert_eq!( + Some(media_type), + MediaType::from_mime(extension.mime()), + "mime lookup for {}", + extension.name() + ); + assert_eq!( + Some(media_type), + MediaType::from_extension(extension.name()), + "extension lookup for {}", + extension.name() + ); + } + } + + #[test] + fn lookups_are_case_insensitive_and_honour_aliases() { + assert_eq!(Some(MediaType::JPEG), MediaType::from_extension("JPG")); + assert_eq!(Some(MediaType::JPEG), MediaType::from_mime("IMAGE/JPEG")); + assert_eq!( + Some(MediaType::AVIF), + MediaType::from_mime("image/avif-sequence") + ); + assert_eq!( + Some(MediaType::HEIC), + MediaType::from_mime("image/heic-sequence") + ); + assert_eq!( + Some(MediaType::HEIF), + MediaType::from_mime("image/heif-sequence") + ); + assert_eq!(Some(MediaType::MPEGVideo), MediaType::from_extension("mpg")); + assert_eq!(Some(MediaType::OGGAudio), MediaType::from_extension("opus")); + assert_eq!(Some(MediaType::MP4Audio), MediaType::from_extension("m4b")); + assert_eq!(None, MediaType::from_mime("application/octet-stream")); + assert_eq!(None, MediaType::from_extension("exe")); + } + + #[test] + fn the_media_block_never_crosses_into_asset_extensions() { + for mime in [ + "video/mp4", + "audio/mp4", + "image/tiff", + "image/bmp", + "video/x-matroska", + "audio/aiff", + ] { + let media_type = MediaType::from_mime(mime).expect("registered media mime"); + assert_eq!(mime, media_type.mime()); + assert!( + !ASSET_EXTENSIONS + .iter() + .any(|extension| MediaType::from(*extension) == media_type) + ); + } + } + + #[test] + fn every_specification_is_reachable_from_its_own_variant() { + for spec in MEDIA_TYPE_SPECS { + assert_eq!(spec.mime, spec.media_type.mime()); + assert_eq!(Some(spec.media_type), MediaType::from_mime(spec.mime)); + assert_eq!( + Some(spec.media_type), + MediaType::from_extension(spec.canonical_extension) + ); + for alias in spec.mime_aliases { + assert_eq!(Some(spec.media_type), MediaType::from_mime(alias)); + } + for alias in spec.extension_aliases { + assert_eq!(Some(spec.media_type), MediaType::from_extension(alias)); + } + } + } +} diff --git a/fluxer_media_proxy/src/metrics.rs b/fluxer_media_proxy/src/metrics.rs deleted file mode 100644 index c00435f67..000000000 --- a/fluxer_media_proxy/src/metrics.rs +++ /dev/null @@ -1,556 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use libc::{CLOCK_MONOTONIC, clock_gettime, timespec}; -use std::fmt::Write; -use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; - -pub fn init_global() { - GLOBAL.start_ms.store(now_ms(), Ordering::Relaxed); -} - -pub fn now_ms() -> i64 { - let mut ts = timespec { - tv_sec: 0, - tv_nsec: 0, - }; - let rc = unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) }; - if rc != 0 { - return 0; - } - ts.tv_sec - .saturating_mul(1_000) - .saturating_add(ts.tv_nsec / 1_000_000) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[repr(usize)] -pub enum RequestKind { - Health, - Metadata, - Thumbnail, - Frames, - AssetImage, - GuildMemberImage, - Attachment, - External, - Static, - Themes, - Upload, - Other, -} - -impl RequestKind { - pub const ALL: [RequestKind; 12] = [ - Self::Health, - Self::Metadata, - Self::Thumbnail, - Self::Frames, - Self::AssetImage, - Self::GuildMemberImage, - Self::Attachment, - Self::External, - Self::Static, - Self::Themes, - Self::Upload, - Self::Other, - ]; - - pub fn label(self) -> &'static str { - match self { - Self::Health => "health", - Self::Metadata => "metadata", - Self::Thumbnail => "thumbnail", - Self::Frames => "frames", - Self::AssetImage => "asset_image", - Self::GuildMemberImage => "guild_member_image", - Self::Attachment => "attachment", - Self::External => "external", - Self::Static => "static", - Self::Themes => "themes", - Self::Upload => "upload", - Self::Other => "other", - } - } -} - -const HISTOGRAM_BUCKETS_MS: &[u64] = &[ - 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, -]; - -pub struct Histogram { - buckets: [AtomicU64; 13], - inf: AtomicU64, - sum_ms: AtomicU64, - count: AtomicU64, -} - -impl Histogram { - pub const fn new() -> Self { - Self { - buckets: [const { AtomicU64::new(0) }; 13], - inf: AtomicU64::new(0), - sum_ms: AtomicU64::new(0), - count: AtomicU64::new(0), - } - } - - pub fn observe(&self, ms: u64) { - for (i, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { - if ms <= upper { - self.buckets[i].fetch_add(1, Ordering::Relaxed); - self.inf.fetch_add(1, Ordering::Relaxed); - self.sum_ms.fetch_add(ms, Ordering::Relaxed); - self.count.fetch_add(1, Ordering::Relaxed); - return; - } - } - self.inf.fetch_add(1, Ordering::Relaxed); - self.sum_ms.fetch_add(ms, Ordering::Relaxed); - self.count.fetch_add(1, Ordering::Relaxed); - } -} - -impl Default for Histogram { - fn default() -> Self { - Self::new() - } -} - -pub struct Metrics { - requests_2xx: [AtomicU64; 12], - requests_3xx: [AtomicU64; 12], - requests_4xx: [AtomicU64; 12], - requests_5xx: [AtomicU64; 12], - pub transform_image_duration: Histogram, - pub transform_video_duration: Histogram, - pub native_transform_wait: Histogram, - pub request_duration: Histogram, - request_duration_per_kind: [Histogram; 12], - pub coalescer_leader: AtomicU64, - pub coalescer_waiter: AtomicU64, - pub transform_cache_hits: AtomicU64, - pub transform_cache_misses: AtomicU64, - pub storage_hits: AtomicU64, - pub storage_misses: AtomicU64, - pub storage_errors: AtomicU64, - pub nsfw_calls_ok: AtomicU64, - pub nsfw_calls_failed: AtomicU64, - pub nsfw_calls_disabled: AtomicU64, - pub transform_failures: AtomicU64, - pub decode_failures: AtomicU64, - pub fetch_failures: AtomicU64, - pub external_stream_overruns: AtomicU64, - pub blocked_url_attempts: AtomicU64, - pub framebuffer_pool_borrows: AtomicU64, - pub framebuffer_pool_grow_events: AtomicU64, - pub relay_upstream_success: AtomicU64, - pub relay_upstream_failures_retryable: AtomicU64, - pub relay_upstream_failures_hard: AtomicU64, - pub relay_upstream_retries: AtomicU64, - pub http_retryable_status: AtomicU64, - pub http_retryable_error: AtomicU64, - pub http_retries: AtomicU64, - pub http_retries_exhausted: AtomicU64, - pub hdr_tone_map_count: AtomicU64, - pub heif_hdr_gain_map_count: AtomicU64, - pub avif_libheif_decode_count: AtomicU64, - pub avif_libheif_decode_failures: AtomicU64, - start_ms: AtomicI64, -} - -impl Metrics { - pub const fn new() -> Self { - Self { - requests_2xx: [const { AtomicU64::new(0) }; 12], - requests_3xx: [const { AtomicU64::new(0) }; 12], - requests_4xx: [const { AtomicU64::new(0) }; 12], - requests_5xx: [const { AtomicU64::new(0) }; 12], - transform_image_duration: Histogram::new(), - transform_video_duration: Histogram::new(), - native_transform_wait: Histogram::new(), - request_duration: Histogram::new(), - request_duration_per_kind: [const { Histogram::new() }; 12], - coalescer_leader: AtomicU64::new(0), - coalescer_waiter: AtomicU64::new(0), - transform_cache_hits: AtomicU64::new(0), - transform_cache_misses: AtomicU64::new(0), - storage_hits: AtomicU64::new(0), - storage_misses: AtomicU64::new(0), - storage_errors: AtomicU64::new(0), - nsfw_calls_ok: AtomicU64::new(0), - nsfw_calls_failed: AtomicU64::new(0), - nsfw_calls_disabled: AtomicU64::new(0), - transform_failures: AtomicU64::new(0), - decode_failures: AtomicU64::new(0), - fetch_failures: AtomicU64::new(0), - external_stream_overruns: AtomicU64::new(0), - blocked_url_attempts: AtomicU64::new(0), - framebuffer_pool_borrows: AtomicU64::new(0), - framebuffer_pool_grow_events: AtomicU64::new(0), - relay_upstream_success: AtomicU64::new(0), - relay_upstream_failures_retryable: AtomicU64::new(0), - relay_upstream_failures_hard: AtomicU64::new(0), - relay_upstream_retries: AtomicU64::new(0), - http_retryable_status: AtomicU64::new(0), - http_retryable_error: AtomicU64::new(0), - http_retries: AtomicU64::new(0), - http_retries_exhausted: AtomicU64::new(0), - hdr_tone_map_count: AtomicU64::new(0), - heif_hdr_gain_map_count: AtomicU64::new(0), - avif_libheif_decode_count: AtomicU64::new(0), - avif_libheif_decode_failures: AtomicU64::new(0), - start_ms: AtomicI64::new(0), - } - } - - pub fn record_request(&self, kind: RequestKind, status: u16) { - let bucket = match status / 100 { - 2 => &self.requests_2xx, - 3 => &self.requests_3xx, - 5 => &self.requests_5xx, - _ => &self.requests_4xx, - }; - bucket[kind as usize].fetch_add(1, Ordering::Relaxed); - } - - pub fn record_request_with_duration(&self, kind: RequestKind, status: u16, ms: u64) { - self.record_request(kind, status); - self.request_duration.observe(ms); - self.request_duration_per_kind[kind as usize].observe(ms); - } -} - -impl Default for Metrics { - fn default() -> Self { - Self::new() - } -} - -pub static GLOBAL: Metrics = Metrics::new(); - -pub fn render() -> String { - let mut out = String::new(); - render_request_series( - &mut out, - "fluxer_media_proxy_requests_2xx_total", - &GLOBAL.requests_2xx, - ); - render_request_series( - &mut out, - "fluxer_media_proxy_requests_3xx_total", - &GLOBAL.requests_3xx, - ); - render_request_series( - &mut out, - "fluxer_media_proxy_requests_4xx_total", - &GLOBAL.requests_4xx, - ); - render_request_series( - &mut out, - "fluxer_media_proxy_requests_5xx_total", - &GLOBAL.requests_5xx, - ); - render_histogram( - &mut out, - "fluxer_media_proxy_transform_image_duration_ms", - &GLOBAL.transform_image_duration, - ); - render_histogram( - &mut out, - "fluxer_media_proxy_transform_video_duration_ms", - &GLOBAL.transform_video_duration, - ); - render_histogram( - &mut out, - "fluxer_media_proxy_native_transform_wait_ms", - &GLOBAL.native_transform_wait, - ); - render_histogram( - &mut out, - "fluxer_media_proxy_request_duration_ms", - &GLOBAL.request_duration, - ); - render_per_kind_histogram( - &mut out, - "fluxer_media_proxy_request_duration_by_route_ms", - &GLOBAL.request_duration_per_kind, - ); - render_counter( - &mut out, - "fluxer_media_proxy_coalescer_leader_total", - &GLOBAL.coalescer_leader, - ); - render_counter( - &mut out, - "fluxer_media_proxy_coalescer_waiter_total", - &GLOBAL.coalescer_waiter, - ); - render_counter( - &mut out, - "fluxer_media_proxy_transform_cache_hits_total", - &GLOBAL.transform_cache_hits, - ); - render_counter( - &mut out, - "fluxer_media_proxy_transform_cache_misses_total", - &GLOBAL.transform_cache_misses, - ); - render_counter( - &mut out, - "fluxer_media_proxy_storage_hits_total", - &GLOBAL.storage_hits, - ); - render_counter( - &mut out, - "fluxer_media_proxy_storage_misses_total", - &GLOBAL.storage_misses, - ); - render_counter( - &mut out, - "fluxer_media_proxy_storage_errors_total", - &GLOBAL.storage_errors, - ); - render_counter( - &mut out, - "fluxer_media_proxy_nsfw_calls_ok_total", - &GLOBAL.nsfw_calls_ok, - ); - render_counter( - &mut out, - "fluxer_media_proxy_nsfw_calls_failed_total", - &GLOBAL.nsfw_calls_failed, - ); - render_counter( - &mut out, - "fluxer_media_proxy_nsfw_calls_disabled_total", - &GLOBAL.nsfw_calls_disabled, - ); - render_counter( - &mut out, - "fluxer_media_proxy_transform_failures_total", - &GLOBAL.transform_failures, - ); - render_counter( - &mut out, - "fluxer_media_proxy_decode_failures_total", - &GLOBAL.decode_failures, - ); - render_counter( - &mut out, - "fluxer_media_proxy_fetch_failures_total", - &GLOBAL.fetch_failures, - ); - render_counter( - &mut out, - "fluxer_media_proxy_external_stream_overruns_total", - &GLOBAL.external_stream_overruns, - ); - render_counter( - &mut out, - "fluxer_media_proxy_blocked_url_attempts_total", - &GLOBAL.blocked_url_attempts, - ); - render_counter( - &mut out, - "fluxer_media_proxy_framebuffer_pool_borrows_total", - &GLOBAL.framebuffer_pool_borrows, - ); - render_counter( - &mut out, - "fluxer_media_proxy_framebuffer_pool_grow_events_total", - &GLOBAL.framebuffer_pool_grow_events, - ); - render_counter( - &mut out, - "fluxer_media_proxy_relay_upstream_success_total", - &GLOBAL.relay_upstream_success, - ); - render_counter( - &mut out, - "fluxer_media_proxy_relay_upstream_retries_total", - &GLOBAL.relay_upstream_retries, - ); - render_counter( - &mut out, - "fluxer_media_proxy_http_retries_total", - &GLOBAL.http_retries, - ); - render_counter( - &mut out, - "fluxer_media_proxy_http_retries_exhausted_total", - &GLOBAL.http_retries_exhausted, - ); - let _ = writeln!( - out, - "# TYPE fluxer_media_proxy_http_retryable_classifications_total counter" - ); - let _ = writeln!( - out, - "fluxer_media_proxy_http_retryable_classifications_total{{reason=\"status\"}} {}", - GLOBAL.http_retryable_status.load(Ordering::Relaxed) - ); - let _ = writeln!( - out, - "fluxer_media_proxy_http_retryable_classifications_total{{reason=\"error\"}} {}", - GLOBAL.http_retryable_error.load(Ordering::Relaxed) - ); - let _ = writeln!( - out, - "# TYPE fluxer_media_proxy_relay_upstream_failures_total counter" - ); - let _ = writeln!( - out, - "fluxer_media_proxy_relay_upstream_failures_total{{status=\"503\",retryable=\"true\"}} {}", - GLOBAL - .relay_upstream_failures_retryable - .load(Ordering::Relaxed) - ); - let _ = writeln!( - out, - "fluxer_media_proxy_relay_upstream_failures_total{{status=\"502\",retryable=\"false\"}} {}", - GLOBAL.relay_upstream_failures_hard.load(Ordering::Relaxed) - ); - render_counter( - &mut out, - "fluxer_media_proxy_hdr_tone_map_count_total", - &GLOBAL.hdr_tone_map_count, - ); - render_counter( - &mut out, - "fluxer_media_proxy_heif_hdr_gain_map_count_total", - &GLOBAL.heif_hdr_gain_map_count, - ); - render_counter( - &mut out, - "fluxer_media_proxy_avif_libheif_decode_count_total", - &GLOBAL.avif_libheif_decode_count, - ); - render_counter( - &mut out, - "fluxer_media_proxy_avif_libheif_decode_failures_total", - &GLOBAL.avif_libheif_decode_failures, - ); - let uptime_ms = now_ms() - GLOBAL.start_ms.load(Ordering::Relaxed); - let _ = writeln!( - out, - "# HELP fluxer_media_proxy_process_uptime_seconds Seconds since process start" - ); - let _ = writeln!( - out, - "# TYPE fluxer_media_proxy_process_uptime_seconds counter" - ); - let _ = writeln!( - out, - "fluxer_media_proxy_process_uptime_seconds {:.3}", - uptime_ms as f64 / 1000.0 - ); - out -} - -fn render_counter(out: &mut String, name: &str, counter: &AtomicU64) { - let _ = writeln!(out, "# TYPE {name} counter"); - let _ = writeln!(out, "{name} {}", counter.load(Ordering::Relaxed)); -} - -fn render_request_series(out: &mut String, name: &str, series: &[AtomicU64; 12]) { - let _ = writeln!(out, "# TYPE {name} counter"); - for kind in RequestKind::ALL { - let _ = writeln!( - out, - "{name}{{kind=\"{}\"}} {}", - kind.label(), - series[kind as usize].load(Ordering::Relaxed) - ); - } -} - -fn render_histogram(out: &mut String, name: &str, hist: &Histogram) { - let _ = writeln!(out, "# TYPE {name} histogram"); - let mut cumulative = 0; - for (i, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { - cumulative += hist.buckets[i].load(Ordering::Relaxed); - let _ = writeln!(out, "{name}_bucket{{le=\"{upper}\"}} {cumulative}"); - } - let _ = writeln!( - out, - "{name}_bucket{{le=\"+Inf\"}} {}", - hist.inf.load(Ordering::Relaxed) - ); - let _ = writeln!(out, "{name}_sum {}", hist.sum_ms.load(Ordering::Relaxed)); - let _ = writeln!(out, "{name}_count {}", hist.count.load(Ordering::Relaxed)); -} - -fn render_per_kind_histogram(out: &mut String, name: &str, hists: &[Histogram; 12]) { - let _ = writeln!(out, "# TYPE {name} histogram"); - for kind in RequestKind::ALL { - let hist = &hists[kind as usize]; - let label = kind.label(); - let mut cumulative = 0; - for (i, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { - cumulative += hist.buckets[i].load(Ordering::Relaxed); - let _ = writeln!( - out, - "{name}_bucket{{kind=\"{label}\",le=\"{upper}\"}} {cumulative}" - ); - } - let _ = writeln!( - out, - "{name}_bucket{{kind=\"{label}\",le=\"+Inf\"}} {}", - hist.inf.load(Ordering::Relaxed) - ); - let _ = writeln!( - out, - "{name}_sum{{kind=\"{label}\"}} {}", - hist.sum_ms.load(Ordering::Relaxed) - ); - let _ = writeln!( - out, - "{name}_count{{kind=\"{label}\"}} {}", - hist.count.load(Ordering::Relaxed) - ); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; - - #[test] - fn histogram_observes_into_correct_bucket() { - let h = Histogram::new(); - h.observe(0); - h.observe(3); - h.observe(50); - h.observe(100_000); - assert_eq!(1, h.buckets[0].load(Ordering::Relaxed)); - assert_eq!(1, h.buckets[1].load(Ordering::Relaxed)); - assert_eq!(1, h.buckets[4].load(Ordering::Relaxed)); - assert_eq!(4, h.inf.load(Ordering::Relaxed)); - assert_eq!(4, h.count.load(Ordering::Relaxed)); - } - - #[test] - fn render_produces_parseable_prometheus_text() { - init_global(); - GLOBAL.record_request(RequestKind::AssetImage, 200); - GLOBAL.transform_image_duration.observe(42); - let text = render(); - assert!(text.contains("# TYPE fluxer_media_proxy_requests_2xx_total counter\n")); - assert!(text.contains("fluxer_media_proxy_requests_2xx_total{kind=\"asset_image\"}")); - assert!(text.contains("fluxer_media_proxy_transform_image_duration_ms_bucket")); - assert!(text.contains("fluxer_media_proxy_heif_hdr_gain_map_count_total ")); - assert!(text.contains("fluxer_media_proxy_http_retries_total ")); - assert!(text.contains( - "fluxer_media_proxy_http_retryable_classifications_total{reason=\"status\"}" - )); - } - - #[test] - fn request_kind_labels_are_unique() { - let mut seen = HashSet::new(); - for kind in RequestKind::ALL { - assert!(seen.insert(kind.label())); - } - } -} diff --git a/fluxer_media_proxy/src/metrics/cache.rs b/fluxer_media_proxy/src/metrics/cache.rs new file mode 100644 index 000000000..573f2b505 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/cache.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Debug)] +pub struct CoalescerMetrics { + pub(super) coalescer_leader: AtomicU64, + pub(super) coalescer_waiter: AtomicU64, + pub(super) coalescer_waiter_rejected: AtomicU64, +} + +impl CoalescerMetrics { + pub(crate) fn new() -> Self { + Self { + coalescer_leader: AtomicU64::new(0), + coalescer_waiter: AtomicU64::new(0), + coalescer_waiter_rejected: AtomicU64::new(0), + } + } + + pub fn record_leader(&self) { + self.coalescer_leader.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_waiter(&self) { + self.coalescer_waiter.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_waiter_rejected(&self) { + self.coalescer_waiter_rejected + .fetch_add(1, Ordering::Relaxed); + } +} + +#[derive(Debug)] +pub struct TransformCacheMetrics { + pub(super) transform_cache_hits: AtomicU64, + pub(super) transform_cache_misses: AtomicU64, + pub(super) transform_cache_insert_rejected: AtomicU64, + pub(super) transform_cache_evictions: AtomicU64, +} + +impl TransformCacheMetrics { + pub(crate) fn new() -> Self { + Self { + transform_cache_hits: AtomicU64::new(0), + transform_cache_misses: AtomicU64::new(0), + transform_cache_insert_rejected: AtomicU64::new(0), + transform_cache_evictions: AtomicU64::new(0), + } + } + + pub fn record_hit(&self) { + self.transform_cache_hits.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_miss(&self) { + self.transform_cache_misses.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_insert_rejected(&self) { + self.transform_cache_insert_rejected + .fetch_add(1, Ordering::Relaxed); + } + + pub fn record_eviction(&self) { + self.transform_cache_evictions + .fetch_add(1, Ordering::Relaxed); + } +} diff --git a/fluxer_media_proxy/src/metrics/external.rs b/fluxer_media_proxy/src/metrics/external.rs new file mode 100644 index 000000000..79950e013 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/external.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Debug)] +pub struct ExternalMetrics { + pub(super) fetch_failures: AtomicU64, + pub(super) blocked_url_attempts: AtomicU64, + pub(super) external_buffer_rejected: AtomicU64, + pub(super) external_stream_overruns: AtomicU64, +} + +impl ExternalMetrics { + pub(crate) fn new() -> Self { + Self { + fetch_failures: AtomicU64::new(0), + blocked_url_attempts: AtomicU64::new(0), + external_buffer_rejected: AtomicU64::new(0), + external_stream_overruns: AtomicU64::new(0), + } + } + + pub fn record_fetch_failure(&self) { + self.fetch_failures.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_blocked_url(&self) { + self.blocked_url_attempts.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_buffer_rejected(&self) { + self.external_buffer_rejected + .fetch_add(1, Ordering::Relaxed); + } + + pub fn record_stream_overrun(&self) { + self.external_stream_overruns + .fetch_add(1, Ordering::Relaxed); + } +} diff --git a/fluxer_media_proxy/src/metrics/histogram.rs b/fluxer_media_proxy/src/metrics/histogram.rs new file mode 100644 index 000000000..4f4ddbdff --- /dev/null +++ b/fluxer_media_proxy/src/metrics/histogram.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt::{self, Write as _}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const HISTOGRAM_BUCKETS_MS: &[u64] = &[ + 1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, +]; + +pub struct Histogram { + pub(super) buckets: [AtomicU64; 13], + pub(super) inf: AtomicU64, + pub(super) sum_ms: AtomicU64, + pub(super) count: AtomicU64, +} + +impl Histogram { + pub const fn new() -> Self { + Self { + buckets: [const { AtomicU64::new(0) }; 13], + inf: AtomicU64::new(0), + sum_ms: AtomicU64::new(0), + count: AtomicU64::new(0), + } + } + + pub fn observe(&self, ms: u64) { + for (index, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { + if ms <= upper { + self.buckets[index].fetch_add(1, Ordering::Relaxed); + break; + } + } + self.inf.fetch_add(1, Ordering::Relaxed); + self.sum_ms.fetch_add(ms, Ordering::Relaxed); + self.count.fetch_add(1, Ordering::Relaxed); + } + + pub(super) fn render_series( + &self, + out: &mut String, + name: &str, + labels: Option<&str>, + ) -> fmt::Result { + let prefix = labels + .map(|labels| format!("{labels},")) + .unwrap_or_default(); + let suffix = labels + .map(|labels| format!("{{{labels}}}")) + .unwrap_or_default(); + let mut cumulative = 0; + for (index, upper) in HISTOGRAM_BUCKETS_MS.iter().copied().enumerate() { + cumulative += self.buckets[index].load(Ordering::Relaxed); + writeln!(out, "{name}_bucket{{{prefix}le=\"{upper}\"}} {cumulative}")?; + } + writeln!( + out, + "{name}_bucket{{{prefix}le=\"+Inf\"}} {}", + self.inf.load(Ordering::Relaxed) + )?; + writeln!( + out, + "{name}_sum{suffix} {}", + self.sum_ms.load(Ordering::Relaxed) + )?; + writeln!( + out, + "{name}_count{suffix} {}", + self.count.load(Ordering::Relaxed) + ) + } +} + +impl Default for Histogram { + fn default() -> Self { + Self::new() + } +} diff --git a/fluxer_media_proxy/src/metrics/http_client.rs b/fluxer_media_proxy/src/metrics/http_client.rs new file mode 100644 index 000000000..67c74e4c7 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/http_client.rs @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Debug)] +pub struct HTTPClientMetrics { + pub(super) http_retries: AtomicU64, + pub(super) http_retries_exhausted: AtomicU64, + pub(super) http_retryable_status: AtomicU64, + pub(super) http_retryable_error: AtomicU64, +} + +impl HTTPClientMetrics { + pub(crate) fn new() -> Self { + Self { + http_retries: AtomicU64::new(0), + http_retries_exhausted: AtomicU64::new(0), + http_retryable_status: AtomicU64::new(0), + http_retryable_error: AtomicU64::new(0), + } + } + + pub fn record_retry(&self) { + self.http_retries.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_retries_exhausted(&self) { + self.http_retries_exhausted.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_retryable_status(&self) { + self.http_retryable_status.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_retryable_error(&self) { + self.http_retryable_error.fetch_add(1, Ordering::Relaxed); + } +} diff --git a/fluxer_media_proxy/src/metrics/mod.rs b/fluxer_media_proxy/src/metrics/mod.rs new file mode 100644 index 000000000..6e513f29a --- /dev/null +++ b/fluxer_media_proxy/src/metrics/mod.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub mod cache; +pub mod external; +pub mod histogram; +pub mod http_client; +pub mod nsfw; +pub mod relay; +mod rendering; +pub mod request; +pub mod storage; +pub mod transform; + +#[cfg(test)] +mod tests; + +use self::{ + cache::{CoalescerMetrics, TransformCacheMetrics}, + external::ExternalMetrics, + http_client::HTTPClientMetrics, + nsfw::NSFWMetrics, + relay::RelayMetrics, + rendering::render_metrics, + request::RequestMetrics, + storage::StorageMetrics, + transform::{NativeTransformMetrics, TransformMetrics}, +}; +use libc::{CLOCK_MONOTONIC, clock_gettime, timespec}; +use std::sync::Arc; +use std::time::Duration; + +pub fn now_ms() -> i64 { + let mut ts = timespec { + tv_sec: 0, + tv_nsec: 0, + }; + let rc = unsafe { clock_gettime(CLOCK_MONOTONIC, &mut ts) }; + if rc != 0 { + return 0; + } + ts.tv_sec + .saturating_mul(1_000) + .saturating_add(ts.tv_nsec / 1_000_000) +} + +pub fn duration_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +pub struct Metrics { + request: Arc, + transform: Arc, + native_transform: Arc, + coalescer: Arc, + transform_cache: Arc, + storage: Arc, + nsfw: Arc, + external: Arc, + relay: Arc, + http_client: Arc, + start_ms: i64, +} + +impl Metrics { + pub fn new() -> Self { + Self { + request: Arc::new(RequestMetrics::new()), + transform: Arc::new(TransformMetrics::new()), + native_transform: Arc::new(NativeTransformMetrics::new()), + coalescer: Arc::new(CoalescerMetrics::new()), + transform_cache: Arc::new(TransformCacheMetrics::new()), + storage: Arc::new(StorageMetrics::new()), + nsfw: Arc::new(NSFWMetrics::new()), + external: Arc::new(ExternalMetrics::new()), + relay: Arc::new(RelayMetrics::new()), + http_client: Arc::new(HTTPClientMetrics::new()), + start_ms: now_ms(), + } + } + + pub fn request(&self) -> Arc { + Arc::clone(&self.request) + } + + pub fn transform(&self) -> Arc { + Arc::clone(&self.transform) + } + + pub fn native_transform(&self) -> Arc { + Arc::clone(&self.native_transform) + } + + pub fn coalescer(&self) -> Arc { + Arc::clone(&self.coalescer) + } + + pub fn transform_cache(&self) -> Arc { + Arc::clone(&self.transform_cache) + } + + pub fn storage(&self) -> Arc { + Arc::clone(&self.storage) + } + + pub fn nsfw(&self) -> Arc { + Arc::clone(&self.nsfw) + } + + pub fn external(&self) -> Arc { + Arc::clone(&self.external) + } + + pub fn relay(&self) -> Arc { + Arc::clone(&self.relay) + } + + pub fn http_client(&self) -> Arc { + Arc::clone(&self.http_client) + } + + pub fn render(&self) -> String { + let mut out = String::new(); + render_metrics(&mut out, self) + .expect("writing media proxy metrics to a String cannot fail"); + out + } +} + +impl Default for Metrics { + fn default() -> Self { + Self::new() + } +} diff --git a/fluxer_media_proxy/src/metrics/nsfw.rs b/fluxer_media_proxy/src/metrics/nsfw.rs new file mode 100644 index 000000000..5134b7688 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/nsfw.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Debug)] +pub struct NSFWMetrics { + pub(super) nsfw_calls_ok: AtomicU64, + pub(super) nsfw_calls_failed: AtomicU64, + pub(super) nsfw_calls_disabled: AtomicU64, +} + +impl NSFWMetrics { + pub(crate) fn new() -> Self { + Self { + nsfw_calls_ok: AtomicU64::new(0), + nsfw_calls_failed: AtomicU64::new(0), + nsfw_calls_disabled: AtomicU64::new(0), + } + } + + pub fn record_success(&self) { + self.nsfw_calls_ok.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_failure(&self) { + self.nsfw_calls_failed.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_disabled(&self) { + self.nsfw_calls_disabled.fetch_add(1, Ordering::Relaxed); + } +} diff --git a/fluxer_media_proxy/src/metrics/relay.rs b/fluxer_media_proxy/src/metrics/relay.rs new file mode 100644 index 000000000..1e4e57ba2 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/relay.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Debug)] +pub struct RelayMetrics { + pub(super) relay_upstream_success: AtomicU64, + pub(super) relay_upstream_retries: AtomicU64, + pub(super) relay_upstream_failures_retryable: AtomicU64, + pub(super) relay_upstream_failures_hard: AtomicU64, +} + +impl RelayMetrics { + pub(crate) fn new() -> Self { + Self { + relay_upstream_success: AtomicU64::new(0), + relay_upstream_retries: AtomicU64::new(0), + relay_upstream_failures_retryable: AtomicU64::new(0), + relay_upstream_failures_hard: AtomicU64::new(0), + } + } + + pub fn record_success(&self) { + self.relay_upstream_success.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_retry(&self) { + self.relay_upstream_retries.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_retryable_failure(&self) { + self.relay_upstream_failures_retryable + .fetch_add(1, Ordering::Relaxed); + } + + pub fn record_hard_failure(&self) { + self.relay_upstream_failures_hard + .fetch_add(1, Ordering::Relaxed); + } +} diff --git a/fluxer_media_proxy/src/metrics/rendering.rs b/fluxer_media_proxy/src/metrics/rendering.rs new file mode 100644 index 000000000..b7c84553b --- /dev/null +++ b/fluxer_media_proxy/src/metrics/rendering.rs @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ + Metrics, + histogram::Histogram, + now_ms, + request::{REQUEST_KIND_COUNT, RequestKind}, +}; +use std::{ + fmt::{self, Write as _}, + sync::atomic::{AtomicU64, Ordering}, +}; + +pub(super) fn render_metrics(out: &mut String, metrics: &Metrics) -> fmt::Result { + render_request_series( + out, + "fluxer_media_proxy_requests_1xx_total", + &metrics.request.requests_1xx, + )?; + render_request_series( + out, + "fluxer_media_proxy_requests_2xx_total", + &metrics.request.requests_2xx, + )?; + render_request_series( + out, + "fluxer_media_proxy_requests_3xx_total", + &metrics.request.requests_3xx, + )?; + render_request_series( + out, + "fluxer_media_proxy_requests_4xx_total", + &metrics.request.requests_4xx, + )?; + render_request_series( + out, + "fluxer_media_proxy_requests_5xx_total", + &metrics.request.requests_5xx, + )?; + render_request_series( + out, + "fluxer_media_proxy_requests_other_total", + &metrics.request.requests_other, + )?; + render_histogram( + out, + "fluxer_media_proxy_transform_image_duration_ms", + &metrics.transform.transform_image_duration, + )?; + render_histogram( + out, + "fluxer_media_proxy_transform_video_duration_ms", + &metrics.transform.transform_video_duration, + )?; + render_histogram( + out, + "fluxer_media_proxy_native_transform_wait_ms", + &metrics.native_transform.native_transform_wait, + )?; + render_counter( + out, + "fluxer_media_proxy_native_transform_rejected_total", + &metrics.native_transform.native_transform_rejected, + )?; + render_counter( + out, + "fluxer_media_proxy_native_tasks_detached_total", + &metrics.native_transform.native_tasks_detached, + )?; + render_gauge( + out, + "fluxer_media_proxy_native_tasks_detached_active", + &metrics.native_transform.native_tasks_detached_active, + )?; + render_histogram( + out, + "fluxer_media_proxy_native_task_detached_duration_ms", + &metrics.native_transform.native_task_detached_duration, + )?; + render_histogram( + out, + "fluxer_media_proxy_request_duration_ms", + &metrics.request.request_duration, + )?; + render_per_kind_histogram( + out, + "fluxer_media_proxy_request_duration_by_route_ms", + &metrics.request.request_duration_per_kind, + )?; + render_counter( + out, + "fluxer_media_proxy_coalescer_leader_total", + &metrics.coalescer.coalescer_leader, + )?; + render_counter( + out, + "fluxer_media_proxy_coalescer_waiter_total", + &metrics.coalescer.coalescer_waiter, + )?; + render_counter( + out, + "fluxer_media_proxy_coalescer_waiter_rejected_total", + &metrics.coalescer.coalescer_waiter_rejected, + )?; + render_counter( + out, + "fluxer_media_proxy_transform_cache_hits_total", + &metrics.transform_cache.transform_cache_hits, + )?; + render_counter( + out, + "fluxer_media_proxy_transform_cache_misses_total", + &metrics.transform_cache.transform_cache_misses, + )?; + render_counter( + out, + "fluxer_media_proxy_transform_cache_insert_rejected_total", + &metrics.transform_cache.transform_cache_insert_rejected, + )?; + render_counter( + out, + "fluxer_media_proxy_transform_cache_evictions_total", + &metrics.transform_cache.transform_cache_evictions, + )?; + render_counter( + out, + "fluxer_media_proxy_storage_hits_total", + &metrics.storage.storage_hits, + )?; + render_counter( + out, + "fluxer_media_proxy_storage_misses_total", + &metrics.storage.storage_misses, + )?; + render_counter( + out, + "fluxer_media_proxy_storage_errors_total", + &metrics.storage.storage_errors, + )?; + render_counter( + out, + "fluxer_media_proxy_nsfw_calls_ok_total", + &metrics.nsfw.nsfw_calls_ok, + )?; + render_counter( + out, + "fluxer_media_proxy_nsfw_calls_failed_total", + &metrics.nsfw.nsfw_calls_failed, + )?; + render_counter( + out, + "fluxer_media_proxy_nsfw_calls_disabled_total", + &metrics.nsfw.nsfw_calls_disabled, + )?; + render_counter( + out, + "fluxer_media_proxy_transform_failures_total", + &metrics.transform.transform_failures, + )?; + render_counter( + out, + "fluxer_media_proxy_decode_failures_total", + &metrics.transform.decode_failures, + )?; + render_counter( + out, + "fluxer_media_proxy_fetch_failures_total", + &metrics.external.fetch_failures, + )?; + render_counter( + out, + "fluxer_media_proxy_blocked_url_attempts_total", + &metrics.external.blocked_url_attempts, + )?; + render_counter( + out, + "fluxer_media_proxy_external_buffer_rejected_total", + &metrics.external.external_buffer_rejected, + )?; + render_counter( + out, + "fluxer_media_proxy_external_stream_overruns_total", + &metrics.external.external_stream_overruns, + )?; + render_counter( + out, + "fluxer_media_proxy_framebuffer_pool_borrows_total", + &metrics.native_transform.framebuffer_pool_borrows, + )?; + render_counter( + out, + "fluxer_media_proxy_framebuffer_pool_grow_events_total", + &metrics.native_transform.framebuffer_pool_grow_events, + )?; + render_counter( + out, + "fluxer_media_proxy_relay_upstream_success_total", + &metrics.relay.relay_upstream_success, + )?; + render_counter( + out, + "fluxer_media_proxy_relay_upstream_retries_total", + &metrics.relay.relay_upstream_retries, + )?; + render_counter( + out, + "fluxer_media_proxy_http_retries_total", + &metrics.http_client.http_retries, + )?; + render_counter( + out, + "fluxer_media_proxy_http_retries_exhausted_total", + &metrics.http_client.http_retries_exhausted, + )?; + writeln!( + out, + "# TYPE fluxer_media_proxy_http_retryable_classifications_total counter" + )?; + writeln!( + out, + "fluxer_media_proxy_http_retryable_classifications_total{{reason=\"status\"}} {}", + metrics + .http_client + .http_retryable_status + .load(Ordering::Relaxed) + )?; + writeln!( + out, + "fluxer_media_proxy_http_retryable_classifications_total{{reason=\"error\"}} {}", + metrics + .http_client + .http_retryable_error + .load(Ordering::Relaxed) + )?; + writeln!( + out, + "# TYPE fluxer_media_proxy_relay_upstream_failures_total counter" + )?; + writeln!( + out, + "fluxer_media_proxy_relay_upstream_failures_total{{status=\"503\",retryable=\"true\"}} {}", + metrics + .relay + .relay_upstream_failures_retryable + .load(Ordering::Relaxed) + )?; + writeln!( + out, + "fluxer_media_proxy_relay_upstream_failures_total{{status=\"502\",retryable=\"false\"}} {}", + metrics + .relay + .relay_upstream_failures_hard + .load(Ordering::Relaxed) + )?; + render_counter( + out, + "fluxer_media_proxy_hdr_tone_map_count_total", + &metrics.transform.hdr_tone_map_count, + )?; + render_counter( + out, + "fluxer_media_proxy_heif_hdr_gain_map_count_total", + &metrics.transform.heif_hdr_gain_map_count, + )?; + render_counter( + out, + "fluxer_media_proxy_avif_libheif_decode_count_total", + &metrics.transform.avif_libheif_decode_count, + )?; + render_counter( + out, + "fluxer_media_proxy_avif_libheif_decode_failures_total", + &metrics.transform.avif_libheif_decode_failures, + )?; + let uptime_ms = now_ms() - metrics.start_ms; + writeln!( + out, + "# HELP fluxer_media_proxy_process_uptime_seconds Seconds since process start" + )?; + writeln!( + out, + "# TYPE fluxer_media_proxy_process_uptime_seconds counter" + )?; + writeln!( + out, + "fluxer_media_proxy_process_uptime_seconds {:.3}", + uptime_ms as f64 / 1000.0 + ) +} + +fn render_counter(out: &mut String, name: &str, counter: &AtomicU64) -> fmt::Result { + writeln!(out, "# TYPE {name} counter")?; + writeln!(out, "{name} {}", counter.load(Ordering::Relaxed)) +} + +fn render_gauge(out: &mut String, name: &str, gauge: &AtomicU64) -> fmt::Result { + writeln!(out, "# TYPE {name} gauge")?; + writeln!(out, "{name} {}", gauge.load(Ordering::Relaxed)) +} + +fn render_request_series( + out: &mut String, + name: &str, + series: &[AtomicU64; REQUEST_KIND_COUNT], +) -> fmt::Result { + writeln!(out, "# TYPE {name} counter")?; + for kind in RequestKind::ALL { + writeln!( + out, + "{name}{{kind=\"{}\"}} {}", + kind.label(), + series[kind as usize].load(Ordering::Relaxed) + )?; + } + Ok(()) +} + +fn render_histogram(out: &mut String, name: &str, histogram: &Histogram) -> fmt::Result { + writeln!(out, "# TYPE {name} histogram")?; + histogram.render_series(out, name, None) +} + +fn render_per_kind_histogram( + out: &mut String, + name: &str, + histograms: &[Histogram; REQUEST_KIND_COUNT], +) -> fmt::Result { + writeln!(out, "# TYPE {name} histogram")?; + for kind in RequestKind::ALL { + let label = format!("kind=\"{}\"", kind.label()); + histograms[kind as usize].render_series(out, name, Some(&label))?; + } + Ok(()) +} diff --git a/fluxer_media_proxy/src/metrics/request.rs b/fluxer_media_proxy/src/metrics/request.rs new file mode 100644 index 000000000..aa7ee9365 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/request.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::histogram::Histogram; +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(usize)] +pub enum RequestKind { + Health, + Metadata, + Thumbnail, + Frames, + AssetImage, + GuildMemberImage, + Attachment, + External, + Static, + Themes, + Upload, + Other, +} + +impl RequestKind { + pub const ALL: [RequestKind; 12] = [ + Self::Health, + Self::Metadata, + Self::Thumbnail, + Self::Frames, + Self::AssetImage, + Self::GuildMemberImage, + Self::Attachment, + Self::External, + Self::Static, + Self::Themes, + Self::Upload, + Self::Other, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Health => "health", + Self::Metadata => "metadata", + Self::Thumbnail => "thumbnail", + Self::Frames => "frames", + Self::AssetImage => "asset_image", + Self::GuildMemberImage => "guild_member_image", + Self::Attachment => "attachment", + Self::External => "external", + Self::Static => "static", + Self::Themes => "themes", + Self::Upload => "upload", + Self::Other => "other", + } + } +} + +pub(super) const REQUEST_KIND_COUNT: usize = RequestKind::ALL.len(); + +pub struct RequestMetrics { + pub(super) requests_1xx: [AtomicU64; REQUEST_KIND_COUNT], + pub(super) requests_2xx: [AtomicU64; REQUEST_KIND_COUNT], + pub(super) requests_3xx: [AtomicU64; REQUEST_KIND_COUNT], + pub(super) requests_4xx: [AtomicU64; REQUEST_KIND_COUNT], + pub(super) requests_5xx: [AtomicU64; REQUEST_KIND_COUNT], + pub(super) requests_other: [AtomicU64; REQUEST_KIND_COUNT], + pub(super) request_duration: Histogram, + pub(super) request_duration_per_kind: [Histogram; REQUEST_KIND_COUNT], +} + +impl RequestMetrics { + pub(crate) fn new() -> Self { + Self { + requests_1xx: [const { AtomicU64::new(0) }; REQUEST_KIND_COUNT], + requests_2xx: [const { AtomicU64::new(0) }; REQUEST_KIND_COUNT], + requests_3xx: [const { AtomicU64::new(0) }; REQUEST_KIND_COUNT], + requests_4xx: [const { AtomicU64::new(0) }; REQUEST_KIND_COUNT], + requests_5xx: [const { AtomicU64::new(0) }; REQUEST_KIND_COUNT], + requests_other: [const { AtomicU64::new(0) }; REQUEST_KIND_COUNT], + request_duration: Histogram::new(), + request_duration_per_kind: [const { Histogram::new() }; REQUEST_KIND_COUNT], + } + } + + pub fn record_request(&self, kind: RequestKind, status: u16) { + let series = match status / 100 { + 1 => &self.requests_1xx, + 2 => &self.requests_2xx, + 3 => &self.requests_3xx, + 4 => &self.requests_4xx, + 5 => &self.requests_5xx, + _ => &self.requests_other, + }; + series[kind as usize].fetch_add(1, Ordering::Relaxed); + } + + pub fn record_request_with_duration(&self, kind: RequestKind, status: u16, ms: u64) { + self.record_request(kind, status); + self.request_duration.observe(ms); + self.request_duration_per_kind[kind as usize].observe(ms); + } +} diff --git a/fluxer_media_proxy/src/metrics/storage.rs b/fluxer_media_proxy/src/metrics/storage.rs new file mode 100644 index 000000000..6475b98c3 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/storage.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Debug)] +pub struct StorageMetrics { + pub(super) storage_hits: AtomicU64, + pub(super) storage_misses: AtomicU64, + pub(super) storage_errors: AtomicU64, +} + +impl StorageMetrics { + pub(crate) fn new() -> Self { + Self { + storage_hits: AtomicU64::new(0), + storage_misses: AtomicU64::new(0), + storage_errors: AtomicU64::new(0), + } + } + + pub fn record_hit(&self) { + self.storage_hits.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_miss(&self) { + self.storage_misses.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_error(&self) { + self.storage_errors.fetch_add(1, Ordering::Relaxed); + } +} diff --git a/fluxer_media_proxy/src/metrics/tests.rs b/fluxer_media_proxy/src/metrics/tests.rs new file mode 100644 index 000000000..16505a437 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/tests.rs @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{Metrics, duration_millis, histogram::Histogram, request::RequestKind}; +use std::collections::HashSet; +use std::sync::atomic::Ordering; +use std::time::Duration; + +const FROZEN_SERIES: &[(&str, &str)] = &[ + ("fluxer_media_proxy_requests_2xx_total", "counter"), + ("fluxer_media_proxy_requests_3xx_total", "counter"), + ("fluxer_media_proxy_requests_4xx_total", "counter"), + ("fluxer_media_proxy_requests_5xx_total", "counter"), + ( + "fluxer_media_proxy_transform_image_duration_ms", + "histogram", + ), + ( + "fluxer_media_proxy_transform_video_duration_ms", + "histogram", + ), + ("fluxer_media_proxy_native_transform_wait_ms", "histogram"), + ("fluxer_media_proxy_request_duration_ms", "histogram"), + ( + "fluxer_media_proxy_request_duration_by_route_ms", + "histogram", + ), + ("fluxer_media_proxy_coalescer_leader_total", "counter"), + ("fluxer_media_proxy_coalescer_waiter_total", "counter"), + ("fluxer_media_proxy_transform_cache_hits_total", "counter"), + ("fluxer_media_proxy_transform_cache_misses_total", "counter"), + ("fluxer_media_proxy_storage_hits_total", "counter"), + ("fluxer_media_proxy_storage_misses_total", "counter"), + ("fluxer_media_proxy_storage_errors_total", "counter"), + ("fluxer_media_proxy_nsfw_calls_ok_total", "counter"), + ("fluxer_media_proxy_nsfw_calls_failed_total", "counter"), + ("fluxer_media_proxy_nsfw_calls_disabled_total", "counter"), + ("fluxer_media_proxy_transform_failures_total", "counter"), + ("fluxer_media_proxy_decode_failures_total", "counter"), + ("fluxer_media_proxy_fetch_failures_total", "counter"), + ("fluxer_media_proxy_blocked_url_attempts_total", "counter"), + ( + "fluxer_media_proxy_framebuffer_pool_borrows_total", + "counter", + ), + ( + "fluxer_media_proxy_framebuffer_pool_grow_events_total", + "counter", + ), + ("fluxer_media_proxy_relay_upstream_success_total", "counter"), + ("fluxer_media_proxy_relay_upstream_retries_total", "counter"), + ("fluxer_media_proxy_http_retries_total", "counter"), + ("fluxer_media_proxy_http_retries_exhausted_total", "counter"), + ( + "fluxer_media_proxy_http_retryable_classifications_total", + "counter", + ), + ( + "fluxer_media_proxy_relay_upstream_failures_total", + "counter", + ), + ("fluxer_media_proxy_hdr_tone_map_count_total", "counter"), + ( + "fluxer_media_proxy_heif_hdr_gain_map_count_total", + "counter", + ), + ( + "fluxer_media_proxy_avif_libheif_decode_count_total", + "counter", + ), + ( + "fluxer_media_proxy_avif_libheif_decode_failures_total", + "counter", + ), + ("fluxer_media_proxy_process_uptime_seconds", "counter"), +]; + +const GOLDEN_IMAGE_DURATION_SERIES: &str = r#"# TYPE fluxer_media_proxy_transform_image_duration_ms histogram +fluxer_media_proxy_transform_image_duration_ms_bucket{le="1"} 0 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="5"} 0 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="10"} 0 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="25"} 0 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="50"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="100"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="250"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="500"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="1000"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="2500"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="5000"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="10000"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="30000"} 1 +fluxer_media_proxy_transform_image_duration_ms_bucket{le="+Inf"} 1 +fluxer_media_proxy_transform_image_duration_ms_sum 42 +fluxer_media_proxy_transform_image_duration_ms_count 1 +"#; + +const GOLDEN_ROUTE_DURATION_SERIES: &str = r#"fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="1"} 0 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="5"} 0 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="10"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="25"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="50"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="100"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="250"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="500"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="1000"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="2500"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="5000"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="10000"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="30000"} 1 +fluxer_media_proxy_request_duration_by_route_ms_bucket{kind="asset_image",le="+Inf"} 1 +fluxer_media_proxy_request_duration_by_route_ms_sum{kind="asset_image"} 7 +fluxer_media_proxy_request_duration_by_route_ms_count{kind="asset_image"} 1 +"#; + +#[test] +fn histogram_observes_into_correct_bucket() { + let h = Histogram::new(); + h.observe(0); + h.observe(3); + h.observe(50); + h.observe(100_000); + assert_eq!(1, h.buckets[0].load(Ordering::Relaxed)); + assert_eq!(1, h.buckets[1].load(Ordering::Relaxed)); + assert_eq!(1, h.buckets[4].load(Ordering::Relaxed)); + assert_eq!(4, h.inf.load(Ordering::Relaxed)); + assert_eq!(4, h.count.load(Ordering::Relaxed)); +} + +#[test] +fn render_produces_parseable_prometheus_text() { + let metrics = Metrics::new(); + metrics + .request() + .record_request(RequestKind::AssetImage, 200); + metrics.transform().observe_image_duration(42); + let text = metrics.render(); + assert!(text.contains("# TYPE fluxer_media_proxy_requests_2xx_total counter\n")); + assert!(text.contains("fluxer_media_proxy_requests_2xx_total{kind=\"asset_image\"}")); + assert!(text.contains("fluxer_media_proxy_transform_image_duration_ms_bucket")); + assert!(text.contains("fluxer_media_proxy_heif_hdr_gain_map_count_total ")); + assert!(text.contains("fluxer_media_proxy_http_retries_total ")); + assert!( + text.contains("fluxer_media_proxy_http_retryable_classifications_total{reason=\"status\"}") + ); +} + +#[test] +fn request_kind_labels_are_unique() { + let mut seen = HashSet::new(); + for kind in RequestKind::ALL { + assert!(seen.insert(kind.label())); + } +} + +#[test] +fn render_keeps_every_frozen_series_name_and_shape() { + let metrics = Metrics::new(); + metrics.transform().observe_image_duration(42); + metrics + .request() + .record_request_with_duration(RequestKind::AssetImage, 200, 7); + let text = metrics.render(); + + for (name, kind) in FROZEN_SERIES { + assert!( + text.contains(&format!("# TYPE {name} {kind}\n")), + "missing frozen series {name}" + ); + } + for kind in RequestKind::ALL { + assert!(text.contains(&format!( + "fluxer_media_proxy_requests_4xx_total{{kind=\"{}\"}} 0\n", + kind.label() + ))); + } + assert!(text.contains(GOLDEN_IMAGE_DURATION_SERIES)); + assert!(text.contains(GOLDEN_ROUTE_DURATION_SERIES)); + assert!(text.contains( + "fluxer_media_proxy_http_retryable_classifications_total{reason=\"status\"} 0\nfluxer_media_proxy_http_retryable_classifications_total{reason=\"error\"} 0\n" + )); + assert!(text.contains( + "fluxer_media_proxy_relay_upstream_failures_total{status=\"503\",retryable=\"true\"} 0\nfluxer_media_proxy_relay_upstream_failures_total{status=\"502\",retryable=\"false\"} 0\n" + )); + assert!(text.contains( + "# HELP fluxer_media_proxy_process_uptime_seconds Seconds since process start\n# TYPE fluxer_media_proxy_process_uptime_seconds counter\nfluxer_media_proxy_process_uptime_seconds 0." + )); +} + +#[test] +fn informational_and_unknown_statuses_stay_out_of_the_client_error_series() { + let metrics = Metrics::new(); + let request = metrics.request(); + request.record_request(RequestKind::External, 100); + request.record_request(RequestKind::External, 404); + request.record_request(RequestKind::External, 700); + let text = metrics.render(); + assert!(text.contains("fluxer_media_proxy_requests_1xx_total{kind=\"external\"} 1\n")); + assert!(text.contains("fluxer_media_proxy_requests_4xx_total{kind=\"external\"} 1\n")); + assert!(text.contains("fluxer_media_proxy_requests_other_total{kind=\"external\"} 1\n")); +} + +#[test] +fn duration_millis_truncates_to_whole_milliseconds() { + assert_eq!(0, duration_millis(Duration::from_micros(999))); + assert_eq!(1_500, duration_millis(Duration::from_millis(1_500))); + assert_eq!(u64::MAX, duration_millis(Duration::MAX)); +} diff --git a/fluxer_media_proxy/src/metrics/transform.rs b/fluxer_media_proxy/src/metrics/transform.rs new file mode 100644 index 000000000..616f01474 --- /dev/null +++ b/fluxer_media_proxy/src/metrics/transform.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::histogram::Histogram; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub struct TransformMetrics { + pub(super) transform_image_duration: Histogram, + pub(super) transform_video_duration: Histogram, + pub(super) transform_failures: AtomicU64, + pub(super) decode_failures: AtomicU64, + pub(super) hdr_tone_map_count: AtomicU64, + pub(super) heif_hdr_gain_map_count: AtomicU64, + pub(super) avif_libheif_decode_count: AtomicU64, + pub(super) avif_libheif_decode_failures: AtomicU64, +} + +impl TransformMetrics { + pub(crate) fn new() -> Self { + Self { + transform_image_duration: Histogram::new(), + transform_video_duration: Histogram::new(), + transform_failures: AtomicU64::new(0), + decode_failures: AtomicU64::new(0), + hdr_tone_map_count: AtomicU64::new(0), + heif_hdr_gain_map_count: AtomicU64::new(0), + avif_libheif_decode_count: AtomicU64::new(0), + avif_libheif_decode_failures: AtomicU64::new(0), + } + } + + pub fn observe_image_duration(&self, ms: u64) { + self.transform_image_duration.observe(ms); + } + + pub fn observe_video_duration(&self, ms: u64) { + self.transform_video_duration.observe(ms); + } + + pub fn record_transform_failure(&self) { + self.transform_failures.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_decode_failure(&self) { + self.decode_failures.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_hdr_tone_map(&self) { + self.hdr_tone_map_count.fetch_add(1, Ordering::Relaxed); + } + + pub fn hdr_tone_map_count(&self) -> u64 { + self.hdr_tone_map_count.load(Ordering::Relaxed) + } + + pub fn record_heif_hdr_gain_map_detected(&self) { + self.heif_hdr_gain_map_count.fetch_add(1, Ordering::Relaxed); + } + + pub fn record_heif_primary_still_decode(&self) { + self.avif_libheif_decode_count + .fetch_add(1, Ordering::Relaxed); + } + + pub fn record_heif_primary_still_decode_failure(&self) { + self.avif_libheif_decode_failures + .fetch_add(1, Ordering::Relaxed); + } +} + +pub struct NativeTransformMetrics { + pub(super) native_transform_wait: Histogram, + pub(super) native_transform_rejected: AtomicU64, + pub(super) native_task_detached_duration: Histogram, + pub(super) native_tasks_detached: AtomicU64, + pub(super) native_tasks_detached_active: AtomicU64, + pub(super) framebuffer_pool_borrows: AtomicU64, + pub(super) framebuffer_pool_grow_events: AtomicU64, +} + +impl NativeTransformMetrics { + pub(crate) fn new() -> Self { + Self { + native_transform_wait: Histogram::new(), + native_transform_rejected: AtomicU64::new(0), + native_task_detached_duration: Histogram::new(), + native_tasks_detached: AtomicU64::new(0), + native_tasks_detached_active: AtomicU64::new(0), + framebuffer_pool_borrows: AtomicU64::new(0), + framebuffer_pool_grow_events: AtomicU64::new(0), + } + } + + pub fn observe_wait(&self, ms: u64) { + self.native_transform_wait.observe(ms); + } + + pub fn record_rejected(&self) { + self.native_transform_rejected + .fetch_add(1, Ordering::Relaxed); + } + + pub fn record_detached_started(&self) { + self.native_tasks_detached.fetch_add(1, Ordering::Relaxed); + self.native_tasks_detached_active + .fetch_add(1, Ordering::Relaxed); + } + + pub fn record_detached_finished(&self, ms: u64) { + let previous = self + .native_tasks_detached_active + .fetch_sub(1, Ordering::Relaxed); + assert!( + previous > 0, + "detached native task count must not underflow" + ); + self.native_task_detached_duration.observe(ms); + } + + pub fn record_framebuffer_pool_borrow(&self) { + self.framebuffer_pool_borrows + .fetch_add(1, Ordering::Relaxed); + } + + pub fn record_framebuffer_pool_grow(&self) { + self.framebuffer_pool_grow_events + .fetch_add(1, Ordering::Relaxed); + } +} diff --git a/fluxer_media_proxy/src/mime.rs b/fluxer_media_proxy/src/mime.rs deleted file mode 100644 index fba4ab034..000000000 --- a/fluxer_media_proxy/src/mime.rs +++ /dev/null @@ -1,859 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::constants::AssetExtension; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum Category { - Image, - Video, - Audio, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct SniffInfo { - pub mime: &'static str, - pub animated: bool, - pub frames: u32, - pub duration_ms: u32, - pub width: u32, - pub height: u32, - pub has_alpha: bool, - pub color_space: &'static str, -} - -impl Default for SniffInfo { - fn default() -> Self { - Self { - mime: "application/octet-stream", - animated: false, - frames: 1, - duration_ms: 0, - width: 0, - height: 0, - has_alpha: false, - color_space: "unknown", - } - } -} - -pub fn normalize(raw: Option<&str>) -> Option<&str> { - let value = raw?; - let semi = value.find(';').unwrap_or(value.len()); - let trimmed = value[..semi].trim_matches([' ', '\t']); - (!trimmed.is_empty()).then_some(trimmed) -} - -pub fn category(mime_type: &str) -> Option { - if mime_type.starts_with("image/") { - Some(Category::Image) - } else if mime_type.starts_with("video/") { - Some(Category::Video) - } else if mime_type.starts_with("audio/") { - Some(Category::Audio) - } else { - None - } -} - -pub fn is_supported_media_mime(mime_type_raw: &str) -> bool { - let Some(mime_type) = normalize(Some(mime_type_raw)) else { - return false; - }; - matches!( - mime_type, - "image/jpeg" - | "image/png" - | "image/apng" - | "image/gif" - | "image/webp" - | "image/avif" - | "image/heic" - | "image/heif" - | "image/jxl" - | "image/svg+xml" - | "image/tiff" - | "image/bmp" - | "video/mp4" - | "video/webm" - | "video/quicktime" - | "video/3gpp" - | "video/x-matroska" - | "video/x-msvideo" - | "video/x-flv" - | "video/ogg" - | "video/mp2t" - | "video/mpeg" - | "video/x-ms-wmv" - | "audio/mpeg" - | "audio/wav" - | "audio/flac" - | "audio/ogg" - | "audio/aac" - | "audio/mp4" - | "audio/webm" - | "audio/aiff" - ) -} - -pub fn extension_mime(filename: &str) -> Option<&'static str> { - let ext = filename.rsplit_once('.')?.1; - if let Some(image_ext) = AssetExtension::parse(ext) { - return Some(image_ext.mime()); - } - match ext.to_ascii_lowercase().as_str() { - "mp4" | "m4v" => Some("video/mp4"), - "webm" => Some("video/webm"), - "mov" => Some("video/quicktime"), - "ogv" => Some("video/ogg"), - "mkv" => Some("video/x-matroska"), - "3gp" => Some("video/3gpp"), - "avi" => Some("video/x-msvideo"), - "flv" => Some("video/x-flv"), - "ts" => Some("video/mp2t"), - "mpg" | "mpeg" => Some("video/mpeg"), - "wmv" => Some("video/x-ms-wmv"), - "mp3" => Some("audio/mpeg"), - "m4a" | "m4b" => Some("audio/mp4"), - "ogg" | "oga" | "opus" => Some("audio/ogg"), - "aac" => Some("audio/aac"), - "weba" => Some("audio/webm"), - "aiff" | "aif" => Some("audio/aiff"), - "flac" => Some("audio/flac"), - "wav" => Some("audio/wav"), - "tif" | "tiff" => Some("image/tiff"), - "bmp" => Some("image/bmp"), - "css" => Some("text/css; charset=utf-8"), - _ => None, - } -} - -fn starts(data: &[u8], prefix: &[u8]) -> bool { - data.len() >= prefix.len() && &data[..prefix.len()] == prefix -} - -pub fn sniff(data: &[u8]) -> SniffInfo { - if starts(data, b"\x89PNG\r\n\x1a\n") { - let mut out = SniffInfo { - mime: "image/png", - has_alpha: true, - ..Default::default() - }; - if data.len() >= 26 && &data[12..16] == b"IHDR" { - out.width = u32::from_be_bytes(data[16..20].try_into().unwrap()); - out.height = u32::from_be_bytes(data[20..24].try_into().unwrap()); - out.has_alpha = data[25] == 4 || data[25] == 6; - } - if png_has_actl_before_idat(data) { - out.mime = "image/apng"; - out.animated = true; - out.frames = 2; - } - return out; - } - if starts(data, b"\xff\xd8\xff") { - return SniffInfo { - mime: "image/jpeg", - ..Default::default() - }; - } - if starts(data, b"GIF87a") || starts(data, b"GIF89a") { - let animated = gif_has_netscape_loop(data); - return SniffInfo { - mime: "image/gif", - animated, - frames: if animated { 2 } else { 1 }, - width: if data.len() >= 10 { - u16::from_le_bytes(data[6..8].try_into().unwrap()) as u32 - } else { - 0 - }, - height: if data.len() >= 10 { - u16::from_le_bytes(data[8..10].try_into().unwrap()) as u32 - } else { - 0 - }, - ..Default::default() - }; - } - if data.len() >= 12 && starts(data, b"RIFF") && &data[8..12] == b"WEBP" { - return webp_sniff(data); - } - if let Some(info) = iso_bmff_sniff(data) { - return info; - } - if starts(data, b"\xff\x0a") || starts(data, b"\x00\x00\x00\x0cJXL \r\n\x87\n") { - return SniffInfo { - mime: "image/jxl", - ..Default::default() - }; - } - if starts(data, b"II*\0") || starts(data, b"MM\0*") { - return SniffInfo { - mime: "image/tiff", - ..Default::default() - }; - } - if starts(data, b"BM") { - return SniffInfo { - mime: "image/bmp", - ..Default::default() - }; - } - if starts(data, b"\x1a\x45\xdf\xa3") { - return matroska_sniff(data); - } - if starts(data, b"FLV") { - return SniffInfo { - mime: "video/x-flv", - ..Default::default() - }; - } - if starts(data, b"RIFF") && data.len() >= 12 && &data[8..12] == b"AVI " { - return SniffInfo { - mime: "video/x-msvideo", - ..Default::default() - }; - } - if starts( - data, - b"\x30\x26\xb2\x75\x8e\x66\xcf\x11\xa6\xd9\x00\xaa\x00\x62\xce\x6c", - ) { - return SniffInfo { - mime: "video/x-ms-wmv", - ..Default::default() - }; - } - if starts(data, b"\x00\x00\x01\xba") || starts(data, b"\x00\x00\x01\xb3") { - return SniffInfo { - mime: "video/mpeg", - ..Default::default() - }; - } - if mpeg_ts_sniff(data) { - return SniffInfo { - mime: "video/mp2t", - ..Default::default() - }; - } - if starts(data, b"ID3") - || starts(data, b"\xff\xfb") - || starts(data, b"\xff\xf3") - || starts(data, b"\xff\xf2") - { - return SniffInfo { - mime: "audio/mpeg", - ..Default::default() - }; - } - if starts(data, b"OggS") { - return ogg_sniff(data); - } - if starts(data, b"fLaC") { - return SniffInfo { - mime: "audio/flac", - ..Default::default() - }; - } - if starts(data, b"RIFF") && data.len() >= 12 && &data[8..12] == b"WAVE" { - return SniffInfo { - mime: "audio/wav", - ..Default::default() - }; - } - if starts(data, b"%PDF-") { - return SniffInfo { - mime: "application/pdf", - ..Default::default() - }; - } - if looks_like_svg(data) { - return SniffInfo { - mime: "image/svg+xml", - ..Default::default() - }; - } - SniffInfo::default() -} - -const PNG_MAX_HEADER_CHUNKS: usize = 64; -const GIF_HEADER_SCAN_LIMIT: usize = 4096; -const WEBP_MAX_HEADER_CHUNKS: usize = 16; - -fn png_has_actl_before_idat(data: &[u8]) -> bool { - let mut offset = 8; - for _ in 0..PNG_MAX_HEADER_CHUNKS { - let Some(header) = data.get(offset..).and_then(|rest| rest.get(..8)) else { - return false; - }; - match &header[4..8] { - b"acTL" => return true, - b"IDAT" | b"IEND" => return false, - _ => {} - } - let length = u32::from_be_bytes(header[0..4].try_into().unwrap()) as usize; - let Some(next) = offset - .checked_add(12) - .and_then(|base| base.checked_add(length)) - else { - return false; - }; - offset = next; - } - false -} - -fn gif_has_netscape_loop(data: &[u8]) -> bool { - let limit = data.len().min(GIF_HEADER_SCAN_LIMIT); - let Some(&packed) = data.get(10) else { - return false; - }; - let mut offset = 13; - if packed & 0x80 != 0 { - offset += 3 << ((packed & 0x07) + 1); - } - while offset < limit { - if data[offset] != 0x21 { - return false; - } - let Some(&label) = data.get(offset + 1) else { - return false; - }; - let mut sub = offset + 2; - if label == 0xff - && data.get(sub) == Some(&11) - && data.get(sub + 1..sub + 12) == Some(b"NETSCAPE2.0".as_slice()) - { - return true; - } - loop { - let Some(&len) = data.get(sub) else { - return false; - }; - sub += 1 + len as usize; - if len == 0 { - break; - } - if sub >= limit { - return false; - } - } - offset = sub; - } - false -} - -fn webp_sniff(data: &[u8]) -> SniffInfo { - let mut out = SniffInfo { - mime: "image/webp", - ..Default::default() - }; - let mut offset = 12; - for _ in 0..WEBP_MAX_HEADER_CHUNKS { - let Some(header) = data.get(offset..).and_then(|rest| rest.get(..8)) else { - break; - }; - let size = u32::from_le_bytes(header[4..8].try_into().unwrap()) as usize; - match &header[0..4] { - b"VP8X" if size >= 10 => { - if let Some(&flags) = data.get(offset + 8) { - out.animated |= flags & 0x02 != 0; - out.has_alpha |= flags & 0x10 != 0; - } - } - b"ANIM" => out.animated = true, - b"ALPH" => out.has_alpha = true, - b"VP8 " | b"VP8L" | b"ANMF" => break, - _ => {} - } - let Some(next) = offset - .checked_add(8) - .and_then(|base| base.checked_add(size)) - .and_then(|base| base.checked_add(size & 1)) - else { - break; - }; - offset = next; - } - if out.animated { - out.frames = 2; - } - out -} - -fn brand_equals(brand: &[u8], literal: &[u8; 4]) -> bool { - brand.len() == 4 && brand == literal -} - -fn iso_bmff_sniff(data: &[u8]) -> Option { - if data.len() < 12 || &data[4..8] != b"ftyp" { - return None; - } - let box_size = u32::from_be_bytes(data[0..4].try_into().unwrap()) as usize; - let scan_end = if box_size >= 16 && box_size <= data.len() { - box_size - } else { - data.len().min(128) - }; - let mut saw_avif = false; - let mut saw_heif = false; - let mut saw_audio_mp4 = false; - let mut saw_mp4 = false; - let mut saw_quicktime = false; - let mut saw_3gp = false; - let mut i = 8; - while i + 4 <= scan_end { - let brand = &data[i..i + 4]; - if brand_equals(brand, b"avif") - || brand_equals(brand, b"avis") - || brand_equals(brand, b"avio") - { - saw_avif = true; - } - if matches!( - brand, - b"heic" - | b"heix" - | b"heif" - | b"heim" - | b"heis" - | b"hevc" - | b"hevx" - | b"hevm" - | b"hevs" - | b"mif1" - | b"msf1" - ) { - saw_heif = true; - } - if matches!( - brand, - b"mp41" - | b"mp42" - | b"isom" - | b"iso2" - | b"iso3" - | b"iso4" - | b"iso5" - | b"iso6" - | b"M4V " - | b"M4P " - | b"dash" - | b"msdh" - | b"msix" - | b"mj2s" - ) { - saw_mp4 = true; - } - if matches!(brand, b"M4A " | b"M4B " | b"M4P ") { - saw_audio_mp4 = true; - } - if brand_equals(brand, b"qt ") { - saw_quicktime = true; - } - if brand.starts_with(b"3gp") || brand.starts_with(b"3g2") { - saw_3gp = true; - } - i += 4; - } - if saw_avif { - return Some(SniffInfo { - mime: "image/avif", - animated: data[8..scan_end].windows(4).any(|w| w == b"avis"), - ..Default::default() - }); - } - if saw_heif { - return Some(SniffInfo { - mime: "image/heic", - ..Default::default() - }); - } - if saw_quicktime { - return Some(SniffInfo { - mime: "video/quicktime", - ..Default::default() - }); - } - if saw_3gp { - return Some(SniffInfo { - mime: "video/3gpp", - ..Default::default() - }); - } - if saw_audio_mp4 { - return Some(SniffInfo { - mime: "audio/mp4", - ..Default::default() - }); - } - if saw_mp4 { - return Some(SniffInfo { - mime: "video/mp4", - ..Default::default() - }); - } - None -} - -fn mpeg_ts_sniff(data: &[u8]) -> bool { - data.len() >= 188 - && data[0] == 0x47 - && (data.len() < 376 || data[188] == 0x47) - && (data.len() < 564 || data[376] == 0x47) -} - -fn looks_like_svg(data: &[u8]) -> bool { - let mut window = &data[..data.len().min(4096)]; - if window.starts_with(b"\xef\xbb\xbf") { - window = &window[3..]; - } - window.windows(4).any(|w| w == b" SniffInfo { - let window = &data[..data.len().min(8192)]; - if window.windows(6).any(|w| w == b"theora" || w == b"Theora") { - SniffInfo { - mime: "video/ogg", - ..Default::default() - } - } else { - SniffInfo { - mime: "audio/ogg", - ..Default::default() - } - } -} - -fn matroska_sniff(data: &[u8]) -> SniffInfo { - let window = &data[..data.len().min(4096)]; - if window.windows(4).any(|w| w == b"webm") { - SniffInfo { - mime: "video/webm", - ..Default::default() - } - } else { - SniffInfo { - mime: "video/x-matroska", - ..Default::default() - } - } -} - -pub fn detect(data: &[u8], filename: &str, header_mime: Option<&str>) -> String { - let sniffed = sniff(data); - if sniffed.mime != "application/octet-stream" { - if sniffed.mime == "video/mp4" && extension_mime(filename) == Some("audio/mp4") { - return "audio/mp4".to_owned(); - } - return sniffed.mime.to_owned(); - } - if let Some(m) = extension_mime(filename) { - return m.to_owned(); - } - if let Some(m) = normalize(header_mime) { - return m.to_owned(); - } - "application/octet-stream".to_owned() -} - -pub fn filename_for_mime(mime_type: &str, fallback: &str) -> String { - if fallback.contains('.') { - return fallback.to_owned(); - } - let ext = match mime_type { - "image/jpeg" => "jpg", - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - "video/mp4" => "mp4", - _ => "bin", - }; - format!("{fallback}.{ext}") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sniffs_common_image_formats() { - assert_eq!("image/png", sniff(b"\x89PNG\r\n\x1a\nxxxx").mime); - assert_eq!("image/jpeg", sniff(b"\xff\xd8\xff").mime); - assert_eq!("image/gif", sniff(b"GIF89a\x01\x00\x01\x00").mime); - assert_eq!("image/tiff", sniff(b"II*\0xxxx").mime); - assert_eq!("image/tiff", sniff(b"MM\0*xxxx").mime); - assert_eq!("image/bmp", sniff(b"BMxxxx").mime); - } - - fn png_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec { - let mut out = (payload.len() as u32).to_be_bytes().to_vec(); - out.extend(kind); - out.extend(payload); - out.extend([0u8; 4]); - out - } - - fn png_file(chunks: &[Vec]) -> Vec { - let mut ihdr = 8u32.to_be_bytes().to_vec(); - ihdr.extend(8u32.to_be_bytes()); - ihdr.extend([8, 6, 0, 0, 0]); - let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); - out.extend(png_chunk(b"IHDR", &ihdr)); - for chunk in chunks { - out.extend(chunk); - } - out - } - - fn gif_file(blocks: &[Vec]) -> Vec { - let mut out = b"GIF89a".to_vec(); - out.extend(4u16.to_le_bytes()); - out.extend(4u16.to_le_bytes()); - out.extend([0x80, 0, 0]); - out.extend([0u8; 6]); - for block in blocks { - out.extend(block); - } - out.push(0x3b); - out - } - - fn gif_netscape_block() -> Vec { - let mut out = vec![0x21, 0xff, 11]; - out.extend(b"NETSCAPE2.0"); - out.extend([3, 1, 0, 0, 0]); - out - } - - fn gif_comment_block(payload: &[u8]) -> Vec { - let mut out = vec![0x21, 0xfe]; - for part in payload.chunks(255) { - out.push(part.len() as u8); - out.extend(part); - } - out.push(0); - out - } - - fn gif_image_block(payload: &[u8]) -> Vec { - let mut out = vec![0x2c]; - out.extend([0u8; 8]); - out.push(0); - out.push(8); - for part in payload.chunks(255) { - out.push(part.len() as u8); - out.extend(part); - } - out.push(0); - out - } - - fn riff_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec { - let mut out = kind.to_vec(); - out.extend((payload.len() as u32).to_le_bytes()); - out.extend(payload); - if payload.len() % 2 == 1 { - out.push(0); - } - out - } - - fn webp_vp8x(flags: u8) -> Vec { - let mut payload = vec![flags, 0, 0, 0]; - payload.extend([7, 0, 0, 7, 0, 0]); - riff_chunk(b"VP8X", &payload) - } - - fn webp_file(chunks: &[Vec]) -> Vec { - let mut body = b"WEBP".to_vec(); - for chunk in chunks { - body.extend(chunk); - } - let mut out = b"RIFF".to_vec(); - out.extend((body.len() as u32).to_le_bytes()); - out.extend(body); - out - } - - fn padded_marker(marker: &[u8], len: usize) -> Vec { - let mut out = vec![0x5a; len]; - out[len / 2..len / 2 + marker.len()].copy_from_slice(marker); - out - } - - #[test] - fn sniffs_apng_via_actl_chunk() { - let info = sniff(&png_file(&[ - png_chunk(b"acTL", &[0, 0, 0, 2, 0, 0, 0, 0]), - png_chunk(b"IDAT", &[0u8; 16]), - ])); - assert_eq!("image/apng", info.mime); - assert!(info.animated); - } - - #[test] - fn sniffs_apng_when_actl_follows_a_large_icc_profile() { - let icc = vec![0x11u8; 300_000]; - let info = sniff(&png_file(&[ - png_chunk(b"iCCP", &icc), - png_chunk(b"acTL", &[0, 0, 0, 2, 0, 0, 0, 0]), - png_chunk(b"IDAT", &[0u8; 16]), - ])); - assert_eq!("image/apng", info.mime); - assert!(info.animated); - } - - #[test] - fn does_not_treat_actl_bytes_inside_png_image_data_as_animation() { - let info = sniff(&png_file(&[png_chunk( - b"IDAT", - &padded_marker(b"acTL", 1_000_000), - )])); - assert_eq!("image/png", info.mime); - assert!(!info.animated); - } - - #[test] - fn sniffs_animated_gif_via_netscape_extension() { - let info = sniff(&gif_file(&[ - gif_comment_block(b"made by a tool"), - gif_netscape_block(), - gif_image_block(&[0u8; 8]), - ])); - assert_eq!("image/gif", info.mime); - assert!(info.animated); - assert_eq!(2, info.frames); - } - - #[test] - fn does_not_treat_netscape_bytes_inside_gif_image_data_as_animation() { - let info = sniff(&gif_file(&[gif_image_block(&padded_marker( - b"NETSCAPE2.0", - 1_000_000, - ))])); - assert_eq!("image/gif", info.mime); - assert!(!info.animated); - assert_eq!(1, info.frames); - } - - #[test] - fn sniffs_animated_webp_via_anim_chunk() { - let info = sniff(&webp_file(&[ - webp_vp8x(0x02), - riff_chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]), - riff_chunk(b"ANMF", &[0u8; 32]), - ])); - assert_eq!("image/webp", info.mime); - assert!(info.animated); - } - - #[test] - fn sniffs_webp_alpha_via_alph_chunk() { - let info = sniff(&webp_file(&[ - webp_vp8x(0x10), - riff_chunk(b"ALPH", &[0u8; 8]), - riff_chunk(b"VP8 ", &[0u8; 16]), - ])); - assert!(info.has_alpha); - assert!(!info.animated); - } - - #[test] - fn does_not_treat_anim_bytes_inside_webp_image_data_as_animation() { - let info = sniff(&webp_file(&[riff_chunk( - b"VP8 ", - &padded_marker(b"ANIM", 1_000_000), - )])); - assert_eq!("image/webp", info.mime); - assert!(!info.animated); - assert!(!info.has_alpha); - } - - #[test] - fn sniffs_ftyp_boxes_for_heic_avif_mp4_variants() { - assert_eq!( - "image/avif", - sniff(b"\x00\x00\x00\x20ftypavifsome bytes").mime - ); - assert_eq!( - "image/avif", - sniff(b"\x00\x00\x00\x20ftypavissome bytes").mime - ); - assert!(sniff(b"\x00\x00\x00\x20ftypavissome bytes").animated); - assert_eq!( - "image/heic", - sniff(b"\x00\x00\x00\x20ftypmif1some bytes").mime - ); - assert_eq!( - "image/heic", - sniff(b"\x00\x00\x00\x20ftypheicsome bytes").mime - ); - assert_eq!( - "video/mp4", - sniff(b"\x00\x00\x00\x20ftypiso5some bytes").mime - ); - assert_eq!( - "video/mp4", - sniff(b"\x00\x00\x00\x20ftypM4V some bytes").mime - ); - assert_eq!( - "audio/mp4", - sniff(b"\x00\x00\x00\x20ftypM4A some bytes").mime - ); - assert_eq!( - "video/quicktime", - sniff(b"\x00\x00\x00\x20ftypqt some bytes").mime - ); - } - - #[test] - fn detect_prefers_m4a_extension_over_generic_mp4_brand() { - assert_eq!( - "audio/mp4", - detect( - b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00isomiso2mp41", - "track.m4a", - None - ) - ); - } - - #[test] - fn sniffs_matroska_vs_webm() { - let mkv = b"\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\xf7\x81\x01\x42\xf2\x81\x04\x42\xf3\x81\x08\x42\x82\x88matroska"; - assert_eq!("video/x-matroska", sniff(mkv).mime); - assert_eq!( - "video/webm", - sniff(b"\x1a\x45\xdf\xa3 here is the webm doctype").mime - ); - } - - #[test] - fn sniffs_audio_variants() { - assert_eq!("audio/mpeg", sniff(b"ID3\x04\x00\x00").mime); - assert_eq!("audio/mpeg", sniff(b"\xff\xfb\x90\x00").mime); - assert_eq!("audio/ogg", sniff(b"OggS\x00\x02").mime); - assert_eq!("video/ogg", sniff(b"OggS\x00\x02xxxx\x80theora").mime); - assert_eq!("audio/flac", sniff(b"fLaC\x00\x00").mime); - assert_eq!("audio/wav", sniff(b"RIFF\x00\x00\x00\x00WAVEdata").mime); - } - - #[test] - fn extension_mime_covers_common_audio_and_video_containers() { - assert_eq!(Some("video/ogg"), extension_mime("movie.ogv")); - assert_eq!(Some("audio/ogg"), extension_mime("voice.opus")); - assert_eq!(Some("audio/flac"), extension_mime("track.flac")); - assert_eq!(Some("video/x-matroska"), extension_mime("clip.mkv")); - assert_eq!(Some("image/tiff"), extension_mime("scan.tiff")); - assert_eq!(Some("image/bmp"), extension_mime("bitmap.bmp")); - } - - #[test] - fn sniffs_pdf() { - assert_eq!("application/pdf", sniff(b"%PDF-1.7\n").mime); - } -} diff --git a/fluxer_media_proxy/src/mime/image_containers.rs b/fluxer_media_proxy/src/mime/image_containers.rs new file mode 100644 index 000000000..39e205495 --- /dev/null +++ b/fluxer_media_proxy/src/mime/image_containers.rs @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::SniffInfo; +use crate::media_type::MediaType; + +const WEBP_CHUNK_HEADER_BYTES: usize = 20; +const WEBP_EXTENDED_HEADER_BYTES: usize = 30; +const WEBP_LOSSLESS_HEADER_BYTES: usize = 25; +const GIF_LOGICAL_SCREEN_BYTES: usize = 13; +const GIF_IMAGE_DESCRIPTOR_BYTES: usize = 9; +const GIF_STRUCTURE_BLOCK_LIMIT: usize = 262_144; +const PNG_SNIFF_CHUNK_LIMIT: usize = 262_144; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum GIFAnimation { + Incomplete, + Static, + Animated, + Invalid, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum PNGAnimation { + Incomplete, + Static, + Animated(u32), + Invalid, +} + +fn gif_color_table_end(data: &[u8], offset: usize, packed: u8) -> Option { + assert!(offset <= data.len()); + if packed & 0x80 == 0 { + return Some(offset); + } + let entries = 1usize << ((packed & 0x07) + 1); + let table_bytes = entries + .checked_mul(3) + .expect("GIF color table byte count must fit usize"); + let end = offset.checked_add(table_bytes)?; + (end <= data.len()).then_some(end) +} + +fn gif_u16(data: &[u8], offset: usize) -> u16 { + assert!(offset <= data.len()); + assert!(data.len() - offset >= 2); + u16::from_le_bytes( + data[offset..offset + 2] + .try_into() + .expect("validated GIF u16 slice"), + ) +} + +fn gif_validate_image_geometry( + data: &[u8], + offset: usize, + canvas_width: u16, + canvas_height: u16, +) -> Result<(), GIFAnimation> { + assert!(offset <= data.len()); + assert!(data.len() - offset >= GIF_IMAGE_DESCRIPTOR_BYTES); + let left = gif_u16(data, offset); + let top = gif_u16(data, offset + 2); + let width = gif_u16(data, offset + 4); + let height = gif_u16(data, offset + 6); + if width == 0 { + return Err(GIFAnimation::Invalid); + } + if height == 0 { + return Err(GIFAnimation::Invalid); + } + if u32::from(left) + u32::from(width) > u32::from(canvas_width) { + return Err(GIFAnimation::Invalid); + } + if u32::from(top) + u32::from(height) > u32::from(canvas_height) { + return Err(GIFAnimation::Invalid); + } + Ok(()) +} + +fn gif_skip_sub_blocks( + data: &[u8], + mut offset: usize, + block_count: &mut usize, +) -> Result { + assert!(offset <= data.len()); + loop { + if *block_count >= GIF_STRUCTURE_BLOCK_LIMIT { + return Err(GIFAnimation::Invalid); + } + let Some(&length) = data.get(offset) else { + return Err(GIFAnimation::Incomplete); + }; + *block_count += 1; + offset += 1; + if length == 0 { + return Ok(offset); + } + let Some(end) = offset.checked_add(usize::from(length)) else { + return Err(GIFAnimation::Invalid); + }; + if end > data.len() { + return Err(GIFAnimation::Incomplete); + } + offset = end; + } +} + +fn gif_skip_image( + data: &[u8], + offset: usize, + canvas_width: u16, + canvas_height: u16, + block_count: &mut usize, +) -> Result { + assert!(offset <= data.len()); + let Some(descriptor_end) = offset.checked_add(GIF_IMAGE_DESCRIPTOR_BYTES) else { + return Err(GIFAnimation::Invalid); + }; + if descriptor_end > data.len() { + return Err(GIFAnimation::Incomplete); + } + gif_validate_image_geometry(data, offset, canvas_width, canvas_height)?; + let packed = data[offset + 8]; + let Some(table_end) = gif_color_table_end(data, descriptor_end, packed) else { + return Err(GIFAnimation::Incomplete); + }; + let Some(&minimum_code_size) = data.get(table_end) else { + return Err(GIFAnimation::Incomplete); + }; + if !(2..=8).contains(&minimum_code_size) { + return Err(GIFAnimation::Invalid); + } + gif_skip_sub_blocks(data, table_end + 1, block_count) +} + +pub(super) fn gif_animation(data: &[u8]) -> GIFAnimation { + if data.len() < 6 { + return GIFAnimation::Incomplete; + } + match &data[..6] { + b"GIF87a" | b"GIF89a" => {} + _ => return GIFAnimation::Invalid, + } + if data.len() < GIF_LOGICAL_SCREEN_BYTES { + return GIFAnimation::Incomplete; + } + let canvas_width = gif_u16(data, 6); + let canvas_height = gif_u16(data, 8); + if canvas_width == 0 { + return GIFAnimation::Invalid; + } + if canvas_height == 0 { + return GIFAnimation::Invalid; + } + let Some(mut offset) = gif_color_table_end(data, GIF_LOGICAL_SCREEN_BYTES, data[10]) else { + return GIFAnimation::Incomplete; + }; + let mut image_count = 0usize; + let mut block_count = 0usize; + loop { + if block_count >= GIF_STRUCTURE_BLOCK_LIMIT { + return GIFAnimation::Invalid; + } + let Some(&introducer) = data.get(offset) else { + return GIFAnimation::Incomplete; + }; + block_count += 1; + offset += 1; + match introducer { + 0x3b if image_count == 1 => return GIFAnimation::Static, + 0x3b => return GIFAnimation::Invalid, + 0x21 => { + if data.get(offset).is_none() { + return GIFAnimation::Incomplete; + } + offset = match gif_skip_sub_blocks(data, offset + 1, &mut block_count) { + Ok(next) => next, + Err(outcome) => return outcome, + }; + } + 0x2c => { + offset = match gif_skip_image( + data, + offset, + canvas_width, + canvas_height, + &mut block_count, + ) { + Ok(next) => next, + Err(outcome) => return outcome, + }; + image_count += 1; + if image_count == 2 { + return GIFAnimation::Animated; + } + } + _ => return GIFAnimation::Invalid, + } + } +} + +pub(super) fn png_animation(data: &[u8]) -> PNGAnimation { + if data.len() < 8 { + return PNGAnimation::Incomplete; + } + if &data[..8] != b"\x89PNG\r\n\x1a\n" { + return PNGAnimation::Invalid; + } + let mut offset = 8usize; + let mut first_chunk = true; + let mut chunk_count = 0usize; + loop { + if chunk_count >= PNG_SNIFF_CHUNK_LIMIT { + return PNGAnimation::Invalid; + } + let Some(length_end) = offset.checked_add(4) else { + return PNGAnimation::Invalid; + }; + let Some(header_end) = offset.checked_add(8) else { + return PNGAnimation::Invalid; + }; + if header_end > data.len() { + break; + } + chunk_count += 1; + let length_bytes = data[offset..length_end] + .try_into() + .expect("validated PNG chunk length slice"); + let length = usize::try_from(u32::from_be_bytes(length_bytes)) + .expect("PNG chunk length must fit the configured platform"); + let kind = &data[length_end..header_end]; + if first_chunk { + if kind != b"IHDR" { + return PNGAnimation::Invalid; + } + if length != 13 { + return PNGAnimation::Invalid; + } + } + first_chunk = false; + if kind == b"acTL" { + if length != 8 { + return PNGAnimation::Invalid; + } + let Some(chunk_end) = offset.checked_add(16) else { + return PNGAnimation::Invalid; + }; + if chunk_end > data.len() { + return PNGAnimation::Incomplete; + } + let frame_end = header_end + .checked_add(4) + .expect("validated PNG animation frame count end"); + let frame_bytes = data[header_end..frame_end] + .try_into() + .expect("validated PNG animation frame count slice"); + let frames = u32::from_be_bytes(frame_bytes); + return if frames > 0 { + PNGAnimation::Animated(frames) + } else { + PNGAnimation::Invalid + }; + } + match kind { + b"IDAT" | b"IEND" => return PNGAnimation::Static, + _ => {} + } + let Some(next) = offset + .checked_add(12) + .and_then(|next| next.checked_add(length)) + else { + return PNGAnimation::Invalid; + }; + if next > data.len() { + return PNGAnimation::Incomplete; + } + offset = next; + } + PNGAnimation::Incomplete +} + +fn webp_u24(data: &[u8]) -> u32 { + u32::from(data[0]) | (u32::from(data[1]) << 8) | (u32::from(data[2]) << 16) +} + +pub(super) fn webp_sniff(data: &[u8]) -> SniffInfo { + let mut out = SniffInfo { + mime: MediaType::WebP.mime(), + ..Default::default() + }; + if data.len() < WEBP_CHUNK_HEADER_BYTES { + return out; + } + match &data[12..16] { + b"VP8X" => { + let chunk_size = u32::from_le_bytes( + data[16..20] + .try_into() + .expect("validated WebP chunk size slice"), + ); + if chunk_size != 10 || data.len() < WEBP_EXTENDED_HEADER_BYTES { + return out; + } + let flags = data[20]; + out.animated = flags & 0x02 != 0; + out.frames = if out.animated { 2 } else { 1 }; + out.has_alpha = flags & 0x10 != 0; + out.width = webp_u24(&data[24..27]) + .checked_add(1) + .expect("24-bit WebP width must fit u32"); + out.height = webp_u24(&data[27..30]) + .checked_add(1) + .expect("24-bit WebP height must fit u32"); + } + b"VP8 " if data.len() >= WEBP_EXTENDED_HEADER_BYTES => { + if &data[23..26] == b"\x9d\x01\x2a" { + out.width = u32::from( + u16::from_le_bytes( + data[26..28].try_into().expect("validated WebP width slice"), + ) & 0x3fff, + ); + out.height = u32::from( + u16::from_le_bytes( + data[28..30] + .try_into() + .expect("validated WebP height slice"), + ) & 0x3fff, + ); + } + } + b"VP8L" if data.len() >= WEBP_LOSSLESS_HEADER_BYTES && data[20] == 0x2f => { + let bits = u32::from_le_bytes( + data[21..25] + .try_into() + .expect("validated lossless WebP dimensions slice"), + ); + out.width = (bits & 0x3fff) + .checked_add(1) + .expect("14-bit WebP width must fit u32"); + out.height = ((bits >> 14) & 0x3fff) + .checked_add(1) + .expect("14-bit WebP height must fit u32"); + out.has_alpha = bits & (1 << 28) != 0; + } + _ => {} + } + out +} + +pub(super) fn webp_sniff_complete(data: &[u8]) -> bool { + if data.len() < WEBP_CHUNK_HEADER_BYTES { + return false; + } + match &data[12..16] { + b"VP8X" => { + let chunk_size = u32::from_le_bytes( + data[16..20] + .try_into() + .expect("validated WebP animation chunk size slice"), + ); + chunk_size != 10 || data.len() >= WEBP_EXTENDED_HEADER_BYTES + } + b"VP8 " => data.len() >= WEBP_EXTENDED_HEADER_BYTES, + b"VP8L" => data.len() >= WEBP_LOSSLESS_HEADER_BYTES, + _ => true, + } +} diff --git a/fluxer_media_proxy/src/mime/iso_bmff.rs b/fluxer_media_proxy/src/mime/iso_bmff.rs new file mode 100644 index 000000000..62d8593da --- /dev/null +++ b/fluxer_media_proxy/src/mime/iso_bmff.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::SniffInfo; +use crate::media_type::MediaType; + +const ISO_BMFF_BRAND_SCAN_BYTES: usize = 128; +const ISO_BMFF_BASE_HEADER_BYTES: usize = 8; +const ISO_BMFF_EXTENDED_HEADER_BYTES: usize = 16; +const ISO_BMFF_FTYP_FIELDS_BYTES: usize = 8; + +struct FTYPLayout { + major_brand_offset: usize, + compatible_brands_offset: usize, + scan_end: usize, +} + +#[derive(Default)] +struct ISOBMFFBrandSet { + avif: bool, + animated_avif: bool, + heic: bool, + heif: bool, + audio_mp4: bool, + mp4: bool, + quicktime: bool, + three_gpp: bool, +} + +impl ISOBMFFBrandSet { + fn record(&mut self, brand: &[u8]) { + if matches!(brand, b"avif" | b"avis" | b"avio") { + self.avif = true; + if brand == b"avis" { + self.animated_avif = true; + } + } + if matches!( + brand, + b"heic" | b"heix" | b"heif" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"hevm" | b"hevs" + ) { + self.heic = true; + } + if matches!(brand, b"mif1" | b"msf1") { + self.heif = true; + } + if matches!( + brand, + b"mp41" + | b"mp42" + | b"isom" + | b"iso2" + | b"iso3" + | b"iso4" + | b"iso5" + | b"iso6" + | b"M4V " + | b"M4P " + | b"dash" + | b"msdh" + | b"msix" + | b"mj2s" + ) { + self.mp4 = true; + } + if matches!(brand, b"M4A " | b"M4B " | b"M4P ") { + self.audio_mp4 = true; + } + if brand == b"qt " { + self.quicktime = true; + } + if brand.starts_with(b"3gp") { + self.three_gpp = true; + } + if brand.starts_with(b"3g2") { + self.three_gpp = true; + } + } + + fn resolve(self) -> Option { + if self.avif { + return Some(SniffInfo { + mime: MediaType::AVIF.mime(), + animated: self.animated_avif, + ..Default::default() + }); + } + if self.heic { + return Some(SniffInfo { + mime: MediaType::HEIC.mime(), + ..Default::default() + }); + } + if self.heif { + return Some(SniffInfo { + mime: MediaType::HEIF.mime(), + ..Default::default() + }); + } + if self.quicktime { + return Some(SniffInfo { + mime: MediaType::QuickTimeVideo.mime(), + ..Default::default() + }); + } + if self.three_gpp { + return Some(SniffInfo { + mime: MediaType::ThreeGPPVideo.mime(), + ..Default::default() + }); + } + if self.audio_mp4 { + return Some(SniffInfo { + mime: MediaType::MP4Audio.mime(), + ..Default::default() + }); + } + if self.mp4 { + return Some(SniffInfo { + mime: MediaType::MP4Video.mime(), + ..Default::default() + }); + } + None + } +} + +fn ftyp_layout(data: &[u8]) -> Option { + if data.len() < ISO_BMFF_BASE_HEADER_BYTES { + return None; + } + if &data[4..8] != b"ftyp" { + return None; + } + let size32 = u32::from_be_bytes( + data[0..4] + .try_into() + .expect("validated ISO BMFF box size slice"), + ); + let (header_bytes, declared_box_size) = if size32 == 1 { + if data.len() < ISO_BMFF_EXTENDED_HEADER_BYTES { + return None; + } + let size64 = u64::from_be_bytes( + data[8..16] + .try_into() + .expect("validated extended ISO BMFF box size slice"), + ); + let box_size = usize::try_from(size64).ok()?; + (ISO_BMFF_EXTENDED_HEADER_BYTES, Some(box_size)) + } else if size32 == 0 { + (ISO_BMFF_BASE_HEADER_BYTES, None) + } else { + let box_size = usize::try_from(size32).expect("u32 ISO BMFF box size must fit usize"); + (ISO_BMFF_BASE_HEADER_BYTES, Some(box_size)) + }; + let box_size = declared_box_size.unwrap_or(data.len()); + let minimum_size = header_bytes.checked_add(ISO_BMFF_FTYP_FIELDS_BYTES)?; + if box_size < minimum_size { + return None; + } + let major_brand_end = header_bytes.checked_add(4)?; + let scan_end = match declared_box_size { + Some(declared) if declared <= data.len() => declared, + _ => data.len().min(ISO_BMFF_BRAND_SCAN_BYTES), + }; + if major_brand_end > scan_end { + return None; + } + Some(FTYPLayout { + major_brand_offset: header_bytes, + compatible_brands_offset: minimum_size, + scan_end, + }) +} + +pub(super) fn iso_bmff_sniff(data: &[u8]) -> Option { + let layout = ftyp_layout(data)?; + let mut brands = ISOBMFFBrandSet::default(); + brands.record(&data[layout.major_brand_offset..layout.major_brand_offset + 4]); + let mut offset = layout.compatible_brands_offset; + loop { + let end = offset + .checked_add(4) + .expect("bounded ISO BMFF brand offset must fit usize"); + if end > layout.scan_end { + break; + } + brands.record(&data[offset..end]); + offset = end; + } + brands.resolve() +} diff --git a/fluxer_media_proxy/src/mime/mod.rs b/fluxer_media_proxy/src/mime/mod.rs new file mode 100644 index 000000000..aee00c55c --- /dev/null +++ b/fluxer_media_proxy/src/mime/mod.rs @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod image_containers; +mod iso_bmff; +mod registry; +mod stream_containers; + +#[cfg(test)] +mod tests; + +use self::image_containers::{ + GIFAnimation, PNGAnimation, gif_animation, png_animation, webp_sniff, webp_sniff_complete, +}; +use self::iso_bmff::iso_bmff_sniff; +use self::stream_containers::{is_adts, looks_like_svg, matroska_sniff, mpeg_ts_sniff, ogg_sniff}; +use crate::media_type::MediaType; + +pub use self::registry::{category, extension_mime, normalize, passthrough_mime}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Category { + Image, + Video, + Audio, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct SniffInfo { + pub mime: &'static str, + pub animated: bool, + pub frames: u32, + pub duration_ms: u32, + pub width: u32, + pub height: u32, + pub has_alpha: bool, + pub color_space: &'static str, +} + +impl Default for SniffInfo { + fn default() -> Self { + Self { + mime: "application/octet-stream", + animated: false, + frames: 1, + duration_ms: 0, + width: 0, + height: 0, + has_alpha: false, + color_space: "unknown", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct MediaPrefixSniff { + pub media: SniffInfo, + pub complete: bool, +} + +pub fn is_supported_media_mime(mime_type_raw: &str) -> bool { + let Some(mime_type) = normalize(Some(mime_type_raw)) else { + return false; + }; + matches!( + mime_type, + "image/jpeg" + | "image/png" + | "image/apng" + | "image/gif" + | "image/webp" + | "image/avif" + | "image/heic" + | "image/heif" + | "image/jxl" + | "image/svg+xml" + | "image/tiff" + | "image/bmp" + | "video/mp4" + | "video/webm" + | "video/quicktime" + | "video/3gpp" + | "video/x-matroska" + | "video/x-msvideo" + | "video/x-flv" + | "video/ogg" + | "video/mp2t" + | "video/mpeg" + | "video/x-ms-wmv" + | "audio/mpeg" + | "audio/wav" + | "audio/flac" + | "audio/ogg" + | "audio/aac" + | "audio/mp4" + | "audio/webm" + | "audio/aiff" + ) +} + +fn starts(data: &[u8], prefix: &[u8]) -> bool { + data.len() >= prefix.len() && &data[..prefix.len()] == prefix +} + +pub fn sniff(data: &[u8]) -> SniffInfo { + if starts(data, b"\x89PNG\r\n\x1a\n") { + let mut out = SniffInfo { + mime: MediaType::PNG.mime(), + has_alpha: true, + ..Default::default() + }; + if data.len() >= 26 && &data[12..16] == b"IHDR" { + out.width = + u32::from_be_bytes(data[16..20].try_into().expect("validated PNG width slice")); + out.height = + u32::from_be_bytes(data[20..24].try_into().expect("validated PNG height slice")); + out.has_alpha = data[25] == 4 || data[25] == 6; + } + if let PNGAnimation::Animated(frames) = png_animation(data) { + out.mime = MediaType::APNG.mime(); + out.animated = frames > 1; + out.frames = frames; + } + return out; + } + if starts(data, b"\xff\xd8\xff") { + return SniffInfo { + mime: MediaType::JPEG.mime(), + ..Default::default() + }; + } + if starts(data, b"GIF87a") || starts(data, b"GIF89a") { + let animated = gif_animation(data) == GIFAnimation::Animated; + return SniffInfo { + mime: MediaType::GIF.mime(), + animated, + frames: if animated { 2 } else { 1 }, + width: if data.len() >= 10 { + u32::from(u16::from_le_bytes( + data[6..8].try_into().expect("validated GIF width slice"), + )) + } else { + 0 + }, + height: if data.len() >= 10 { + u32::from(u16::from_le_bytes( + data[8..10].try_into().expect("validated GIF height slice"), + )) + } else { + 0 + }, + ..Default::default() + }; + } + if data.len() >= 12 && starts(data, b"RIFF") && &data[8..12] == b"WEBP" { + return webp_sniff(data); + } + if let Some(info) = iso_bmff_sniff(data) { + return info; + } + if starts(data, b"\xff\x0a") || starts(data, b"\x00\x00\x00\x0cJXL \r\n\x87\n") { + return SniffInfo { + mime: MediaType::JXL.mime(), + ..Default::default() + }; + } + if starts(data, b"II*\0") || starts(data, b"MM\0*") { + return SniffInfo { + mime: MediaType::TIFF.mime(), + ..Default::default() + }; + } + if starts(data, b"BM") { + return SniffInfo { + mime: MediaType::BMP.mime(), + ..Default::default() + }; + } + if starts(data, b"\x1a\x45\xdf\xa3") { + return matroska_sniff(data); + } + if starts(data, b"FLV") { + return SniffInfo { + mime: MediaType::FLVVideo.mime(), + ..Default::default() + }; + } + if starts(data, b"RIFF") && data.len() >= 12 && &data[8..12] == b"AVI " { + return SniffInfo { + mime: MediaType::AVIVideo.mime(), + ..Default::default() + }; + } + if starts( + data, + b"\x30\x26\xb2\x75\x8e\x66\xcf\x11\xa6\xd9\x00\xaa\x00\x62\xce\x6c", + ) { + return SniffInfo { + mime: MediaType::WMVVideo.mime(), + ..Default::default() + }; + } + if starts(data, b"\x00\x00\x01\xba") || starts(data, b"\x00\x00\x01\xb3") { + return SniffInfo { + mime: MediaType::MPEGVideo.mime(), + ..Default::default() + }; + } + if mpeg_ts_sniff(data) { + return SniffInfo { + mime: MediaType::MPEGTSVideo.mime(), + ..Default::default() + }; + } + if starts(data, b"ID3") + || starts(data, b"\xff\xfb") + || starts(data, b"\xff\xf3") + || starts(data, b"\xff\xf2") + { + return SniffInfo { + mime: MediaType::MPEGAudio.mime(), + ..Default::default() + }; + } + if starts(data, b"OggS") { + return ogg_sniff(data); + } + if starts(data, b"fLaC") { + return SniffInfo { + mime: MediaType::FLACAudio.mime(), + ..Default::default() + }; + } + if is_adts(data) { + return SniffInfo { + mime: MediaType::AACAudio.mime(), + ..Default::default() + }; + } + if starts(data, b"RIFF") && data.len() >= 12 && &data[8..12] == b"WAVE" { + return SniffInfo { + mime: MediaType::WAVAudio.mime(), + ..Default::default() + }; + } + if starts(data, b"FORM") && data.len() >= 12 && matches!(&data[8..12], b"AIFF" | b"AIFC") { + return SniffInfo { + mime: MediaType::AIFFAudio.mime(), + ..Default::default() + }; + } + if starts(data, b"%PDF-") { + return SniffInfo { + mime: "application/pdf", + ..Default::default() + }; + } + if looks_like_svg(data) { + return SniffInfo { + mime: MediaType::SVG.mime(), + ..Default::default() + }; + } + SniffInfo::default() +} + +pub fn sniff_prefix(data: &[u8], total_len: usize) -> MediaPrefixSniff { + assert!(data.len() <= total_len); + let sniffed = sniff(data); + if data.len() == total_len { + return MediaPrefixSniff { + media: sniffed, + complete: true, + }; + } + let complete = + if sniffed.mime == MediaType::PNG.mime() || sniffed.mime == MediaType::APNG.mime() { + png_animation(data) != PNGAnimation::Incomplete + } else if sniffed.mime == MediaType::WebP.mime() { + webp_sniff_complete(data) + } else if sniffed.mime == MediaType::GIF.mime() { + gif_animation(data) != GIFAnimation::Incomplete + } else { + matches!( + MediaType::from_mime(sniffed.mime), + Some( + MediaType::JPEG + | MediaType::JXL + | MediaType::TIFF + | MediaType::BMP + | MediaType::SVG + ) + ) + }; + MediaPrefixSniff { + media: sniffed, + complete, + } +} + +pub fn detect(data: &[u8], filename: &str, header_mime: Option<&str>) -> String { + let sniffed = sniff(data); + if sniffed.mime != "application/octet-stream" { + if sniffed.mime == "video/mp4" && extension_mime(filename) == Some("audio/mp4") { + return "audio/mp4".to_owned(); + } + return sniffed.mime.to_owned(); + } + if let Some(m) = extension_mime(filename) { + return m.to_owned(); + } + if let Some(m) = normalize(header_mime) { + return m.to_owned(); + } + "application/octet-stream".to_owned() +} + +pub fn filename_for_mime(mime_type: &str, fallback: &str) -> String { + if fallback.contains('.') { + return fallback.to_owned(); + } + let ext = match mime_type { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + "video/mp4" => "mp4", + _ => "bin", + }; + format!("{fallback}.{ext}") +} diff --git a/fluxer_media_proxy/src/mime/registry.rs b/fluxer_media_proxy/src/mime/registry.rs new file mode 100644 index 000000000..2e82daa83 --- /dev/null +++ b/fluxer_media_proxy/src/mime/registry.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::Category; +use crate::media_type::MediaType; +use http::HeaderValue; + +pub fn normalize(raw: Option<&str>) -> Option<&str> { + let value = raw?; + let semi = value.find(';').unwrap_or(value.len()); + let trimmed = value[..semi].trim_matches([' ', '\t']); + (!trimmed.is_empty() && HeaderValue::from_bytes(trimmed.as_bytes()).is_ok()).then_some(trimmed) +} + +pub fn category(mime_type: &str) -> Option { + let prefix = mime_type.as_bytes().get(..6); + if prefix.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"image/")) { + Some(Category::Image) + } else if prefix.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"video/")) { + Some(Category::Video) + } else if prefix.is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"audio/")) { + Some(Category::Audio) + } else { + None + } +} + +pub fn passthrough_mime(raw: Option<&str>) -> Option<&'static str> { + let normalized = normalize(raw)?; + if normalized.eq_ignore_ascii_case("application/pdf") { + return Some("application/pdf"); + } + if normalized.eq_ignore_ascii_case("text/css") { + return Some("text/css; charset=utf-8"); + } + MediaType::from_mime(normalized).map(MediaType::mime) +} + +pub fn extension_mime(filename: &str) -> Option<&'static str> { + let ext = filename.rsplit_once('.')?.1; + if ext.eq_ignore_ascii_case("css") { + return Some("text/css; charset=utf-8"); + } + MediaType::from_extension(ext).map(MediaType::mime) +} diff --git a/fluxer_media_proxy/src/mime/stream_containers.rs b/fluxer_media_proxy/src/mime/stream_containers.rs new file mode 100644 index 000000000..1d0acef4d --- /dev/null +++ b/fluxer_media_proxy/src/mime/stream_containers.rs @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::SniffInfo; +use crate::media_type::MediaType; + +pub(super) fn mpeg_ts_sniff(data: &[u8]) -> bool { + data.len() >= 188 + && data[0] == 0x47 + && (data.len() < 376 || data[188] == 0x47) + && (data.len() < 564 || data[376] == 0x47) +} + +pub(super) fn is_adts(data: &[u8]) -> bool { + if data.len() < 7 || data[0] != 0xff || data[1] & 0xf6 != 0xf0 { + return false; + } + let sample_rate_index = (data[2] >> 2) & 0x0f; + if sample_rate_index == 0x0f { + return false; + } + let frame_length = (usize::from(data[3] & 0x03) << 11) + | (usize::from(data[4]) << 3) + | (usize::from(data[5]) >> 5); + frame_length >= 7 && frame_length <= data.len() +} + +pub(super) fn looks_like_svg(data: &[u8]) -> bool { + let mut window = &data[..data.len().min(4096)]; + if window.starts_with(b"\xef\xbb\xbf") { + window = &window[3..]; + } + window.windows(4).any(|w| w == b" SniffInfo { + let window = &data[..data.len().min(8192)]; + if window.windows(6).any(|w| w == b"theora" || w == b"Theora") { + SniffInfo { + mime: MediaType::OGGVideo.mime(), + ..Default::default() + } + } else { + SniffInfo { + mime: MediaType::OGGAudio.mime(), + ..Default::default() + } + } +} + +pub(super) fn matroska_sniff(data: &[u8]) -> SniffInfo { + let window = &data[..data.len().min(4096)]; + if window.windows(4).any(|w| w == b"webm") { + SniffInfo { + mime: MediaType::WebMVideo.mime(), + ..Default::default() + } + } else { + SniffInfo { + mime: MediaType::MatroskaVideo.mime(), + ..Default::default() + } + } +} diff --git a/fluxer_media_proxy/src/mime/tests.rs b/fluxer_media_proxy/src/mime/tests.rs new file mode 100644 index 000000000..7dd264bba --- /dev/null +++ b/fluxer_media_proxy/src/mime/tests.rs @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::*; +use crate::test_fixtures::{ + adversarial_media_bytes, animated_gif_fixture, apng_header, minimal_gif, +}; + +fn vp8x_webp(flags: u8, width_minus_one: u32, height_minus_one: u32) -> Vec { + let mut webp = vec![0_u8; 30]; + webp[0..4].copy_from_slice(b"RIFF"); + webp[8..12].copy_from_slice(b"WEBP"); + webp[12..16].copy_from_slice(b"VP8X"); + webp[16..20].copy_from_slice(&10_u32.to_le_bytes()); + webp[20] = flags; + webp[24..27].copy_from_slice(&width_minus_one.to_le_bytes()[..3]); + webp[27..30].copy_from_slice(&height_minus_one.to_le_bytes()[..3]); + webp +} + +fn png_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut chunk = Vec::new(); + chunk.extend_from_slice( + &u32::try_from(payload.len()) + .expect("test payload fits a PNG chunk") + .to_be_bytes(), + ); + chunk.extend_from_slice(kind); + chunk.extend_from_slice(payload); + chunk.extend_from_slice(&[0; 4]); + chunk +} + +#[test] +fn sniffs_common_image_formats() { + assert_eq!("image/png", sniff(b"\x89PNG\r\n\x1a\nxxxx").mime); + assert_eq!("image/jpeg", sniff(b"\xff\xd8\xff").mime); + assert_eq!("image/gif", sniff(b"GIF89a\x01\x00\x01\x00").mime); + assert_eq!("image/tiff", sniff(b"II*\0xxxx").mime); + assert_eq!("image/tiff", sniff(b"MM\0*xxxx").mime); + assert_eq!("image/bmp", sniff(b"BMxxxx").mime); +} + +#[test] +fn sniffs_apng_via_actl_chunk() { + let apng = apng_header(2); + let info = sniff(&apng); + assert_eq!("image/apng", info.mime); + assert!(info.animated); +} + +#[test] +fn sniffs_animated_webp_via_anim_chunk() { + let info = sniff(&vp8x_webp(0x02, 0, 0)); + assert_eq!("image/webp", info.mime); + assert!(info.animated); +} + +#[test] +fn sniffs_ftyp_boxes_for_heic_avif_mp4_variants() { + assert_eq!( + "image/avif", + sniff(b"\x00\x00\x00\x20ftypavifsome bytes").mime + ); + assert_eq!( + "image/avif", + sniff(b"\x00\x00\x00\x20ftypavissome bytes").mime + ); + assert!(sniff(b"\x00\x00\x00\x20ftypavissome bytes").animated); + assert_eq!( + "image/heif", + sniff(b"\x00\x00\x00\x20ftypmif1some bytes").mime + ); + assert_eq!( + "image/heic", + sniff(b"\x00\x00\x00\x20ftypheicsome bytes").mime + ); + assert_eq!( + "video/mp4", + sniff(b"\x00\x00\x00\x20ftypiso5some bytes").mime + ); + assert_eq!( + "video/mp4", + sniff(b"\x00\x00\x00\x20ftypM4V some bytes").mime + ); + assert_eq!( + "audio/mp4", + sniff(b"\x00\x00\x00\x20ftypM4A some bytes").mime + ); + assert_eq!( + "video/quicktime", + sniff(b"\x00\x00\x00\x20ftypqt some bytes").mime + ); +} + +#[test] +fn sniffs_the_literal_heif_ftyp_brand() { + let heif = b"\x00\x00\x00\x20ftypheifsome bytes"; + assert_eq!("image/heic", sniff(heif).mime); + assert!(!sniff(heif).animated); +} + +#[test] +fn scans_every_compatible_brand_of_a_fully_present_ftyp_box() { + let compatible_brands = 40; + let box_size = 16 + compatible_brands * 4; + let mut ftyp = Vec::new(); + ftyp.extend_from_slice( + &u32::try_from(box_size) + .expect("test ftyp box size fits u32") + .to_be_bytes(), + ); + ftyp.extend_from_slice(b"ftyp"); + ftyp.extend_from_slice(b"isom"); + ftyp.extend_from_slice(&0x0000_0200_u32.to_be_bytes()); + for _ in 0..compatible_brands - 1 { + ftyp.extend_from_slice(b"isom"); + } + ftyp.extend_from_slice(b"avif"); + assert_eq!(box_size, ftyp.len()); + assert!(box_size - 4 > 128); + assert_eq!("image/avif", sniff(&ftyp).mime); + + let mut truncated_box = ftyp.clone(); + truncated_box[0..4].copy_from_slice(&u32::MAX.to_be_bytes()); + assert_eq!("video/mp4", sniff(&truncated_box).mime); +} + +#[test] +fn detect_prefers_m4a_extension_over_generic_mp4_brand() { + assert_eq!( + "audio/mp4", + detect( + b"\x00\x00\x00\x20ftypisom\x00\x00\x02\x00isomiso2mp41", + "track.m4a", + None + ) + ); +} + +#[test] +fn sniffs_matroska_vs_webm() { + let mkv = b"\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\xf7\x81\x01\x42\xf2\x81\x04\x42\xf3\x81\x08\x42\x82\x88matroska"; + assert_eq!("video/x-matroska", sniff(mkv).mime); + assert_eq!( + "video/webm", + sniff(b"\x1a\x45\xdf\xa3 here is the webm doctype").mime + ); +} + +#[test] +fn sniffs_audio_variants() { + assert_eq!("audio/mpeg", sniff(b"ID3\x04\x00\x00").mime); + assert_eq!("audio/mpeg", sniff(b"\xff\xfb\x90\x00").mime); + assert_eq!("audio/ogg", sniff(b"OggS\x00\x02").mime); + assert_eq!("video/ogg", sniff(b"OggS\x00\x02xxxx\x80theora").mime); + assert_eq!("audio/flac", sniff(b"fLaC\x00\x00").mime); + assert_eq!("audio/wav", sniff(b"RIFF\x00\x00\x00\x00WAVEdata").mime); +} + +#[test] +fn extension_mime_covers_common_audio_and_video_containers() { + assert_eq!(Some("video/ogg"), extension_mime("movie.ogv")); + assert_eq!(Some("audio/ogg"), extension_mime("voice.opus")); + assert_eq!(Some("audio/flac"), extension_mime("track.flac")); + assert_eq!(Some("video/x-matroska"), extension_mime("clip.mkv")); + assert_eq!(Some("image/tiff"), extension_mime("scan.tiff")); + assert_eq!(Some("image/bmp"), extension_mime("bitmap.bmp")); +} + +#[test] +fn sniffs_pdf() { + assert_eq!("application/pdf", sniff(b"%PDF-1.7\n").mime); +} + +#[test] +fn mime_sniff_recognizes_bounded_container_headers() { + let png = apng_header(2); + let sniffed = sniff(&png); + assert_eq!("image/apng", sniffed.mime); + assert!(sniffed.animated); + assert_eq!(2, sniffed.frames); + assert_eq!((2, 3), (sniffed.width, sniffed.height)); + assert!(sniffed.has_alpha); + + let gif = minimal_gif(); + let sniffed = sniff(&gif); + assert_eq!("image/gif", sniffed.mime); + assert!(!sniffed.animated); + assert_eq!((1, 1), (sniffed.width, sniffed.height)); + + let animated = animated_gif_fixture(); + let sniffed = sniff(&animated); + assert_eq!("image/gif", sniffed.mime); + assert!(sniffed.animated); + assert_eq!(2, sniffed.frames); + assert_eq!((32, 32), (sniffed.width, sniffed.height)); + + let sniffed = sniff(&vp8x_webp(0x12, 1, 2)); + assert_eq!("image/webp", sniffed.mime); + assert!(sniffed.animated); + assert!(sniffed.has_alpha); + assert_eq!((2, 3), (sniffed.width, sniffed.height)); + + let mut bmff = Vec::new(); + bmff.extend_from_slice(&24_u32.to_be_bytes()); + bmff.extend_from_slice(b"ftyp"); + bmff.extend_from_slice(b"M4A "); + bmff.extend_from_slice(&0_u32.to_be_bytes()); + bmff.extend_from_slice(b"isom"); + bmff.extend_from_slice(b"M4A "); + assert_eq!("audio/mp4", sniff(&bmff).mime); +} + +#[test] +fn mime_sniffing_stays_bounded_on_adversarial_containers() { + for bytes in adversarial_media_bytes() { + let sniffed = sniff(&bytes); + assert!(sniffed.frames >= 1, "zero frames for {} bytes", bytes.len()); + + let complete = sniff_prefix(&bytes, bytes.len()); + assert!(complete.complete, "incomplete for {} bytes", bytes.len()); + assert!(complete.media.frames >= 1); + + let truncated = sniff_prefix(&bytes, bytes.len().saturating_add(1024)); + assert!(truncated.media.frames >= 1); + } +} + +#[test] +fn prefix_sniffing_reports_truncated_containers_as_incomplete() { + let gif = minimal_gif(); + for prefix_length in 0..gif.len() { + let result = sniff_prefix(&gif[..prefix_length], gif.len()); + if prefix_length < 6 { + assert_eq!("application/octet-stream", result.media.mime); + } + } + let complete = sniff_prefix(&gif, gif.len()); + assert!(complete.complete); + assert_eq!("image/gif", complete.media.mime); + assert!(!sniff_prefix(&gif[..gif.len() - 1], gif.len()).complete); + + let apng = apng_header(2); + assert!(sniff_prefix(&apng, apng.len() + 1024).complete); + assert!(!sniff_prefix(&apng[..20], apng.len()).complete); + + let webp = vp8x_webp(0x02, 0, 0); + assert!(sniff_prefix(&webp, webp.len() + 1024).complete); + assert!(!sniff_prefix(&webp[..24], webp.len()).complete); + + assert!(sniff_prefix(b"\xff\xd8\xff\xe0", 4096).complete); + assert!(!sniff_prefix(b"OggS\x00\x02", 4096).complete); +} + +#[test] +fn structural_parsers_reject_the_byte_scan_false_positives() { + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend_from_slice(&png_chunk(b"IHDR", &[0; 13])); + png.extend_from_slice(&png_chunk(b"IDAT", b"acTL rides inside the pixel data")); + let info = sniff(&png); + assert_eq!("image/png", info.mime); + assert!(!info.animated); + + let mut gif = minimal_gif(); + gif.extend_from_slice(b"\x21\xffNETSCAPE2.0"); + let info = sniff(&gif); + assert_eq!("image/gif", info.mime); + assert!(!info.animated); + + let info = sniff(b"RIFF\x00\x00\x00\x00WEBPVP8XANIMxxxx"); + assert_eq!("image/webp", info.mime); + assert!(!info.animated); + assert!(!info.has_alpha); +} + +#[test] +fn detect_falls_back_to_the_extension_then_the_declared_header() { + assert_eq!("audio/mp4", detect(b"", "clip.m4a", None)); + assert_eq!( + "IMAGE/PNG", + detect(b"", "unknown.bin", Some("IMAGE/PNG; charset=binary")) + ); + assert_eq!("application/octet-stream", detect(b"", "unknown.bin", None)); + assert_eq!( + "image/png", + detect(b"\x89PNG\r\n\x1a\nxxxx", "audio.m4a", Some("image/gif")) + ); + assert_eq!("photo.png", filename_for_mime("image/png", "photo")); + assert_eq!("photo.gif", filename_for_mime("image/png", "photo.gif")); + assert_eq!("blob.bin", filename_for_mime("application/pdf", "blob")); +} + +#[test] +fn sniffs_adts_aac_and_aiff_containers() { + let mut adts = vec![0xff, 0xf1, 0x50, 0x80, 0x01, 0xa0, 0xfc]; + adts.resize(13, 0); + assert_eq!("audio/aac", sniff(&adts).mime); + assert_eq!("audio/aiff", sniff(b"FORM\x00\x00\x00\x12AIFF").mime); + assert_eq!("audio/aiff", sniff(b"FORM\x00\x00\x00\x12AIFC").mime); + assert_eq!( + "application/octet-stream", + sniff(b"FORM\x00\x00\x00\x12WAVE").mime + ); +} + +#[test] +fn content_type_normalization_and_categories_ignore_case_and_reject_control_bytes() { + assert_eq!(Some("image/png"), normalize(Some("image/png; charset=x"))); + assert_eq!(Some("image/png"), normalize(Some(" \timage/png\t "))); + assert_eq!(None, normalize(Some("image/p\rng"))); + assert_eq!(None, normalize(Some(" "))); + assert_eq!(None, normalize(None)); + + assert_eq!(Some(Category::Image), category("IMAGE/PNG")); + assert_eq!(Some(Category::Video), category("Video/MP4")); + assert_eq!(Some(Category::Audio), category("AUDIO/wav")); + assert_eq!(None, category("text/css")); + assert_eq!(None, category("image")); +} + +#[test] +fn registry_lookups_canonicalize_extensions_and_passthrough_types() { + assert_eq!(Some("image/jpeg"), extension_mime("photo.JPG")); + assert_eq!(Some("text/css; charset=utf-8"), extension_mime("app.css")); + assert_eq!(None, extension_mime("manual.PDF")); + assert_eq!(None, extension_mime("archive.zip")); + assert_eq!(None, extension_mime("noextension")); + + assert_eq!( + Some("image/png"), + passthrough_mime(Some("IMAGE/PNG; charset=binary")) + ); + assert_eq!( + Some("image/avif"), + passthrough_mime(Some("image/avif-sequence")) + ); + assert_eq!( + Some("application/pdf"), + passthrough_mime(Some("Application/PDF")) + ); + assert_eq!( + Some("text/css; charset=utf-8"), + passthrough_mime(Some("text/css")) + ); + assert_eq!(None, passthrough_mime(Some("application/octet-stream"))); + assert_eq!(None, passthrough_mime(None)); +} + +#[test] +fn supported_media_mimes_gate_metadata_on_the_frozen_allow_list() { + assert!(is_supported_media_mime("image/heif")); + assert!(is_supported_media_mime("image/heic")); + assert!(is_supported_media_mime("video/mp4; codecs=avc1")); + assert!(!is_supported_media_mime("image/avif-sequence")); + assert!(!is_supported_media_mime("IMAGE/PNG")); + assert!(!is_supported_media_mime("application/pdf")); + assert!(!is_supported_media_mime("")); +} + +#[test] +fn every_iso_bmff_brand_matches_the_old_era_table() { + for (brand, expected) in [ + (&b"heic"[..], "image/heic"), + (b"heix", "image/heic"), + (b"heif", "image/heic"), + (b"heim", "image/heic"), + (b"heis", "image/heic"), + (b"hevc", "image/heic"), + (b"hevx", "image/heic"), + (b"hevm", "image/heic"), + (b"hevs", "image/heic"), + (b"mif1", "image/heif"), + (b"msf1", "image/heif"), + ] { + let mut bytes = b"\x00\x00\x00\x20ftyp".to_vec(); + bytes.extend_from_slice(brand); + bytes.extend_from_slice(b"some bytes"); + let info = sniff(&bytes); + assert_eq!(expected, info.mime, "brand {brand:?}"); + assert!(!info.animated, "brand {brand:?} must not be animated"); + } +} diff --git a/fluxer_media_proxy/src/native.rs b/fluxer_media_proxy/src/native.rs deleted file mode 100644 index 5f795c8a5..000000000 --- a/fluxer_media_proxy/src/native.rs +++ /dev/null @@ -1,312 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use libc::{c_char, c_double, c_int, c_longlong, c_void, size_t}; -use std::{ - marker::{PhantomData, PhantomPinned}, - slice, -}; - -#[repr(C)] -pub struct VipsImage { - _data: [u8; 0], - _marker: PhantomData<(*mut u8, PhantomPinned)>, -} - -#[repr(C)] -pub struct WebpAnimLimits { - pub max_frames: c_int, - pub max_duration_ms: c_int, - pub deadline_unix_ms: c_longlong, -} - -#[repr(C)] -#[derive(Clone, Copy)] -pub struct FluxerNsfwFrameOut { - pub data: *mut c_void, - pub len: size_t, -} - -impl FluxerNsfwFrameOut { - pub const fn empty() -> Self { - Self { - data: std::ptr::null_mut(), - len: 0, - } - } -} - -pub type VipsWriteCb = unsafe extern "C" fn(*mut c_void, *const c_void, size_t) -> c_int; - -unsafe extern "C" { - pub static fluxer_vips_format_uchar: c_int; - - pub fn fluxer_vips_init(argv0: *const c_char) -> c_int; - pub fn fluxer_vips_error_clear(); - pub fn fluxer_vips_error_buffer() -> *const c_char; - pub fn fluxer_vips_tune_for_server(per_pipeline_threads: c_int); - pub fn fluxer_vips_probe_animated( - buf: *const c_void, - len: size_t, - width: *mut c_int, - height: *mut c_int, - pages: *mut c_int, - ) -> c_int; - pub fn fluxer_vips_image_new_from_buffer( - buf: *const c_void, - len: size_t, - option_string: *const c_char, - ) -> *mut VipsImage; - pub fn fluxer_vips_image_new_from_memory_copy( - data: *const c_void, - size: size_t, - width: c_int, - height: c_int, - bands: c_int, - format: c_int, - ) -> *mut VipsImage; - pub fn fluxer_vips_image_write_to_buffer( - image: *mut VipsImage, - suffix: *const c_char, - buf: *mut *mut c_void, - size: *mut size_t, - ) -> c_int; - pub fn fluxer_vips_image_write_to_callback( - image: *mut VipsImage, - suffix: *const c_char, - cb: Option, - user_data: *mut c_void, - ) -> c_int; - pub fn fluxer_vips_image_get_width(image: *mut VipsImage) -> c_int; - pub fn fluxer_vips_image_get_height(image: *mut VipsImage) -> c_int; - pub fn fluxer_vips_image_get_bands(image: *mut VipsImage) -> c_int; - pub fn fluxer_vips_image_get_int( - image: *mut VipsImage, - field: *const c_char, - out: *mut c_int, - ) -> c_int; - pub fn fluxer_vips_set_page_height(image: *mut VipsImage, page_height: c_int); - pub fn fluxer_vips_read_delays_ms( - image: *mut VipsImage, - n_pages: c_int, - out_delays: *mut *mut c_int, - out_len: *mut c_int, - ) -> c_int; - pub fn fluxer_vips_autorot(input: *mut VipsImage, out: *mut *mut VipsImage) -> c_int; - pub fn fluxer_vips_extract_area( - input: *mut VipsImage, - out: *mut *mut VipsImage, - left: c_int, - top: c_int, - width: c_int, - height: c_int, - ) -> c_int; - pub fn fluxer_vips_resize( - input: *mut VipsImage, - out: *mut *mut VipsImage, - scale: c_double, - ) -> c_int; - pub fn fluxer_vips_thumbnail_buffer_ex( - buf: *const c_void, - len: size_t, - out: *mut *mut VipsImage, - width: c_int, - height: c_int, - n: c_int, - crop_mode: c_int, - ) -> c_int; - pub fn fluxer_vips_extract_rgba( - input: *mut VipsImage, - out_buf: *mut *mut c_void, - out_size: *mut size_t, - ) -> c_int; - pub fn fluxer_vips_unref(image: *mut VipsImage); - pub fn fluxer_vips_free(mem: *mut c_void); - pub fn fluxer_free_int_array(values: *mut c_int); - - pub fn fluxer_vips_image_is_hdr(image: *mut VipsImage) -> c_int; - pub fn fluxer_vips_tone_map_hdr_to_sdr( - input: *mut VipsImage, - out: *mut *mut VipsImage, - ) -> c_int; - pub fn fluxer_heif_decode_animated_ex2( - buf: *const c_void, - len: size_t, - out: *mut *mut VipsImage, - n_max_pages: c_int, - max_total_pixels: size_t, - was_hdr: *mut c_int, - had_hdr_gain_map: *mut c_int, - ) -> c_int; - pub fn fluxer_heif_aux_type_is_hdr_gain_map_for_test(typ: *const c_char) -> c_int; - pub fn fluxer_heif_has_tmap_item_for_test(buf: *const c_void, len: size_t) -> c_int; - pub fn fluxer_avif_parse_track_delays_for_test( - buf: *const c_void, - len: size_t, - out_delays_ms: *mut *mut c_int, - out_n_samples: *mut c_int, - ) -> c_int; - pub fn fluxer_avif_free_delays(delays: *mut c_int); - pub fn fluxer_vips_set_anim_metadata_for_test( - image: *mut VipsImage, - page_height: c_int, - n_pages: c_int, - delays_ms: *const c_int, - ); - pub fn fluxer_hdr_to_sdr_test( - r: u16, - g: u16, - b: u16, - bit_depth: c_int, - transfer: c_int, - out_rgb: *mut u8, - ) -> c_int; - - pub fn fluxer_ffmpeg_resize_gif( - gif_data: *const c_void, - gif_len: size_t, - target_width: c_int, - target_height: c_int, - deadline_unix_ms: c_longlong, - max_frames: c_longlong, - max_total_pixels: c_longlong, - out_buf: *mut *mut c_void, - out_size: *mut size_t, - ) -> c_int; - pub fn fluxer_ffmpeg_video_thumbnail( - media_data: *const c_void, - media_len: size_t, - suffix: *const c_char, - max_packets: c_int, - out_buf: *mut *mut c_void, - out_size: *mut size_t, - ) -> c_int; - pub fn fluxer_av_probe( - media_data: *const c_void, - media_len: size_t, - out_has_video: *mut c_int, - out_has_audio: *mut c_int, - out_duration_seconds: *mut c_double, - ) -> c_int; - pub fn fluxer_av_extract_frames_for_nsfw( - media_data: *const c_void, - media_len: size_t, - timestamps_secs: *const c_double, - n_timestamps: size_t, - out_frames: *mut FluxerNsfwFrameOut, - ) -> c_int; - pub fn fluxer_nsfw_frames_free(frames: *mut FluxerNsfwFrameOut, n: size_t); - pub fn fluxer_ffmpeg_decode_apng( - apng_data: *const c_void, - apng_len: size_t, - out: *mut *mut VipsImage, - max_frames: c_int, - max_total_pixels: size_t, - ) -> c_int; - pub fn fluxer_webp_encode_animated( - image: *mut VipsImage, - quality: c_int, - lossless: c_int, - effort: c_int, - alpha_q: c_int, - smart_subsample: c_int, - loop_count: c_int, - full_canvas_frames: c_int, - limits: *const WebpAnimLimits, - scratch: *mut u8, - scratch_cap: size_t, - out_buf: *mut *mut c_void, - out_size: *mut size_t, - ) -> c_int; - pub fn fluxer_webp_free(mem: *mut c_void); -} - -pub const THUMB_CROP_NONE: c_int = 0; -pub const THUMB_CROP_CENTRE: c_int = 1; - -pub struct VipsImageHandle(*mut VipsImage); - -impl VipsImageHandle { - pub fn new(ptr: *mut VipsImage) -> Option { - if ptr.is_null() { None } else { Some(Self(ptr)) } - } - - pub fn as_ptr(&self) -> *mut VipsImage { - self.0 - } -} - -impl Drop for VipsImageHandle { - fn drop(&mut self) { - unsafe { fluxer_vips_unref(self.0) }; - } -} - -pub struct VipsBuffer { - ptr: *mut c_void, - len: size_t, -} - -impl VipsBuffer { - pub fn new(ptr: *mut c_void, len: size_t) -> Option { - if ptr.is_null() { - None - } else { - Some(Self { ptr, len }) - } - } - - pub fn len(&self) -> usize { - self.len - } - - pub fn is_empty(&self) -> bool { - self.len == 0 - } - - pub fn as_ptr(&self) -> *const c_void { - self.ptr.cast_const() - } - - pub fn to_vec(&self) -> Vec { - unsafe { slice::from_raw_parts(self.ptr.cast::(), self.len).to_vec() } - } -} - -impl Drop for VipsBuffer { - fn drop(&mut self) { - unsafe { fluxer_vips_free(self.ptr) }; - } -} - -pub struct WebpBuffer { - ptr: *mut c_void, - len: size_t, -} - -impl WebpBuffer { - pub fn new(ptr: *mut c_void, len: size_t) -> Option { - if ptr.is_null() { - None - } else { - Some(Self { ptr, len }) - } - } - - pub fn len(&self) -> usize { - self.len - } - - pub fn is_empty(&self) -> bool { - self.len == 0 - } - - pub fn to_vec(&self) -> Vec { - unsafe { slice::from_raw_parts(self.ptr.cast::(), self.len).to_vec() } - } -} - -impl Drop for WebpBuffer { - fn drop(&mut self) { - unsafe { fluxer_webp_free(self.ptr) }; - } -} diff --git a/fluxer_media_proxy/src/native/buffer.rs b/fluxer_media_proxy/src/native/buffer.rs new file mode 100644 index 000000000..a13b17300 --- /dev/null +++ b/fluxer_media_proxy/src/native/buffer.rs @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{fluxer_av_free, fluxer_vips_free, fluxer_webp_free}; +use libc::{c_void, size_t}; +use std::{collections::TryReserveError, ptr::NonNull, slice}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NativeAllocator { + AV, + Vips, + WebP, +} + +impl NativeAllocator { + fn free(self, ptr: NonNull) { + match self { + Self::AV => unsafe { fluxer_av_free(ptr.as_ptr()) }, + Self::Vips => unsafe { fluxer_vips_free(ptr.as_ptr()) }, + Self::WebP => unsafe { fluxer_webp_free(ptr.as_ptr()) }, + } + } +} + +#[derive(Debug)] +pub struct NativeBuffer { + ptr: NonNull, + len: size_t, + resident_bytes: size_t, + allocator: NativeAllocator, +} + +unsafe impl Send for NativeBuffer {} + +impl NativeBuffer { + pub(crate) unsafe fn from_av_owned_with_resident_bytes( + ptr: *mut c_void, + len: size_t, + resident_bytes: size_t, + ) -> Option { + Self::from_raw_owned(ptr, len, resident_bytes, NativeAllocator::AV) + } + + pub(crate) unsafe fn from_vips_owned(ptr: *mut c_void, len: size_t) -> Option { + Self::from_raw_owned(ptr, len, len, NativeAllocator::Vips) + } + + pub(crate) unsafe fn from_vips_owned_with_resident_bytes( + ptr: *mut c_void, + len: size_t, + resident_bytes: size_t, + ) -> Option { + Self::from_raw_owned(ptr, len, resident_bytes, NativeAllocator::Vips) + } + + pub(crate) unsafe fn from_webp_owned(ptr: *mut c_void, len: size_t) -> Option { + Self::from_raw_owned(ptr, len, len, NativeAllocator::WebP) + } + + fn from_raw_owned( + ptr: *mut c_void, + len: size_t, + resident_bytes: size_t, + allocator: NativeAllocator, + ) -> Option { + let ptr = NonNull::new(ptr)?; + if len == 0 || resident_bytes < len { + allocator.free(ptr); + return None; + } + if len > isize::MAX as usize { + allocator.free(ptr); + return None; + } + Some(Self { + ptr, + len, + resident_bytes, + allocator, + }) + } + + pub fn len(&self) -> usize { + assert!(self.len > 0, "owned native buffer must not be empty"); + self.len + } + + pub fn is_empty(&self) -> bool { + false + } + + pub fn resident_bytes(&self) -> usize { + assert!(self.resident_bytes >= self.len); + self.resident_bytes + } + + pub fn as_slice(&self) -> &[u8] { + assert!(self.len > 0, "owned native buffer must not be empty"); + assert!( + self.len <= isize::MAX as usize, + "owned native buffer length must fit isize" + ); + unsafe { slice::from_raw_parts(self.ptr.cast::().as_ptr(), self.len) } + } + + pub fn try_to_vec(&self) -> Result, TryReserveError> { + let mut buffer = Vec::new(); + buffer.try_reserve_exact(self.len)?; + buffer.extend_from_slice(self.as_slice()); + Ok(buffer) + } +} + +impl Drop for NativeBuffer { + fn drop(&mut self) { + self.allocator.free(self.ptr); + } +} + +impl AsRef<[u8]> for NativeBuffer { + fn as_ref(&self) -> &[u8] { + self.as_slice() + } +} diff --git a/fluxer_media_proxy/src/native/delay_array.rs b/fluxer_media_proxy/src/native/delay_array.rs new file mode 100644 index 000000000..39e0eed92 --- /dev/null +++ b/fluxer_media_proxy/src/native/delay_array.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{NativeStatus, VipsImageHandle, fluxer_free_int_array, fluxer_vips_read_delays_ms}; +use libc::c_int; +use std::{ptr::NonNull, slice}; + +pub struct VipsDelayArray { + ptr: NonNull, + len: usize, +} + +impl VipsDelayArray { + pub fn read(image: &VipsImageHandle<'_>, n_pages: c_int) -> Result { + assert!(n_pages > 0, "native delay page count must be positive"); + let mut out_ptr = std::ptr::null_mut(); + let mut out_len = 0; + let status = NativeStatus::from_code(unsafe { + fluxer_vips_read_delays_ms(image.as_ptr(), n_pages, &mut out_ptr, &mut out_len) + }); + if status != NativeStatus::Ok { + return Err(status); + } + let Some(ptr) = NonNull::new(out_ptr) else { + return Err(NativeStatus::CodecFailure); + }; + let expected_len = + usize::try_from(n_pages).expect("positive native delay page count must fit usize"); + let len = match usize::try_from(out_len) { + Ok(len) if len == expected_len => len, + _ => { + unsafe { fluxer_free_int_array(ptr.as_ptr()) }; + return Err(NativeStatus::CodecFailure); + } + }; + let byte_len = match len.checked_mul(std::mem::size_of::()) { + Some(byte_len) if byte_len <= isize::MAX as usize => byte_len, + _ => { + unsafe { fluxer_free_int_array(ptr.as_ptr()) }; + return Err(NativeStatus::CodecFailure); + } + }; + debug_assert!(byte_len > 0); + Ok(Self { ptr, len }) + } + + pub fn as_slice(&self) -> &[c_int] { + assert!(self.len > 0, "owned native delay array must not be empty"); + unsafe { slice::from_raw_parts(self.ptr.as_ptr(), self.len) } + } +} + +impl Drop for VipsDelayArray { + fn drop(&mut self) { + unsafe { fluxer_free_int_array(self.ptr.as_ptr()) }; + } +} diff --git a/fluxer_media_proxy/src/native/mod.rs b/fluxer_media_proxy/src/native/mod.rs new file mode 100644 index 000000000..e397ab305 --- /dev/null +++ b/fluxer_media_proxy/src/native/mod.rs @@ -0,0 +1,470 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use libc::{c_char, c_double, c_int, c_longlong, c_void, size_t}; +use std::{ + marker::{PhantomData, PhantomPinned}, + ptr::NonNull, +}; + +pub mod buffer; +pub mod delay_array; +pub mod nsfw_frame_output; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(i32)] +pub enum NativeStatus { + Ok = 0, + Unsupported = 1, + CodecFailure = -1, + DeadlineExceeded = -2, + WorkLimitExceeded = -3, + InvalidDimensions = -4, + OutputLimitExceeded = -5, + AllocationFailed = -6, +} + +impl NativeStatus { + pub fn from_code(code: c_int) -> Self { + match code { + 0 => Self::Ok, + 1 => Self::Unsupported, + -1 => Self::CodecFailure, + -2 => Self::DeadlineExceeded, + -3 => Self::WorkLimitExceeded, + -4 => Self::InvalidDimensions, + -5 => Self::OutputLimitExceeded, + -6 => Self::AllocationFailed, + _ => panic!("native function returned undeclared status {code}"), + } + } +} + +#[repr(C)] +pub struct VipsImage { + _data: [u8; 0], + _marker: PhantomData<(*mut u8, PhantomPinned)>, +} + +#[repr(C)] +pub struct WebpAnimLimits { + pub max_frames: c_int, + pub max_duration_ms: c_int, + pub deadline_monotonic_ms: c_longlong, +} + +#[repr(C)] +pub struct FluxerAVMetadataOut { + pub has_video: c_int, + pub has_audio: c_int, + pub frame_count: c_int, + pub duration_seconds: c_double, + pub display_width: c_int, + pub display_height: c_int, + pub rgba_width: c_int, + pub rgba_height: c_int, + pub rgba: *mut c_void, + pub rgba_size: size_t, +} + +impl FluxerAVMetadataOut { + pub const fn empty() -> Self { + Self { + has_video: 0, + has_audio: 0, + frame_count: 0, + duration_seconds: 0.0, + display_width: 0, + display_height: 0, + rgba_width: 0, + rgba_height: 0, + rgba: std::ptr::null_mut(), + rgba_size: 0, + } + } +} + +#[repr(C)] +pub struct FluxerHEIFPrimaryStillDecodeFacts { + pub hdr_tone_mapped: c_int, + pub hdr_gain_map_detected: c_int, +} + +impl FluxerHEIFPrimaryStillDecodeFacts { + pub const fn empty() -> Self { + Self { + hdr_tone_mapped: 0, + hdr_gain_map_detected: 0, + } + } +} + +#[repr(C)] +pub struct FluxerNSFWFrameOut { + data: *mut c_void, + len: size_t, +} + +impl FluxerNSFWFrameOut { + const fn empty() -> Self { + Self { + data: std::ptr::null_mut(), + len: 0, + } + } +} + +pub type VipsWriteCb = unsafe extern "C" fn(*mut c_void, *const c_void, size_t) -> c_int; + +unsafe extern "C" { + pub static fluxer_vips_format_uchar: c_int; + pub static fluxer_vips_format_ushort: c_int; + pub static fluxer_vips_format_float: c_int; + + pub fn fluxer_vips_init(argv0: *const c_char) -> c_int; + pub fn fluxer_vips_error_clear(); + pub fn fluxer_vips_error_buffer() -> *const c_char; + pub fn fluxer_vips_tune_for_server(per_pipeline_threads: c_int); + pub fn fluxer_vips_probe_animated( + buf: *const c_void, + len: size_t, + width: *mut c_int, + height: *mut c_int, + pages: *mut c_int, + ) -> c_int; + pub fn fluxer_apng_probe( + buf: *const c_void, + len: size_t, + max_frames: c_int, + max_total_pixels: size_t, + width: *mut c_int, + height: *mut c_int, + frames: *mut c_int, + ) -> c_int; + pub fn fluxer_vips_image_new_from_buffer( + buf: *const c_void, + len: size_t, + option_string: *const c_char, + ) -> *mut VipsImage; + pub fn fluxer_vips_image_new_from_memory( + data: *const c_void, + size: size_t, + width: c_int, + height: c_int, + bands: c_int, + format: c_int, + ) -> *mut VipsImage; + pub fn fluxer_vips_image_new_from_memory_copy( + data: *const c_void, + size: size_t, + width: c_int, + height: c_int, + bands: c_int, + format: c_int, + ) -> *mut VipsImage; + pub fn fluxer_vips_image_write_to_buffer( + image: *mut VipsImage, + suffix: *const c_char, + buf: *mut *mut c_void, + size: *mut size_t, + ) -> c_int; + pub fn fluxer_vips_image_write_to_callback( + image: *mut VipsImage, + suffix: *const c_char, + deadline_monotonic_ms: c_longlong, + cb: Option, + user_data: *mut c_void, + ) -> c_int; + pub fn fluxer_vips_image_get_width(image: *mut VipsImage) -> c_int; + pub fn fluxer_vips_image_get_height(image: *mut VipsImage) -> c_int; + pub fn fluxer_vips_image_get_orientation_swap(image: *mut VipsImage) -> c_int; + pub fn fluxer_vips_image_get_bands(image: *mut VipsImage) -> c_int; + pub fn fluxer_vips_image_get_format(image: *mut VipsImage) -> c_int; + pub fn fluxer_vips_image_has_field(image: *mut VipsImage, field: *const c_char) -> c_int; + pub fn fluxer_vips_image_get_int( + image: *mut VipsImage, + field: *const c_char, + out: *mut c_int, + ) -> c_int; + pub fn fluxer_vips_set_page_height(image: *mut VipsImage, page_height: c_int); + pub fn fluxer_vips_set_animation_loop_count(image: *mut VipsImage, loop_count: c_int) -> c_int; + fn fluxer_vips_read_delays_ms( + image: *mut VipsImage, + n_pages: c_int, + out_delays: *mut *mut c_int, + out_len: *mut c_int, + ) -> c_int; + pub fn fluxer_vips_autorot( + input: *mut VipsImage, + deadline_monotonic_ms: c_longlong, + out: *mut *mut VipsImage, + ) -> c_int; + pub fn fluxer_vips_extract_area( + input: *mut VipsImage, + out: *mut *mut VipsImage, + left: c_int, + top: c_int, + width: c_int, + height: c_int, + ) -> c_int; + pub fn fluxer_vips_resize( + input: *mut VipsImage, + out: *mut *mut VipsImage, + scale: c_double, + ) -> c_int; + pub fn fluxer_vips_join_animation_pages( + source: *mut VipsImage, + pages: *mut *mut VipsImage, + n_pages: c_int, + max_pages: c_int, + max_total_pixels: size_t, + out: *mut *mut VipsImage, + ) -> c_int; + pub fn fluxer_vips_thumbnail_buffer_ex( + buf: *const c_void, + len: size_t, + deadline_monotonic_ms: c_longlong, + out: *mut *mut VipsImage, + width: c_int, + height: c_int, + n: c_int, + crop_mode: c_int, + max_pages: c_int, + max_total_pixels: size_t, + ) -> c_int; + pub fn fluxer_vips_image_to_rgba(input: *mut VipsImage, out: *mut *mut VipsImage) -> c_int; + pub fn fluxer_vips_extract_rgba( + input: *mut VipsImage, + deadline_monotonic_ms: c_longlong, + out_buf: *mut *mut c_void, + out_size: *mut size_t, + ) -> c_int; + fn fluxer_vips_unref(image: *mut VipsImage); + fn fluxer_vips_free(mem: *mut c_void); + fn fluxer_av_free(mem: *mut c_void); + fn fluxer_free_int_array(values: *mut c_int); + + pub fn fluxer_heif_validate( + buf: *const c_void, + len: size_t, + deadline_monotonic_ms: c_longlong, + ) -> c_int; + pub fn fluxer_heif_decode_primary_still( + buf: *const c_void, + len: size_t, + deadline_monotonic_ms: c_longlong, + out: *mut *mut VipsImage, + max_pixels: size_t, + max_dimension: c_int, + facts: *mut FluxerHEIFPrimaryStillDecodeFacts, + ) -> c_int; + pub fn fluxer_ffmpeg_decode_heif_sequence( + heif_data: *const c_void, + heif_len: size_t, + decoder_threads: c_int, + deadline_monotonic_ms: c_longlong, + out: *mut *mut VipsImage, + max_frames: c_int, + max_total_pixels: size_t, + out_frame_count: *mut c_int, + ) -> c_int; + + pub fn fluxer_ffmpeg_resize_gif( + gif_data: *const c_void, + gif_len: size_t, + decoder_threads: c_int, + target_width: c_int, + target_height: c_int, + deadline_monotonic_ms: c_longlong, + max_source_frames: c_int, + max_encode_frames: c_int, + max_encode_duration_ms: c_int, + max_total_pixels: size_t, + max_output_size: size_t, + out_buf: *mut *mut c_void, + out_size: *mut size_t, + out_capacity: *mut size_t, + ) -> c_int; + pub fn fluxer_validate_gif_animation( + gif_data: *const c_void, + gif_len: size_t, + max_frames: c_int, + max_duration_ms: c_int, + max_total_pixels: size_t, + ) -> c_int; + pub fn fluxer_ffmpeg_video_thumbnail_ex( + media_data: *const c_void, + media_len: size_t, + decoder_threads: c_int, + deadline_monotonic_ms: c_longlong, + suffix: *const c_char, + max_packets: c_int, + max_width: c_int, + max_height: c_int, + max_output_size: size_t, + out_display_width: *mut c_int, + out_display_height: *mut c_int, + out_buf: *mut *mut c_void, + out_size: *mut size_t, + out_capacity: *mut size_t, + ) -> c_int; + pub fn fluxer_av_metadata( + media_data: *const c_void, + media_len: size_t, + decoder_threads: c_int, + deadline_monotonic_ms: c_longlong, + max_packets: c_int, + max_width: c_int, + max_height: c_int, + out: *mut FluxerAVMetadataOut, + ) -> c_int; + pub fn fluxer_ffmpeg_extract_apng_frames_for_nsfw( + apng_data: *const c_void, + apng_len: size_t, + decoder_threads: c_int, + deadline_monotonic_ms: c_longlong, + frame_indices: *const c_int, + n_indices: size_t, + max_frames: c_int, + max_total_pixels: size_t, + max_frame_output_size: size_t, + out_frames: *mut FluxerNSFWFrameOut, + ) -> c_int; + pub fn fluxer_ffmpeg_extract_gif_frames_for_nsfw( + gif_data: *const c_void, + gif_len: size_t, + decoder_threads: c_int, + deadline_monotonic_ms: c_longlong, + frame_indices: *const c_int, + n_indices: size_t, + max_frames: c_int, + max_total_pixels: size_t, + max_frame_output_size: size_t, + out_frames: *mut FluxerNSFWFrameOut, + ) -> c_int; + pub fn fluxer_av_extract_frames_for_nsfw( + media_data: *const c_void, + media_len: size_t, + decoder_threads: c_int, + deadline_monotonic_ms: c_longlong, + timestamps_secs: *const c_double, + n_timestamps: size_t, + max_frame_output_size: size_t, + out_frames: *mut FluxerNSFWFrameOut, + ) -> c_int; + fn fluxer_nsfw_frames_free(frames: *mut FluxerNSFWFrameOut, n: size_t); + pub fn fluxer_ffmpeg_decode_apng( + apng_data: *const c_void, + apng_len: size_t, + decoder_threads: c_int, + deadline_monotonic_ms: c_longlong, + out: *mut *mut VipsImage, + max_frames: c_int, + max_total_pixels: size_t, + require_complete: c_int, + out_num_plays: *mut u32, + ) -> c_int; + pub fn fluxer_ffmpeg_decode_bmp( + bmp_data: *const c_void, + bmp_len: size_t, + decoder_threads: c_int, + deadline_monotonic_ms: c_longlong, + out: *mut *mut VipsImage, + max_total_pixels: size_t, + ) -> c_int; + pub fn fluxer_webp_encode_animated( + image: *mut VipsImage, + quality: c_int, + lossless: c_int, + effort: c_int, + alpha_q: c_int, + smart_subsample: c_int, + thread_level: c_int, + loop_count: c_int, + full_canvas_frames: c_int, + limits: *const WebpAnimLimits, + max_output_size: size_t, + out_buf: *mut *mut c_void, + out_size: *mut size_t, + ) -> c_int; + pub fn fluxer_webp_transform_animated( + webp_data: *const c_void, + webp_len: size_t, + max_width: c_int, + max_height: c_int, + quality: c_int, + lossless: c_int, + effort: c_int, + alpha_q: c_int, + smart_subsample: c_int, + thread_level: c_int, + max_source_frames: c_int, + max_total_pixels: size_t, + limits: *const WebpAnimLimits, + max_output_size: size_t, + out_buf: *mut *mut c_void, + out_size: *mut size_t, + ) -> c_int; + pub fn fluxer_webp_extract_frames_for_nsfw( + webp_data: *const c_void, + webp_len: size_t, + thread_level: c_int, + deadline_monotonic_ms: c_longlong, + frame_indices: *const c_int, + n_indices: size_t, + max_frames: c_int, + max_total_pixels: size_t, + max_frame_output_size: size_t, + out_frames: *mut FluxerNSFWFrameOut, + ) -> c_int; + fn fluxer_webp_free(mem: *mut c_void); +} + +pub const THUMB_CROP_NONE: c_int = 0; +pub const THUMB_CROP_CENTRE: c_int = 1; + +pub struct VipsImageHandle<'source> { + ptr: NonNull, + source: PhantomData<&'source [u8]>, +} + +impl VipsImageHandle<'static> { + pub(crate) unsafe fn from_raw_owned(ptr: *mut VipsImage) -> Option { + NonNull::new(ptr).map(|ptr| Self { + ptr, + source: PhantomData, + }) + } +} + +impl<'source> VipsImageHandle<'source> { + pub(crate) unsafe fn from_raw_borrowing( + ptr: *mut VipsImage, + source: &'source [u8], + ) -> Option { + if source.is_empty() { + assert!(ptr.is_null(), "empty libvips source returned an image"); + return None; + } + NonNull::new(ptr).map(|ptr| Self { + ptr, + source: PhantomData, + }) + } + + pub(crate) unsafe fn adopt_derived_raw(&self, ptr: *mut VipsImage) -> Option { + NonNull::new(ptr).map(|ptr| Self { + ptr, + source: PhantomData, + }) + } + + pub fn as_ptr(&self) -> *mut VipsImage { + self.ptr.as_ptr() + } +} + +impl Drop for VipsImageHandle<'_> { + fn drop(&mut self) { + unsafe { fluxer_vips_unref(self.ptr.as_ptr()) }; + } +} diff --git a/fluxer_media_proxy/src/native/nsfw_frame_output.rs b/fluxer_media_proxy/src/native/nsfw_frame_output.rs new file mode 100644 index 000000000..6076f7e5a --- /dev/null +++ b/fluxer_media_proxy/src/native/nsfw_frame_output.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{FluxerNSFWFrameOut, fluxer_nsfw_frames_free}; +use crate::nsfw::NSFW_MAX_FRAME_BYTES; +use std::{ptr::NonNull, slice}; + +const MAX_NSFW_FRAME_OUTPUTS: usize = 3; + +pub struct NSFWFrameOutput { + slots: [FluxerNSFWFrameOut; MAX_NSFW_FRAME_OUTPUTS], + expected: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum NSFWFrameCopyError { + AllocationFailed, + InvalidOutput, +} + +impl NSFWFrameOutput { + pub fn new(expected: usize) -> Self { + assert!(expected > 0, "native NSFW frame count must be positive"); + assert!( + expected <= MAX_NSFW_FRAME_OUTPUTS, + "native NSFW frame count exceeds slots" + ); + Self { + slots: std::array::from_fn(|_| FluxerNSFWFrameOut::empty()), + expected, + } + } + + pub fn as_mut_ptr(&mut self) -> *mut FluxerNSFWFrameOut { + self.slots.as_mut_ptr() + } + + pub fn copy_frames(&self) -> Result>, NSFWFrameCopyError> { + let mut frames = Vec::new(); + frames + .try_reserve_exact(self.expected) + .map_err(|_| NSFWFrameCopyError::AllocationFailed)?; + for slot in self.slots.iter().take(self.expected) { + // a sample the native decoder could not reach leaves its slot empty + let Some(data) = NonNull::new(slot.data.cast::()) else { + if slot.len != 0 { + return Err(NSFWFrameCopyError::InvalidOutput); + } + continue; + }; + if !(1..=NSFW_MAX_FRAME_BYTES).contains(&slot.len) || slot.len > isize::MAX as usize { + return Err(NSFWFrameCopyError::InvalidOutput); + } + let bytes = unsafe { slice::from_raw_parts(data.as_ptr(), slot.len) }; + let mut frame = Vec::new(); + frame + .try_reserve_exact(slot.len) + .map_err(|_| NSFWFrameCopyError::AllocationFailed)?; + frame.extend_from_slice(bytes); + frames.push(frame); + } + if frames.is_empty() { + return Err(NSFWFrameCopyError::InvalidOutput); + } + Ok(frames) + } +} + +impl Drop for NSFWFrameOutput { + fn drop(&mut self) { + unsafe { fluxer_nsfw_frames_free(self.slots.as_mut_ptr(), self.expected) }; + } +} diff --git a/fluxer_media_proxy/src/native_shim/animated_webp.c b/fluxer_media_proxy/src/native_shim/animated_webp.c new file mode 100644 index 000000000..ac82bcc23 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/animated_webp.c @@ -0,0 +1,519 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +#define FLUXER_WEBP_MAX_NON_FRAME_CHUNKS ((size_t)64) + +int fluxer_configure_webp_encoder( + WebPConfig *config, + int quality, + int lossless, + int effort, + int alpha_q, + int smart_subsample +) { + if (config == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!WebPConfigInit(config)) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (lossless) { + int lossless_effort = effort; + if (lossless_effort < 0) lossless_effort = 0; + if (lossless_effort > 9) lossless_effort = 9; + if (!WebPConfigLosslessPreset(config, lossless_effort)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + config->quality = (float)quality; + } else { + int lossy_effort = effort; + if (lossy_effort < 0) lossy_effort = 0; + if (lossy_effort > 6) lossy_effort = 6; + config->lossless = 0; + config->quality = (float)quality; + config->method = lossy_effort; + config->alpha_quality = alpha_q; + config->use_sharp_yuv = smart_subsample ? 1 : 0; + } + return WebPValidateConfig(config) + ? FLUXER_NATIVE_STATUS_OK + : FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +static uint32_t webp_read_le32(const uint8_t *value) { + return (uint32_t)value[0] | + ((uint32_t)value[1] << 8) | + ((uint32_t)value[2] << 16) | + ((uint32_t)value[3] << 24); +} + +static int webp_animation_structure_status( + const void *webp_data, + size_t webp_len, + int max_frames, + long long deadline_monotonic_ms, + uint32_t *frame_count +) { + if (webp_data == NULL || webp_len < 12 || max_frames <= 0 || + deadline_monotonic_ms < 0 || frame_count == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *frame_count = 0; + const uint8_t *data = webp_data; + if (memcmp(data, "RIFF", 4) != 0 || memcmp(data + 8, "WEBP", 4) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + uint32_t riff_size = webp_read_le32(data + 4); + if (riff_size < 4 || (size_t)riff_size != webp_len - 8) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + size_t chunk_limit = (size_t)max_frames + + FLUXER_WEBP_MAX_NON_FRAME_CHUNKS; + size_t chunk_count = 0; + size_t offset = 12; + while (offset < webp_len) { + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + if (chunk_count >= chunk_limit) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + chunk_count++; + size_t remaining = webp_len - offset; + if (remaining < 8) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + uint32_t chunk_size_u32 = webp_read_le32(data + offset + 4); + size_t chunk_size = chunk_size_u32; + size_t padded_size = chunk_size + (chunk_size & 1u); + if (padded_size < chunk_size || padded_size > remaining - 8) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (memcmp(data + offset, "ANMF", 4) == 0) { + if (chunk_size < 16) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (*frame_count >= (uint32_t)max_frames) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + *frame_count += 1; + } + offset += 8 + padded_size; + } + if (*frame_count < 2) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return FLUXER_NATIVE_STATUS_OK; +} + +static int webp_animation_canvas_status( + uint32_t canvas_width, + uint32_t canvas_height, + size_t max_total_pixels +) { + if (max_total_pixels == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (canvas_width == 0 || canvas_width > INT_MAX / 4) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (canvas_height == 0 || canvas_height > INT_MAX) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t width = canvas_width; + size_t height = canvas_height; + if (height > SIZE_MAX / width) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t frame_pixels = width * height; + if (frame_pixels > SIZE_MAX / 4u) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (frame_pixels > max_total_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_webp_animation_preflight( + const void *webp_data, + size_t webp_len, + int max_frames, + size_t max_total_pixels, + long long deadline_monotonic_ms, + struct fluxer_webp_animation_facts *facts +) { + if (facts == NULL || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + memset(facts, 0, sizeof(*facts)); + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + uint32_t frame_count = 0; + int structure_status = webp_animation_structure_status( + webp_data, webp_len, max_frames, deadline_monotonic_ms, + &frame_count); + if (structure_status != FLUXER_NATIVE_STATUS_OK) return structure_status; + deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + WebPBitstreamFeatures features; + VP8StatusCode features_status = WebPGetFeatures( + webp_data, webp_len, &features); + deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + if (features_status != VP8_STATUS_OK) { + return features_status == VP8_STATUS_OUT_OF_MEMORY + ? FLUXER_NATIVE_STATUS_ALLOCATION_FAILED + : FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!features.has_animation) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int canvas_status = webp_animation_canvas_status( + (uint32_t)features.width, (uint32_t)features.height, + max_total_pixels); + if (canvas_status != FLUXER_NATIVE_STATUS_OK) return canvas_status; + size_t frame_pixels = + (size_t)features.width * (size_t)features.height; + if (frame_count > max_total_pixels / frame_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + facts->canvas_width = (uint32_t)features.width; + facts->canvas_height = (uint32_t)features.height; + facts->frame_count = frame_count; + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_vips_read_delays_ms(VipsImage *image, int n_pages, int **out_delays, int *out_len) { + if (out_delays != NULL) *out_delays = NULL; + if (out_len != NULL) *out_len = 0; + if (out_delays == NULL || out_len == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int delays_status = fluxer_vips_read_animation_delays_ms( + image, n_pages, out_delays); + if (delays_status != FLUXER_NATIVE_STATUS_OK) return delays_status; + *out_len = n_pages; + return FLUXER_NATIVE_STATUS_OK; +} + +void fluxer_free_int_array(int *values) { + free(values); +} + +static int fluxer_webp_add_vips_animation_frame( + struct fluxer_webp_animation_encoder *encoder, + VipsImage *image, + int frame_index, + int width, + int page_height, + int delay, + unsigned char *scratch, + size_t scratch_cap, + long long deadline_monotonic_ms +) { + if (encoder == NULL || image == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (frame_index < 0 || width <= 0 || page_height <= 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (frame_index > INT_MAX / page_height) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + VipsImage *strip = NULL; + int top = frame_index * page_height; + if (vips_extract_area( + image, &strip, 0, top, width, page_height, NULL) != 0 || + strip == NULL) { + if (strip != NULL) g_object_unref(strip); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + size_t rgba_size = 0; + int extract_status = fluxer_vips_extract_animation_rgba_strip( + strip, scratch, scratch_cap, deadline_monotonic_ms, &rgba_size); + g_object_unref(strip); + if (extract_status != FLUXER_NATIVE_STATUS_OK) return extract_status; + size_t width_size = (size_t)width; + size_t page_height_size = (size_t)page_height; + if (width_size > SIZE_MAX / page_height_size) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t expected_pixels = width_size * page_height_size; + if (expected_pixels > SIZE_MAX / 4u) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t expected_size = expected_pixels * 4u; + if (rgba_size != expected_size) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int add_status = fluxer_webp_animation_encoder_add( + encoder, scratch, (size_t)width * 4u, delay); + return add_status; +} + +static int fluxer_webp_vips_animation_frame_budget( + const int *delays, + int n_pages, + int max_frames, + int max_duration_ms, + long long deadline_monotonic_ms, + int *out_frames +) { + if (delays == NULL || n_pages <= 0 || max_frames <= 0 || + max_duration_ms <= 0 || deadline_monotonic_ms < 0 || + out_frames == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out_frames = 0; + int64_t timestamp = 0; + int frames = 0; + for (int i = 0; i < n_pages; i++) { + if (fluxer_monotonic_deadline_status(deadline_monotonic_ms) != + FLUXER_DEADLINE_PENDING) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + if (frames >= max_frames) break; + if (timestamp >= max_duration_ms) break; + int delay = delays[i]; + if (delay <= 0) return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + if (delay > FLUXER_WEBP_MAX_FRAME_DURATION_MS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (timestamp > INT_MAX - delay) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + timestamp += delay; + frames++; + } + if (frames <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_frames = frames; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_webp_encode_vips_animation_frames( + struct fluxer_webp_animation_encoder *encoder, + VipsImage *image, + int width, + int page_height, + int n_pages, + const int *delays, + long long deadline_monotonic_ms, + unsigned char *scratch, + size_t scratch_cap +) { + assert(encoder != NULL); + assert(image != NULL); + assert(width > 0); + assert(page_height > 0); + assert(n_pages > 0); + assert(delays != NULL); + assert(deadline_monotonic_ms >= 0); + assert(scratch != NULL); + assert(scratch_cap > 0); + for (int i = 0; i < n_pages; i++) { + if (fluxer_monotonic_deadline_status(deadline_monotonic_ms) != + FLUXER_DEADLINE_PENDING) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + int delay = delays[i]; + int add_status = fluxer_webp_add_vips_animation_frame( + encoder, image, i, width, page_height, delay, scratch, scratch_cap, + deadline_monotonic_ms); + if (add_status != 0) return add_status; + } + return FLUXER_NATIVE_STATUS_OK; +} + +struct fluxer_webp_vips_encode_request { + VipsImage *image; + int quality; + int lossless; + int effort; + int alpha_q; + int smart_subsample; + int thread_level; + int loop_count; + int full_canvas_frames; + const struct fluxer_webp_anim_limits *limits; + size_t max_output_size; + void **out_buf; + size_t *out_size; +}; + +struct fluxer_webp_vips_encode_context { + WebPConfig config; + struct fluxer_webp_animation_encoder *encoder; + int *delays; + unsigned char *scratch; + size_t scratch_capacity; + int width; + int page_height; + int n_pages; + int encode_frames; +}; + +static int fluxer_webp_vips_encode_request_status( + const struct fluxer_webp_vips_encode_request *request +) { + if (request == NULL || request->image == NULL || + request->out_buf == NULL || request->out_size == NULL || + request->limits == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->max_output_size == 0) { + return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } + if (request->thread_level < 0 || request->thread_level > 1 || + request->limits->max_frames <= 0 || + request->limits->max_duration_ms <= 0 || + request->limits->deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_webp_vips_encode_geometry( + struct fluxer_webp_vips_encode_context *context, + VipsImage *image +) { + assert(context != NULL); + assert(image != NULL); + int total_height = vips_image_get_height(image); + int width = vips_image_get_width(image); + if (width <= 0 || total_height <= 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int page_height = total_height; + if (vips_image_get_typeof(image, "page-height") != 0 && + vips_image_get_int(image, "page-height", &page_height) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (page_height <= 0 || page_height > total_height || + total_height % page_height != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t width_size = (size_t)width; + size_t height_size = (size_t)page_height; + if (width_size > SIZE_MAX / height_size) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t pixels = width_size * height_size; + if (pixels > FLUXER_MAX_VIDEO_RGBA_BYTES / 4u) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + context->width = width; + context->page_height = page_height; + context->n_pages = total_height / page_height; + context->scratch_capacity = pixels * 4u; + return FLUXER_NATIVE_STATUS_OK; +} + +static void fluxer_webp_vips_encode_context_clear( + struct fluxer_webp_vips_encode_context *context +) { + if (context == NULL) return; + fluxer_webp_animation_encoder_delete(context->encoder); + free(context->scratch); + free(context->delays); + memset(context, 0, sizeof(*context)); +} + +static int fluxer_webp_vips_encode_context_open( + struct fluxer_webp_vips_encode_context *context, + const struct fluxer_webp_vips_encode_request *request +) { + assert(context != NULL); + assert(request != NULL); + int status = fluxer_webp_vips_encode_geometry(context, request->image); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = fluxer_vips_read_animation_delays_ms( + request->image, context->n_pages, &context->delays); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = fluxer_webp_vips_animation_frame_budget( + context->delays, context->n_pages, + request->limits->max_frames, + request->limits->max_duration_ms, + request->limits->deadline_monotonic_ms, + &context->encode_frames); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = fluxer_configure_webp_encoder( + &context->config, request->quality, request->lossless, + request->effort, request->alpha_q, request->smart_subsample); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->config.thread_level = request->thread_level; + if (!WebPValidateConfig(&context->config)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + status = fluxer_native_deadline_status( + request->limits->deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->scratch = malloc(context->scratch_capacity); + if (context->scratch == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + struct fluxer_webp_animation_encoder_settings settings = { + .config = &context->config, + .canvas_width = context->width, + .canvas_height = context->page_height, + .loop_count = request->loop_count, + .full_canvas_frames = request->full_canvas_frames, + .pixel_layout = FLUXER_WEBP_PIXEL_LAYOUT_RGBA, + .deadline_monotonic_ms = request->limits->deadline_monotonic_ms, + .max_output_size = request->max_output_size, + }; + return fluxer_webp_animation_encoder_create( + &settings, &context->encoder); +} + +static int fluxer_webp_vips_encode_context_run( + struct fluxer_webp_vips_encode_context *context, + const struct fluxer_webp_vips_encode_request *request +) { + assert(context != NULL); + assert(context->encoder != NULL); + assert(request != NULL); + int status = fluxer_webp_encode_vips_animation_frames( + context->encoder, request->image, context->width, + context->page_height, context->encode_frames, context->delays, + request->limits->deadline_monotonic_ms, context->scratch, + context->scratch_capacity); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return fluxer_webp_animation_encoder_finish( + context->encoder, request->out_buf, request->out_size); +} + +int fluxer_webp_encode_animated( + VipsImage *image, + int quality, + int lossless, + int effort, + int alpha_q, + int smart_subsample, + int thread_level, + int loop_count, + int full_canvas_frames, + const struct fluxer_webp_anim_limits *limits, + size_t max_output_size, + void **out_buf, + size_t *out_size +) { + if (out_buf != NULL) *out_buf = NULL; + if (out_size != NULL) *out_size = 0; + struct fluxer_webp_vips_encode_request request = { + .image = image, + .quality = quality, + .lossless = lossless, + .effort = effort, + .alpha_q = alpha_q, + .smart_subsample = smart_subsample, + .thread_level = thread_level, + .loop_count = loop_count, + .full_canvas_frames = full_canvas_frames, + .limits = limits, + .max_output_size = max_output_size, + .out_buf = out_buf, + .out_size = out_size, + }; + int status = fluxer_webp_vips_encode_request_status(&request); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + struct fluxer_webp_vips_encode_context context = {0}; + status = fluxer_webp_vips_encode_context_open(&context, &request); + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_webp_vips_encode_context_run(&context, &request); + } + fluxer_webp_vips_encode_context_clear(&context); + return status; +} diff --git a/fluxer_media_proxy/src/native_shim/animation_decode.c b/fluxer_media_proxy/src/native_shim/animation_decode.c new file mode 100644 index 000000000..c3b71f5da --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/animation_decode.c @@ -0,0 +1,1012 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +#define FLUXER_ANIMATION_AVIO_BUFFER_SIZE (64u * 1024u) +#define FLUXER_ANIMATION_PROBE_SIZE (5u * 1024u * 1024u) +#define FLUXER_ANIMATION_ANALYZE_DURATION (5 * AV_TIME_BASE) +#define FLUXER_VIPS_RGBA_REGION_ROWS 64 + +enum ff_append_frame_result { + FF_APPEND_FRAME_LIMIT = INT_MIN +}; + +static int ff_animation_frame_delay_ms(AVFrame *frame, AVStream *stream, + int64_t packet_duration, int *out_delay) { + if (frame == NULL || stream == NULL || out_delay == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out_delay = 0; + int64_t duration = frame->duration > 0 ? frame->duration : packet_duration; + if (duration <= 0) { + *out_delay = FLUXER_DEFAULT_ANIMATION_FRAME_DELAY_MS; + return FLUXER_NATIVE_STATUS_OK; + } + if (stream->time_base.num <= 0 || stream->time_base.den <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + long double milliseconds = (long double)duration * + (long double)stream->time_base.num * 1000.0L / + (long double)stream->time_base.den; + if (!isfinite(milliseconds) || milliseconds > (long double)INT_MAX) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + int delay = (int)floorl(milliseconds + 0.5L); + if (delay < FLUXER_MIN_ANIMATION_FRAME_DELAY_MS) { + delay = FLUXER_MIN_ANIMATION_FRAME_DELAY_MS; + } + *out_delay = delay; + return FLUXER_NATIVE_STATUS_OK; +} + +struct ff_animation_decode_request { + const uint8_t *data; + size_t len; + int decoder_threads; + long long deadline_monotonic_ms; + int max_frames; + size_t max_total_pixels; + int require_complete; + const char *allowed_formats; + const AVInputFormat *forced_format; + int expected_frames; + int minimum_frames; + int decode_pixels; +}; + +struct ff_animation_decode_context { + struct ff_animation_decode_request request; + struct ff_mem_reader reader; + unsigned char *input_avio_buffer; + AVIOContext *input_avio; + AVFormatContext *input_format; + AVCodecContext *decoder; + AVStream *input_stream; + int stream_index; + struct SwsContext *sws; + AVPacket *packet; + AVFrame *frame; + uint8_t *pixels; + int *delays; + VipsImage *image; + int expected_frames; + int capacity_hint; + int capacity; + int frames; + int canvas_width; + int canvas_height; + int packet_limit; + int packets_read; + int stopped; + int64_t last_packet_duration; +}; + +static int ff_animation_frame_geometry( + struct ff_animation_decode_context *context, + AVFrame *frame, + int *out_width, + int *out_height, + size_t *out_frame_bytes +) { + assert(context != NULL); + assert(frame != NULL); + assert(out_width != NULL); + assert(out_height != NULL); + assert(out_frame_bytes != NULL); + int width = frame->width; + int height = frame->height; + if (width <= 0) width = context->canvas_width; + if (height <= 0) height = context->canvas_height; + size_t frame_bytes = 0; + if (ff_validate_rgba_geometry(width, height, &frame_bytes) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (context->frames == 0) { + context->canvas_width = width; + context->canvas_height = height; + } else { + if (width != context->canvas_width) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (height != context->canvas_height) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + } + size_t frame_pixels = frame_bytes / 4u; + if (frame_pixels > context->request.max_total_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + *out_width = width; + *out_height = height; + *out_frame_bytes = frame_bytes; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_animation_reserve_frames( + struct ff_animation_decode_context *context, + size_t frame_bytes +) { + assert(context != NULL); + assert(frame_bytes > 0); + assert(context->capacity_hint > 0); + assert(context->frames >= 0); + if (context->capacity > context->frames) return FLUXER_NATIVE_STATUS_OK; + size_t frame_pixels = frame_bytes / 4u; + int capacity_limit = context->request.max_frames; + size_t pixel_limit = context->request.max_total_pixels / frame_pixels; + if (pixel_limit < (size_t)capacity_limit) { + capacity_limit = (int)pixel_limit; + } + + int new_capacity = context->capacity_hint; + if (context->capacity > 0) { + if (context->capacity > INT_MAX / 2) { + new_capacity = INT_MAX; + } else { + new_capacity = context->capacity * 2; + } + } + if (new_capacity > capacity_limit) new_capacity = capacity_limit; + if (new_capacity <= context->frames) new_capacity = context->frames + 1; + if ((size_t)new_capacity > SIZE_MAX / frame_bytes) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + + size_t pixel_bytes = (size_t)new_capacity * frame_bytes; + uint8_t *new_pixels = realloc(context->pixels, pixel_bytes); + if (new_pixels == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + context->pixels = new_pixels; + int *new_delays = realloc( + context->delays, (size_t)new_capacity * sizeof(int)); + if (new_delays == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + context->delays = new_delays; + context->capacity = new_capacity; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_animation_accept_frame( + struct ff_animation_decode_context *context, + int64_t packet_duration +) { + assert(context != NULL); + assert(context->frame != NULL); + assert(context->input_stream != NULL); + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (context->frames >= context->request.max_frames) { + return FF_APPEND_FRAME_LIMIT; + } + int width = 0; + int height = 0; + size_t frame_bytes = 0; + int status = ff_animation_frame_geometry( + context, context->frame, &width, &height, &frame_bytes); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + size_t frame_pixels = frame_bytes / 4u; + if ((size_t)(context->frames + 1) > + context->request.max_total_pixels / frame_pixels) { + return FF_APPEND_FRAME_LIMIT; + } + if (context->request.decode_pixels == 0) { + context->frames++; + return FLUXER_NATIVE_STATUS_OK; + } + status = ff_animation_reserve_frames(context, frame_bytes); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + + uint8_t *destination = + context->pixels + (size_t)context->frames * frame_bytes; + int convert_status = fluxer_av_frame_convert_to_rgba( + context->frame, + width, + height, + width, + height, + &context->sws, + context->request.deadline_monotonic_ms, + destination); + if (convert_status != FLUXER_NATIVE_STATUS_OK) return convert_status; + int delay = 0; + status = ff_animation_frame_delay_ms( + context->frame, context->input_stream, packet_duration, &delay); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->delays[context->frames] = delay; + context->frames++; + return FLUXER_NATIVE_STATUS_OK; +} +static int ff_animation_decode_request_valid( + const struct ff_animation_decode_request *request +) { + assert(request != NULL); + if (request->data == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->len == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->len > (size_t)INT64_MAX) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (!fluxer_ffmpeg_decoder_threads_valid(request->decoder_threads)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->max_frames <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->max_total_pixels == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->require_complete < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->require_complete > 1) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->allowed_formats == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->allowed_formats[0] == '\0') { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->expected_frames < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->minimum_frames <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->minimum_frames > request->max_frames) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (request->decode_pixels < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->decode_pixels > 1) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static void ff_animation_decode_context_init( + struct ff_animation_decode_context *context, + const struct ff_animation_decode_request *request +) { + assert(context != NULL); + assert(request != NULL); + memset(context, 0, sizeof(*context)); + context->request = *request; + context->reader.data = request->data; + context->reader.len = request->len; + context->stream_index = -1; + context->expected_frames = request->expected_frames; +} + +static int ff_animation_open_input( + struct ff_animation_decode_context *context +) { + assert(context != NULL); + assert(context->input_avio == NULL); + assert(context->input_format == NULL); + context->input_avio_buffer = av_malloc( + FLUXER_ANIMATION_AVIO_BUFFER_SIZE); + if (context->input_avio_buffer == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + context->input_avio = avio_alloc_context( + context->input_avio_buffer, + (int)FLUXER_ANIMATION_AVIO_BUFFER_SIZE, + 0, + &context->reader, + ff_mem_read_packet, + NULL, + ff_mem_seek); + if (context->input_avio == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + context->input_avio_buffer = NULL; + context->input_format = avformat_alloc_context(); + if (context->input_format == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + int status = fluxer_restrict_untrusted_av_context( + context->input_format, context->request.allowed_formats); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->input_format->pb = context->input_avio; + context->input_format->interrupt_callback.callback = + fluxer_ffmpeg_interrupt_deadline; + context->input_format->interrupt_callback.opaque = + &context->request.deadline_monotonic_ms; + context->input_format->flags |= AVFMT_FLAG_CUSTOM_IO; + context->input_format->probesize = FLUXER_ANIMATION_PROBE_SIZE; + context->input_format->max_analyze_duration = + FLUXER_ANIMATION_ANALYZE_DURATION; + if (context->request.require_complete != 0) { + context->input_format->error_recognition = AV_EF_EXPLODE; + } + int av_status = avformat_open_input( + &context->input_format, NULL, context->request.forced_format, NULL); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + status = fluxer_prepare_untrusted_av_input(context->input_format); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + av_status = fluxer_find_stream_info_bounded(context->input_format); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + return fluxer_prepare_untrusted_av_input(context->input_format); +} + +static int ff_animation_validate_stream_limits( + struct ff_animation_decode_context *context +) { + assert(context != NULL); + assert(context->input_stream != NULL); + assert(context->input_stream->codecpar != NULL); + if (context->expected_frames == 0) { + int64_t stream_frames = context->input_stream->nb_frames; + if (stream_frames < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (stream_frames > INT_MAX) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + context->expected_frames = (int)stream_frames; + } + if (context->expected_frames > 0) { + if (context->expected_frames < context->request.minimum_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } + if (context->request.require_complete != 0) { + if (context->expected_frames > context->request.max_frames) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + } + + int width = context->input_stream->codecpar->width; + int height = context->input_stream->codecpar->height; + size_t frame_bytes = 0; + if (ff_validate_rgba_geometry(width, height, &frame_bytes) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t frame_pixels = frame_bytes / 4u; + if (frame_pixels > context->request.max_total_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (context->request.require_complete != 0) { + if (context->expected_frames > 0) { + if ((size_t)context->expected_frames > + context->request.max_total_pixels / frame_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + } + } + + context->capacity_hint = 1; + int packet_frame_limit = context->request.max_frames; + if (context->request.require_complete != 0) { + if (context->expected_frames > 0) { + context->capacity_hint = context->expected_frames; + packet_frame_limit = context->expected_frames; + } + } + if (packet_frame_limit <= 0) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (packet_frame_limit > + INT_MAX / FLUXER_ANIMATION_PACKETS_PER_FRAME_LIMIT) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + context->packet_limit = + packet_frame_limit * FLUXER_ANIMATION_PACKETS_PER_FRAME_LIMIT; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_animation_open_decoder( + struct ff_animation_decode_context *context +) { + assert(context != NULL); + assert(context->input_format != NULL); + assert(context->decoder == NULL); + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + const AVCodec *decoder = NULL; + int stream_index = ff_find_primary_video_stream( + context->input_format, &decoder); + if (stream_index < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if ((unsigned int)stream_index >= context->input_format->nb_streams) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + context->input_stream = context->input_format->streams[stream_index]; + if (context->input_stream == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (context->input_stream->codecpar == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int status = ff_animation_validate_stream_limits(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (decoder == NULL) { + decoder = avcodec_find_decoder( + context->input_stream->codecpar->codec_id); + } + if (!fluxer_video_decoder_allowed(context->input_stream, decoder)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + context->decoder = avcodec_alloc_context3(decoder); + if (context->decoder == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + int av_status = avcodec_parameters_to_context( + context->decoder, context->input_stream->codecpar); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + context->decoder->max_pixels = (int64_t)FLUXER_MAX_VIDEO_PIXELS; + context->decoder->thread_count = context->request.decoder_threads; + if (context->request.require_complete != 0) { + context->decoder->err_recognition = + AV_EF_CRCCHECK | AV_EF_BITSTREAM | AV_EF_BUFFER | AV_EF_EXPLODE; + } + av_status = avcodec_open2(context->decoder, decoder, NULL); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + context->stream_index = stream_index; + context->packet = av_packet_alloc(); + context->frame = av_frame_alloc(); + if (context->packet == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (context->frame == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_animation_receive_frames( + struct ff_animation_decode_context *context, + int64_t packet_duration, + int flushing +) { + assert(context != NULL); + assert(context->decoder != NULL); + assert(context->frame != NULL); + assert(flushing >= 0); + assert(flushing <= 1); + while (1) { + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int receive_status = avcodec_receive_frame( + context->decoder, context->frame); + if (receive_status == AVERROR_EOF) return FLUXER_NATIVE_STATUS_OK; + if (receive_status == AVERROR(EAGAIN)) { + if (flushing != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return FLUXER_NATIVE_STATUS_OK; + } + if (receive_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + receive_status, context->request.deadline_monotonic_ms); + } + if (context->request.require_complete != 0) { + if (context->frame->decode_error_flags != 0) { + av_frame_unref(context->frame); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } + int status = ff_animation_accept_frame(context, packet_duration); + av_frame_unref(context->frame); + if (status == FF_APPEND_FRAME_LIMIT) { + if (context->request.require_complete != 0) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + context->stopped = 1; + return FLUXER_NATIVE_STATUS_OK; + } + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } +} + +static int ff_animation_flush_decoder( + struct ff_animation_decode_context *context +) { + assert(context != NULL); + assert(context->decoder != NULL); + int av_status = avcodec_send_packet(context->decoder, NULL); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + return ff_animation_receive_frames( + context, context->last_packet_duration, 1); +} + +static int ff_animation_decode_packets( + struct ff_animation_decode_context *context +) { + assert(context != NULL); + assert(context->input_format != NULL); + assert(context->packet != NULL); + int read_status = 0; + while (!context->stopped) { + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + read_status = av_read_frame(context->input_format, context->packet); + if (read_status < 0) break; + context->packets_read++; + if (context->packets_read > context->packet_limit) { + av_packet_unref(context->packet); + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (context->packet->stream_index != context->stream_index) { + av_packet_unref(context->packet); + continue; + } + int64_t packet_duration = context->packet->duration; + context->last_packet_duration = packet_duration; + int send_status = avcodec_send_packet( + context->decoder, context->packet); + av_packet_unref(context->packet); + if (send_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + send_status, context->request.deadline_monotonic_ms); + } + int status = ff_animation_receive_frames( + context, packet_duration, 0); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } + if (context->stopped) return FLUXER_NATIVE_STATUS_OK; + if (read_status != AVERROR_EOF) { + return fluxer_native_status_from_av_error_with_deadline( + read_status, context->request.deadline_monotonic_ms); + } + return ff_animation_flush_decoder(context); +} + +static int ff_animation_validate_decoded_frames( + const struct ff_animation_decode_context *context +) { + assert(context != NULL); + assert(context->frames >= 0); + if (context->frames < context->request.minimum_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (context->request.require_complete == 0) { + return FLUXER_NATIVE_STATUS_OK; + } + if (context->expected_frames == 0) { + return FLUXER_NATIVE_STATUS_OK; + } + if (context->frames != context->expected_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_animation_build_image( + struct ff_animation_decode_context *context, + VipsImage **out +) { + assert(context != NULL); + assert(out != NULL); + assert(*out == NULL); + assert(context->request.decode_pixels == 1); + assert(context->pixels != NULL); + assert(context->delays != NULL); + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + assert(context->frames >= context->request.minimum_frames); + if (context->canvas_width <= 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (context->canvas_height <= 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (context->canvas_height > INT_MAX / context->frames) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t frame_bytes = 0; + if (ff_validate_rgba_geometry( + context->canvas_width, context->canvas_height, + &frame_bytes) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if ((size_t)context->frames > SIZE_MAX / frame_bytes) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t used_bytes = frame_bytes * (size_t)context->frames; + context->image = vips_image_new_from_memory( + context->pixels, + used_bytes, + context->canvas_width, + context->canvas_height * context->frames, + 4, + VIPS_FORMAT_UCHAR); + if (context->image == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (g_signal_connect_swapped( + context->image, "postclose", G_CALLBACK(free), + context->pixels) == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + context->pixels = NULL; + vips_image_set_int( + context->image, "page-height", context->canvas_height); + if (context->frames > 1) { + vips_image_set_int(context->image, "n-pages", context->frames); + } + vips_image_set_array_int( + context->image, "delay", context->delays, context->frames); + *out = context->image; + context->image = NULL; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_animation_decode_execute( + struct ff_animation_decode_context *context +) { + assert(context != NULL); + int status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = ff_animation_open_input(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = ff_animation_open_decoder(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = ff_animation_decode_packets(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return ff_animation_validate_decoded_frames(context); +} + +static void ff_animation_decode_context_clear( + struct ff_animation_decode_context *context +) { + assert(context != NULL); + if (context->image != NULL) g_object_unref(context->image); + if (context->pixels != NULL) free(context->pixels); + if (context->delays != NULL) free(context->delays); + if (context->sws != NULL) sws_freeContext(context->sws); + if (context->frame != NULL) av_frame_free(&context->frame); + if (context->packet != NULL) av_packet_free(&context->packet); + if (context->decoder != NULL) avcodec_free_context(&context->decoder); + if (context->input_format != NULL) { + avformat_close_input(&context->input_format); + } + if (context->input_avio != NULL) { + if (context->input_avio->buffer != NULL) { + av_freep(&context->input_avio->buffer); + } + avio_context_free(&context->input_avio); + } + if (context->input_avio_buffer != NULL) { + av_free(context->input_avio_buffer); + } +} + +static int ffmpeg_decode_animation_stack( + const void *media_data, + size_t media_len, + int decoder_threads, + long long deadline_monotonic_ms, + VipsImage **out, + int *out_frame_count, + int max_frames, + size_t max_total_pixels, + int require_complete, + const char *allowed_formats, + const AVInputFormat *forced_format, + int expected_frames, + int minimum_frames, + int decode_pixels +) { + if (out_frame_count == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_frame_count = 0; + if (decode_pixels != 0) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + } else if (out != NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + struct ff_animation_decode_request request = { + .data = media_data, + .len = media_len, + .decoder_threads = decoder_threads, + .deadline_monotonic_ms = deadline_monotonic_ms, + .max_frames = max_frames, + .max_total_pixels = max_total_pixels, + .require_complete = require_complete, + .allowed_formats = allowed_formats, + .forced_format = forced_format, + .expected_frames = expected_frames, + .minimum_frames = minimum_frames, + .decode_pixels = decode_pixels, + }; + int status = ff_animation_decode_request_valid(&request); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + + struct ff_animation_decode_context context; + ff_animation_decode_context_init(&context, &request); + status = ff_animation_decode_execute(&context); + if (status == FLUXER_NATIVE_STATUS_OK) { + if (decode_pixels != 0) { + status = ff_animation_build_image(&context, out); + } + } + if (status == FLUXER_NATIVE_STATUS_OK) { + *out_frame_count = context.frames; + if (decode_pixels == 0) { + assert(context.pixels == NULL); + assert(context.delays == NULL); + } + } + ff_animation_decode_context_clear(&context); + if (status != FLUXER_NATIVE_STATUS_OK) { + assert(*out_frame_count == 0); + if (out != NULL) assert(*out == NULL); + } + return status; +} +int fluxer_ffmpeg_decode_apng( + const void *apng_data, + size_t apng_len, + int decoder_threads, + long long deadline_monotonic_ms, + VipsImage **out, + int max_frames, + size_t max_total_pixels, + int require_complete, + uint32_t *out_num_plays +) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + if (out_num_plays == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_num_plays = 0; + int expected_frames = 0; + uint32_t num_plays = 0; + int validate_rc = fluxer_validate_complete_apng( + apng_data, apng_len, + require_complete ? max_frames : 0, + require_complete ? max_total_pixels : 0, + NULL, NULL, + &expected_frames, &num_plays); + if (validate_rc != 0) return validate_rc; + int deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int decoded_frames = 0; + int status = ffmpeg_decode_animation_stack( + apng_data, apng_len, decoder_threads, deadline_monotonic_ms, + out, &decoded_frames, max_frames, max_total_pixels, + require_complete, "apng", av_find_input_format("apng"), + expected_frames, 1, 1); + if (status == FLUXER_NATIVE_STATUS_OK) { + *out_num_plays = num_plays; + } else { + assert(*out_num_plays == 0); + } + return status; +} + +int fluxer_ffmpeg_decode_bmp( + const void *bmp_data, + size_t bmp_len, + int decoder_threads, + long long deadline_monotonic_ms, + VipsImage **out, + size_t max_total_pixels +) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + const AVInputFormat *bmp_format = + av_find_input_format(FLUXER_AV_BMP_INPUT_FORMAT); + if (bmp_format == NULL) return FLUXER_NATIVE_STATUS_UNSUPPORTED; + int decoded_frames = 0; + int status = ffmpeg_decode_animation_stack( + bmp_data, bmp_len, decoder_threads, deadline_monotonic_ms, + out, &decoded_frames, 1, max_total_pixels, 1, + FLUXER_AV_BMP_INPUT_FORMAT, bmp_format, 1, 1, 1); + if (status == FLUXER_NATIVE_STATUS_OK) { + assert(*out != NULL); + assert(decoded_frames == 1); + } else { + assert(*out == NULL); + } + return status; +} + +int fluxer_ffmpeg_count_heif_sequence_frames( + const void *heif_data, + size_t heif_len, + int decoder_threads, + long long deadline_monotonic_ms, + int max_frames, + size_t max_total_pixels, + int *out_frame_count +) { + if (out_frame_count == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_frame_count = 0; + int validation_status = fluxer_heif_validate( + heif_data, heif_len, deadline_monotonic_ms); + if (validation_status != FLUXER_NATIVE_STATUS_OK) return validation_status; + int status = ffmpeg_decode_animation_stack( + heif_data, heif_len, decoder_threads, deadline_monotonic_ms, + NULL, out_frame_count, max_frames, max_total_pixels, + 1, FLUXER_AV_INPUT_FORMATS, NULL, 0, 2, 0); + if (status == FLUXER_NATIVE_STATUS_OK) { + assert(*out_frame_count >= 2); + } else { + assert(*out_frame_count == 0); + } + return status; +} + +int fluxer_ffmpeg_decode_heif_sequence( + const void *heif_data, + size_t heif_len, + int decoder_threads, + long long deadline_monotonic_ms, + VipsImage **out, + int max_frames, + size_t max_total_pixels, + int *out_frame_count +) { + if (out_frame_count == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_frame_count = 0; + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + int validation_status = fluxer_heif_validate( + heif_data, heif_len, deadline_monotonic_ms); + if (validation_status != FLUXER_NATIVE_STATUS_OK) return validation_status; + int status = ffmpeg_decode_animation_stack( + heif_data, heif_len, decoder_threads, deadline_monotonic_ms, + out, out_frame_count, max_frames, max_total_pixels, + 1, FLUXER_AV_INPUT_FORMATS, NULL, 0, 2, 1); + if (status == FLUXER_NATIVE_STATUS_OK) { + assert(*out != NULL); + assert(*out_frame_count >= 2); + } else { + assert(*out == NULL); + assert(*out_frame_count == 0); + } + return status; +} + +static int fluxer_vips_copy_animation_rgba( + VipsRegion *region, + uint8_t *destination, + size_t row_bytes, + int width, + int height, + long long deadline_monotonic_ms +) { + assert(region != NULL); + assert(destination != NULL); + assert(row_bytes > 0); + assert(width > 0); + assert(height > 0); + assert(deadline_monotonic_ms >= 0); + for (int top = 0; top < height; top += FLUXER_VIPS_RGBA_REGION_ROWS) { + if (fluxer_monotonic_deadline_status(deadline_monotonic_ms) != + FLUXER_DEADLINE_PENDING) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + int rows = height - top; + if (rows > FLUXER_VIPS_RGBA_REGION_ROWS) rows = FLUXER_VIPS_RGBA_REGION_ROWS; + VipsRect area = { .left = 0, .top = top, .width = width, .height = rows }; + if (vips_region_prepare(region, &area) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!vips_rect_includesrect(®ion->valid, &area)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (region->data == NULL || region->bpl <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if ((size_t)region->bpl < row_bytes) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + for (int y = top; y < top + rows; y++) { + const uint8_t *source = VIPS_REGION_ADDR(region, 0, y); + memcpy(destination + (size_t)y * row_bytes, source, row_bytes); + } + } + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_vips_extract_animation_rgba_strip( + VipsImage *input, + uint8_t *destination, + size_t destination_capacity, + long long deadline_monotonic_ms, + size_t *out_size +) { + if (out_size != NULL) *out_size = 0; + if (input == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (destination == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (out_size == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + VipsImage *rgba = NULL; + int status = fluxer_vips_image_to_rgba(input, &rgba); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int width = vips_image_get_width(rgba); + int height = vips_image_get_height(rgba); + size_t required = 0; + if (ff_validate_rgba_geometry(width, height, &required) != 0) { + g_object_unref(rgba); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (destination_capacity != required) { + g_object_unref(rgba); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + + VipsRegion *region = vips_region_new(rgba); + if (region == NULL) { + g_object_unref(rgba); + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + size_t row_bytes = (size_t)width * 4u; + status = fluxer_vips_copy_animation_rgba( + region, destination, row_bytes, width, height, + deadline_monotonic_ms); + g_object_unref(region); + g_object_unref(rgba); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + *out_size = required; + return FLUXER_NATIVE_STATUS_OK; +} +int fluxer_vips_read_animation_delays_ms(VipsImage *image, int n_pages, + int **out_delays) { + if (out_delays == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_delays = NULL; + if (image == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (n_pages <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if ((size_t)n_pages > SIZE_MAX / sizeof(int)) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (vips_image_get_typeof(image, "delay") != 0) { + int *arr = NULL; + int n = 0; + if (vips_image_get_array_int(image, "delay", &arr, &n) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (arr == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (n != n_pages) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int *out = (int *)malloc((size_t)n_pages * sizeof(int)); + if (out == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + for (int i = 0; i < n_pages; i++) { + if (arr[i] <= 0) { + out[i] = FLUXER_DEFAULT_ANIMATION_FRAME_DELAY_MS; + } else if (arr[i] < FLUXER_MIN_ANIMATION_FRAME_DELAY_MS) { + out[i] = FLUXER_MIN_ANIMATION_FRAME_DELAY_MS; + } else { + out[i] = arr[i]; + } + } + *out_delays = out; + return FLUXER_NATIVE_STATUS_OK; + } + if (vips_image_get_typeof(image, "gif-delay") == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int gif_delay = 0; + if (vips_image_get_int(image, "gif-delay", &gif_delay) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (gif_delay > INT_MAX / 10) return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + int *out = (int *)malloc((size_t)n_pages * sizeof(int)); + if (out == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + int delay_ms = gif_delay > 0 ? gif_delay * 10 : 0; + if (delay_ms <= 0) { + delay_ms = FLUXER_DEFAULT_ANIMATION_FRAME_DELAY_MS; + } else if (delay_ms < FLUXER_MIN_ANIMATION_FRAME_DELAY_MS) { + delay_ms = FLUXER_MIN_ANIMATION_FRAME_DELAY_MS; + } + for (int i = 0; i < n_pages; i++) out[i] = delay_ms; + *out_delays = out; + return FLUXER_NATIVE_STATUS_OK; +} diff --git a/fluxer_media_proxy/src/native_shim/apng_validation.c b/fluxer_media_proxy/src/native_shim/apng_validation.c new file mode 100644 index 000000000..0aa4539ea --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/apng_validation.c @@ -0,0 +1,523 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +#include + +enum fluxer_png_idat_state { + FLUXER_PNG_BEFORE_IDAT = 0, + FLUXER_PNG_READING_IDAT = 1, + FLUXER_PNG_AFTER_IDAT = 2 +}; + +enum fluxer_apng_frame_data_kind { + FLUXER_APNG_NO_FRAME_DATA = 0, + FLUXER_APNG_IDAT_FRAME_DATA = 1, + FLUXER_APNG_FDAT_FRAME_DATA = 2 +}; + +struct fluxer_apng_chunk { + uint32_t length; + const uint8_t *type; + const uint8_t *payload; + size_t next_offset; +}; + +struct fluxer_apng_validation { + const uint8_t *data; + size_t len; + size_t offset; + size_t chunk_count; + int chunk_index; + int max_frames; + size_t max_total_pixels; + uint32_t width; + uint32_t height; + uint32_t declared_frames; + uint32_t declared_plays; + uint32_t frame_controls; + uint32_t next_sequence; + uint8_t color_type; + int saw_actl; + int saw_plte; + int saw_idat; + int saw_idat_payload; + int saw_fdat; + int saw_iend; + int active_frame; + int active_frame_has_data; + enum fluxer_png_idat_state idat_state; + enum fluxer_apng_frame_data_kind active_frame_data; + const AVCRC *crc_table; +}; + +static uint32_t fluxer_read_be32(const uint8_t *value) { + assert(value != NULL); + return ((uint32_t)value[0] << 24) | + ((uint32_t)value[1] << 16) | + ((uint32_t)value[2] << 8) | + (uint32_t)value[3]; +} + +static int fluxer_png_chunk_type_valid(const uint8_t *type) { + if (type == NULL) return 0; + for (size_t index = 0; index < 4; index++) { + uint8_t value = type[index]; + int uppercase = value >= 'A' && value <= 'Z'; + int lowercase = value >= 'a' && value <= 'z'; + if (!uppercase && !lowercase) return 0; + } + return (type[2] & 0x20u) == 0; +} + +static int fluxer_png_chunk_crc_valid( + const AVCRC *table, + const struct fluxer_apng_chunk *chunk +) { + assert(table != NULL); + assert(chunk != NULL); + uint32_t crc = av_crc(table, UINT32_MAX, chunk->type, 4); + crc = av_crc(table, crc, chunk->payload, chunk->length) ^ UINT32_MAX; + uint32_t expected = fluxer_read_be32(chunk->payload + chunk->length); + return crc == expected; +} + +static int fluxer_png_ihdr_valid(const uint8_t *payload) { + assert(payload != NULL); + uint8_t bit_depth = payload[8]; + uint8_t color_type = payload[9]; + int bit_depth_valid = 0; + switch (color_type) { + case 0: + bit_depth_valid = bit_depth == 1 || bit_depth == 2 || + bit_depth == 4 || bit_depth == 8 || + bit_depth == 16; + break; + case 2: + case 4: + case 6: + bit_depth_valid = bit_depth == 8 || bit_depth == 16; + break; + case 3: + bit_depth_valid = bit_depth == 1 || bit_depth == 2 || + bit_depth == 4 || bit_depth == 8; + break; + default: + return 0; + } + if (!bit_depth_valid) return 0; + if (payload[10] != 0) return 0; + if (payload[11] != 0) return 0; + return payload[12] <= 1; +} + +static int fluxer_apng_sequence_advance( + const uint8_t *payload, + uint32_t *next_sequence +) { + assert(payload != NULL); + assert(next_sequence != NULL); + uint32_t sequence = fluxer_read_be32(payload); + if (sequence > INT_MAX) return -1; + if (sequence != *next_sequence) return -1; + if (*next_sequence == UINT32_MAX) return -1; + *next_sequence += 1; + return 0; +} + +static int fluxer_apng_read_chunk( + struct fluxer_apng_validation *validation, + struct fluxer_apng_chunk *chunk +) { + assert(validation != NULL); + assert(chunk != NULL); + if (validation->chunk_count >= FLUXER_MAX_APNG_CHUNKS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (validation->offset > validation->len) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + size_t remaining = validation->len - validation->offset; + if (remaining < 12) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + uint32_t length = fluxer_read_be32(validation->data + validation->offset); + if ((size_t)length > remaining - 12) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + chunk->length = length; + chunk->type = validation->data + validation->offset + 4; + chunk->payload = validation->data + validation->offset + 8; + chunk->next_offset = validation->offset + 12 + (size_t)length; + validation->chunk_count++; + if (!fluxer_png_chunk_type_valid(chunk->type)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!fluxer_png_chunk_crc_valid(validation->crc_table, chunk)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_accept_ihdr( + struct fluxer_apng_validation *validation, + const struct fluxer_apng_chunk *chunk, + int is_ihdr +) { + assert(validation != NULL); + assert(chunk != NULL); + if (validation->chunk_index != 0) { + if (is_ihdr) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return FLUXER_NATIVE_STATUS_OK; + } + if (!is_ihdr) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->length != 13) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!fluxer_png_ihdr_valid(chunk->payload)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + uint32_t width = fluxer_read_be32(chunk->payload); + uint32_t height = fluxer_read_be32(chunk->payload + 4); + if (width == 0 || height == 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (width > FLUXER_MAX_VIDEO_FRAME_DIMENSION) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (height > FLUXER_MAX_VIDEO_FRAME_DIMENSION) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if ((size_t)width > FLUXER_MAX_VIDEO_PIXELS / (size_t)height) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + validation->width = width; + validation->height = height; + validation->color_type = chunk->payload[9]; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_accept_actl( + struct fluxer_apng_validation *validation, + const struct fluxer_apng_chunk *chunk +) { + assert(validation != NULL); + assert(chunk != NULL); + if (validation->saw_actl) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->saw_idat) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->frame_controls != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->length != 8) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + uint32_t declared_frames = fluxer_read_be32(chunk->payload); + uint32_t declared_plays = fluxer_read_be32(chunk->payload + 4); + if (declared_frames == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (declared_frames > INT_MAX) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->max_frames > 0) { + if (declared_frames > (uint32_t)validation->max_frames) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + } + validation->declared_frames = declared_frames; + validation->declared_plays = declared_plays; + validation->saw_actl = 1; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_frame_geometry_valid( + const struct fluxer_apng_validation *validation, + const uint8_t *payload +) { + assert(validation != NULL); + assert(payload != NULL); + uint32_t frame_width = fluxer_read_be32(payload + 4); + uint32_t frame_height = fluxer_read_be32(payload + 8); + uint32_t frame_x = fluxer_read_be32(payload + 12); + uint32_t frame_y = fluxer_read_be32(payload + 16); + if (frame_width == 0 || frame_height == 0) return 0; + if (frame_x > validation->width) return 0; + if (frame_y > validation->height) return 0; + if (frame_width > validation->width - frame_x) return 0; + if (frame_height > validation->height - frame_y) return 0; + if (validation->frame_controls == 0 && !validation->saw_idat) { + if (frame_width != validation->width) return 0; + if (frame_height != validation->height) return 0; + if (frame_x != 0) return 0; + if (frame_y != 0) return 0; + } + return 1; +} + +static int fluxer_apng_accept_fctl( + struct fluxer_apng_validation *validation, + const struct fluxer_apng_chunk *chunk +) { + assert(validation != NULL); + assert(chunk != NULL); + if (!validation->saw_actl) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->length != 26) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->frame_controls >= validation->declared_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (validation->active_frame && !validation->active_frame_has_data) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (fluxer_apng_sequence_advance( + chunk->payload, &validation->next_sequence) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!fluxer_apng_frame_geometry_valid(validation, chunk->payload)) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (chunk->payload[24] > 2) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->payload[25] > 1) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + validation->active_frame = 1; + validation->active_frame_has_data = 0; + if (validation->saw_idat) { + validation->active_frame_data = FLUXER_APNG_FDAT_FRAME_DATA; + } else { + validation->active_frame_data = FLUXER_APNG_IDAT_FRAME_DATA; + } + validation->frame_controls++; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_accept_plte( + struct fluxer_apng_validation *validation, + const struct fluxer_apng_chunk *chunk +) { + assert(validation != NULL); + assert(chunk != NULL); + if (validation->saw_plte) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->saw_idat) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->length == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->length > 768) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->length % 3 != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->color_type == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->color_type == 4) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + validation->saw_plte = 1; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_accept_idat( + struct fluxer_apng_validation *validation, + const struct fluxer_apng_chunk *chunk +) { + assert(validation != NULL); + assert(chunk != NULL); + if (!validation->saw_actl) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->idat_state == FLUXER_PNG_AFTER_IDAT) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (validation->saw_fdat) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->color_type == 3 && !validation->saw_plte) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (validation->active_frame) { + if (validation->active_frame_data == FLUXER_APNG_FDAT_FRAME_DATA) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } + validation->idat_state = FLUXER_PNG_READING_IDAT; + validation->saw_idat = 1; + if (chunk->length == 0) return FLUXER_NATIVE_STATUS_OK; + validation->saw_idat_payload = 1; + if (validation->active_frame) { + if (validation->active_frame_data == FLUXER_APNG_IDAT_FRAME_DATA) { + validation->active_frame_has_data = 1; + } + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_accept_fdat( + struct fluxer_apng_validation *validation, + const struct fluxer_apng_chunk *chunk +) { + assert(validation != NULL); + assert(chunk != NULL); + if (!validation->saw_actl) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->length <= 4) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!validation->saw_idat) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->idat_state != FLUXER_PNG_AFTER_IDAT) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!validation->active_frame) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->active_frame_data != FLUXER_APNG_FDAT_FRAME_DATA) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (fluxer_apng_sequence_advance( + chunk->payload, &validation->next_sequence) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + validation->active_frame_has_data = 1; + validation->saw_fdat = 1; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_accept_iend( + struct fluxer_apng_validation *validation, + const struct fluxer_apng_chunk *chunk +) { + assert(validation != NULL); + assert(chunk != NULL); + if (chunk->length != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (chunk->next_offset != validation->len) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!validation->active_frame) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!validation->active_frame_has_data) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + validation->saw_iend = 1; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_process_chunk( + struct fluxer_apng_validation *validation, + const struct fluxer_apng_chunk *chunk +) { + assert(validation != NULL); + assert(chunk != NULL); + int is_ihdr = memcmp(chunk->type, "IHDR", 4) == 0; + int is_plte = memcmp(chunk->type, "PLTE", 4) == 0; + int is_idat = memcmp(chunk->type, "IDAT", 4) == 0; + int is_iend = memcmp(chunk->type, "IEND", 4) == 0; + int known_critical = is_ihdr || is_plte || is_idat || is_iend; + if ((chunk->type[0] & 0x20u) == 0 && !known_critical) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (validation->idat_state == FLUXER_PNG_READING_IDAT && !is_idat) { + validation->idat_state = FLUXER_PNG_AFTER_IDAT; + } + int status = fluxer_apng_accept_ihdr(validation, chunk, is_ihdr); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (memcmp(chunk->type, "acTL", 4) == 0) { + return fluxer_apng_accept_actl(validation, chunk); + } + if (memcmp(chunk->type, "fcTL", 4) == 0) { + return fluxer_apng_accept_fctl(validation, chunk); + } + if (is_plte) return fluxer_apng_accept_plte(validation, chunk); + if (is_idat) return fluxer_apng_accept_idat(validation, chunk); + if (memcmp(chunk->type, "fdAT", 4) == 0) { + return fluxer_apng_accept_fdat(validation, chunk); + } + if (is_iend) return fluxer_apng_accept_iend(validation, chunk); + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_apng_finish_validation( + const struct fluxer_apng_validation *validation, + int *out_width, + int *out_height, + int *out_expected_frames, + uint32_t *out_num_plays +) { + assert(validation != NULL); + assert(out_expected_frames != NULL); + if (!validation->saw_actl) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!validation->saw_iend) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!validation->saw_idat) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!validation->saw_idat_payload) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (validation->offset != validation->len) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (validation->frame_controls != validation->declared_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (validation->color_type == 3 && !validation->saw_plte) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if ((size_t)validation->width > SIZE_MAX / (size_t)validation->height) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t frame_pixels = + (size_t)validation->width * (size_t)validation->height; + if (frame_pixels == 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (frame_pixels > SIZE_MAX / 4u) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (validation->max_total_pixels > 0) { + if (frame_pixels > validation->max_total_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if ((size_t)validation->declared_frames > + validation->max_total_pixels / frame_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + } + if (out_width != NULL) *out_width = (int)validation->width; + if (out_height != NULL) *out_height = (int)validation->height; + *out_expected_frames = (int)validation->declared_frames; + if (out_num_plays != NULL) *out_num_plays = validation->declared_plays; + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_validate_complete_apng( + const uint8_t *data, + size_t len, + int max_frames, + size_t max_total_pixels, + int *out_width, + int *out_height, + int *out_expected_frames, + uint32_t *out_num_plays +) { + static const uint8_t signature[8] = { + 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n' + }; + if (out_width != NULL) *out_width = 0; + if (out_height != NULL) *out_height = 0; + if (out_expected_frames == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out_expected_frames = 0; + if (out_num_plays != NULL) *out_num_plays = 0; + if (data == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (len < sizeof(signature)) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (max_frames < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (memcmp(data, signature, sizeof(signature)) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + struct fluxer_apng_validation validation = { + .data = data, + .len = len, + .offset = sizeof(signature), + .max_frames = max_frames, + .max_total_pixels = max_total_pixels, + .idat_state = FLUXER_PNG_BEFORE_IDAT, + .active_frame_data = FLUXER_APNG_NO_FRAME_DATA, + .crc_table = av_crc_get_table(AV_CRC_32_IEEE_LE), + }; + if (validation.crc_table == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + while (validation.offset < validation.len) { + struct fluxer_apng_chunk chunk; + int status = fluxer_apng_read_chunk(&validation, &chunk); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = fluxer_apng_process_chunk(&validation, &chunk); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + validation.offset = chunk.next_offset; + validation.chunk_index++; + } + return fluxer_apng_finish_validation( + &validation, out_width, out_height, out_expected_frames, + out_num_plays); +} + +int fluxer_apng_probe( + const void *buffer, + size_t len, + int max_frames, + size_t max_total_pixels, + int *width, + int *height, + int *frames +) { + if (width != NULL) *width = 0; + if (height != NULL) *height = 0; + if (frames != NULL) *frames = 0; + if (width == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (height == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (frames == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return fluxer_validate_complete_apng( + buffer, len, max_frames, max_total_pixels, width, height, frames, + NULL); +} diff --git a/fluxer_media_proxy/src/native_shim/av_frame_rgba.c b/fluxer_media_proxy/src/native_shim/av_frame_rgba.c new file mode 100644 index 000000000..fe7b5d3a3 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/av_frame_rgba.c @@ -0,0 +1,461 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +enum ff_sdr_transfer { + FF_SDR_TRANSFER_SRGB = 0, + FF_SDR_TRANSFER_BT709 = 1, + FF_SDR_TRANSFER_LINEAR = 2, +}; + +static uint8_t ff_bt709_to_srgb_lut[256]; +static uint8_t ff_linear_to_srgb_lut[256]; +static pthread_once_t ff_transfer_lut_once = PTHREAD_ONCE_INIT; + +static uint8_t ff_encode_srgb_byte(double linear) { + double srgb = linear <= 0.0031308 + ? 12.92 * linear + : 1.055 * pow(linear, 1.0 / 2.4) - 0.055; + long quantized = lround(srgb * 255.0); + if (quantized < 0) quantized = 0; + if (quantized > 255) quantized = 255; + return (uint8_t)quantized; +} + +static void ff_initialize_transfer_luts(void) { + for (int index = 0; index < 256; index++) { + double encoded = (double)index / 255.0; + double linear = encoded < 0.081 + ? encoded / 4.5 + : pow((encoded + 0.099) / 1.099, 1.0 / 0.45); + ff_bt709_to_srgb_lut[index] = ff_encode_srgb_byte(linear); + ff_linear_to_srgb_lut[index] = ff_encode_srgb_byte(encoded); + } +} + +static const uint8_t *ff_transfer_to_srgb_lut(enum ff_sdr_transfer transfer) { + switch (transfer) { + case FF_SDR_TRANSFER_BT709: + return ff_bt709_to_srgb_lut; + case FF_SDR_TRANSFER_LINEAR: + return ff_linear_to_srgb_lut; + default: + return NULL; + } +} + +static int ff_frame_sdr_transfer( + const AVFrame *frame, + enum ff_sdr_transfer *out_transfer +) { + assert(frame != NULL); + assert(out_transfer != NULL); + switch (frame->colorspace) { + case AVCOL_SPC_RGB: + case AVCOL_SPC_BT709: + case AVCOL_SPC_UNSPECIFIED: + case AVCOL_SPC_FCC: + case AVCOL_SPC_BT470BG: + case AVCOL_SPC_SMPTE170M: + case AVCOL_SPC_SMPTE240M: + break; + default: + return -1; + } + if (frame->color_range != AVCOL_RANGE_UNSPECIFIED && + frame->color_range != AVCOL_RANGE_MPEG && + frame->color_range != AVCOL_RANGE_JPEG) { + return -1; + } + switch (frame->color_primaries) { + case AVCOL_PRI_UNSPECIFIED: + case AVCOL_PRI_BT709: + case AVCOL_PRI_BT470M: + case AVCOL_PRI_BT470BG: + case AVCOL_PRI_SMPTE170M: + case AVCOL_PRI_SMPTE240M: + case AVCOL_PRI_FILM: + break; + default: + return -1; + } + switch (frame->color_trc) { + case AVCOL_TRC_UNSPECIFIED: + case AVCOL_TRC_IEC61966_2_1: + case AVCOL_TRC_GAMMA22: + case AVCOL_TRC_GAMMA28: + *out_transfer = FF_SDR_TRANSFER_SRGB; + return 0; + case AVCOL_TRC_BT709: + case AVCOL_TRC_SMPTE170M: + case AVCOL_TRC_SMPTE240M: + *out_transfer = FF_SDR_TRANSFER_BT709; + return 0; + case AVCOL_TRC_LINEAR: + *out_transfer = FF_SDR_TRANSFER_LINEAR; + return 0; + default: + return -1; + } +} + +static int ff_convert_rgba_transfer_to_srgb( + enum ff_sdr_transfer transfer, + uint8_t *data, + int width, + int height, + long long deadline_monotonic_ms +) { + assert(data != NULL); + assert(width > 0); + assert(height > 0); + if (deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (pthread_once( + &ff_transfer_lut_once, + ff_initialize_transfer_luts) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + const uint8_t *lut = ff_transfer_to_srgb_lut(transfer); + if (lut == NULL) { + return fluxer_native_deadline_status(deadline_monotonic_ms); + } + size_t row_bytes = (size_t)width * 4u; + for (int row = 0; row < height; row++) { + if (row % FLUXER_VIDEO_DEADLINE_ROWS == 0) { + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } + uint8_t *row_data = data + (size_t)row * row_bytes; + for (int column = 0; column < width; column++) { + uint8_t *pixel = row_data + (size_t)column * 4u; + pixel[0] = lut[pixel[0]]; + pixel[1] = lut[pixel[1]]; + pixel[2] = lut[pixel[2]]; + } + } + return fluxer_native_deadline_status(deadline_monotonic_ms); +} + +typedef int (*ff_i420_to_abgr_fn)( + const uint8_t *, int, + const uint8_t *, int, + const uint8_t *, int, + uint8_t *, int, + int, int +); + +static int ff_swscale_colorspace(enum AVColorSpace colorspace) { + switch (colorspace) { + case AVCOL_SPC_BT709: + return SWS_CS_ITU709; + case AVCOL_SPC_FCC: + return SWS_CS_FCC; + case AVCOL_SPC_BT470BG: + return SWS_CS_ITU624; + case AVCOL_SPC_SMPTE170M: + return SWS_CS_SMPTE170M; + case AVCOL_SPC_SMPTE240M: + return SWS_CS_SMPTE240M; + default: + return SWS_CS_DEFAULT; + } +} + +static int ff_configure_swscale_color( + struct SwsContext *sws, + const AVFrame *frame +) { + assert(sws != NULL); + assert(frame != NULL); + int colorspace = ff_swscale_colorspace(frame->colorspace); + const int *coefficients = sws_getCoefficients(colorspace); + if (coefficients == NULL) return -1; + int source_full_range = frame->color_range == AVCOL_RANGE_JPEG; + const AVPixFmtDescriptor *descriptor = av_pix_fmt_desc_get(frame->format); + if (descriptor == NULL) return -1; + if (frame->color_range == AVCOL_RANGE_UNSPECIFIED && + (descriptor->flags & AV_PIX_FMT_FLAG_RGB) != 0) { + source_full_range = 1; + } + int rc = sws_setColorspaceDetails( + sws, coefficients, source_full_range, coefficients, 1, + 0, 1 << 16, 1 << 16); + return rc < 0 ? -1 : 0; +} + +static ff_i420_to_abgr_fn ff_libyuv_converter(const AVFrame *frame) { + if (frame == NULL || + (frame->color_range != AVCOL_RANGE_MPEG && + frame->color_range != AVCOL_RANGE_UNSPECIFIED)) { + return NULL; + } + switch (frame->colorspace) { + case AVCOL_SPC_BT709: + return H420ToABGR; + case AVCOL_SPC_UNSPECIFIED: + case AVCOL_SPC_BT470BG: + case AVCOL_SPC_SMPTE170M: + return I420ToABGR; + default: + return NULL; + } +} + +static int ff_convert_i420_frame_to_rgba_libyuv( + AVFrame *frame, + int source_width, + int source_height, + int output_width, + int output_height, + long long deadline_monotonic_ms, + uint8_t *dst, + int *out_applied +) { + assert(out_applied != NULL); + *out_applied = 0; +#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + if (frame == NULL || frame->format != AV_PIX_FMT_YUV420P || dst == NULL) { + return FLUXER_NATIVE_STATUS_OK; + } + if (source_width != output_width || source_height != output_height) { + return FLUXER_NATIVE_STATUS_OK; + } + ff_i420_to_abgr_fn convert = ff_libyuv_converter(frame); + if (convert == NULL || frame->data[0] == NULL || + frame->data[1] == NULL || frame->data[2] == NULL) { + return FLUXER_NATIVE_STATUS_OK; + } + int source_chroma_width = (source_width + 1) / 2; + if (frame->linesize[0] < source_width || + frame->linesize[1] < source_chroma_width || + frame->linesize[2] < source_chroma_width) { + return FLUXER_NATIVE_STATUS_OK; + } + *out_applied = 1; + for (int row = 0; row < output_height;) { + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int rows = output_height - row; + if (rows > FLUXER_VIDEO_DEADLINE_ROWS) { + rows = FLUXER_VIDEO_DEADLINE_ROWS; + } + const uint8_t *y = frame->data[0] + (size_t)row * frame->linesize[0]; + const uint8_t *u = + frame->data[1] + (size_t)(row / 2) * frame->linesize[1]; + const uint8_t *v = + frame->data[2] + (size_t)(row / 2) * frame->linesize[2]; + uint8_t *output = + dst + (size_t)row * (size_t)output_width * 4u; + int convert_rc = convert( + y, frame->linesize[0], + u, frame->linesize[1], + v, frame->linesize[2], + output, output_width * 4, + output_width, rows); + if (convert_rc != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + row += rows; + } + return fluxer_native_deadline_status(deadline_monotonic_ms); +#else + (void)frame; + (void)source_width; + (void)source_height; + (void)output_width; + (void)output_height; + (void)deadline_monotonic_ms; + (void)dst; + return FLUXER_NATIVE_STATUS_OK; +#endif +} + +struct ff_swscale_source_layout { + unsigned int required_planes; + int chroma_alignment; + int is_paletted; + int is_bayer; +}; + +static int ff_swscale_validate_source_layout( + const AVFrame *frame, + const AVPixFmtDescriptor *descriptor, + struct ff_swscale_source_layout *out +) { + assert(frame != NULL); + assert(descriptor != NULL); + assert(out != NULL); + if (descriptor->nb_components == 0) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + if (descriptor->nb_components > 4) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + unsigned int required_planes = 0; + for (int component = 0; + component < descriptor->nb_components; + component++) { + int plane = descriptor->comp[component].plane; + if (plane < 0) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + if (plane >= 4) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + required_planes |= 1u << plane; + } + for (int plane = 0; plane < 4; plane++) { + if ((required_planes & (1u << plane)) == 0) continue; + if (frame->data[plane] == NULL) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + if (frame->linesize[plane] <= 0) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + } + int is_paletted = (descriptor->flags & AV_PIX_FMT_FLAG_PAL) != 0; + if (is_paletted) { + if (frame->data[1] == NULL) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + } + if (descriptor->log2_chroma_h >= sizeof(int) * CHAR_BIT - 1) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + int chroma_alignment = 1 << descriptor->log2_chroma_h; + if (chroma_alignment > FLUXER_VIDEO_DEADLINE_ROWS) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + out->required_planes = required_planes; + out->chroma_alignment = chroma_alignment; + out->is_paletted = is_paletted; + out->is_bayer = (descriptor->flags & AV_PIX_FMT_FLAG_BAYER) != 0; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_swscale_frame_to_rgba( + struct SwsContext *sws, + const AVFrame *frame, + int source_height, + int output_width, + int output_height, + long long deadline_monotonic_ms, + uint8_t *dst +) { + assert(sws != NULL); + assert(frame != NULL); + assert(source_height > 0); + assert(output_width > 0); + assert(output_height > 0); + assert(dst != NULL); + const AVPixFmtDescriptor *descriptor = av_pix_fmt_desc_get(frame->format); + if (descriptor == NULL) return FLUXER_NATIVE_STATUS_UNSUPPORTED; + struct ff_swscale_source_layout layout; + int status = ff_swscale_validate_source_layout(frame, descriptor, &layout); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int slice_alignment = layout.is_bayer ? 2 : layout.chroma_alignment; + if (layout.is_bayer) { + if (source_height < 2) return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + uint8_t *dst_data[4] = { dst, NULL, NULL, NULL }; + int dst_linesize[4] = { output_width * 4, 0, 0, 0 }; + int output_rows = 0; + for (int source_row = 0; source_row < source_height;) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int source_rows = source_height - source_row; + if (source_rows > FLUXER_VIDEO_DEADLINE_ROWS) { + source_rows = FLUXER_VIDEO_DEADLINE_ROWS; + } + if (source_row + source_rows < source_height) { + source_rows -= source_rows % slice_alignment; + if (layout.is_bayer) { + if (source_height - source_row - source_rows == 1) { + source_rows -= slice_alignment; + } + } + } + if (source_rows <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + const uint8_t *source_data[4] = { NULL, NULL, NULL, NULL }; + for (int plane = 0; plane < 4; plane++) { + if ((layout.required_planes & (1u << plane)) == 0) continue; + int row = source_row; + if (plane == 1) row >>= descriptor->log2_chroma_h; + if (plane == 2) row >>= descriptor->log2_chroma_h; + source_data[plane] = + frame->data[plane] + (size_t)row * frame->linesize[plane]; + } + if (layout.is_paletted) source_data[1] = frame->data[1]; + int scaled_rows = sws_scale( + sws, source_data, frame->linesize, + source_row, source_rows, dst_data, dst_linesize); + if (scaled_rows < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (scaled_rows > output_height - output_rows) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + output_rows += scaled_rows; + source_row += source_rows; + } + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return output_rows == output_height + ? FLUXER_NATIVE_STATUS_OK + : FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +int fluxer_av_frame_convert_to_rgba( + AVFrame *frame, + int source_width, + int source_height, + int output_width, + int output_height, + struct SwsContext **sws, + long long deadline_monotonic_ms, + uint8_t *dst +) { + if (frame == NULL || sws == NULL || dst == NULL || + deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (ff_validate_rgba_geometry(source_width, source_height, NULL) != 0 || + ff_validate_rgba_geometry(output_width, output_height, NULL) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + enum ff_sdr_transfer transfer = FF_SDR_TRANSFER_SRGB; + if (ff_frame_sdr_transfer(frame, &transfer) != 0) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + int libyuv_applied = 0; + status = ff_convert_i420_frame_to_rgba_libyuv( + frame, source_width, source_height, output_width, output_height, + deadline_monotonic_ms, dst, &libyuv_applied); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (libyuv_applied) { + if (transfer != FF_SDR_TRANSFER_SRGB) { + return ff_convert_rgba_transfer_to_srgb( + transfer, dst, output_width, output_height, + deadline_monotonic_ms); + } + return FLUXER_NATIVE_STATUS_OK; + } + *sws = sws_getCachedContext(*sws, + source_width, source_height, + (enum AVPixelFormat)frame->format, + output_width, output_height, AV_PIX_FMT_RGBA, + SWS_FAST_BILINEAR, NULL, NULL, NULL); + if (*sws == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (ff_configure_swscale_color(*sws, frame) != 0) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + status = ff_swscale_frame_to_rgba( + *sws, frame, source_height, output_width, output_height, + deadline_monotonic_ms, dst); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (transfer != FF_SDR_TRANSFER_SRGB) { + return ff_convert_rgba_transfer_to_srgb( + transfer, dst, output_width, output_height, deadline_monotonic_ms); + } + return FLUXER_NATIVE_STATUS_OK; +} diff --git a/fluxer_media_proxy/src/native_shim/av_input.c b/fluxer_media_proxy/src/native_shim/av_input.c new file mode 100644 index 000000000..fc7e58847 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/av_input.c @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +static int fluxer_monotonic_ms(long long *out_monotonic_ms) { + if (out_monotonic_ms == NULL) return -1; + struct timespec ts; + if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0 || ts.tv_sec < 0 || + ts.tv_nsec < 0 || ts.tv_nsec >= 1000000000L || + (uintmax_t)ts.tv_sec > (uintmax_t)(LLONG_MAX / 1000)) return -1; + long long seconds_ms = (long long)ts.tv_sec * 1000; + long long nanoseconds_ms = ts.tv_nsec / 1000000; + if (seconds_ms > LLONG_MAX - nanoseconds_ms) return -1; + *out_monotonic_ms = seconds_ms + nanoseconds_ms; + return 0; +} + +int fluxer_monotonic_deadline_status(long long deadline_monotonic_ms) { + assert(deadline_monotonic_ms >= 0); + if (deadline_monotonic_ms == 0) return FLUXER_DEADLINE_PENDING; + long long now_monotonic_ms = 0; + if (fluxer_monotonic_ms(&now_monotonic_ms) != 0) { + return FLUXER_DEADLINE_CLOCK_FAILED; + } + return now_monotonic_ms >= deadline_monotonic_ms + ? FLUXER_DEADLINE_REACHED + : FLUXER_DEADLINE_PENDING; +} + +int fluxer_native_deadline_status(long long deadline_monotonic_ms) { + assert(deadline_monotonic_ms >= 0); + return fluxer_monotonic_deadline_status(deadline_monotonic_ms) == + FLUXER_DEADLINE_PENDING + ? FLUXER_NATIVE_STATUS_OK + : FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; +} + +int fluxer_ffmpeg_interrupt_deadline(void *opaque) { + if (opaque == NULL) return 1; + const long long *deadline_monotonic_ms = opaque; + return fluxer_native_deadline_status(*deadline_monotonic_ms) != + FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_ffmpeg_decoder_threads_valid(int decoder_threads) { + return decoder_threads >= 1 && decoder_threads <= FLUXER_MAX_THREADS_PER_PIPELINE; +} + +int fluxer_native_status_from_av_error(int error) { + if (error == AVERROR(ENOMEM)) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + if (error == AVERROR(ENOSPC)) return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + if (error == AVERROR(E2BIG)) return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +int fluxer_native_status_from_av_error_with_deadline( + int error, + long long deadline_monotonic_ms +) { + int deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + return fluxer_native_status_from_av_error(error); +} + +int fluxer_native_status_from_heif_error(struct heif_error error) { + if (error.subcode == heif_suberror_Security_limit_exceeded) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (error.code == heif_error_Memory_allocation_error) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (error.code == heif_error_Unsupported_filetype || + error.code == heif_error_Unsupported_feature) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +int ff_mem_read_packet(void *opaque, uint8_t *buf, int buf_size) { + struct ff_mem_reader *r = (struct ff_mem_reader *)opaque; + if (r == NULL || r->data == NULL || buf == NULL || buf_size < 0) { + return AVERROR(EINVAL); + } + if (buf_size == 0) return 0; + if (r->offset > r->len) return AVERROR(EINVAL); + if (r->offset == r->len) return AVERROR_EOF; + size_t remaining = r->len - r->offset; + size_t n = remaining < (size_t)buf_size ? remaining : (size_t)buf_size; + memcpy(buf, r->data + r->offset, n); + r->offset += n; + return (int)n; +} + +int64_t ff_mem_seek(void *opaque, int64_t offset, int whence) { + struct ff_mem_reader *r = (struct ff_mem_reader *)opaque; + if (r == NULL || r->len > (size_t)INT64_MAX) return AVERROR(EINVAL); + if (whence == AVSEEK_SIZE) return (int64_t)r->len; + int mode = whence & ~AVSEEK_FORCE; + int64_t base = 0; + if (mode == SEEK_SET) { + base = 0; + } else if (mode == SEEK_CUR) { + base = (int64_t)r->offset; + } else if (mode == SEEK_END) { + base = (int64_t)r->len; + } else { + return AVERROR(EINVAL); + } + if (offset < -base || offset > INT64_MAX - base) return AVERROR(EINVAL); + int64_t next = base + offset; + if (next < 0 || (uint64_t)next > r->len) return AVERROR(EINVAL); + r->offset = (size_t)next; + return next; +} + +static int fluxer_video_codec_id_allowed(enum AVCodecID id) { + switch (id) { + case AV_CODEC_ID_GIF: + case AV_CODEC_ID_APNG: + case AV_CODEC_ID_H264: + case AV_CODEC_ID_HEVC: + case AV_CODEC_ID_VP8: + case AV_CODEC_ID_VP9: + case AV_CODEC_ID_AV1: + case AV_CODEC_ID_MPEG1VIDEO: + case AV_CODEC_ID_MPEG2VIDEO: + case AV_CODEC_ID_MPEG4: + case AV_CODEC_ID_H263: + case AV_CODEC_ID_H263P: + case AV_CODEC_ID_H263I: + case AV_CODEC_ID_THEORA: + case AV_CODEC_ID_FLV1: + case AV_CODEC_ID_VP6: + case AV_CODEC_ID_VP6F: + case AV_CODEC_ID_VP6A: + case AV_CODEC_ID_WMV1: + case AV_CODEC_ID_WMV2: + case AV_CODEC_ID_WMV3: + case AV_CODEC_ID_VC1: + case AV_CODEC_ID_MJPEG: + case AV_CODEC_ID_BMP: + return 1; + default: + return 0; + } +} + +int fluxer_prepare_untrusted_av_input(AVFormatContext *format) { + if (format == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (format->nb_streams > FLUXER_MAX_AV_STREAMS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (format->nb_streams > 0 && format->streams == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + for (unsigned int i = 0; i < format->nb_streams; i++) { + AVStream *stream = format->streams[i]; + if (stream == NULL || stream->codecpar == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + enum AVMediaType type = stream->codecpar->codec_type; + enum AVCodecID id = stream->codecpar->codec_id; + if (type == AVMEDIA_TYPE_VIDEO && !ff_stream_is_attached_picture(stream)) { + if (id != AV_CODEC_ID_NONE && !fluxer_video_codec_id_allowed(id)) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + int width = stream->codecpar->width; + int height = stream->codecpar->height; + if (width < 0 || height < 0 || + (width > 0 && height > 0 && + (width > FLUXER_MAX_VIDEO_FRAME_DIMENSION || + height > FLUXER_MAX_VIDEO_FRAME_DIMENSION || + (size_t)width > FLUXER_MAX_VIDEO_PIXELS / (size_t)height))) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + } else { + stream->discard = AVDISCARD_ALL; + } + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_deny_child_av_io(AVFormatContext *format, AVIOContext **io, + const char *url, int flags, + AVDictionary **options) { + (void)format; + (void)io; + (void)url; + (void)flags; + (void)options; + return AVERROR(EACCES); +} + +int fluxer_restrict_untrusted_av_context(AVFormatContext *format, + const char *format_whitelist) { + if (format == NULL || format_whitelist == NULL || format_whitelist[0] == '\0') { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + format->io_open = fluxer_deny_child_av_io; + format->format_whitelist = av_strdup(format_whitelist); + format->protocol_whitelist = av_strdup(""); + format->codec_whitelist = av_strdup(FLUXER_ALLOWED_VIDEO_DECODERS); + format->max_streams = FLUXER_MAX_AV_STREAMS; + if (format->format_whitelist == NULL || + format->protocol_whitelist == NULL || + format->codec_whitelist == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_find_stream_info_bounded(AVFormatContext *format) { + if (format == NULL) return AVERROR(EINVAL); + unsigned int stream_count = format->nb_streams; + if (stream_count > FLUXER_MAX_AV_STREAMS) return AVERROR(E2BIG); + if (stream_count == 0 || format->streams == NULL) return AVERROR_INVALIDDATA; + format->max_streams = stream_count; + + AVDictionary **options = av_calloc(stream_count, sizeof(*options)); + if (options == NULL) return AVERROR(ENOMEM); + int rc = 0; + for (unsigned int i = 0; i < stream_count; i++) { + const AVStream *stream = format->streams[i]; + if (stream == NULL || stream->codecpar == NULL) continue; + if (av_dict_set_int(&options[i], "max_pixels", + (int64_t)FLUXER_MAX_VIDEO_PIXELS, 0) < 0 || + av_dict_set(&options[i], "threads", "1", 0) < 0) { + rc = AVERROR(ENOMEM); + break; + } + if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO && + av_dict_set(&options[i], "codec_whitelist", + FLUXER_ALLOWED_VIDEO_DECODERS, 0) < 0) { + rc = AVERROR(ENOMEM); + break; + } + } + if (rc == 0) rc = avformat_find_stream_info(format, options); + for (unsigned int i = 0; i < stream_count; i++) av_dict_free(&options[i]); + av_free(options); + return rc; +} + +int fluxer_video_decoder_allowed(const AVStream *stream, const AVCodec *decoder) { + if (stream == NULL || stream->codecpar == NULL || decoder == NULL || + stream->codecpar->codec_id != decoder->id) { + return 0; + } + return fluxer_video_codec_id_allowed(decoder->id); +} + +int ff_validate_rgba_geometry(int width, int height, size_t *out_size) { + if (width <= 0 || height <= 0 || + width > FLUXER_MAX_VIDEO_FRAME_DIMENSION || + height > FLUXER_MAX_VIDEO_FRAME_DIMENSION) { + return -1; + } + size_t row_bytes = (size_t)width * 4; + if (row_bytes == 0 || (size_t)height > SIZE_MAX / row_bytes) return -1; + size_t rgba_size = row_bytes * (size_t)height; + if (rgba_size == 0 || rgba_size > FLUXER_MAX_VIDEO_RGBA_BYTES) return -1; + if (out_size != NULL) *out_size = rgba_size; + return 0; +} + +int ff_stream_is_attached_picture(const AVStream *stream) { + if (stream == NULL) return 0; + int attached = 0; +#ifdef AV_DISPOSITION_ATTACHED_PIC + if ((stream->disposition & AV_DISPOSITION_ATTACHED_PIC) != 0) attached = 1; +#endif +#ifdef AV_DISPOSITION_TIMED_THUMBNAILS + if ((stream->disposition & AV_DISPOSITION_TIMED_THUMBNAILS) != 0) attached = 1; +#endif +#ifdef AV_DISPOSITION_STILL_IMAGE + if ((stream->disposition & AV_DISPOSITION_STILL_IMAGE) != 0) attached = 1; +#endif + return attached; +} + +int ff_find_primary_video_stream( + AVFormatContext *format, + const AVCodec **out_codec +) { + if (format == NULL || out_codec == NULL) return -1; + *out_codec = NULL; + int best = av_find_best_stream( + format, AVMEDIA_TYPE_VIDEO, -1, -1, out_codec, 0); + AVStream *best_stream = best >= 0 ? format->streams[best] : NULL; + if (best_stream != NULL && !ff_stream_is_attached_picture(best_stream) && + best_stream->nb_frames != 1) return best; + int fallback = -1; + const AVCodec *fallback_codec = NULL; + if (best_stream != NULL && !ff_stream_is_attached_picture(best_stream)) { + fallback = best; + fallback_codec = *out_codec; + } + for (unsigned int i = 0; i < format->nb_streams; i++) { + AVStream *stream = format->streams[i]; + if (stream == NULL || stream->codecpar == NULL) continue; + if (stream->codecpar->codec_type != AVMEDIA_TYPE_VIDEO) continue; + if (ff_stream_is_attached_picture(stream)) continue; + const AVCodec *codec = avcodec_find_decoder(stream->codecpar->codec_id); + if (!fluxer_video_decoder_allowed(stream, codec)) continue; + if (stream->nb_frames > 1) { + *out_codec = codec; + return (int)i; + } + if (fallback < 0) { + fallback = (int)i; + fallback_codec = codec; + } + } + *out_codec = fallback_codec; + return fallback; +} diff --git a/fluxer_media_proxy/src/native_shim/gif_filter.c b/fluxer_media_proxy/src/native_shim/gif_filter.c new file mode 100644 index 000000000..eece88a81 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/gif_filter.c @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +static int gif_filter_create_source( + AVFilterGraph *graph, + int width, + int height, + enum AVPixelFormat pixel_format, + AVRational time_base, + AVFilterContext **out_source +) { + assert(graph != NULL); + assert(out_source != NULL); + assert(*out_source == NULL); + char arguments[512]; + int written = snprintf( + arguments, + sizeof(arguments), + "video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=1/1", + width, + height, + (int)pixel_format, + time_base.num, + time_base.den); + if (written < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if ((size_t)written >= sizeof(arguments)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + const AVFilter *filter = avfilter_get_by_name("buffer"); + if (filter == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int av_status = avfilter_graph_create_filter( + out_source, filter, "in", arguments, NULL, graph); + if (av_status < 0) return fluxer_native_status_from_av_error(av_status); + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_filter_create_sink( + AVFilterGraph *graph, + AVFilterContext **out_sink +) { + assert(graph != NULL); + assert(out_sink != NULL); + assert(*out_sink == NULL); + const AVFilter *filter = avfilter_get_by_name("buffersink"); + if (filter == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + AVFilterContext *sink = avfilter_graph_alloc_filter(graph, filter, "out"); + if (sink == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + enum AVPixelFormat pixel_formats[] = { AV_PIX_FMT_PAL8 }; + int av_status = av_opt_set_array( + sink, + "pixel_formats", + AV_OPT_SEARCH_CHILDREN, + 0, + 1, + AV_OPT_TYPE_PIXEL_FMT, + pixel_formats); + if (av_status < 0) return fluxer_native_status_from_av_error(av_status); + av_status = avfilter_init_dict(sink, NULL); + if (av_status < 0) return fluxer_native_status_from_av_error(av_status); + *out_sink = sink; + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_filter_create_endpoint( + const char *name, + AVFilterContext *filter, + AVFilterInOut **out_endpoint +) { + assert(name != NULL); + assert(filter != NULL); + assert(out_endpoint != NULL); + assert(*out_endpoint == NULL); + AVFilterInOut *endpoint = avfilter_inout_alloc(); + if (endpoint == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + endpoint->name = av_strdup(name); + if (endpoint->name == NULL) { + avfilter_inout_free(&endpoint); + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + endpoint->filter_ctx = filter; + endpoint->pad_idx = 0; + endpoint->next = NULL; + *out_endpoint = endpoint; + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_filter_connect( + AVFilterGraph *graph, + AVFilterContext *source, + AVFilterContext *sink, + int target_width, + int target_height +) { + assert(graph != NULL); + assert(source != NULL); + assert(sink != NULL); + char description[256]; + int written = snprintf( + description, + sizeof(description), + "scale=%d:%d:flags=lanczos,format=rgba," + "split[a][b];" + "[a]palettegen=reserve_transparent=1:stats_mode=single[p];" + "[b][p]paletteuse=alpha_threshold=128:dither=none:new=1", + target_width, + target_height); + if (written < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if ((size_t)written >= sizeof(description)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + AVFilterInOut *outputs = NULL; + AVFilterInOut *inputs = NULL; + int status = gif_filter_create_endpoint("in", source, &outputs); + if (status == FLUXER_NATIVE_STATUS_OK) { + status = gif_filter_create_endpoint("out", sink, &inputs); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + int av_status = avfilter_graph_parse_ptr( + graph, description, &inputs, &outputs, NULL); + if (av_status < 0) { + status = fluxer_native_status_from_av_error(av_status); + } + } + avfilter_inout_free(&outputs); + avfilter_inout_free(&inputs); + return status; +} + +int fluxer_gif_setup_filter_graph( + AVFilterGraph **out_graph, + AVFilterContext **out_source, + AVFilterContext **out_sink, + int source_width, + int source_height, + enum AVPixelFormat source_format, + AVRational frame_time_base, + int target_width, + int target_height +) { + if (out_graph == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (out_source == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (out_sink == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (source_width <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (source_height <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (target_width <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (target_height <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (frame_time_base.num <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (frame_time_base.den <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_graph = NULL; + *out_source = NULL; + *out_sink = NULL; + + AVFilterGraph *graph = avfilter_graph_alloc(); + if (graph == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + graph->nb_threads = 1; + AVFilterContext *source = NULL; + AVFilterContext *sink = NULL; + int status = gif_filter_create_source( + graph, source_width, source_height, source_format, + frame_time_base, &source); + if (status == FLUXER_NATIVE_STATUS_OK) { + status = gif_filter_create_sink(graph, &sink); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = gif_filter_connect( + graph, source, sink, target_width, target_height); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + int av_status = avfilter_graph_config(graph, NULL); + if (av_status < 0) { + status = fluxer_native_status_from_av_error(av_status); + } + } + if (status != FLUXER_NATIVE_STATUS_OK) { + avfilter_graph_free(&graph); + return status; + } + *out_graph = graph; + *out_source = source; + *out_sink = sink; + return FLUXER_NATIVE_STATUS_OK; +} diff --git a/fluxer_media_proxy/src/native_shim/gif_timing.c b/fluxer_media_proxy/src/native_shim/gif_timing.c new file mode 100644 index 000000000..de56476a1 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/gif_timing.c @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +static int skip_gif_image_block(const uint8_t *bytes, size_t len, + size_t *offset, size_t *block_count) { + if (bytes == NULL || offset == NULL || block_count == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (*offset > len || len - *offset < 9) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + uint8_t packed = bytes[*offset + 8]; + *offset += 9; + if ((packed & 0x80) != 0) { + size_t table_bytes = ((size_t)1 << ((packed & 0x07) + 1)) * 3u; + if (table_bytes > len - *offset) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *offset += table_bytes; + } + if (*offset >= len) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + uint8_t minimum_code_size = bytes[*offset]; + *offset += 1; + if (minimum_code_size < 2 || minimum_code_size > 8) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return fluxer_skip_gif_sub_blocks_checked(bytes, len, offset, block_count); +} + +int fluxer_patch_gif_frame_delays(uint8_t *bytes, size_t len, + const int *delays_cs, int n_delays) { + if (bytes == NULL || len < 14 || delays_cs == NULL || n_delays <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + size_t offset = 13; + if ((bytes[10] & 0x80) != 0) { + size_t table_bytes = ((size_t)1 << ((bytes[10] & 0x07) + 1)) * 3u; + if (table_bytes > len - offset) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + offset += table_bytes; + } + size_t pending_delay_offset = SIZE_MAX; + size_t block_count = 0; + int patched = 0; + while (offset < len) { + if (block_count >= FLUXER_MAX_GIF_STRUCTURE_BLOCKS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + block_count++; + uint8_t introducer = bytes[offset++]; + if (introducer == 0x3b) { + if (offset != len || pending_delay_offset != SIZE_MAX || patched != n_delays) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; + } + if (introducer == 0x21) { + if (offset >= len) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + uint8_t label = bytes[offset++]; + if (label == 0xf9) { + if (pending_delay_offset != SIZE_MAX || len - offset < 6) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (bytes[offset] != 4 || bytes[offset + 5] != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + pending_delay_offset = offset + 2; + offset += 6; + continue; + } + if (label == 0x01 && pending_delay_offset != SIZE_MAX) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int skip_status = fluxer_skip_gif_sub_blocks_checked( + bytes, len, &offset, &block_count); + if (skip_status != FLUXER_NATIVE_STATUS_OK) return skip_status; + continue; + } + if (introducer != 0x2c || pending_delay_offset == SIZE_MAX || patched >= n_delays) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int skip_status = skip_gif_image_block(bytes, len, &offset, &block_count); + if (skip_status != FLUXER_NATIVE_STATUS_OK) return skip_status; + int delay = delays_cs[patched]; + if (delay < 1 || delay > 65535) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + bytes[pending_delay_offset] = (uint8_t)(delay & 0xff); + bytes[pending_delay_offset + 1] = (uint8_t)((delay >> 8) & 0xff); + pending_delay_offset = SIZE_MAX; + patched++; + } + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +static int read_gif_graphics_control_delay(const uint8_t *bytes, size_t len, + size_t *offset, int *delay_cs) { + if (bytes == NULL || offset == NULL || delay_cs == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (*offset > len || len - *offset < 6) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (bytes[*offset] != 4 || bytes[*offset + 5] != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int delay = (int)fluxer_gif_read_le16(bytes + *offset + 2); + if (delay <= 0) { + delay = FLUXER_DEFAULT_ANIMATION_FRAME_DELAY_MS / 10; + } else if (delay * 10 < FLUXER_MIN_ANIMATION_FRAME_DELAY_MS) { + delay = FLUXER_MIN_ANIMATION_FRAME_DELAY_MS / 10; + } + *delay_cs = delay; + *offset += 6; + return FLUXER_NATIVE_STATUS_OK; +} + +static int read_gif_application_loop(const uint8_t *bytes, size_t len, + size_t *offset, size_t *block_count, + int *loop_count, int *has_loop) { + static const uint8_t netscape_id[] = { + 'N', 'E', 'T', 'S', 'C', 'A', 'P', 'E', '2', '.', '0' + }; + static const uint8_t animexts_id[] = { + 'A', 'N', 'I', 'M', 'E', 'X', 'T', 'S', '1', '.', '0' + }; + if (bytes == NULL || offset == NULL || block_count == NULL || + loop_count == NULL || has_loop == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (*offset >= len) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (*block_count >= FLUXER_MAX_GIF_STRUCTURE_BLOCKS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + (*block_count)++; + uint8_t application_len = bytes[(*offset)++]; + if ((size_t)application_len > len - *offset) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int recognized = application_len == sizeof(netscape_id) && + (memcmp(bytes + *offset, netscape_id, sizeof(netscape_id)) == 0 || + memcmp(bytes + *offset, animexts_id, sizeof(animexts_id)) == 0); + *offset += application_len; + if (!recognized) { + if (application_len == 0) return FLUXER_NATIVE_STATUS_OK; + return fluxer_skip_gif_sub_blocks_checked( + bytes, len, offset, block_count); + } + if (*has_loop) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (*offset >= len) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (*block_count >= FLUXER_MAX_GIF_STRUCTURE_BLOCKS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + (*block_count)++; + uint8_t value_len = bytes[(*offset)++]; + if (value_len != 3 || len - *offset < 3) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (bytes[*offset] != 1) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *loop_count = (int)fluxer_gif_read_le16(bytes + *offset + 1); + *offset += 3; + if (*offset >= len) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (*block_count >= FLUXER_MAX_GIF_STRUCTURE_BLOCKS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + (*block_count)++; + if (bytes[(*offset)++] != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *has_loop = 1; + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_read_gif_frame_delays(const uint8_t *bytes, size_t len, + int *delays_cs, int expected_frames, + int max_duration_ms, + int *out_loop_count) { + if (out_loop_count != NULL) *out_loop_count = -1; + if (bytes == NULL || len < 14 || expected_frames <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + size_t offset = 13; + if ((bytes[10] & 0x80) != 0) { + size_t table_bytes = ((size_t)1 << ((bytes[10] & 0x07) + 1)) * 3u; + if (table_bytes > len - offset) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + offset += table_bytes; + } + size_t block_count = 0; + int pending_delay_cs = 0; + int has_pending_delay = 0; + int loop_count = -1; + int has_loop = 0; + int frame_count = 0; + int64_t duration_ms = 0; + while (offset < len) { + if (block_count >= FLUXER_MAX_GIF_STRUCTURE_BLOCKS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + block_count++; + uint8_t introducer = bytes[offset++]; + if (introducer == 0x3b) { + if (offset != len || has_pending_delay || frame_count != expected_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (out_loop_count != NULL) *out_loop_count = loop_count; + return FLUXER_NATIVE_STATUS_OK; + } + if (introducer == 0x21) { + if (offset >= len) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + uint8_t label = bytes[offset++]; + if (label == 0xf9) { + if (has_pending_delay) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int status = read_gif_graphics_control_delay( + bytes, len, &offset, &pending_delay_cs); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + has_pending_delay = 1; + continue; + } + if (label == 0xff) { + int status = read_gif_application_loop( + bytes, len, &offset, &block_count, + &loop_count, &has_loop); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + continue; + } + if (label == 0x01) has_pending_delay = 0; + int status = fluxer_skip_gif_sub_blocks_checked( + bytes, len, &offset, &block_count); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + continue; + } + if (introducer != 0x2c || frame_count >= expected_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int status = skip_gif_image_block(bytes, len, &offset, &block_count); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + /* A frame with no Graphics Control Extension declares no delay, which renders at the + * 100 ms browser default rather than the fast-frame minimum. */ + int delay_cs = has_pending_delay + ? pending_delay_cs + : FLUXER_DEFAULT_ANIMATION_FRAME_DELAY_MS / 10; + if (duration_ms > INT64_MAX - (int64_t)delay_cs * 10) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + duration_ms += (int64_t)delay_cs * 10; + if (max_duration_ms > 0 && duration_ms > max_duration_ms) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (delays_cs != NULL) delays_cs[frame_count] = delay_cs; + frame_count++; + has_pending_delay = 0; + } + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +int fluxer_gif_animation_frame_budget(const int *delays_cs, int n_frames, + int max_frames, int max_duration_ms, + int *out_frames) { + if (delays_cs == NULL || n_frames <= 0 || max_frames <= 0 || + max_duration_ms <= 0 || out_frames == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out_frames = 0; + int64_t duration_ms = 0; + int frames = 0; + for (int i = 0; i < n_frames; i++) { + if (frames >= max_frames) break; + if (duration_ms >= (int64_t)max_duration_ms) break; + int delay_cs = delays_cs[i]; + if (delay_cs <= 0 || delay_cs > 65535) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (duration_ms > INT64_MAX - (int64_t)delay_cs * 10) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + duration_ms += (int64_t)delay_cs * 10; + frames++; + } + if (frames <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_frames = frames; + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_validate_gif_animation(const void *gif_data, size_t gif_len, + int max_frames, int max_duration_ms, + size_t max_total_pixels) { + if (max_duration_ms <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int expected_frames = 0; + int status = fluxer_validate_complete_gif( + gif_data, gif_len, max_frames, max_total_pixels, &expected_frames); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return fluxer_read_gif_frame_delays( + gif_data, gif_len, NULL, expected_frames, max_duration_ms, NULL); +} diff --git a/fluxer_media_proxy/src/native_shim/gif_transform.c b/fluxer_media_proxy/src/native_shim/gif_transform.c new file mode 100644 index 000000000..54c6b27cf --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/gif_transform.c @@ -0,0 +1,990 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +#define FLUXER_GIF_AVIO_BUFFER_SIZE (64u * 1024u) +#define FLUXER_GIF_PROBE_SIZE (5u * 1024u * 1024u) +#define FLUXER_GIF_ANALYZE_DURATION (5 * AV_TIME_BASE) + +struct gif_output_buffer { + uint8_t *data; + size_t len; + size_t position; + size_t capacity; + size_t max_size; + int error; +}; + +struct gif_resize_request { + const uint8_t *data; + size_t len; + int decoder_threads; + int target_width; + int target_height; + long long deadline_monotonic_ms; + int max_source_frames; + int max_encode_frames; + int max_encode_duration_ms; + size_t max_total_pixels; + size_t max_output_size; +}; + +struct gif_resize_result { + void *data; + size_t size; + size_t capacity; +}; + +struct gif_resize_context { + struct gif_resize_request request; + struct ff_mem_reader reader; + unsigned char *input_avio_buffer; + AVIOContext *input_avio; + AVFormatContext *input_format; + AVCodecContext *decoder; + int stream_index; + unsigned char *output_avio_buffer; + AVIOContext *output_avio; + AVFormatContext *output_format; + AVCodecContext *encoder; + AVStream *output_stream; + AVFilterGraph *filter_graph; + AVFilterContext *filter_source; + AVFilterContext *filter_sink; + AVPacket *input_packet; + AVPacket *encoded_packet; + AVFrame *decoded_frame; + AVFrame *scaled_frame; + struct gif_output_buffer output; + int *frame_delays_cs; + int expected_frames; + int encode_frames; + int input_packet_limit; + int input_packets; + int decode_complete; + int decoded_frames; + int frames_written; + int packets_written; + int loop_count; + int64_t next_pts; +}; + +static int gif_output_reject(struct gif_output_buffer *output, int error) { + if (output->error == 0) output->error = error; + return output->error; +} + +static int gif_output_reserve(struct gif_output_buffer *output, size_t required) { + if (required <= output->capacity) return 0; + if (required > output->max_size) return gif_output_reject(output, AVERROR(ENOSPC)); + + size_t next_capacity = output->capacity > 0 ? output->capacity : required; + while (next_capacity < required) { + if (next_capacity > output->max_size / 2u) { + next_capacity = output->max_size; + } else { + next_capacity *= 2u; + } + } + if (next_capacity < required || next_capacity > output->max_size) { + return gif_output_reject(output, AVERROR(ENOSPC)); + } + + uint8_t *next = av_realloc(output->data, next_capacity); + if (next == NULL) return gif_output_reject(output, AVERROR(ENOMEM)); + output->data = next; + output->capacity = next_capacity; + return 0; +} + +static int gif_output_write(void *opaque, const uint8_t *bytes, int byte_count) { + if (opaque == NULL) return AVERROR(EINVAL); + struct gif_output_buffer *output = opaque; + if (output->error != 0) return output->error; + if (byte_count < 0 || (byte_count > 0 && bytes == NULL)) { + return gif_output_reject(output, AVERROR(EINVAL)); + } + if (byte_count == 0) return 0; + + size_t incoming = (size_t)byte_count; + if (output->position > output->max_size || + incoming > output->max_size - output->position) { + return gif_output_reject(output, AVERROR(ENOSPC)); + } + size_t required = output->position + incoming; + int reserve_rc = gif_output_reserve(output, required); + if (reserve_rc != 0) return reserve_rc; + if (output->position > output->len) { + memset(output->data + output->len, 0, output->position - output->len); + } + memcpy(output->data + output->position, bytes, incoming); + output->position = required; + if (required > output->len) output->len = required; + return byte_count; +} + +static int64_t gif_output_seek(void *opaque, int64_t offset, int whence) { + if (opaque == NULL) return AVERROR(EINVAL); + struct gif_output_buffer *output = opaque; + if (output->error != 0) return output->error; + if (whence == AVSEEK_SIZE) { + if (output->len > (size_t)INT64_MAX) { + return gif_output_reject(output, AVERROR(EOVERFLOW)); + } + return (int64_t)output->len; + } + + int mode = whence & ~AVSEEK_FORCE; + size_t base_size = 0; + if (mode == SEEK_SET) { + base_size = 0; + } else if (mode == SEEK_CUR) { + base_size = output->position; + } else if (mode == SEEK_END) { + base_size = output->len; + } else { + return gif_output_reject(output, AVERROR(EINVAL)); + } + if (base_size > (size_t)INT64_MAX) { + return gif_output_reject(output, AVERROR(EOVERFLOW)); + } + int64_t base = (int64_t)base_size; + if (offset < -base || offset > INT64_MAX - base) { + return gif_output_reject(output, AVERROR(EINVAL)); + } + int64_t next = base + offset; + if (next < 0 || (uint64_t)next > output->max_size) { + return gif_output_reject(output, AVERROR(ENOSPC)); + } + output->position = (size_t)next; + return next; +} + +static int gif_output_flush(AVIOContext *avio, struct gif_output_buffer *output) { + if (avio == NULL || output == NULL) return AVERROR(EINVAL); + avio_flush(avio); + if (output->error != 0) return output->error; + if (avio->error < 0) return avio->error; + return 0; +} + +static int write_encoded_gif_packets(AVFormatContext *out_fmt, AVCodecContext *enc_ctx, + AVStream *out_stream, AVPacket *packet, AVFrame *frame, + struct gif_output_buffer *output, int max_packets, + int *packets_written, + long long deadline_monotonic_ms) { + if (out_fmt == NULL || enc_ctx == NULL || out_stream == NULL || + packet == NULL || output == NULL || max_packets <= 0 || + packets_written == NULL || *packets_written < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int rc = avcodec_send_frame(enc_ctx, frame); + if (rc < 0) return fluxer_native_status_from_av_error(rc); + while (1) { + deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + av_packet_unref(packet); + return deadline_status; + } + rc = avcodec_receive_packet(enc_ctx, packet); + if (rc == AVERROR(EAGAIN) || rc == AVERROR_EOF) { + av_packet_unref(packet); + return FLUXER_NATIVE_STATUS_OK; + } + if (rc < 0) { + av_packet_unref(packet); + return fluxer_native_status_from_av_error(rc); + } + if (*packets_written >= max_packets) { + av_packet_unref(packet); + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + av_packet_rescale_ts(packet, enc_ctx->time_base, out_stream->time_base); + packet->stream_index = out_stream->index; + rc = av_interleaved_write_frame(out_fmt, packet); + av_packet_unref(packet); + if (rc < 0) return fluxer_native_status_from_av_error(rc); + rc = gif_output_flush(out_fmt->pb, output); + if (rc < 0) return fluxer_native_status_from_av_error(rc); + (*packets_written)++; + } +} + +static int validate_gif_resize_request( + const struct gif_resize_request *request, + int *out_expected_frames +) { + assert(request != NULL); + assert(out_expected_frames != NULL); + *out_expected_frames = 0; + if (request->data == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->len == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->len > (size_t)INT64_MAX) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (request->target_width <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (request->target_height <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (!fluxer_ffmpeg_decoder_threads_valid(request->decoder_threads)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->max_source_frames <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->max_encode_frames <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->max_encode_duration_ms <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->max_total_pixels == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->max_output_size == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + + int expected_frames = 0; + int status = fluxer_validate_complete_gif( + request->data, request->len, request->max_source_frames, + request->max_total_pixels, &expected_frames); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = fluxer_native_deadline_status(request->deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if ((size_t)expected_frames > SIZE_MAX / sizeof(int)) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (request->target_width > FLUXER_MAX_VIDEO_FRAME_DIMENSION) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (request->target_height > FLUXER_MAX_VIDEO_FRAME_DIMENSION) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + size_t target_height = (size_t)request->target_height; + if ((size_t)request->target_width > request->max_total_pixels / target_height) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + size_t target_pixels = (size_t)request->target_width * target_height; + if ((size_t)expected_frames > request->max_total_pixels / target_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (expected_frames > INT_MAX / FLUXER_ANIMATION_PACKETS_PER_FRAME_LIMIT) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + *out_expected_frames = expected_frames; + return FLUXER_NATIVE_STATUS_OK; +} + +static void gif_resize_context_init( + struct gif_resize_context *context, + const struct gif_resize_request *request, + int expected_frames +) { + assert(context != NULL); + assert(request != NULL); + assert(expected_frames > 0); + assert(request->max_output_size > 0); + memset(context, 0, sizeof(*context)); + context->request = *request; + context->reader.data = request->data; + context->reader.len = request->len; + context->stream_index = -1; + context->output.max_size = request->max_output_size; + context->expected_frames = expected_frames; + context->loop_count = -1; + context->input_packet_limit = + expected_frames * FLUXER_ANIMATION_PACKETS_PER_FRAME_LIMIT; +} + +static int gif_resize_load_frame_delays(struct gif_resize_context *context) { + assert(context != NULL); + assert(context->frame_delays_cs == NULL); + assert(context->expected_frames > 0); + size_t delays_size = (size_t)context->expected_frames * sizeof(int); + context->frame_delays_cs = malloc(delays_size); + if (context->frame_delays_cs == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + int status = fluxer_read_gif_frame_delays( + context->request.data, + context->request.len, + context->frame_delays_cs, + context->expected_frames, + 0, + &context->loop_count); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return fluxer_gif_animation_frame_budget( + context->frame_delays_cs, + context->expected_frames, + context->request.max_encode_frames, + context->request.max_encode_duration_ms, + &context->encode_frames); +} + +static int gif_resize_open_input(struct gif_resize_context *context) { + assert(context != NULL); + assert(context->input_avio == NULL); + assert(context->input_format == NULL); + context->input_avio_buffer = av_malloc(FLUXER_GIF_AVIO_BUFFER_SIZE); + if (context->input_avio_buffer == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + context->input_avio = avio_alloc_context( + context->input_avio_buffer, + (int)FLUXER_GIF_AVIO_BUFFER_SIZE, + 0, + &context->reader, + ff_mem_read_packet, + NULL, + ff_mem_seek); + if (context->input_avio == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + context->input_avio_buffer = NULL; + context->input_format = avformat_alloc_context(); + if (context->input_format == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + int status = fluxer_restrict_untrusted_av_context(context->input_format, "gif"); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->input_format->pb = context->input_avio; + context->input_format->interrupt_callback.callback = + fluxer_ffmpeg_interrupt_deadline; + context->input_format->interrupt_callback.opaque = + &context->request.deadline_monotonic_ms; + context->input_format->flags |= AVFMT_FLAG_CUSTOM_IO; + context->input_format->probesize = FLUXER_GIF_PROBE_SIZE; + context->input_format->max_analyze_duration = FLUXER_GIF_ANALYZE_DURATION; + context->input_format->error_recognition = AV_EF_EXPLODE; + + int av_status = avformat_open_input(&context->input_format, NULL, NULL, NULL); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + status = fluxer_prepare_untrusted_av_input(context->input_format); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + av_status = fluxer_find_stream_info_bounded(context->input_format); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + return fluxer_prepare_untrusted_av_input(context->input_format); +} + +static int gif_resize_open_decoder(struct gif_resize_context *context) { + assert(context != NULL); + assert(context->input_format != NULL); + assert(context->decoder == NULL); + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + const AVCodec *decoder = NULL; + int stream_index = av_find_best_stream( + context->input_format, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0); + if (stream_index < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if ((unsigned int)stream_index >= context->input_format->nb_streams) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + AVStream *stream = context->input_format->streams[stream_index]; + if (stream == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (stream->codecpar == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (decoder == NULL) { + decoder = avcodec_find_decoder(stream->codecpar->codec_id); + } + if (decoder == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!fluxer_video_decoder_allowed(stream, decoder)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + context->decoder = avcodec_alloc_context3(decoder); + if (context->decoder == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + int av_status = avcodec_parameters_to_context(context->decoder, stream->codecpar); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + context->decoder->max_pixels = (int64_t)FLUXER_MAX_VIDEO_PIXELS; + context->decoder->thread_count = context->request.decoder_threads; + context->decoder->err_recognition = + AV_EF_CRCCHECK | AV_EF_BITSTREAM | AV_EF_BUFFER | AV_EF_EXPLODE; + av_status = avcodec_open2(context->decoder, decoder, NULL); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + context->stream_index = stream_index; + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_resize_open_output_io(struct gif_resize_context *context) { + assert(context != NULL); + assert(context->output_format == NULL); + assert(context->output_avio == NULL); + int av_status = avformat_alloc_output_context2( + &context->output_format, NULL, "gif", NULL); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + if (context->output_format == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + + size_t buffer_size = FLUXER_GIF_AVIO_BUFFER_SIZE; + if (context->request.max_output_size < buffer_size) { + buffer_size = context->request.max_output_size; + } + context->output_avio_buffer = av_malloc(buffer_size); + if (context->output_avio_buffer == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + context->output_avio = avio_alloc_context( + context->output_avio_buffer, + (int)buffer_size, + 1, + &context->output, + NULL, + gif_output_write, + gif_output_seek); + if (context->output_avio == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + context->output_avio_buffer = NULL; + context->output_format->pb = context->output_avio; + context->output_format->flags |= AVFMT_FLAG_CUSTOM_IO; + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_resize_open_encoder(struct gif_resize_context *context) { + assert(context != NULL); + assert(context->output_format != NULL); + assert(context->encoder == NULL); + const AVCodec *encoder = avcodec_find_encoder(AV_CODEC_ID_GIF); + if (encoder == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + context->output_stream = avformat_new_stream(context->output_format, NULL); + if (context->output_stream == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + assert(context->output_stream->codecpar != NULL); + context->encoder = avcodec_alloc_context3(encoder); + if (context->encoder == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + context->encoder->thread_count = 1; + context->encoder->width = context->request.target_width; + context->encoder->height = context->request.target_height; + context->encoder->pix_fmt = AV_PIX_FMT_PAL8; + context->encoder->time_base = (AVRational){ 1, 100 }; + context->encoder->framerate = (AVRational){ 100, 1 }; + if (context->output_format->oformat != NULL) { + if ((context->output_format->oformat->flags & AVFMT_GLOBALHEADER) != 0) { + context->encoder->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; + } + } + + AVDictionary *options = NULL; + int av_status = av_dict_set(&options, "gifflags", "-offsetting", 0); + if (av_status >= 0) { + av_status = avcodec_open2(context->encoder, encoder, &options); + } + av_dict_free(&options); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + av_status = avcodec_parameters_from_context( + context->output_stream->codecpar, context->encoder); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + context->output_stream->time_base = context->encoder->time_base; + if (context->loop_count < -1 || context->loop_count > UINT16_MAX) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + AVDictionary *mux_options = NULL; + av_status = av_dict_set_int( + &mux_options, "loop", context->loop_count, 0); + if (av_status >= 0) { + av_status = avformat_write_header( + context->output_format, &mux_options); + } + if (av_status >= 0 && av_dict_count(mux_options) != 0) { + av_status = AVERROR(EINVAL); + } + av_dict_free(&mux_options); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + av_status = gif_output_flush(context->output_avio, &context->output); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_resize_open_pipeline(struct gif_resize_context *context) { + assert(context != NULL); + assert(context->decoder != NULL); + assert(context->encoder != NULL); + int status = fluxer_gif_setup_filter_graph( + &context->filter_graph, + &context->filter_source, + &context->filter_sink, + context->decoder->width, + context->decoder->height, + context->decoder->pix_fmt, + context->encoder->time_base, + context->request.target_width, + context->request.target_height); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + + context->input_packet = av_packet_alloc(); + context->encoded_packet = av_packet_alloc(); + context->decoded_frame = av_frame_alloc(); + context->scaled_frame = av_frame_alloc(); + if (context->input_packet == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (context->encoded_packet == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (context->decoded_frame == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (context->scaled_frame == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_resize_drain_filter( + struct gif_resize_context *context, + int require_eof +) { + assert(context != NULL); + assert(context->filter_sink != NULL); + assert(context->scaled_frame != NULL); + assert(context->output_format != NULL); + assert(context->encoder != NULL); + assert(context->output_stream != NULL); + assert(context->encoded_packet != NULL); + assert(context->encode_frames > 0); + assert(context->frames_written >= 0); + assert(context->packets_written >= 0); + assert(require_eof >= 0); + assert(require_eof <= 1); + while (1) { + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int sink_status = av_buffersink_get_frame( + context->filter_sink, context->scaled_frame); + if (sink_status == AVERROR_EOF) return FLUXER_NATIVE_STATUS_OK; + if (sink_status == AVERROR(EAGAIN)) { + if (require_eof != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return FLUXER_NATIVE_STATUS_OK; + } + if (sink_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + sink_status, context->request.deadline_monotonic_ms); + } + if (context->frames_written >= context->encode_frames) { + av_frame_unref(context->scaled_frame); + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + int status = write_encoded_gif_packets( + context->output_format, + context->encoder, + context->output_stream, + context->encoded_packet, + context->scaled_frame, + &context->output, + context->encode_frames, + &context->packets_written, + context->request.deadline_monotonic_ms); + av_frame_unref(context->scaled_frame); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->frames_written++; + } +} + +static int gif_resize_submit_decoded_frame( + struct gif_resize_context *context +) { + assert(context != NULL); + assert(context->decoded_frame != NULL); + assert(context->frame_delays_cs != NULL); + assert(context->filter_source != NULL); + if (context->decoded_frame->decode_error_flags != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (context->decoded_frames >= context->encode_frames) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + int64_t duration_cs = context->frame_delays_cs[context->decoded_frames]; + if (duration_cs <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (context->next_pts > INT64_MAX - duration_cs) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + context->decoded_frame->pts = context->next_pts; + context->decoded_frame->duration = duration_cs; + context->next_pts += duration_cs; + context->decoded_frames++; + + int av_status = av_buffersrc_add_frame_flags( + context->filter_source, context->decoded_frame, 0); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + av_frame_unref(context->decoded_frame); + return gif_resize_drain_filter(context, 0); +} + +static int gif_resize_receive_frames( + struct gif_resize_context *context, + int flushing +) { + assert(context != NULL); + assert(context->decoder != NULL); + assert(context->decoded_frame != NULL); + assert(flushing >= 0); + assert(flushing <= 1); + while (1) { + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (context->decoded_frames >= context->encode_frames) { + context->decode_complete = 1; + return FLUXER_NATIVE_STATUS_OK; + } + int receive_status = avcodec_receive_frame( + context->decoder, context->decoded_frame); + if (receive_status == AVERROR_EOF) return FLUXER_NATIVE_STATUS_OK; + if (receive_status == AVERROR(EAGAIN)) { + if (flushing != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return FLUXER_NATIVE_STATUS_OK; + } + if (receive_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + receive_status, context->request.deadline_monotonic_ms); + } + int status = gif_resize_submit_decoded_frame(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } +} + +static int gif_resize_decode_packets(struct gif_resize_context *context) { + assert(context != NULL); + assert(context->input_format != NULL); + assert(context->input_packet != NULL); + assert(context->decoder != NULL); + int read_status = 0; + while (context->decode_complete == 0 && + (read_status = av_read_frame( + context->input_format, context->input_packet)) >= 0) { + context->input_packets++; + if (context->input_packets > context->input_packet_limit) { + av_packet_unref(context->input_packet); + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (fluxer_native_deadline_status( + context->request.deadline_monotonic_ms) != + FLUXER_NATIVE_STATUS_OK) { + av_packet_unref(context->input_packet); + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + if (context->input_packet->stream_index != context->stream_index) { + av_packet_unref(context->input_packet); + continue; + } + int send_status = avcodec_send_packet( + context->decoder, context->input_packet); + av_packet_unref(context->input_packet); + if (send_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + send_status, context->request.deadline_monotonic_ms); + } + int status = gif_resize_receive_frames(context, 0); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } + if (context->decode_complete != 0) return FLUXER_NATIVE_STATUS_OK; + if (read_status != AVERROR_EOF) { + return fluxer_native_status_from_av_error_with_deadline( + read_status, context->request.deadline_monotonic_ms); + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_resize_flush_pipeline(struct gif_resize_context *context) { + assert(context != NULL); + assert(context->decoder != NULL); + assert(context->filter_source != NULL); + assert(context->output_format != NULL); + assert(context->output_avio != NULL); + int av_status = avcodec_send_packet(context->decoder, NULL); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + int status = gif_resize_receive_frames(context, 1); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (context->next_pts <= 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + + av_status = av_buffersrc_add_frame_flags(context->filter_source, NULL, 0); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + status = gif_resize_drain_filter(context, 1); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = write_encoded_gif_packets( + context->output_format, + context->encoder, + context->output_stream, + context->encoded_packet, + NULL, + &context->output, + context->encode_frames, + &context->packets_written, + context->request.deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + + av_status = av_write_trailer(context->output_format); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + av_status = gif_output_flush(context->output_avio, &context->output); + if (av_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + av_status, context->request.deadline_monotonic_ms); + } + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (context->decoded_frames != context->encode_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (context->frames_written != context->encode_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (context->packets_written != context->encode_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_resize_take_output( + struct gif_resize_context *context, + struct gif_resize_result *result +) { + assert(context != NULL); + assert(result != NULL); + assert(result->data == NULL); + assert(result->size == 0); + assert(result->capacity == 0); + int deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (context->output.len == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (context->output.data == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + assert(context->output.len <= context->output.capacity); + assert(context->output.capacity <= context->output.max_size); + + int output_frames = 0; + int status = fluxer_validate_complete_gif( + context->output.data, + context->output.len, + context->encode_frames, + context->request.max_total_pixels, + &output_frames); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (output_frames != context->encode_frames) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + status = fluxer_patch_gif_frame_delays( + context->output.data, + context->output.len, + context->frame_delays_cs, + context->encode_frames); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int output_loop_count = -2; + status = fluxer_read_gif_frame_delays( + context->output.data, + context->output.len, + NULL, + context->encode_frames, + 0, + &output_loop_count); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (output_loop_count != context->loop_count) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + deadline_status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + + result->data = context->output.data; + result->size = context->output.len; + result->capacity = context->output.capacity; + context->output.data = NULL; + context->output.len = 0; + context->output.position = 0; + context->output.capacity = 0; + return FLUXER_NATIVE_STATUS_OK; +} + +static int gif_resize_execute( + struct gif_resize_context *context, + struct gif_resize_result *result +) { + assert(context != NULL); + assert(result != NULL); + int status = fluxer_native_deadline_status( + context->request.deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = gif_resize_load_frame_delays(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = gif_resize_open_input(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = gif_resize_open_decoder(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = gif_resize_open_output_io(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = gif_resize_open_encoder(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = gif_resize_open_pipeline(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = gif_resize_decode_packets(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = gif_resize_flush_pipeline(context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return gif_resize_take_output(context, result); +} + +static int gif_resize_resolve_output_error( + const struct gif_resize_context *context, + int status +) { + assert(context != NULL); + if (status == FLUXER_NATIVE_STATUS_OK) return status; + if (context->output.error >= 0) return status; + int output_status = fluxer_native_status_from_av_error_with_deadline( + context->output.error, context->request.deadline_monotonic_ms); + if (output_status == FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED) { + return output_status; + } + if (output_status == FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED) { + return output_status; + } + if (output_status == FLUXER_NATIVE_STATUS_ALLOCATION_FAILED) { + return output_status; + } + return status; +} + +static void gif_resize_context_clear(struct gif_resize_context *context) { + assert(context != NULL); + if (context->output_avio != NULL) { + if (context->output_format != NULL) { + context->output_format->pb = NULL; + } + av_freep(&context->output_avio->buffer); + avio_context_free(&context->output_avio); + } + if (context->output_avio_buffer != NULL) { + av_free(context->output_avio_buffer); + } + if (context->output.data != NULL) av_free(context->output.data); + if (context->scaled_frame != NULL) av_frame_free(&context->scaled_frame); + if (context->decoded_frame != NULL) av_frame_free(&context->decoded_frame); + if (context->encoded_packet != NULL) av_packet_free(&context->encoded_packet); + if (context->input_packet != NULL) av_packet_free(&context->input_packet); + if (context->filter_graph != NULL) avfilter_graph_free(&context->filter_graph); + if (context->frame_delays_cs != NULL) free(context->frame_delays_cs); + if (context->encoder != NULL) avcodec_free_context(&context->encoder); + if (context->output_format != NULL) { + avformat_free_context(context->output_format); + } + if (context->decoder != NULL) avcodec_free_context(&context->decoder); + if (context->input_format != NULL) { + avformat_close_input(&context->input_format); + } + if (context->input_avio != NULL) { + if (context->input_avio->buffer != NULL) { + av_freep(&context->input_avio->buffer); + } + avio_context_free(&context->input_avio); + } + if (context->input_avio_buffer != NULL) { + av_free(context->input_avio_buffer); + } +} + +int fluxer_ffmpeg_resize_gif( + const void *gif_data, + size_t gif_len, + int decoder_threads, + int target_width, + int target_height, + long long deadline_monotonic_ms, + int max_source_frames, + int max_encode_frames, + int max_encode_duration_ms, + size_t max_total_pixels, + size_t max_output_size, + void **out_buf, + size_t *out_size, + size_t *out_capacity +) { + if (out_buf != NULL) *out_buf = NULL; + if (out_size != NULL) *out_size = 0; + if (out_capacity != NULL) *out_capacity = 0; + if (out_buf == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (out_size == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (out_capacity == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + + struct gif_resize_request request = { + .data = gif_data, + .len = gif_len, + .decoder_threads = decoder_threads, + .target_width = target_width, + .target_height = target_height, + .deadline_monotonic_ms = deadline_monotonic_ms, + .max_source_frames = max_source_frames, + .max_encode_frames = max_encode_frames, + .max_encode_duration_ms = max_encode_duration_ms, + .max_total_pixels = max_total_pixels, + .max_output_size = max_output_size, + }; + int expected_frames = 0; + int status = validate_gif_resize_request(&request, &expected_frames); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + + struct gif_resize_context context; + gif_resize_context_init(&context, &request, expected_frames); + struct gif_resize_result result = {0}; + status = gif_resize_execute(&context, &result); + status = gif_resize_resolve_output_error(&context, status); + gif_resize_context_clear(&context); + if (status != FLUXER_NATIVE_STATUS_OK) { + assert(result.data == NULL); + return status; + } + + assert(result.data != NULL); + assert(result.size > 0); + assert(result.size <= result.capacity); + *out_buf = result.data; + *out_size = result.size; + *out_capacity = result.capacity; + return FLUXER_NATIVE_STATUS_OK; +} diff --git a/fluxer_media_proxy/src/native_shim/gif_validation.c b/fluxer_media_proxy/src/native_shim/gif_validation.c new file mode 100644 index 000000000..614e154c0 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/gif_validation.c @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +uint16_t fluxer_gif_read_le16(const uint8_t *value) { + assert(value != NULL); + return (uint16_t)((uint16_t)value[0] | ((uint16_t)value[1] << 8)); +} + +int fluxer_skip_gif_sub_blocks_checked(const uint8_t *bytes, size_t len, + size_t *offset, size_t *block_count) { + if (bytes == NULL || offset == NULL || block_count == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + while (*offset < len) { + if (*block_count >= FLUXER_MAX_GIF_STRUCTURE_BLOCKS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + (*block_count)++; + uint8_t block_len = bytes[*offset]; + *offset += 1; + if (block_len == 0) return FLUXER_NATIVE_STATUS_OK; + if ((size_t)block_len > len - *offset) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *offset += block_len; + } + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +struct fluxer_gif_validation_context { + const uint8_t *bytes; + size_t len; + size_t offset; + size_t image_count; + size_t block_count; + size_t canvas_pixels; + size_t max_total_pixels; + uint16_t canvas_width; + uint16_t canvas_height; + int max_frames; +}; + +static int fluxer_gif_skip_color_table( + struct fluxer_gif_validation_context *context, + uint8_t packed +) { + assert(context != NULL); + if ((packed & 0x80) == 0) return FLUXER_NATIVE_STATUS_OK; + size_t table_bytes = ((size_t)1 << ((packed & 0x07) + 1)) * 3u; + if (table_bytes > context->len - context->offset) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + context->offset += table_bytes; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_gif_validation_context_init( + struct fluxer_gif_validation_context *context, + const uint8_t *bytes, + size_t len, + int max_frames, + size_t max_total_pixels +) { + assert(context != NULL); + if (bytes == NULL || len < 14 || max_frames <= 0 || + max_total_pixels == 0 || + (memcmp(bytes, "GIF87a", 6) != 0 && + memcmp(bytes, "GIF89a", 6) != 0)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + uint16_t canvas_width = fluxer_gif_read_le16(bytes + 6); + uint16_t canvas_height = fluxer_gif_read_le16(bytes + 8); + if (canvas_width == 0 || canvas_height == 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t canvas_pixels = (size_t)canvas_width * (size_t)canvas_height; + if (canvas_pixels > max_total_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + *context = (struct fluxer_gif_validation_context) { + .bytes = bytes, + .len = len, + .offset = 13, + .canvas_pixels = canvas_pixels, + .max_total_pixels = max_total_pixels, + .canvas_width = canvas_width, + .canvas_height = canvas_height, + .max_frames = max_frames, + }; + return fluxer_gif_skip_color_table(context, bytes[10]); +} + +static int fluxer_gif_validate_image_block( + struct fluxer_gif_validation_context *context +) { + assert(context != NULL); + if (context->len - context->offset < 9) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + const uint8_t *descriptor = context->bytes + context->offset; + uint16_t left = fluxer_gif_read_le16(descriptor); + uint16_t top = fluxer_gif_read_le16(descriptor + 2); + uint16_t width = fluxer_gif_read_le16(descriptor + 4); + uint16_t height = fluxer_gif_read_le16(descriptor + 6); + uint8_t packed = descriptor[8]; + context->offset += 9; + if (width == 0 || height == 0 || + (uint32_t)left + (uint32_t)width > context->canvas_width || + (uint32_t)top + (uint32_t)height > context->canvas_height) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int status = fluxer_gif_skip_color_table(context, packed); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (context->offset >= context->len) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + uint8_t minimum_code_size = context->bytes[context->offset++]; + if (minimum_code_size < 2 || minimum_code_size > 8) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + status = fluxer_skip_gif_sub_blocks_checked( + context->bytes, context->len, &context->offset, + &context->block_count); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->image_count++; + if (context->image_count > (size_t)context->max_frames || + context->image_count > + context->max_total_pixels / context->canvas_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_validate_complete_gif(const uint8_t *bytes, size_t len, + int max_frames, size_t max_total_pixels, + int *out_expected_frames) { + if (out_expected_frames == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_expected_frames = 0; + struct fluxer_gif_validation_context context; + int status = fluxer_gif_validation_context_init( + &context, bytes, len, max_frames, max_total_pixels); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + while (context.offset < context.len) { + if (context.block_count >= FLUXER_MAX_GIF_STRUCTURE_BLOCKS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + context.block_count++; + uint8_t introducer = context.bytes[context.offset++]; + if (introducer == 0x3b) { + if (context.image_count == 0 || context.offset != context.len || + context.image_count > (size_t)INT_MAX) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out_expected_frames = (int)context.image_count; + return FLUXER_NATIVE_STATUS_OK; + } + if (introducer == 0x21) { + if (context.offset >= context.len) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + context.offset++; + status = fluxer_skip_gif_sub_blocks_checked( + context.bytes, context.len, &context.offset, + &context.block_count); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + continue; + } + if (introducer != 0x2c) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + status = fluxer_gif_validate_image_block(&context); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} diff --git a/fluxer_media_proxy/src/native_shim/heif_decode.c b/fluxer_media_proxy/src/native_shim/heif_decode.c new file mode 100644 index 000000000..da931c25c --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/heif_decode.c @@ -0,0 +1,731 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +static void fluxer_clamp_heif_u64(uint64_t *value, uint64_t limit) { + if (*value == 0 || *value > limit) *value = limit; +} + +static void fluxer_clamp_heif_u32(uint32_t *value, uint32_t limit) { + if (*value == 0 || *value > limit) *value = limit; +} + +static int fluxer_set_heif_security_limits(struct heif_context *ctx, + size_t max_total_pixels) { + if (ctx == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + struct heif_security_limits *limits = heif_context_get_security_limits(ctx); + if (limits == NULL || limits->version < 1) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + uint64_t pixel_limit = max_total_pixels > 0 + ? (uint64_t)max_total_pixels + : (uint64_t)FLUXER_MAX_VIDEO_PIXELS; + fluxer_clamp_heif_u64(&limits->max_image_size_pixels, pixel_limit); + fluxer_clamp_heif_u64(&limits->max_number_of_tiles, 4096); + fluxer_clamp_heif_u32(&limits->max_bayer_pattern_pixels, 4096); + fluxer_clamp_heif_u32(&limits->max_items, 4096); + fluxer_clamp_heif_u32(&limits->max_color_profile_size, 4u * 1024u * 1024u); + fluxer_clamp_heif_u64(&limits->max_memory_block_size, 512u * 1024u * 1024u); + fluxer_clamp_heif_u32(&limits->max_components, 64); + fluxer_clamp_heif_u32(&limits->max_iloc_extents_per_item, 4096); + fluxer_clamp_heif_u32(&limits->max_size_entity_group, 4096); + fluxer_clamp_heif_u32(&limits->max_children_per_box, 65536); + return FLUXER_NATIVE_STATUS_OK; +} + +#define FLUXER_BMFF_MAX_DEPTH 32u +#define FLUXER_BMFF_MAX_BOXES 65536u +#define FLUXER_BMFF_MAX_IINF_ENTRIES 16384u +#define FLUXER_BMFF_MAX_ILOC_ITEMS 4096u +#define FLUXER_BMFF_MAX_ILOC_EXTENTS_PER_ITEM 4096u +#define FLUXER_BMFF_MAX_ILOC_EXTENTS_TOTAL 65536u + +static uint32_t bmff_read_u32(const uint8_t *p) { + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | + ((uint32_t)p[2] << 8) | (uint32_t)p[3]; +} +static uint16_t bmff_read_u16(const uint8_t *p) { + return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]); +} +static uint64_t bmff_read_u64(const uint8_t *p) { + return ((uint64_t)bmff_read_u32(p) << 32) | (uint64_t)bmff_read_u32(p + 4); +} + +typedef struct { + size_t next; + size_t end; + uint8_t type[4]; + size_t payload_start; +} bmff_frame; + +typedef struct { + uint8_t type[4]; + size_t payload_start; + size_t payload_end; +} bmff_box; + +typedef struct { + const uint8_t *data; + size_t data_len; + bmff_frame frames[FLUXER_BMFF_MAX_DEPTH]; + size_t depth; + size_t *box_count; + uint8_t parent_type[4]; + size_t parent_payload_start; +} bmff_cursor; + +typedef enum { + BMFF_ITEM_BOX_OTHER = 0, + BMFF_ITEM_BOX_IINF, + BMFF_ITEM_BOX_ILOC, + BMFF_ITEM_BOX_IDAT, +} bmff_item_box_kind; +static int bmff_type_is(const uint8_t type[4], const char *expected) { + return memcmp(type, expected, 4) == 0; +} + +static int bmff_container_kind(const uint8_t type[4]) { + if (bmff_type_is(type, "meta")) return 2; + if (bmff_type_is(type, "moov") || bmff_type_is(type, "trak") || + bmff_type_is(type, "mdia") || bmff_type_is(type, "minf") || + bmff_type_is(type, "stbl") || bmff_type_is(type, "edts") || + bmff_type_is(type, "dinf")) { + return 1; + } + return 0; +} + +static int bmff_parse_box(const uint8_t *data, size_t data_len, + size_t start, size_t end, bmff_box *out) { + if (data == NULL || out == NULL || start > end || end > data_len) return -1; + size_t remaining = end - start; + if (remaining < 8) return -1; + + uint32_t size32 = bmff_read_u32(data + start); + uint64_t box_size = size32; + size_t header_len = 8; + if (size32 == 1) { + if (remaining < 16) return -1; + box_size = bmff_read_u64(data + start + 8); + header_len = 16; + } else if (size32 == 0) { + box_size = (uint64_t)remaining; + } + if (box_size < (uint64_t)header_len || box_size > (uint64_t)remaining) return -1; + + size_t parsed_size = (size_t)box_size; + memcpy(out->type, data + start + 4, sizeof(out->type)); + out->payload_start = start + header_len; + out->payload_end = start + parsed_size; + return 0; +} +static int bmff_cursor_init(bmff_cursor *cursor, const uint8_t *data, + size_t data_len, size_t start, size_t end, + size_t *box_count) { + if (cursor == NULL || data == NULL || box_count == NULL) return -1; + if (start > end || end > data_len) return -1; + + memset(cursor, 0, sizeof(*cursor)); + cursor->data = data; + cursor->data_len = data_len; + cursor->frames[0].next = start; + cursor->frames[0].end = end; + cursor->depth = 1; + cursor->box_count = box_count; + return 0; +} + +static int bmff_cursor_parent_is(const bmff_cursor *cursor, + const char *expected) { + if (cursor == NULL) return 0; + if (expected == NULL) return 0; + return bmff_type_is(cursor->parent_type, expected); +} +static bmff_item_box_kind bmff_item_box_kind_from_type(const uint8_t type[4]) { + if (bmff_type_is(type, "iinf")) return BMFF_ITEM_BOX_IINF; + if (bmff_type_is(type, "iloc")) return BMFF_ITEM_BOX_ILOC; + if (bmff_type_is(type, "idat")) return BMFF_ITEM_BOX_IDAT; + return BMFF_ITEM_BOX_OTHER; +} + +static int bmff_cursor_next(bmff_cursor *cursor, bmff_box *out) { + if (cursor == NULL || out == NULL || cursor->data == NULL || + cursor->box_count == NULL) return -1; + while (cursor->depth > 0) { + bmff_frame *frame = &cursor->frames[cursor->depth - 1]; + if (frame->next == frame->end) { + cursor->depth--; + continue; + } + if (frame->next > frame->end) return -1; + if (*cursor->box_count >= FLUXER_BMFF_MAX_BOXES) return -1; + if (bmff_parse_box(cursor->data, cursor->data_len, + frame->next, frame->end, out) != 0) return -1; + frame->next = out->payload_end; + (*cursor->box_count)++; + memcpy(cursor->parent_type, frame->type, 4); + cursor->parent_payload_start = frame->payload_start; + + int container_kind = bmff_container_kind(out->type); + if (container_kind != 0) { + size_t child_start = out->payload_start; + if (container_kind == 2) { + if (out->payload_end - child_start < 4) return -1; + child_start += 4; + } + if (child_start < out->payload_end) { + if (cursor->depth >= FLUXER_BMFF_MAX_DEPTH) return -1; + cursor->frames[cursor->depth].next = child_start; + cursor->frames[cursor->depth].end = out->payload_end; + memcpy(cursor->frames[cursor->depth].type, out->type, 4); + cursor->frames[cursor->depth].payload_start = out->payload_start; + cursor->depth++; + } + } + return 1; + } + return 0; +} + +static int bmff_infe_has_tmap(const uint8_t *payload, size_t len, int *found) { + if (payload == NULL || found == NULL || len < 4) return -1; + *found = 0; + uint8_t version = payload[0]; + if (version == 0 || version == 1) return 0; + if (version != 2 && version != 3) return -1; + + size_t pos = 4; + size_t item_id_len = version == 2 ? 2 : 4; + if (len - pos < item_id_len) return -1; + pos += item_id_len; + if (len - pos < 6) return -1; + pos += 2; + if (memcmp(payload + pos, "tmap", 4) == 0) *found = 1; + return 0; +} + +static int bmff_parse_iinf(const uint8_t *payload, size_t len, + size_t *box_count, size_t *iinf_entry_count, + long long deadline_monotonic_ms, + int *found) { + if (payload == NULL || box_count == NULL || iinf_entry_count == NULL || + found == NULL || len < 4) return -1; + uint8_t version = payload[0]; + size_t off = 4; + uint32_t entry_count; + if (version == 0) { + if (len - off < 2) return -1; + entry_count = bmff_read_u16(payload + off); + off += 2; + } else { + if (len - off < 4) return -1; + entry_count = bmff_read_u32(payload + off); + off += 4; + } + if (*iinf_entry_count > FLUXER_BMFF_MAX_IINF_ENTRIES) return -1; + if ((size_t)entry_count > FLUXER_BMFF_MAX_IINF_ENTRIES - *iinf_entry_count) return -1; + if ((size_t)entry_count > (len - off) / 8) return -1; + + for (uint32_t index = 0; index < entry_count; index++) { + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + if (*box_count >= FLUXER_BMFF_MAX_BOXES) return -1; + bmff_box box; + if (bmff_parse_box(payload, len, off, len, &box) != 0) return -1; + (*box_count)++; + (*iinf_entry_count)++; + if (!bmff_type_is(box.type, "infe")) return -1; + int entry_found = 0; + if (bmff_infe_has_tmap(payload + box.payload_start, + box.payload_end - box.payload_start, + &entry_found) != 0) return -1; + if (entry_found) *found = 1; + off = box.payload_end; + } + return off == len ? 0 : -1; +} + +static int bmff_read_sized_uint(const uint8_t *data, size_t len, + size_t *position, uint8_t width, + uint64_t *value) { + if (data == NULL || position == NULL || value == NULL || width > 8) return -1; + if (*position > len || (size_t)width > len - *position) return -1; + uint64_t parsed = 0; + for (uint8_t index = 0; index < width; index++) { + parsed = (parsed << 8) | data[*position + index]; + } + *position += width; + *value = parsed; + return 0; +} + +static int bmff_iloc_extent_is_in_bounds(uint64_t base_offset, + uint64_t extent_offset, + uint64_t extent_length, + size_t data_len) { + if (base_offset > UINT64_MAX - extent_offset) return 0; + uint64_t start = base_offset + extent_offset; + if (start > UINT64_MAX - extent_length) return 0; + return start + extent_length <= (uint64_t)data_len; +} + +static int bmff_iloc_field_width_is_valid(uint8_t width) { + return width == 0 || width == 4 || width == 8; +} + +typedef struct { + const uint8_t *payload; + size_t len; + size_t position; + size_t data_len; + size_t idat_len; + size_t total_extent_count; + uint8_t version; + uint8_t offset_size; + uint8_t length_size; + uint8_t base_offset_size; + uint8_t index_size; + int has_idat; +} bmff_iloc_validation; + +static int bmff_iloc_validation_init( + bmff_iloc_validation *validation, + const uint8_t *payload, + size_t len, + size_t data_len, + int has_idat, + size_t idat_len, + uint64_t *item_count +) { + if (validation == NULL || payload == NULL || item_count == NULL) return -1; + if (len < 8 || has_idat < 0 || has_idat > 1) return -1; + uint8_t version = payload[0]; + if (version > 2) return -1; + uint8_t offset_size = payload[4] >> 4; + uint8_t length_size = payload[4] & 0x0f; + uint8_t base_offset_size = payload[5] >> 4; + uint8_t encoded_index_size = payload[5] & 0x0f; + if (!bmff_iloc_field_width_is_valid(offset_size) || + !bmff_iloc_field_width_is_valid(length_size) || + !bmff_iloc_field_width_is_valid(base_offset_size)) { + return -1; + } + if (version == 0 && encoded_index_size != 0) return -1; + if (version > 0 && + !bmff_iloc_field_width_is_valid(encoded_index_size)) return -1; + *validation = (bmff_iloc_validation) { + .payload = payload, + .len = len, + .position = 6, + .data_len = data_len, + .idat_len = idat_len, + .version = version, + .offset_size = offset_size, + .length_size = length_size, + .base_offset_size = base_offset_size, + .index_size = version == 0 ? 0 : encoded_index_size, + .has_idat = has_idat, + }; + uint8_t count_size = version == 2 ? 4 : 2; + if (bmff_read_sized_uint( + payload, len, &validation->position, + count_size, item_count) != 0) return -1; + return *item_count <= FLUXER_BMFF_MAX_ILOC_ITEMS ? 0 : -1; +} + +static int bmff_validate_iloc_extents( + bmff_iloc_validation *validation, + uint64_t extent_count, + uint64_t base_offset, + size_t source_len, + long long deadline_monotonic_ms +) { + assert(validation != NULL); + uint64_t item_extent_bytes = 0; + for (uint64_t index = 0; index < extent_count; index++) { + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + uint64_t ignored = 0; + if (bmff_read_sized_uint( + validation->payload, validation->len, + &validation->position, validation->index_size, + &ignored) != 0) return -1; + uint64_t extent_offset = 0; + uint64_t extent_length = 0; + if (bmff_read_sized_uint( + validation->payload, validation->len, + &validation->position, validation->offset_size, + &extent_offset) != 0) return -1; + if (bmff_read_sized_uint( + validation->payload, validation->len, + &validation->position, validation->length_size, + &extent_length) != 0) return -1; + if (!bmff_iloc_extent_is_in_bounds( + base_offset, extent_offset, extent_length, source_len)) { + return -1; + } + if (extent_length > (uint64_t)source_len || + item_extent_bytes > (uint64_t)source_len - extent_length) { + return -1; + } + item_extent_bytes += extent_length; + } + return 0; +} + +static int bmff_validate_iloc_item( + bmff_iloc_validation *validation, + long long deadline_monotonic_ms +) { + assert(validation != NULL); + uint64_t ignored = 0; + uint8_t item_id_size = validation->version == 2 ? 4 : 2; + if (bmff_read_sized_uint( + validation->payload, validation->len, &validation->position, + item_id_size, &ignored) != 0) return -1; + uint64_t construction_method = 0; + if (validation->version > 0) { + if (bmff_read_sized_uint( + validation->payload, validation->len, + &validation->position, 2, &construction_method) != 0) { + return -1; + } + if ((construction_method & UINT64_C(0xfff0)) != 0 || + construction_method > 1) return -1; + } + uint64_t data_reference_index = 0; + if (bmff_read_sized_uint( + validation->payload, validation->len, &validation->position, + 2, &data_reference_index) != 0 || data_reference_index != 0) { + return -1; + } + uint64_t base_offset = 0; + if (bmff_read_sized_uint( + validation->payload, validation->len, &validation->position, + validation->base_offset_size, &base_offset) != 0) return -1; + uint64_t extent_count = 0; + if (bmff_read_sized_uint( + validation->payload, validation->len, &validation->position, + 2, &extent_count) != 0) return -1; + if (extent_count > FLUXER_BMFF_MAX_ILOC_EXTENTS_PER_ITEM || + (size_t)extent_count > FLUXER_BMFF_MAX_ILOC_EXTENTS_TOTAL - + validation->total_extent_count) return -1; + validation->total_extent_count += (size_t)extent_count; + size_t source_len = validation->data_len; + if (construction_method == 1) { + if (!validation->has_idat) return -1; + source_len = validation->idat_len; + } + return bmff_validate_iloc_extents( + validation, extent_count, base_offset, source_len, + deadline_monotonic_ms); +} + +static int bmff_validate_iloc_payload(const uint8_t *payload, size_t len, + size_t data_len, int has_idat, + size_t idat_len, + long long deadline_monotonic_ms) { + bmff_iloc_validation validation; + uint64_t item_count = 0; + if (bmff_iloc_validation_init( + &validation, payload, len, data_len, has_idat, idat_len, + &item_count) != 0) return -1; + for (uint64_t index = 0; index < item_count; index++) { + int status = bmff_validate_iloc_item( + &validation, deadline_monotonic_ms); + if (status != 0) return status; + } + return validation.position == len ? 0 : -1; +} + +static int validate_isobmff_item_metadata(const void *buf, size_t len, + long long deadline_monotonic_ms, + int *has_tmap_item) { + if (buf == NULL) return -1; + if (len < 16) return -1; + if (has_tmap_item == NULL) return -1; + *has_tmap_item = 0; + const uint8_t *data = (const uint8_t *)buf; + size_t box_count = 0; + size_t iinf_entry_count = 0; + size_t iinf_count = 0; + size_t idat_count = 0; + size_t metadata_parent = SIZE_MAX; + size_t idat_len = 0; + const uint8_t *iloc_payload = NULL; + size_t iloc_payload_len = 0; + bmff_cursor cursor; + if (bmff_cursor_init(&cursor, data, len, 0, len, &box_count) != 0) return -1; + for (;;) { + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + bmff_box box; + int result = bmff_cursor_next(&cursor, &box); + if (result < 0) return -1; + if (result == 0) break; + bmff_item_box_kind kind = bmff_item_box_kind_from_type(box.type); + if (kind == BMFF_ITEM_BOX_OTHER) continue; + if (!bmff_cursor_parent_is(&cursor, "meta")) return -1; + if (metadata_parent == SIZE_MAX) { + metadata_parent = cursor.parent_payload_start; + } else if (metadata_parent != cursor.parent_payload_start) { + return -1; + } + if (kind == BMFF_ITEM_BOX_IINF) { + iinf_count++; + if (iinf_count > 1) return -1; + int status = bmff_parse_iinf( + data + box.payload_start, + box.payload_end - box.payload_start, + &box_count, &iinf_entry_count, + deadline_monotonic_ms, has_tmap_item); + if (status != 0) return status; + } else if (kind == BMFF_ITEM_BOX_ILOC) { + if (iloc_payload != NULL) return -1; + iloc_payload = data + box.payload_start; + iloc_payload_len = box.payload_end - box.payload_start; + } else { + idat_count++; + if (idat_count > 1) return -1; + idat_len = box.payload_end - box.payload_start; + } + } + if (iinf_count != 1) return -1; + if (iloc_payload == NULL) return -1; + return bmff_validate_iloc_payload( + iloc_payload, iloc_payload_len, len, + idat_count == 1, idat_len, deadline_monotonic_ms); +} + +static int fluxer_heif_validate_with_tmap(const void *buf, size_t len, + long long deadline_monotonic_ms, + int *has_tmap_item) { + if (has_tmap_item == NULL || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int status = validate_isobmff_item_metadata( + buf, len, deadline_monotonic_ms, has_tmap_item); + if (status == 0) return FLUXER_NATIVE_STATUS_OK; + if (status == FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED) return status; + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +int fluxer_heif_validate( + const void *buf, + size_t len, + long long deadline_monotonic_ms +) { + int has_tmap_item = 0; + return fluxer_heif_validate_with_tmap( + buf, len, deadline_monotonic_ms, &has_tmap_item); +} + +typedef struct { + uint8_t *pixels; + size_t pixels_len; + int width; + int height; + int hdr_tone_mapped; + int auxiliary_gain_map_detected; +} heif_primary_still_pixels; + +typedef struct { + struct heif_context *context; + struct heif_image_handle *handle; +} heif_primary_still_context; + +static void heif_primary_still_context_clear( + heif_primary_still_context *context +) { + if (context == NULL) return; + if (context->handle != NULL) { + heif_image_handle_release(context->handle); + } + if (context->context != NULL) heif_context_free(context->context); + memset(context, 0, sizeof(*context)); +} + +static int heif_primary_still_context_open( + heif_primary_still_context *context, + const void *data, + size_t len, + size_t max_pixels, + long long deadline_monotonic_ms +) { + assert(context != NULL); + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->context = heif_context_alloc(); + if (context->context == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + status = fluxer_set_heif_security_limits( + context->context, max_pixels); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + heif_context_set_max_decoding_threads(context->context, 1); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + struct heif_error error = heif_context_read_from_memory_without_copy( + context->context, data, len, NULL); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (error.code != heif_error_Ok) { + return fluxer_native_status_from_heif_error(error); + } + error = heif_context_get_primary_image_handle( + context->context, &context->handle); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (error.code != heif_error_Ok) { + return fluxer_native_status_from_heif_error(error); + } + if (context->handle == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return FLUXER_NATIVE_STATUS_OK; +} + +static int heif_primary_still_geometry( + struct heif_image_handle *handle, + size_t max_pixels, + int max_dimension, + int *out_width, + int *out_height, + size_t *out_pixels_len +) { + assert(handle != NULL); + assert(out_width != NULL); + assert(out_height != NULL); + assert(out_pixels_len != NULL); + int width = heif_image_handle_get_width(handle); + int height = heif_image_handle_get_height(handle); + if (width <= 0 || height <= 0 || width > max_dimension || + height > max_dimension) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + size_t pixels_len = 0; + if (fluxer_heif_checked_rgba_size(width, height, &pixels_len) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (pixels_len / 4u > max_pixels) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + *out_width = width; + *out_height = height; + *out_pixels_len = pixels_len; + return FLUXER_NATIVE_STATUS_OK; +} + +static int decode_heif_primary_still_pixels( + const void *buf, size_t len, size_t max_pixels, int max_dimension, + long long deadline_monotonic_ms, + heif_primary_still_pixels *decoded) { + if (buf == NULL || len == 0 || max_pixels == 0 || max_dimension <= 0 || + deadline_monotonic_ms < 0 || decoded == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + memset(decoded, 0, sizeof(*decoded)); + heif_primary_still_context context = {0}; + uint8_t *pixels = NULL; + int status = heif_primary_still_context_open( + &context, buf, len, max_pixels, deadline_monotonic_ms); + int width = 0; + int height = 0; + size_t pixels_len = 0; + if (status == FLUXER_NATIVE_STATUS_OK) { + status = heif_primary_still_geometry( + context.handle, max_pixels, max_dimension, + &width, &height, &pixels_len); + } + int gain_map_detected = 0; + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_heif_detect_hdr_gain_map( + context.handle, deadline_monotonic_ms, &gain_map_detected); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + pixels = g_try_malloc(pixels_len); + if (pixels == NULL) status = FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + int hdr_tone_mapped = 0; + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_heif_decode_to_sdr_rgba8( + context.handle, pixels, pixels_len, width, height, + deadline_monotonic_ms, + &hdr_tone_mapped); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + *decoded = (heif_primary_still_pixels) { + .pixels = pixels, + .pixels_len = pixels_len, + .width = width, + .height = height, + .hdr_tone_mapped = hdr_tone_mapped, + .auxiliary_gain_map_detected = gain_map_detected, + }; + pixels = NULL; + } + g_free(pixels); + heif_primary_still_context_clear(&context); + return status; +} + +int fluxer_heif_decode_primary_still( + const void *buf, size_t len, long long deadline_monotonic_ms, + VipsImage **out, size_t max_pixels, int max_dimension, + struct fluxer_heif_primary_still_decode_facts *facts) { + if (out != NULL) *out = NULL; + if (facts != NULL) memset(facts, 0, sizeof(*facts)); + if (buf == NULL || len == 0 || out == NULL || max_pixels == 0 || + max_dimension <= 0 || deadline_monotonic_ms < 0 || facts == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int has_tmap_item = 0; + int validate_status = fluxer_heif_validate_with_tmap( + buf, len, deadline_monotonic_ms, &has_tmap_item); + if (validate_status != FLUXER_NATIVE_STATUS_OK) return validate_status; + + heif_primary_still_pixels decoded; + int decode_status = decode_heif_primary_still_pixels( + buf, len, max_pixels, max_dimension, + deadline_monotonic_ms, &decoded); + if (decode_status != FLUXER_NATIVE_STATUS_OK) return decode_status; + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + g_free(decoded.pixels); + return deadline_status; + } + VipsImage *image = vips_image_new_from_memory(decoded.pixels, + decoded.pixels_len, + decoded.width, + decoded.height, + 4, VIPS_FORMAT_UCHAR); + if (image == NULL) { + g_free(decoded.pixels); + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + g_object_unref(image); + g_free(decoded.pixels); + return deadline_status; + } + if (g_signal_connect_swapped( + image, "postclose", G_CALLBACK(g_free), decoded.pixels) == 0) { + g_object_unref(image); + g_free(decoded.pixels); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + facts->hdr_tone_mapped = decoded.hdr_tone_mapped; + facts->hdr_gain_map_detected = has_tmap_item > 0 || decoded.auxiliary_gain_map_detected; + *out = image; + return FLUXER_NATIVE_STATUS_OK; +} diff --git a/fluxer_media_proxy/src/native_shim/heif_pixels.c b/fluxer_media_proxy/src/native_shim/heif_pixels.c new file mode 100644 index 000000000..606a530d0 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/heif_pixels.c @@ -0,0 +1,1050 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +#define FLUXER_HDR_PQ_LUT_SIZE 4096 +#define FLUXER_HDR_HLG_LUT_SIZE 4096 +#define FLUXER_HDR_SRGB_LUT_SIZE 4096 +#define FLUXER_PQ_SDR_TARGET_NORM 0.0203f +#define FLUXER_HLG_REFERENCE_PEAK_NORM 0.1f +#define FLUXER_HEIF_MAX_AUXILIARY_IMAGES 4096 +#define FLUXER_HEIF_DEADLINE_ROWS 64 +#define FLUXER_HEIF_ICC_PROFILE_BYTES_MAX ((size_t)4 * 1024 * 1024) + +enum fluxer_heif_gamut { + FLUXER_HEIF_GAMUT_SRGB = 0, + FLUXER_HEIF_GAMUT_BT2020 = 1, + FLUXER_HEIF_GAMUT_DISPLAY_P3 = 2, +}; + +static float fluxer_pq_lut[FLUXER_HDR_PQ_LUT_SIZE]; +static float fluxer_hlg_lut[FLUXER_HDR_HLG_LUT_SIZE]; +static float fluxer_hlg_ootf_scale_lut[FLUXER_HDR_HLG_LUT_SIZE]; +static float fluxer_pq_tone_scale_lut[FLUXER_HDR_PQ_LUT_SIZE]; +static float fluxer_hlg_tone_scale_lut[FLUXER_HDR_HLG_LUT_SIZE]; +static uint8_t fluxer_srgb_lut[FLUXER_HDR_SRGB_LUT_SIZE]; +static pthread_once_t fluxer_hdr_lut_once = PTHREAD_ONCE_INIT; +static float fluxer_pq_sdr_target_perceptual; +static float fluxer_hlg_source_peak_perceptual; +static float fluxer_hlg_sdr_target_perceptual; + +static inline float fluxer_pq_oetf(float luminance); +static inline float fluxer_hdr_tone_scale( + float maximum, + float target_normalized, + float source_peak_perceptual, + float target_perceptual +); +static inline float fluxer_srgb_oetf(float value); +static inline uint8_t fluxer_quantize8(float value); + +static void fluxer_init_hdr_luts(void) { + const double m1 = 0.1593017578125; + const double m2 = 78.84375; + const double c1 = 0.8359375; + const double c2 = 18.8515625; + const double c3 = 18.6875; + for (int index = 0; index < FLUXER_HDR_PQ_LUT_SIZE; index++) { + double encoded = (double)index / (FLUXER_HDR_PQ_LUT_SIZE - 1); + double encoded_power = pow(encoded, 1.0 / m2); + double numerator = encoded_power - c1; + if (numerator < 0.0) numerator = 0.0; + double denominator = c2 - c3 * encoded_power; + double luminance = denominator > 0.0 + ? pow(numerator / denominator, 1.0 / m1) + : 0.0; + if (luminance < 0.0) luminance = 0.0; + if (luminance > 1.0) luminance = 1.0; + fluxer_pq_lut[index] = (float)luminance; + } + const double a = 0.17883277; + const double b = 0.28466892; + const double c = 0.55991073; + for (int index = 0; index < FLUXER_HDR_HLG_LUT_SIZE; index++) { + double encoded = (double)index / (FLUXER_HDR_HLG_LUT_SIZE - 1); + double scene = encoded <= 0.5 + ? (encoded * encoded) / 3.0 + : (exp((encoded - c) / a) + b) / 12.0; + if (scene < 0.0) scene = 0.0; + if (scene > 1.0) scene = 1.0; + fluxer_hlg_lut[index] = (float)scene; + double normalized = (double)index / (FLUXER_HDR_HLG_LUT_SIZE - 1); + fluxer_hlg_ootf_scale_lut[index] = normalized > 0.0 + ? (float)pow(normalized, 0.2) + : 0.0f; + } + fluxer_pq_sdr_target_perceptual = + fluxer_pq_oetf(FLUXER_PQ_SDR_TARGET_NORM); + fluxer_hlg_source_peak_perceptual = + fluxer_pq_oetf(FLUXER_HLG_REFERENCE_PEAK_NORM); + fluxer_hlg_sdr_target_perceptual = + fluxer_pq_sdr_target_perceptual / + fluxer_hlg_source_peak_perceptual; + for (int index = 0; index < FLUXER_HDR_PQ_LUT_SIZE; index++) { + fluxer_pq_tone_scale_lut[index] = fluxer_hdr_tone_scale( + fluxer_pq_lut[index], FLUXER_PQ_SDR_TARGET_NORM, + 1.0f, + fluxer_pq_sdr_target_perceptual); + } + for (int index = 0; index < FLUXER_HDR_HLG_LUT_SIZE; index++) { + float maximum = (float)index / (FLUXER_HDR_HLG_LUT_SIZE - 1); + float absolute_maximum = + maximum * FLUXER_HLG_REFERENCE_PEAK_NORM; + fluxer_hlg_tone_scale_lut[index] = + FLUXER_HLG_REFERENCE_PEAK_NORM * fluxer_hdr_tone_scale( + absolute_maximum, FLUXER_PQ_SDR_TARGET_NORM, + fluxer_hlg_source_peak_perceptual, + fluxer_hlg_sdr_target_perceptual); + } + for (int index = 0; index < FLUXER_HDR_SRGB_LUT_SIZE; index++) { + float linear = (float)index / (FLUXER_HDR_SRGB_LUT_SIZE - 1); + fluxer_srgb_lut[index] = fluxer_quantize8( + fluxer_srgb_oetf(linear)); + } +} + +static inline uint16_t fluxer_hdr_lut_index(uint16_t code, int bit_depth) { + assert(bit_depth == 10 || bit_depth == 12); + if (bit_depth == 12) return code & 0x0fffu; + uint16_t code10 = code & 0x03ffu; + return (uint16_t)((code10 << 2) | (code10 >> 8)); +} + +static inline uint16_t fluxer_unit_lut_index(float value) { + if (value <= 0.0f) return 0; + if (value >= 1.0f) return FLUXER_HDR_HLG_LUT_SIZE - 1; + return (uint16_t)( + value * (FLUXER_HDR_HLG_LUT_SIZE - 1) + 0.5f); +} + +static inline uint16_t fluxer_heif_read_le16(const uint8_t *value) { + return (uint16_t)((uint16_t)value[0] | ((uint16_t)value[1] << 8)); +} + +static inline float fluxer_bt2390_eetf_perceptual( + float encoded, + float max_luminance +) { + if (encoded <= 0.0f) return 0.0f; + if (max_luminance >= 1.0f) { + return encoded > 1.0f ? 1.0f : encoded; + } + float knee = 1.5f * max_luminance - 0.5f; + if (encoded < knee) return encoded; + if (encoded >= 1.0f) return max_luminance; + float position = (encoded - knee) / (1.0f - knee); + float squared = position * position; + float cubed = squared * position; + float start_basis = 2.0f * cubed - 3.0f * squared + 1.0f; + float tangent_basis = cubed - 2.0f * squared + position; + float end_basis = -2.0f * cubed + 3.0f * squared; + float mapped = start_basis * knee + tangent_basis * (1.0f - knee) + + end_basis * max_luminance; + if (mapped > max_luminance) mapped = max_luminance; + if (mapped < 0.0f) mapped = 0.0f; + return mapped; +} + +static inline float fluxer_pq_oetf(float luminance) { + if (luminance <= 0.0f) return 0.0f; + if (luminance >= 1.0f) luminance = 1.0f; + const float m1 = 0.1593017578125f; + const float m2 = 78.84375f; + const float c1 = 0.8359375f; + const float c2 = 18.8515625f; + const float c3 = 18.6875f; + float power = powf(luminance, m1); + return powf((c1 + c2 * power) / (1.0f + c3 * power), m2); +} + +static inline float fluxer_srgb_oetf(float value) { + if (value <= 0.0f) return 0.0f; + if (value >= 1.0f) return 1.0f; + if (value <= 0.0031308f) return 12.92f * value; + return 1.055f * powf(value, 1.0f / 2.4f) - 0.055f; +} + +static inline uint8_t fluxer_quantize8(float value) { + if (value <= 0.0f) return 0; + if (value >= 1.0f) return 255; + int quantized = (int)(value * 255.0f + 0.5f); + if (quantized < 0) return 0; + if (quantized > 255) return 255; + return (uint8_t)quantized; +} + +static inline uint8_t fluxer_srgb_lut_quantize(float value) { + if (value <= 0.0f) return 0; + if (value >= 1.0f) return 255; + size_t index = (size_t)( + value * (FLUXER_HDR_SRGB_LUT_SIZE - 1) + 0.5f); + assert(index < FLUXER_HDR_SRGB_LUT_SIZE); + return fluxer_srgb_lut[index]; +} + +static inline void fluxer_bt2020_to_bt709_linear( + float red, + float green, + float blue, + float *out_red, + float *out_green, + float *out_blue +) { + *out_red = 1.6605f * red - 0.5876f * green - 0.0728f * blue; + *out_green = -0.1246f * red + 1.1329f * green - 0.0083f * blue; + *out_blue = -0.0182f * red - 0.1006f * green + 1.1187f * blue; +} + +static inline void fluxer_display_p3_to_srgb_linear( + float red, + float green, + float blue, + float *out_red, + float *out_green, + float *out_blue +) { + *out_red = 1.2249401f * red - 0.2249404f * green; + *out_green = -0.0420569f * red + 1.0420571f * green; + *out_blue = -0.0196376f * red - 0.0786361f * green + 1.0982735f * blue; +} + +static inline float fluxer_inverse_srgb(float encoded) { + if (encoded <= 0.0f) return 0.0f; + if (encoded >= 1.0f) return 1.0f; + if (encoded <= 0.04045f) return encoded / 12.92f; + return powf((encoded + 0.055f) / 1.055f, 2.4f); +} + +static inline float fluxer_inverse_bt709(float encoded) { + if (encoded <= 0.0f) return 0.0f; + if (encoded >= 1.0f) return 1.0f; + if (encoded < 0.081f) return encoded / 4.5f; + return powf((encoded + 0.099f) / 1.099f, 1.0f / 0.45f); +} + +static inline float fluxer_inverse_bt2020_12(float encoded) { + if (encoded <= 0.0f) return 0.0f; + if (encoded >= 1.0f) return 1.0f; + if (encoded < 0.08145f) return encoded / 4.5f; + return powf((encoded + 0.0993f) / 1.0993f, 1.0f / 0.45f); +} + +static inline void fluxer_heif_convert_gamut_linear( + int gamut, + float red, + float green, + float blue, + float *out_red, + float *out_green, + float *out_blue +) { + if (gamut == FLUXER_HEIF_GAMUT_BT2020) { + fluxer_bt2020_to_bt709_linear( + red, green, blue, out_red, out_green, out_blue); + return; + } + if (gamut == FLUXER_HEIF_GAMUT_DISPLAY_P3) { + fluxer_display_p3_to_srgb_linear( + red, green, blue, out_red, out_green, out_blue); + return; + } + assert(gamut == FLUXER_HEIF_GAMUT_SRGB); + *out_red = red; + *out_green = green; + *out_blue = blue; +} + +static inline float fluxer_heif_linear_luma( + int gamut, + float red, + float green, + float blue +) { + if (gamut == FLUXER_HEIF_GAMUT_BT2020) { + return 0.2627f * red + 0.6780f * green + 0.0593f * blue; + } + if (gamut == FLUXER_HEIF_GAMUT_DISPLAY_P3) { + return 0.2289746f * red + 0.6917385f * green + 0.0792869f * blue; + } + assert(gamut == FLUXER_HEIF_GAMUT_SRGB); + return 0.2126f * red + 0.7152f * green + 0.0722f * blue; +} + +static inline float fluxer_inverse_pq(float encoded) { + const float m1 = 0.1593017578125f; + const float m2 = 78.84375f; + const float c1 = 0.8359375f; + const float c2 = 18.8515625f; + const float c3 = 18.6875f; + float power = powf(encoded, 1.0f / m2); + float numerator = power - c1; + if (numerator < 0.0f) numerator = 0.0f; + float denominator = c2 - c3 * power; + if (denominator <= 0.0f) return 0.0f; + float luminance = powf(numerator / denominator, 1.0f / m1); + return luminance < 0.0f ? 0.0f : luminance; +} + +static inline float fluxer_hdr_tone_scale( + float maximum, + float target_normalized, + float source_peak_perceptual, + float target_perceptual +) { + if (maximum <= 0.0f) return 0.0f; + assert(source_peak_perceptual > 0.0f); + assert(source_peak_perceptual <= 1.0f); + float perceptual = fluxer_pq_oetf(maximum) / + source_peak_perceptual; + float mapped_perceptual = fluxer_bt2390_eetf_perceptual( + perceptual, target_perceptual); + float mapped = fluxer_inverse_pq( + mapped_perceptual * source_peak_perceptual); + return (mapped / maximum) / target_normalized; +} + +static inline void fluxer_hdr_pipeline_pixel( + float red, + float green, + float blue, + float scale, + int gamut, + uint8_t *output +) { + float display_red = red * scale; + float display_green = green * scale; + float display_blue = blue * scale; + if (display_red < 0.0f) display_red = 0.0f; + if (display_green < 0.0f) display_green = 0.0f; + if (display_blue < 0.0f) display_blue = 0.0f; + if (display_red > 1.0f) display_red = 1.0f; + if (display_green > 1.0f) display_green = 1.0f; + if (display_blue > 1.0f) display_blue = 1.0f; + float linear_red = display_red; + float linear_green = display_green; + float linear_blue = display_blue; + fluxer_heif_convert_gamut_linear( + gamut, display_red, display_green, display_blue, + &linear_red, &linear_green, &linear_blue); + if (linear_red < 0.0f) linear_red = 0.0f; + if (linear_green < 0.0f) linear_green = 0.0f; + if (linear_blue < 0.0f) linear_blue = 0.0f; + if (linear_red > 1.0f) linear_red = 1.0f; + if (linear_green > 1.0f) linear_green = 1.0f; + if (linear_blue > 1.0f) linear_blue = 1.0f; + output[0] = fluxer_srgb_lut_quantize(linear_red); + output[1] = fluxer_srgb_lut_quantize(linear_green); + output[2] = fluxer_srgb_lut_quantize(linear_blue); +} + +static unsigned char fluxer_ascii_lower(unsigned char value) { + if (value >= 'A' && value <= 'Z') { + return (unsigned char)(value + ('a' - 'A')); + } + return value; +} + +static int fluxer_ascii_contains_folded( + const char *haystack, + const char *needle +) { + if (haystack == NULL || needle == NULL || needle[0] == '\0') return 0; + size_t needle_length = strlen(needle); + for (const char *position = haystack; *position != '\0'; position++) { + size_t index = 0; + while (index < needle_length && position[index] != '\0' && + fluxer_ascii_lower((unsigned char)position[index]) == + fluxer_ascii_lower((unsigned char)needle[index])) { + index++; + } + if (index == needle_length) return 1; + } + return 0; +} + +static int fluxer_heif_aux_type_is_hdr_gain_map(const char *type) { + if (type == NULL || type[0] == '\0') return 0; + if (fluxer_ascii_contains_folded(type, "hdrgainmap") || + fluxer_ascii_contains_folded(type, "hdr_gain_map") || + fluxer_ascii_contains_folded(type, "hdr-gain-map")) { + return 1; + } + if (!fluxer_ascii_contains_folded(type, "gainmap")) return 0; + return fluxer_ascii_contains_folded(type, "hdr") || + fluxer_ascii_contains_folded(type, "21496") || + fluxer_ascii_contains_folded(type, "iso"); +} + +static int fluxer_heif_auxiliary_is_gain_map( + struct heif_image_handle *primary, + heif_item_id identifier, + long long deadline_monotonic_ms, + int *found +) { + assert(primary != NULL); + assert(found != NULL); + *found = 0; + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + struct heif_image_handle *auxiliary = NULL; + struct heif_error error = heif_image_handle_get_auxiliary_image_handle( + primary, identifier, &auxiliary); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) { + if (auxiliary != NULL) heif_image_handle_release(auxiliary); + return status; + } + if (error.code != heif_error_Ok) { + if (auxiliary != NULL) heif_image_handle_release(auxiliary); + return fluxer_native_status_from_heif_error(error); + } + if (auxiliary == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + const char *type = NULL; + error = heif_image_handle_get_auxiliary_type(auxiliary, &type); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) { + if (type != NULL) { + heif_image_handle_release_auxiliary_type(auxiliary, &type); + } + heif_image_handle_release(auxiliary); + return status; + } + if (error.code != heif_error_Ok) { + if (type != NULL) { + heif_image_handle_release_auxiliary_type(auxiliary, &type); + } + heif_image_handle_release(auxiliary); + return fluxer_native_status_from_heif_error(error); + } + if (type == NULL) { + heif_image_handle_release(auxiliary); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *found = fluxer_heif_aux_type_is_hdr_gain_map(type); + heif_image_handle_release_auxiliary_type(auxiliary, &type); + heif_image_handle_release(auxiliary); + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_heif_detect_hdr_gain_map( + struct heif_image_handle *handle, + long long deadline_monotonic_ms, + int *detected +) { + if (handle == NULL || deadline_monotonic_ms < 0 || detected == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *detected = 0; + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int filter = LIBHEIF_AUX_IMAGE_FILTER_OMIT_ALPHA | + LIBHEIF_AUX_IMAGE_FILTER_OMIT_DEPTH; + int count = heif_image_handle_get_number_of_auxiliary_images( + handle, filter); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (count < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (count > FLUXER_HEIF_MAX_AUXILIARY_IMAGES) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (count == 0) return FLUXER_NATIVE_STATUS_OK; + heif_item_id *identifiers = calloc((size_t)count, sizeof(*identifiers)); + if (identifiers == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + int received = heif_image_handle_get_list_of_auxiliary_image_IDs( + handle, filter, identifiers, count); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status == FLUXER_NATIVE_STATUS_OK && received != count) { + status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + for (int index = 0; + status == FLUXER_NATIVE_STATUS_OK && index < received; + index++) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) break; + int found = 0; + status = fluxer_heif_auxiliary_is_gain_map( + handle, identifiers[index], deadline_monotonic_ms, &found); + if (found) { + *detected = 1; + break; + } + } + free(identifiers); + return status; +} + +int fluxer_heif_checked_rgba_size( + int width, + int height, + size_t *out_size +) { + if (width <= 0 || height <= 0 || out_size == NULL) return -1; + if ((size_t)width > SIZE_MAX / 4u) return -1; + size_t row_bytes = (size_t)width * 4u; + if ((size_t)height > SIZE_MAX / row_bytes) return -1; + *out_size = row_bytes * (size_t)height; + return 0; +} + +struct fluxer_heif_color_profile { + int transfer; + int primaries; + int matrix; + int nclx_present; + size_t icc_size; +}; + +static int fluxer_heif_read_color_profile( + struct heif_image_handle *handle, + long long deadline_monotonic_ms, + struct fluxer_heif_color_profile *profile +) { + assert(handle != NULL); + assert(profile != NULL); + memset(profile, 0, sizeof(*profile)); + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + profile->icc_size = heif_image_handle_get_raw_color_profile_size(handle); + if (profile->icc_size > FLUXER_HEIF_ICC_PROFILE_BYTES_MAX) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + struct heif_color_profile_nclx *nclx = NULL; + struct heif_error error = + heif_image_handle_get_nclx_color_profile(handle, &nclx); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) { + if (nclx != NULL) heif_nclx_color_profile_free(nclx); + return status; + } + if (error.code == heif_error_Color_profile_does_not_exist) { + if (nclx != NULL) { + heif_nclx_color_profile_free(nclx); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; + } + if (error.code != heif_error_Ok) { + if (nclx != NULL) heif_nclx_color_profile_free(nclx); + return fluxer_native_status_from_heif_error(error); + } + if (nclx == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + profile->nclx_present = 1; + profile->transfer = (int)nclx->transfer_characteristics; + profile->primaries = (int)nclx->color_primaries; + profile->matrix = (int)nclx->matrix_coefficients; + heif_nclx_color_profile_free(nclx); + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_heif_apply_icc_profile( + struct heif_image_handle *handle, + uint8_t *destination, + int width, + int height, + size_t profile_size, + long long deadline_monotonic_ms +) { + assert(handle != NULL); + assert(destination != NULL); + assert(profile_size > 0); + assert(profile_size <= FLUXER_HEIF_ICC_PROFILE_BYTES_MAX); + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + uint8_t *profile_bytes = malloc(profile_size); + if (profile_bytes == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + struct heif_error error = heif_image_handle_get_raw_color_profile( + handle, profile_bytes); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status == FLUXER_NATIVE_STATUS_OK && error.code != heif_error_Ok) { + status = fluxer_native_status_from_heif_error(error); + } + cmsHPROFILE input_profile = NULL; + cmsHPROFILE output_profile = NULL; + cmsHTRANSFORM transform = NULL; + uint8_t *source_row = NULL; + if (status == FLUXER_NATIVE_STATUS_OK) { + input_profile = cmsOpenProfileFromMem( + profile_bytes, (cmsUInt32Number)profile_size); + output_profile = cmsCreate_sRGBProfile(); + if (input_profile == NULL || output_profile == NULL) { + status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } + if (status == FLUXER_NATIVE_STATUS_OK && + (cmsGetColorSpace(input_profile) != cmsSigRgbData || + (cmsGetPCS(input_profile) != cmsSigXYZData && + cmsGetPCS(input_profile) != cmsSigLabData) || + (cmsGetDeviceClass(input_profile) != cmsSigInputClass && + cmsGetDeviceClass(input_profile) != cmsSigDisplayClass && + cmsGetDeviceClass(input_profile) != cmsSigColorSpaceClass) || + !cmsIsMatrixShaper(input_profile))) { + status = FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + transform = cmsCreateTransform( + input_profile, TYPE_RGBA_8, + output_profile, TYPE_RGBA_8, + INTENT_RELATIVE_COLORIMETRIC, + cmsFLAGS_BLACKPOINTCOMPENSATION | cmsFLAGS_COPY_ALPHA); + if (transform == NULL) status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + } + size_t row_bytes = (size_t)width * 4u; + if (status == FLUXER_NATIVE_STATUS_OK) { + source_row = malloc(row_bytes); + if (source_row == NULL) status = FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + for (int row = 0; + status == FLUXER_NATIVE_STATUS_OK && row < height; + row++) { + if (row % FLUXER_HEIF_DEADLINE_ROWS == 0) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) break; + } + uint8_t *destination_row = destination + (size_t)row * row_bytes; + memcpy(source_row, destination_row, row_bytes); + cmsDoTransform(transform, source_row, destination_row, (cmsUInt32Number)width); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + } + free(source_row); + if (transform != NULL) cmsDeleteTransform(transform); + if (output_profile != NULL) cmsCloseProfile(output_profile); + if (input_profile != NULL) cmsCloseProfile(input_profile); + free(profile_bytes); + return status; +} + +static int fluxer_heif_nclx_gamut(int primaries, int *out_gamut) { + assert(out_gamut != NULL); + switch (primaries) { + case heif_color_primaries_ITU_R_BT_709_5: + *out_gamut = FLUXER_HEIF_GAMUT_SRGB; + return FLUXER_NATIVE_STATUS_OK; + case heif_color_primaries_ITU_R_BT_2020_2_and_2100_0: + *out_gamut = FLUXER_HEIF_GAMUT_BT2020; + return FLUXER_NATIVE_STATUS_OK; + case heif_color_primaries_SMPTE_EG_432_1: + *out_gamut = FLUXER_HEIF_GAMUT_DISPLAY_P3; + return FLUXER_NATIVE_STATUS_OK; + default: + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } +} + +static int fluxer_heif_apply_sdr_nclx( + uint8_t *destination, + int width, + int height, + const struct fluxer_heif_color_profile *profile, + long long deadline_monotonic_ms +) { + assert(destination != NULL); + assert(profile != NULL); + int gamut = FLUXER_HEIF_GAMUT_SRGB; + int status = fluxer_heif_nclx_gamut(profile->primaries, &gamut); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int transfer = profile->transfer; + int is_srgb = transfer == heif_transfer_characteristic_IEC_61966_2_1; + int is_bt709 = transfer == heif_transfer_characteristic_ITU_R_BT_709_5 || + transfer == heif_transfer_characteristic_ITU_R_BT_601_6 || + transfer == heif_transfer_characteristic_ITU_R_BT_2020_2_10bit; + int is_bt2020_12 = + transfer == heif_transfer_characteristic_ITU_R_BT_2020_2_12bit; + int is_linear = transfer == heif_transfer_characteristic_linear; + if (!is_srgb && !is_bt709 && !is_bt2020_12 && !is_linear) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + if (is_srgb && gamut == FLUXER_HEIF_GAMUT_SRGB) { + return FLUXER_NATIVE_STATUS_OK; + } + size_t row_bytes = (size_t)width * 4u; + for (int row = 0; row < height; row++) { + if (row % FLUXER_HEIF_DEADLINE_ROWS == 0) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } + uint8_t *row_data = destination + (size_t)row * row_bytes; + for (int column = 0; column < width; column++) { + uint8_t *pixel = row_data + (size_t)column * 4u; + float red = (float)pixel[0] / 255.0f; + float green = (float)pixel[1] / 255.0f; + float blue = (float)pixel[2] / 255.0f; + if (is_srgb) { + red = fluxer_inverse_srgb(red); + green = fluxer_inverse_srgb(green); + blue = fluxer_inverse_srgb(blue); + } else if (is_bt709) { + red = fluxer_inverse_bt709(red); + green = fluxer_inverse_bt709(green); + blue = fluxer_inverse_bt709(blue); + } else if (is_bt2020_12) { + red = fluxer_inverse_bt2020_12(red); + green = fluxer_inverse_bt2020_12(green); + blue = fluxer_inverse_bt2020_12(blue); + } + float srgb_red = 0.0f; + float srgb_green = 0.0f; + float srgb_blue = 0.0f; + fluxer_heif_convert_gamut_linear( + gamut, red, green, blue, + &srgb_red, &srgb_green, &srgb_blue); + pixel[0] = fluxer_quantize8(fluxer_srgb_oetf(srgb_red)); + pixel[1] = fluxer_quantize8(fluxer_srgb_oetf(srgb_green)); + pixel[2] = fluxer_quantize8(fluxer_srgb_oetf(srgb_blue)); + } + } + return fluxer_native_deadline_status(deadline_monotonic_ms); +} + +static int fluxer_heif_cancel_decoding(void *opaque) { + if (opaque == NULL) return 1; + const long long *deadline_monotonic_ms = opaque; + return fluxer_native_deadline_status(*deadline_monotonic_ms) != + FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_heif_decode_interleaved( + struct heif_image_handle *handle, + enum heif_chroma chroma, + long long deadline_monotonic_ms, + struct heif_image **out_image +) { + assert(handle != NULL); + assert(out_image != NULL); + *out_image = NULL; + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + struct heif_decoding_options *options = heif_decoding_options_alloc(); + if (options == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + options->progress_user_data = &deadline_monotonic_ms; + options->cancel_decoding = fluxer_heif_cancel_decoding; +#if LIBHEIF_HAVE_VERSION(1, 21, 0) + struct heif_color_profile_nclx *source_nclx = NULL; + size_t raw_profile_size = + heif_image_handle_get_raw_color_profile_size(handle); + struct heif_error profile_error = + heif_image_handle_get_nclx_color_profile(handle, &source_nclx); + if (profile_error.code == heif_error_Ok && source_nclx == NULL) { + heif_decoding_options_free(options); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (profile_error.code == heif_error_Ok) { + options->output_image_nclx_profile = source_nclx; + } else if (profile_error.code != heif_error_Color_profile_does_not_exist || + source_nclx != NULL) { + if (source_nclx != NULL) heif_nclx_color_profile_free(source_nclx); + heif_decoding_options_free(options); + return fluxer_native_status_from_heif_error(profile_error); + } +#if !LIBHEIF_HAVE_VERSION(1, 23, 0) + if (source_nclx == NULL && raw_profile_size > 0) { + heif_decoding_options_free(options); + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } +#endif +#endif +#if LIBHEIF_HAVE_VERSION(1, 23, 0) + options->output_image_nclx_profile_passthrough = + source_nclx != NULL || raw_profile_size > 0; +#endif + struct heif_error error = heif_decode_image( + handle, out_image, heif_colorspace_RGB, chroma, options); +#if LIBHEIF_HAVE_VERSION(1, 21, 0) + if (source_nclx != NULL) heif_nclx_color_profile_free(source_nclx); +#endif + heif_decoding_options_free(options); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) { + if (*out_image != NULL) { + heif_image_release(*out_image); + *out_image = NULL; + } + return status; + } + if (error.code != heif_error_Ok) { + if (*out_image != NULL) { + heif_image_release(*out_image); + *out_image = NULL; + } + return fluxer_native_status_from_heif_error(error); + } + if (*out_image == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_heif_interleaved_plane( + struct heif_image *image, + int width, + int height, + enum heif_chroma chroma, + int storage_bits, + int value_bits, + size_t row_bytes, + const uint8_t **out_plane, + int *out_stride +) { + assert(image != NULL); + assert(out_plane != NULL); + assert(out_stride != NULL); + *out_plane = heif_image_get_plane_readonly( + image, heif_channel_interleaved, out_stride); + if (heif_image_get_primary_width(image) != width || + heif_image_get_primary_height(image) != height) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int actual_storage_bits = heif_image_get_bits_per_pixel( + image, heif_channel_interleaved); + int actual_value_bits = heif_image_get_bits_per_pixel_range( + image, heif_channel_interleaved); + if (heif_image_get_colorspace(image) != heif_colorspace_RGB || + heif_image_get_chroma_format(image) != chroma || + actual_storage_bits != storage_bits || + actual_value_bits != value_bits || *out_plane == NULL || + *out_stride <= 0 || (size_t)*out_stride < row_bytes) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_heif_decode_sdr( + struct heif_image_handle *handle, + uint8_t *destination, + int width, + int height, + long long deadline_monotonic_ms, + const struct fluxer_heif_color_profile *profile +) { + struct heif_image *image = NULL; + int status = fluxer_heif_decode_interleaved( + handle, heif_chroma_interleaved_RGBA, + deadline_monotonic_ms, &image); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + size_t row_bytes = (size_t)width * 4u; + const uint8_t *plane = NULL; + int stride = 0; + status = fluxer_heif_interleaved_plane( + image, width, height, heif_chroma_interleaved_RGBA, + 32, 8, row_bytes, &plane, &stride); + if (status == FLUXER_NATIVE_STATUS_OK) { + for (int row = 0; row < height; row++) { + if (row % FLUXER_HEIF_DEADLINE_ROWS == 0) { + status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) break; + } + memcpy(destination + (size_t)row * row_bytes, + plane + (size_t)row * (size_t)stride, row_bytes); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + } + } + heif_image_release(image); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (profile->icc_size > 0) { + return fluxer_heif_apply_icc_profile( + handle, destination, width, height, + profile->icc_size, deadline_monotonic_ms); + } + if (profile->nclx_present) { + return fluxer_heif_apply_sdr_nclx( + destination, width, height, profile, deadline_monotonic_ms); + } + return status; +} + +struct fluxer_heif_hdr_transform { + int bit_depth; + int mask; + int gamut; + int is_hlg; + const float *linear_lut; + const float *tone_scale_lut; +}; + +static void fluxer_heif_transform_hdr_row( + const uint8_t *source, + uint8_t *destination, + int width, + const struct fluxer_heif_hdr_transform *transform +) { + assert(source != NULL); + assert(destination != NULL); + assert(transform != NULL); + for (int column = 0; column < width; column++) { + const uint8_t *source_pixel = source + (size_t)column * 8u; + uint16_t red_code = + fluxer_heif_read_le16(source_pixel) & transform->mask; + uint16_t green_code = + fluxer_heif_read_le16(source_pixel + 2) & transform->mask; + uint16_t blue_code = + fluxer_heif_read_le16(source_pixel + 4) & transform->mask; + uint16_t red_index = fluxer_hdr_lut_index( + red_code, transform->bit_depth); + uint16_t green_index = fluxer_hdr_lut_index( + green_code, transform->bit_depth); + uint16_t blue_index = fluxer_hdr_lut_index( + blue_code, transform->bit_depth); + float red = transform->linear_lut[red_index]; + float green = transform->linear_lut[green_index]; + float blue = transform->linear_lut[blue_index]; + uint16_t maximum_index = red_index; + if (green_index > maximum_index) maximum_index = green_index; + if (blue_index > maximum_index) maximum_index = blue_index; + if (transform->is_hlg) { + float luma = fluxer_heif_linear_luma( + transform->gamut, red, green, blue); + float ootf_scale = + fluxer_hlg_ootf_scale_lut[fluxer_unit_lut_index(luma)]; + red *= ootf_scale; + green *= ootf_scale; + blue *= ootf_scale; + float maximum = fmaxf(red, fmaxf(green, blue)); + maximum_index = fluxer_unit_lut_index(maximum); + } + float scale = transform->tone_scale_lut[maximum_index]; + uint8_t *destination_pixel = destination + (size_t)column * 4u; + fluxer_hdr_pipeline_pixel( + red, green, blue, scale, transform->gamut, + destination_pixel); + uint16_t alpha = + fluxer_heif_read_le16(source_pixel + 6) & transform->mask; + destination_pixel[3] = (uint8_t)( + (alpha * 255 + (transform->mask >> 1)) / transform->mask); + } +} + +static int fluxer_heif_decode_hdr( + struct heif_image_handle *handle, + uint8_t *destination, + int width, + int height, + long long deadline_monotonic_ms, + const struct fluxer_heif_color_profile *profile +) { + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (pthread_once(&fluxer_hdr_lut_once, fluxer_init_hdr_luts) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int bit_depth = heif_image_handle_get_luma_bits_per_pixel(handle); + if (bit_depth != 10 && bit_depth != 12) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + if (!profile->nclx_present) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + int gamut = FLUXER_HEIF_GAMUT_SRGB; + if (fluxer_heif_nclx_gamut(profile->primaries, &gamut) != + FLUXER_NATIVE_STATUS_OK) { + gamut = FLUXER_HEIF_GAMUT_SRGB; + } + if ((size_t)width > SIZE_MAX / 8u) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + struct heif_image *image = NULL; + status = fluxer_heif_decode_interleaved( + handle, heif_chroma_interleaved_RRGGBBAA_LE, + deadline_monotonic_ms, &image); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + const uint8_t *plane = NULL; + int stride = 0; + status = fluxer_heif_interleaved_plane( + image, width, height, heif_chroma_interleaved_RRGGBBAA_LE, + 64, bit_depth, (size_t)width * 8u, &plane, &stride); + struct fluxer_heif_hdr_transform transform = { + .bit_depth = bit_depth, + .mask = (1 << bit_depth) - 1, + .gamut = gamut, + .is_hlg = + profile->transfer == heif_transfer_characteristic_ITU_R_BT_2100_0_HLG, + .linear_lut = + profile->transfer == heif_transfer_characteristic_ITU_R_BT_2100_0_PQ + ? fluxer_pq_lut + : fluxer_hlg_lut, + .tone_scale_lut = + profile->transfer == heif_transfer_characteristic_ITU_R_BT_2100_0_PQ + ? fluxer_pq_tone_scale_lut + : fluxer_hlg_tone_scale_lut, + }; + if (status == FLUXER_NATIVE_STATUS_OK) { + size_t destination_stride = (size_t)width * 4u; + for (int row = 0; row < height; row++) { + if (row % FLUXER_HEIF_DEADLINE_ROWS == 0) { + status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) break; + } + fluxer_heif_transform_hdr_row( + plane + (size_t)row * (size_t)stride, + destination + (size_t)row * destination_stride, + width, &transform); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_native_deadline_status(deadline_monotonic_ms); + } + } + heif_image_release(image); + return status; +} + +int fluxer_heif_decode_to_sdr_rgba8( + struct heif_image_handle *handle, + uint8_t *destination, + size_t destination_capacity, + int width, + int height, + long long deadline_monotonic_ms, + int *out_was_hdr +) { + if (handle == NULL || destination == NULL || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (out_was_hdr != NULL) *out_was_hdr = 0; + if (heif_image_handle_is_premultiplied_alpha(handle)) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + size_t expected_size = 0; + if (fluxer_heif_checked_rgba_size(width, height, &expected_size) != 0 || + destination_capacity < expected_size) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + struct fluxer_heif_color_profile profile; + int status = fluxer_heif_read_color_profile( + handle, deadline_monotonic_ms, &profile); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (!profile.nclx_present) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + int is_hdr = + profile.transfer == heif_transfer_characteristic_ITU_R_BT_2100_0_PQ || + profile.transfer == heif_transfer_characteristic_ITU_R_BT_2100_0_HLG; + if (!is_hdr) { + if (profile.matrix == heif_matrix_coefficients_unspecified || + profile.matrix == + heif_matrix_coefficients_ITU_R_BT_2020_2_constant_luminance || + profile.matrix == + heif_matrix_coefficients_chromaticity_derived_constant_luminance) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + return fluxer_heif_decode_sdr( + handle, destination, width, height, + deadline_monotonic_ms, &profile); + } + status = fluxer_heif_decode_hdr( + handle, destination, width, height, + deadline_monotonic_ms, &profile); + if (status == FLUXER_NATIVE_STATUS_OK && out_was_hdr != NULL) { + *out_was_hdr = 1; + } + return status; +} diff --git a/fluxer_media_proxy/src/native_shim/native_shim_internal.h b/fluxer_media_proxy/src/native_shim/native_shim_internal.h new file mode 100644 index 000000000..32055eda1 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/native_shim_internal.h @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#define _GNU_SOURCE +#define _DARWIN_C_SOURCE +#define _POSIX_C_SOURCE 200809L + +#include "vips_shim.h" +#include "webp_animation.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if LIBAVFILTER_VERSION_INT < AV_VERSION_INT(11, 0, 100) +#error "media-proxy needs FFmpeg >= 8.0 (buffersink pixel_formats array option)" +#endif +#if !LIBHEIF_HAVE_VERSION(1, 19, 0) +#error "media-proxy needs libheif >= 1.19.0 (security limits + cancel_decoding)" +#endif +#if VIPS_MAJOR_VERSION < 8 || (VIPS_MAJOR_VERSION == 8 && VIPS_MINOR_VERSION < 13) +#error "media-proxy needs libvips >= 8.13 (vips_block_untrusted_set)" +#endif + +#define FLUXER_MAX_VIDEO_FRAME_DIMENSION 16384 +#define FLUXER_MAX_VIDEO_RGBA_BYTES ((size_t)128 * 1024 * 1024) +#define FLUXER_MAX_VIDEO_PIXELS (FLUXER_MAX_VIDEO_RGBA_BYTES / 4u) +#define FLUXER_ALLOWED_VIDEO_DECODERS \ + "gif,apng,h264,hevc,vp8,vp9,libvpx-vp9,av1,libaom-av1,libdav1d," \ + "mpeg1video,mpeg2video,mpeg4,h263,theora,flv,vp6,vp6f,vp6a," \ + "wmv1,wmv2,wmv3,vc1,mjpeg,bmp" +#define FLUXER_AV_INPUT_FORMATS \ + "mov,matroska,webm,avi,flv,ogg,mpegts,mpeg,mpegvideo,asf,mp3,wav,flac,aac,aiff" +#define FLUXER_AV_BMP_INPUT_FORMAT "bmp_pipe" +#define FLUXER_NSFW_FRAME_MAX_DIMENSION 512 +#define FLUXER_MAX_VIDEO_THUMBNAIL_PACKETS 512 +#define FLUXER_ANIMATION_PACKETS_PER_FRAME_LIMIT 4 +#define FLUXER_MIN_ANIMATION_FRAME_DELAY_MS 20 +/* A frame that declares no delay at all is not a "very fast" frame: GIF authoring tools emit 0 + * and every browser renders it at 100 ms. Clamping it to the 20 ms minimum instead would play + * the animation five times too fast. */ +#define FLUXER_DEFAULT_ANIMATION_FRAME_DELAY_MS 100 +#define FLUXER_MAX_AV_STREAMS 32u +#define FLUXER_MAX_GIF_STRUCTURE_BLOCKS 262144u +#define FLUXER_MAX_APNG_CHUNKS 262144u +#define FLUXER_WEBP_MAX_FRAME_DURATION_MS ((1 << 24) - 1) +#define FLUXER_MAX_THREADS_PER_PIPELINE 4 +#define FLUXER_VIDEO_DEADLINE_ROWS 64 +#define FLUXER_MAX_NSFW_SAMPLES 3 + +struct ff_mem_reader { + const uint8_t *data; + size_t len; + size_t offset; +}; + +typedef struct { + struct ff_mem_reader reader; + unsigned char *avio_buffer; + AVIOContext *avio; + AVFormatContext *format; + AVCodecContext *decoder; + AVPacket *packet; + AVFrame *frame; + AVStream *stream; + int stream_index; + int strict_decode; + long long deadline_monotonic_ms; +} ff_thumbnail_context; + +int fluxer_ffmpeg_decoder_threads_valid(int decoder_threads); +int fluxer_native_deadline_status(long long deadline_monotonic_ms); +int fluxer_ffmpeg_interrupt_deadline(void *opaque); +int fluxer_native_status_from_av_error(int error); +int fluxer_native_status_from_av_error_with_deadline( + int error, long long deadline_monotonic_ms); +int fluxer_native_status_from_heif_error(struct heif_error error); +int ff_mem_read_packet(void *opaque, uint8_t *buf, int buf_size); +int64_t ff_mem_seek(void *opaque, int64_t offset, int whence); +int fluxer_prepare_untrusted_av_input(AVFormatContext *format); +int fluxer_restrict_untrusted_av_context(AVFormatContext *format, + const char *format_whitelist); +int fluxer_find_stream_info_bounded(AVFormatContext *format); +int fluxer_video_decoder_allowed(const AVStream *stream, const AVCodec *decoder); +int ff_validate_rgba_geometry(int width, int height, size_t *out_size); +int ff_stream_is_attached_picture(const AVStream *stream); +int ff_find_primary_video_stream(AVFormatContext *format, const AVCodec **out_codec); +int fluxer_av_frame_convert_to_rgba(AVFrame *frame, int source_width, + int source_height, int output_width, + int output_height, struct SwsContext **sws, + long long deadline_monotonic_ms, + uint8_t *dst); +int fluxer_validate_complete_apng(const uint8_t *data, size_t len, + int max_frames, size_t max_total_pixels, + int *out_width, int *out_height, + int *out_expected_frames, + uint32_t *out_num_plays); +int fluxer_vips_read_animation_delays_ms(VipsImage *image, int n_pages, + int **out_delays); +int fluxer_heif_checked_rgba_size(int width, int height, size_t *out_size); +int fluxer_heif_detect_hdr_gain_map(struct heif_image_handle *handle, + long long deadline_monotonic_ms, + int *detected); +int fluxer_heif_decode_to_sdr_rgba8(struct heif_image_handle *handle, + uint8_t *destination, + size_t destination_capacity, + int width, int height, + long long deadline_monotonic_ms, + int *out_was_hdr); +int fluxer_vips_extract_animation_rgba_strip(VipsImage *input, + uint8_t *destination, + size_t destination_capacity, + long long deadline_monotonic_ms, + size_t *out_size); +uint16_t fluxer_gif_read_le16(const uint8_t *value); +int fluxer_skip_gif_sub_blocks_checked(const uint8_t *bytes, size_t len, + size_t *offset, size_t *block_count); +int fluxer_validate_complete_gif(const uint8_t *bytes, size_t len, + int max_frames, size_t max_total_pixels, + int *out_expected_frames); +int fluxer_read_gif_frame_delays(const uint8_t *bytes, size_t len, + int *delays_cs, int expected_frames, + int max_duration_ms, + int *out_loop_count); +int fluxer_patch_gif_frame_delays(uint8_t *bytes, size_t len, + const int *delays_cs, int n_delays); +int fluxer_gif_animation_frame_budget(const int *delays_cs, int n_frames, + int max_frames, int max_duration_ms, + int *out_frames); +int fluxer_gif_setup_filter_graph(AVFilterGraph **out_graph, + AVFilterContext **out_source, + AVFilterContext **out_sink, + int source_width, int source_height, + enum AVPixelFormat source_format, + AVRational frame_time_base, + int target_width, int target_height); +int fluxer_vips_image_write_to_buffer_bounded(VipsImage *image, + const char *suffix, + long long deadline_monotonic_ms, + size_t max_output_size, + void **out_buf, + size_t *out_size, + size_t *out_capacity); +int fluxer_vips_image_write_to_memory_deadline( + VipsImage *image, long long deadline_monotonic_ms, + size_t max_output_size, void **out_buf, size_t *out_size); +int ff_fit_frame_image(VipsImage **image, int max_width, int max_height); +int ff_emit_frame_thumbnail(AVFrame *frame, AVCodecContext *dec_ctx, + AVFormatContext *in_fmt, AVStream *in_stream, + const char *suffix, int max_width, int max_height, + long long deadline_monotonic_ms, + size_t max_output_size, + int *out_display_width, int *out_display_height, + void **out_buf, size_t *out_size, + size_t *out_capacity); +void ff_thumbnail_context_clear(ff_thumbnail_context *context); +int ff_thumbnail_context_open(ff_thumbnail_context *context, + const void *media_data, size_t media_len, + const char *format_whitelist, int strict_decode, + long long deadline_monotonic_ms); +int ff_thumbnail_decoder_open(ff_thumbnail_context *context, int decoder_threads); +void fluxer_nsfw_frames_reset(struct fluxer_nsfw_frame_out *frames, + size_t count); +int fluxer_nsfw_animation_selection_valid(const int *indices, + size_t count, + int expected_frames); diff --git a/fluxer_media_proxy/src/native_shim/nsfw_frame.c b/fluxer_media_proxy/src/native_shim/nsfw_frame.c new file mode 100644 index 000000000..ea90992aa --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/nsfw_frame.c @@ -0,0 +1,958 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +#define FLUXER_MAX_VIDEO_PACKETS_FOR_NSFW 512 +#define FLUXER_MAX_VIDEO_FRAMES_FOR_NSFW_PER_SAMPLE 512 +#define FLUXER_NSFW_BYTE_SEEK_REWIND (5u * 1024u * 1024u) + +void fluxer_nsfw_frames_free(struct fluxer_nsfw_frame_out *frames, size_t n) { + if (frames == NULL) return; + for (size_t i = 0; i < n; i++) { + if (frames[i].data != NULL) { + g_free(frames[i].data); + frames[i].data = NULL; + } + frames[i].len = 0; + } +} + +void fluxer_nsfw_frames_reset(struct fluxer_nsfw_frame_out *frames, size_t n) { + assert(n <= FLUXER_MAX_NSFW_SAMPLES); + if (frames == NULL) return; + for (size_t i = 0; i < n; i++) { + frames[i].data = NULL; + frames[i].len = 0; + } +} + +static int fluxer_seconds_to_pts( + double seconds, + AVRational time_base, + int64_t *out_pts +) { + if (out_pts == NULL) return -1; + *out_pts = 0; + if (!isfinite(seconds) || seconds < 0.0) return -1; + if (time_base.num <= 0 || time_base.den <= 0) return -1; + long double scaled = (long double)seconds * (long double)time_base.den / + (long double)time_base.num; + if (!isfinite(scaled) || scaled < 0.0L || scaled > (long double)INT64_MAX) return -1; + *out_pts = (int64_t)scaled; + return 0; +} + +static int fluxer_emit_nsfw_frame( + AVFrame *frame, + AVCodecContext *dec_ctx, + AVFormatContext *in_fmt, + AVStream *in_stream, + long long deadline_monotonic_ms, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out +) { + if (out == NULL || max_frame_output_size == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + void *out_buf = NULL; + size_t out_size = 0; + size_t out_capacity = 0; + int rc = ff_emit_frame_thumbnail( + frame, dec_ctx, in_fmt, in_stream, ".jpg[Q=65,strip]", + FLUXER_NSFW_FRAME_MAX_DIMENSION, + FLUXER_NSFW_FRAME_MAX_DIMENSION, + deadline_monotonic_ms, + max_frame_output_size, + NULL, NULL, + &out_buf, &out_size, &out_capacity); + if (rc != FLUXER_NATIVE_STATUS_OK) { + if (out_buf != NULL) g_free(out_buf); + return rc; + } + if (out_buf == NULL || out_size == 0 || out_capacity < out_size || + out_capacity > max_frame_output_size) { + if (out_buf != NULL) g_free(out_buf); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + out->data = out_buf; + out->len = out_size; + return FLUXER_NATIVE_STATUS_OK; +} + +enum ff_selected_receive_result { + FF_SELECTED_RECEIVE_MORE = 100, + FF_SELECTED_RECEIVE_EOF = 101 +}; + +struct ff_animation_selection { + const int *indices; + size_t count; + size_t next; + int decoded; + int expected; + size_t max_frame_output_size; + struct fluxer_nsfw_frame_out *outputs; +}; + +static int ff_receive_selected_animation_frames( + ff_thumbnail_context *context, + struct ff_animation_selection *selection +) { + while (1) { + int deadline_status = fluxer_native_deadline_status( + context->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + int receive_rc = avcodec_receive_frame(context->decoder, context->frame); + if (receive_rc == AVERROR(EAGAIN)) return FF_SELECTED_RECEIVE_MORE; + if (receive_rc == AVERROR_EOF) return FF_SELECTED_RECEIVE_EOF; + if (receive_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + receive_rc, context->deadline_monotonic_ms); + } + if (context->frame->decode_error_flags != 0 || + selection->decoded >= selection->expected) { + av_frame_unref(context->frame); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int frame_index = selection->decoded++; + if (frame_index > selection->indices[selection->next]) { + av_frame_unref(context->frame); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (frame_index == selection->indices[selection->next]) { + int emit_rc = fluxer_emit_nsfw_frame( + context->frame, context->decoder, context->format, context->stream, + context->deadline_monotonic_ms, + selection->max_frame_output_size, + &selection->outputs[selection->next]); + if (emit_rc != FLUXER_NATIVE_STATUS_OK) { + av_frame_unref(context->frame); + return emit_rc; + } + selection->next++; + } + av_frame_unref(context->frame); + } +} + +int fluxer_nsfw_animation_selection_valid( + const int *indices, + size_t count, + int expected_frames +) { + if (indices == NULL || count == 0 || count > FLUXER_MAX_NSFW_SAMPLES) return 0; + if (expected_frames <= 0 || indices[0] != 0) return 0; + for (size_t i = 0; i < count; i++) { + if (indices[i] < 0 || indices[i] >= expected_frames) return 0; + if (i > 0 && indices[i] <= indices[i - 1]) return 0; + } + return indices[count - 1] == expected_frames - 1; +} + +static int ff_read_selected_animation_packets( + ff_thumbnail_context *context, + struct ff_animation_selection *selection, + int packet_limit, + int *out_read_status +) { + assert(context != NULL); + assert(selection != NULL); + assert(packet_limit > 0); + assert(out_read_status != NULL); + int packets_read = 0; + int read_status = 0; + while (1) { + int deadline_status = fluxer_native_deadline_status( + context->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + read_status = av_read_frame(context->format, context->packet); + if (read_status < 0) break; + packets_read++; + if (packets_read > packet_limit) { + av_packet_unref(context->packet); + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (context->packet->stream_index != context->stream_index) { + av_packet_unref(context->packet); + continue; + } + int send_status = avcodec_send_packet( + context->decoder, context->packet); + av_packet_unref(context->packet); + if (send_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + send_status, context->deadline_monotonic_ms); + } + int receive_status = ff_receive_selected_animation_frames( + context, selection); + if (receive_status == FF_SELECTED_RECEIVE_EOF) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (receive_status != FF_SELECTED_RECEIVE_MORE) return receive_status; + } + *out_read_status = read_status; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_flush_selected_animation_frames( + ff_thumbnail_context *context, + struct ff_animation_selection *selection, + int read_status +) { + assert(context != NULL); + assert(selection != NULL); + if (read_status != AVERROR_EOF) { + return fluxer_native_status_from_av_error_with_deadline( + read_status, context->deadline_monotonic_ms); + } + int deadline_status = fluxer_native_deadline_status( + context->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int send_status = avcodec_send_packet(context->decoder, NULL); + if (send_status < 0) { + return fluxer_native_status_from_av_error_with_deadline( + send_status, context->deadline_monotonic_ms); + } + int receive_status = ff_receive_selected_animation_frames( + context, selection); + if (receive_status == FF_SELECTED_RECEIVE_EOF) { + return FLUXER_NATIVE_STATUS_OK; + } + if (receive_status == FF_SELECTED_RECEIVE_MORE) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return receive_status; +} + +static int ff_extract_selected_animation_frames( + const void *media_data, + size_t media_len, + int decoder_threads, + long long deadline_monotonic_ms, + const char *allowed_format, + const int *frame_indices, + size_t n_indices, + int expected_frames, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out_frames +) { + if (deadline_monotonic_ms < 0 || + !fluxer_ffmpeg_decoder_threads_valid(decoder_threads)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!fluxer_nsfw_animation_selection_valid( + frame_indices, n_indices, expected_frames)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (allowed_format == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (out_frames == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (max_frame_output_size == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (expected_frames > + INT_MAX / FLUXER_ANIMATION_PACKETS_PER_FRAME_LIMIT) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + + ff_thumbnail_context context; + int status = ff_thumbnail_context_open( + &context, media_data, media_len, allowed_format, 1, + deadline_monotonic_ms); + if (status == FLUXER_NATIVE_STATUS_OK) { + status = ff_thumbnail_decoder_open(&context, decoder_threads); + } + struct ff_animation_selection selection = { + .indices = frame_indices, + .count = n_indices, + .expected = expected_frames, + .max_frame_output_size = max_frame_output_size, + .outputs = out_frames, + }; + int read_status = 0; + if (status == FLUXER_NATIVE_STATUS_OK) { + int packet_limit = + expected_frames * FLUXER_ANIMATION_PACKETS_PER_FRAME_LIMIT; + status = ff_read_selected_animation_packets( + &context, &selection, packet_limit, &read_status); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = ff_flush_selected_animation_frames( + &context, &selection, read_status); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + if (selection.next != selection.count) { + status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } else if (selection.decoded != expected_frames) { + status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } + ff_thumbnail_context_clear(&context); + if (status != FLUXER_NATIVE_STATUS_OK) { + fluxer_nsfw_frames_free(out_frames, n_indices); + } + return status; +} +int fluxer_ffmpeg_extract_apng_frames_for_nsfw( + const void *apng_data, + size_t apng_len, + int decoder_threads, + long long deadline_monotonic_ms, + const int *frame_indices, + size_t n_indices, + int max_frames, + size_t max_total_pixels, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out_frames +) { + if (n_indices > FLUXER_MAX_NSFW_SAMPLES) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + fluxer_nsfw_frames_reset(out_frames, n_indices); + if (apng_data == NULL || apng_len == 0 || frame_indices == NULL || + out_frames == NULL || n_indices == 0 || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (max_frames <= 0 || max_total_pixels == 0 || max_frame_output_size == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int expected_frames = 0; + int rc = fluxer_validate_complete_apng( + apng_data, apng_len, max_frames, max_total_pixels, + NULL, NULL, &expected_frames, NULL); + if (rc != 0) return rc; + deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + return ff_extract_selected_animation_frames( + apng_data, apng_len, decoder_threads, deadline_monotonic_ms, + "apng", frame_indices, n_indices, + expected_frames, max_frame_output_size, out_frames); +} + +int fluxer_ffmpeg_extract_gif_frames_for_nsfw( + const void *gif_data, + size_t gif_len, + int decoder_threads, + long long deadline_monotonic_ms, + const int *frame_indices, + size_t n_indices, + int max_frames, + size_t max_total_pixels, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out_frames +) { + if (n_indices > FLUXER_MAX_NSFW_SAMPLES) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + fluxer_nsfw_frames_reset(out_frames, n_indices); + if (gif_data == NULL || gif_len == 0 || frame_indices == NULL || + out_frames == NULL || n_indices == 0 || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (max_frames <= 0 || max_total_pixels == 0 || max_frame_output_size == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int expected_frames = 0; + int rc = fluxer_validate_complete_gif( + gif_data, gif_len, max_frames, max_total_pixels, &expected_frames); + if (rc != 0) return rc; + deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + return ff_extract_selected_animation_frames( + gif_data, gif_len, decoder_threads, deadline_monotonic_ms, + "gif", frame_indices, n_indices, + expected_frames, max_frame_output_size, out_frames); +} + +static int fluxer_nsfw_uses_byte_seek(const AVFormatContext *format) { + if (format == NULL || format->iformat == NULL || format->iformat->name == NULL) return 0; + const char *name = format->iformat->name; + return strcmp(name, "mpegts") == 0 || + strcmp(name, "mpeg") == 0 || + strcmp(name, "mpegvideo") == 0; +} + +static int fluxer_nsfw_seek( + AVFormatContext *format, + int stream_index, + int64_t target_pts, + double target_seconds, + size_t media_len, + long long deadline_monotonic_ms +) { + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (fluxer_nsfw_uses_byte_seek(format) && + format->duration > 0 && media_len > 0) { + double duration_seconds = + (double)format->duration / (double)AV_TIME_BASE; + if (isfinite(duration_seconds) && duration_seconds > 0.0) { + double ratio = fmax( + 0.0, fmin(1.0, target_seconds / duration_seconds)); + long double projected_offset = + floorl((long double)media_len * (long double)ratio); + size_t offset = media_len; + if (projected_offset < (long double)media_len) { + offset = (size_t)projected_offset; + } + if (offset > FLUXER_NSFW_BYTE_SEEK_REWIND) { + offset -= FLUXER_NSFW_BYTE_SEEK_REWIND; + } else { + offset = 0; + } + int rc = av_seek_frame( + format, -1, (int64_t)offset, AVSEEK_FLAG_BYTE | AVSEEK_FLAG_BACKWARD); + deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + if (rc >= 0) return FLUXER_NATIVE_STATUS_OK; + } + } + int rc = av_seek_frame(format, stream_index, target_pts, AVSEEK_FLAG_BACKWARD); + deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (rc >= 0) return FLUXER_NATIVE_STATUS_OK; + rc = avformat_seek_file( + format, stream_index, INT64_MIN, target_pts, target_pts, AVSEEK_FLAG_BACKWARD); + if (rc >= 0) return fluxer_native_deadline_status(deadline_monotonic_ms); + return fluxer_native_status_from_av_error_with_deadline( + rc, deadline_monotonic_ms); +} + +struct fluxer_nsfw_target { + double seconds; + int64_t pts; + int64_t keyframe_position; + int64_t keyframe_pts; + int has_indexed_keyframe; +}; + +static int fluxer_nsfw_target_pts( + AVStream *stream, + double seconds, + int64_t *out_pts +) { + assert(stream != NULL); + assert(out_pts != NULL); + int64_t target_pts = 0; + if (fluxer_seconds_to_pts(seconds, stream->time_base, &target_pts) != 0) return -1; + if (stream->start_time == AV_NOPTS_VALUE) { + *out_pts = target_pts; + return 0; + } + if (stream->start_time > 0 && target_pts > INT64_MAX - stream->start_time) { + return -1; + } + if (stream->start_time < 0 && target_pts < INT64_MIN - stream->start_time) { + return -1; + } + *out_pts = target_pts + stream->start_time; + return 0; +} + +static int fluxer_nsfw_targets_init( + AVFormatContext *format, + AVStream *stream, + const double *timestamps, + size_t count, + long long deadline_monotonic_ms, + struct fluxer_nsfw_target *targets +) { + assert(stream != NULL); + assert(timestamps != NULL); + assert(targets != NULL); + assert(count <= FLUXER_MAX_NSFW_SAMPLES); + int indexed_seek = !fluxer_nsfw_uses_byte_seek(format); + for (size_t i = 0; i < count; i++) { + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + double seconds = timestamps[i]; + assert(isfinite(seconds)); + assert(seconds >= 0.0); + targets[i].seconds = seconds; + if (fluxer_nsfw_target_pts(stream, seconds, &targets[i].pts) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + targets[i].keyframe_position = -1; + targets[i].keyframe_pts = AV_NOPTS_VALUE; + targets[i].has_indexed_keyframe = 0; + if (!indexed_seek) continue; + const AVIndexEntry *entry = avformat_index_get_entry_from_timestamp( + stream, targets[i].pts, AVSEEK_FLAG_BACKWARD); + if (entry == NULL || entry->pos < 0 || (entry->flags & AVINDEX_KEYFRAME) == 0) continue; + targets[i].keyframe_position = entry->pos; + targets[i].keyframe_pts = entry->timestamp; + targets[i].has_indexed_keyframe = 1; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static size_t fluxer_nsfw_group_limit( + const struct fluxer_nsfw_target *targets, + size_t count, + size_t first +) { + assert(targets != NULL); + assert(first < count); + assert(count <= FLUXER_MAX_NSFW_SAMPLES); + const struct fluxer_nsfw_target *origin = &targets[first]; + size_t limit = first + 1; + while (limit < count) { + const struct fluxer_nsfw_target *previous = &targets[limit - 1]; + const struct fluxer_nsfw_target *next = &targets[limit]; + if (!origin->has_indexed_keyframe || !next->has_indexed_keyframe) break; + if (next->pts < previous->pts) break; + if (next->keyframe_position != origin->keyframe_position) break; + if (next->keyframe_pts != origin->keyframe_pts) break; + limit++; + } + return limit; +} + +static int fluxer_nsfw_decoder_open(ff_thumbnail_context *context, int decoder_threads) { + assert(context != NULL); + assert(fluxer_ffmpeg_decoder_threads_valid(decoder_threads)); + int status = fluxer_native_deadline_status( + context->deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + const AVCodec *decoder = NULL; + context->stream_index = ff_find_primary_video_stream(context->format, &decoder); + if (context->stream_index < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + context->stream = context->format->streams[context->stream_index]; + if (context->stream == NULL || context->stream->codecpar == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int width = context->stream->codecpar->width; + int height = context->stream->codecpar->height; + if (width <= 0 || height <= 0 || + width > FLUXER_MAX_VIDEO_FRAME_DIMENSION || + height > FLUXER_MAX_VIDEO_FRAME_DIMENSION || + (size_t)width > FLUXER_MAX_VIDEO_PIXELS / (size_t)height) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (decoder == NULL) decoder = avcodec_find_decoder(context->stream->codecpar->codec_id); + if (!fluxer_video_decoder_allowed(context->stream, decoder)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + context->decoder = avcodec_alloc_context3(decoder); + if (context->decoder == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + int parameters_rc = avcodec_parameters_to_context( + context->decoder, context->stream->codecpar); + if (parameters_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + parameters_rc, context->deadline_monotonic_ms); + } + context->decoder->max_pixels = (int64_t)FLUXER_MAX_VIDEO_PIXELS; + context->decoder->thread_count = decoder_threads; + int open_rc = avcodec_open2(context->decoder, decoder, NULL); + if (open_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + open_rc, context->deadline_monotonic_ms); + } + status = fluxer_native_deadline_status(context->deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->packet = av_packet_alloc(); + context->frame = av_frame_alloc(); + if (context->packet == NULL || context->frame == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +enum fluxer_nsfw_receive_result { + FLUXER_NSFW_RECEIVE_MORE = 100, + FLUXER_NSFW_RECEIVE_EOF = 101, + FLUXER_NSFW_RECEIVE_COMPLETE = 102, + FLUXER_NSFW_RECEIVE_RESEEK = 103 +}; + +struct fluxer_nsfw_decode_group { + ff_thumbnail_context *context; + AVFrame *candidate; + const struct fluxer_nsfw_target *targets; + struct fluxer_nsfw_frame_out *outputs; + size_t next; + size_t limit; + int packets; + int decoded_frames; + size_t max_frame_output_size; +}; + +static int fluxer_nsfw_copy_output( + const struct fluxer_nsfw_frame_out *source, + long long deadline_monotonic_ms, + struct fluxer_nsfw_frame_out *destination +) { + assert(source != NULL); + assert(destination != NULL); + if (source->data == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (source->len == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + void *copy = g_try_malloc(source->len); + if (copy == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + memcpy(copy, source->data, source->len); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) { + g_free(copy); + return status; + } + destination->data = copy; + destination->len = source->len; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_nsfw_emit_frame_range( + struct fluxer_nsfw_decode_group *group, + AVFrame *frame, + size_t first, + size_t limit +) { + assert(group != NULL); + assert(frame != NULL); + assert(first < limit); + assert(limit <= group->limit); + int status = fluxer_emit_nsfw_frame( + frame, group->context->decoder, group->context->format, + group->context->stream, group->context->deadline_monotonic_ms, + group->max_frame_output_size, + &group->outputs[first]); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + for (size_t index = first + 1; index < limit; index++) { + status = fluxer_nsfw_copy_output( + &group->outputs[first], group->context->deadline_monotonic_ms, + &group->outputs[index]); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static void fluxer_nsfw_keep_candidate(struct fluxer_nsfw_decode_group *group) { + assert(group != NULL); + assert(group->context != NULL); + assert(group->context->frame != NULL); + assert(group->candidate != NULL); + av_frame_unref(group->candidate); + av_frame_move_ref(group->candidate, group->context->frame); +} + +static int fluxer_nsfw_consume_frame(struct fluxer_nsfw_decode_group *group) { + assert(group != NULL); + assert(group->next < group->limit); + AVFrame *frame = group->context->frame; + int64_t frame_pts = frame->best_effort_timestamp; + if (frame_pts == AV_NOPTS_VALUE) { + int rc = fluxer_emit_nsfw_frame( + frame, group->context->decoder, group->context->format, + group->context->stream, group->context->deadline_monotonic_ms, + group->max_frame_output_size, + &group->outputs[group->next]); + av_frame_unref(frame); + if (rc != FLUXER_NATIVE_STATUS_OK) return rc; + group->next++; + return group->next == group->limit + ? FLUXER_NSFW_RECEIVE_COMPLETE + : FLUXER_NSFW_RECEIVE_RESEEK; + } + if (frame_pts < group->targets[group->next].pts) { + fluxer_nsfw_keep_candidate(group); + return FLUXER_NSFW_RECEIVE_MORE; + } + size_t first = group->next; + size_t limit = first; + while (limit < group->limit && frame_pts >= group->targets[limit].pts) { + limit++; + } + int rc = fluxer_nsfw_emit_frame_range(group, frame, first, limit); + if (rc != FLUXER_NATIVE_STATUS_OK) { + av_frame_unref(frame); + return rc; + } + group->next = limit; + if (group->next == group->limit) { + av_frame_unref(frame); + return FLUXER_NSFW_RECEIVE_COMPLETE; + } + fluxer_nsfw_keep_candidate(group); + return FLUXER_NSFW_RECEIVE_MORE; +} + +static int fluxer_nsfw_receive_frames(struct fluxer_nsfw_decode_group *group) { + assert(group != NULL); + while (1) { + int deadline_status = fluxer_native_deadline_status( + group->context->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + int rc = avcodec_receive_frame(group->context->decoder, group->context->frame); + if (rc == AVERROR(EAGAIN)) return FLUXER_NSFW_RECEIVE_MORE; + if (rc == AVERROR_EOF) return FLUXER_NSFW_RECEIVE_EOF; + if (rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + rc, group->context->deadline_monotonic_ms); + } + group->decoded_frames++; + if (group->decoded_frames > FLUXER_MAX_VIDEO_FRAMES_FOR_NSFW_PER_SAMPLE) { + av_frame_unref(group->context->frame); + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + int consume_rc = fluxer_nsfw_consume_frame(group); + if (consume_rc != FLUXER_NSFW_RECEIVE_MORE) return consume_rc; + } +} + +static int fluxer_nsfw_emit_candidate(struct fluxer_nsfw_decode_group *group) { + assert(group != NULL); + if (group->candidate->data[0] == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + size_t first = group->next; + int status = fluxer_nsfw_emit_frame_range( + group, group->candidate, first, group->limit); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + group->next = group->limit; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_nsfw_decode_group(struct fluxer_nsfw_decode_group *group) { + assert(group != NULL); + assert(group->next < group->limit); + int read_rc = 0; + while (group->packets < FLUXER_MAX_VIDEO_PACKETS_FOR_NSFW) { + int deadline_status = fluxer_native_deadline_status( + group->context->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + read_rc = av_read_frame(group->context->format, group->context->packet); + if (read_rc < 0) break; + group->packets++; + if (group->context->packet->stream_index != group->context->stream_index) { + av_packet_unref(group->context->packet); + continue; + } + int send_rc = avcodec_send_packet(group->context->decoder, group->context->packet); + av_packet_unref(group->context->packet); + if (send_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + send_rc, group->context->deadline_monotonic_ms); + } + int receive_rc = fluxer_nsfw_receive_frames(group); + if (receive_rc == FLUXER_NSFW_RECEIVE_EOF) { + return fluxer_nsfw_emit_candidate(group); + } + if (receive_rc == FLUXER_NSFW_RECEIVE_COMPLETE || + receive_rc == FLUXER_NSFW_RECEIVE_RESEEK) { + return FLUXER_NATIVE_STATUS_OK; + } + if (receive_rc != FLUXER_NSFW_RECEIVE_MORE) return receive_rc; + } + if (group->packets == FLUXER_MAX_VIDEO_PACKETS_FOR_NSFW && read_rc >= 0) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (read_rc < 0 && read_rc != AVERROR_EOF) { + return fluxer_native_status_from_av_error_with_deadline( + read_rc, group->context->deadline_monotonic_ms); + } + int send_rc = avcodec_send_packet(group->context->decoder, NULL); + if (send_rc < 0 && send_rc != AVERROR_EOF) { + return fluxer_native_status_from_av_error_with_deadline( + send_rc, group->context->deadline_monotonic_ms); + } + int receive_rc = fluxer_nsfw_receive_frames(group); + if (receive_rc == FLUXER_NSFW_RECEIVE_COMPLETE || + receive_rc == FLUXER_NSFW_RECEIVE_RESEEK) { + return FLUXER_NATIVE_STATUS_OK; + } + if (receive_rc == FLUXER_NSFW_RECEIVE_EOF) { + return fluxer_nsfw_emit_candidate(group); + } + if (receive_rc == FLUXER_NSFW_RECEIVE_MORE) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return receive_rc; +} + +static int fluxer_av_nsfw_request_valid( + const void *media_data, + size_t media_len, + int decoder_threads, + const double *timestamps, + size_t timestamp_count, + long long deadline_monotonic_ms, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *outputs +) { + if (media_data == NULL) return 0; + if (media_len == 0) return 0; + if (timestamps == NULL) return 0; + if (outputs == NULL) return 0; + if (timestamp_count == 0) return 0; + if (timestamp_count > FLUXER_MAX_NSFW_SAMPLES) return 0; + if (deadline_monotonic_ms < 0) return 0; + if (max_frame_output_size == 0) return 0; + if (!fluxer_ffmpeg_decoder_threads_valid(decoder_threads)) return 0; + for (size_t index = 0; index < timestamp_count; index++) { + if (!isfinite(timestamps[index])) return 0; + if (timestamps[index] < 0.0) return 0; + } + return 1; +} + +static int fluxer_nsfw_sample_failure_fatal(int status) { + return status == FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED || + status == FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; +} + +static size_t fluxer_nsfw_produced_frames( + const struct fluxer_nsfw_frame_out *outputs, + size_t count +) { + assert(outputs != NULL); + size_t produced = 0; + for (size_t index = 0; index < count; index++) { + if (outputs[index].data != NULL) produced++; + } + return produced; +} + +static int fluxer_nsfw_decode_targets( + ff_thumbnail_context *context, + AVFrame *candidate, + const struct fluxer_nsfw_target *targets, + size_t target_count, + size_t media_len, + long long deadline_monotonic_ms, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *outputs +) { + assert(context != NULL); + assert(candidate != NULL); + assert(targets != NULL); + assert(target_count > 0); + assert(target_count <= FLUXER_MAX_NSFW_SAMPLES); + assert(outputs != NULL); + size_t next = 0; + int first_failure = FLUXER_NATIVE_STATUS_OK; + while (next < target_count) { + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + size_t limit = fluxer_nsfw_group_limit( + targets, target_count, next); + size_t first = next; + av_frame_unref(candidate); + struct fluxer_nsfw_decode_group group = { + .context = context, + .candidate = candidate, + .targets = targets, + .outputs = outputs, + .next = next, + .limit = limit, + .max_frame_output_size = max_frame_output_size, + }; + int status = fluxer_nsfw_seek( + context->format, + context->stream_index, + targets[next].pts, + targets[next].seconds, + media_len, + deadline_monotonic_ms); + if (status == FLUXER_NATIVE_STATUS_OK) { + avcodec_flush_buffers(context->decoder); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_nsfw_decode_group(&group); + } + if (status == FLUXER_NATIVE_STATUS_OK && group.next > first) { + next = group.next; + continue; + } + if (fluxer_nsfw_sample_failure_fatal(status)) return status; + /* A sample the decoder cannot reach falls back to the last frame it did + decode, and otherwise leaves its slot empty, so the samples that did + decode still reach the classifier. */ + if (group.next < limit && outputs[group.next].data == NULL) { + (void)fluxer_nsfw_emit_candidate(&group); + } + if (first_failure == FLUXER_NATIVE_STATUS_OK) first_failure = status; + next = limit; + } + if (fluxer_nsfw_produced_frames(outputs, target_count) == 0) { + return first_failure != FLUXER_NATIVE_STATUS_OK + ? first_failure + : FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_av_extract_frames_for_nsfw( + const void *media_data, + size_t media_len, + int decoder_threads, + long long deadline_monotonic_ms, + const double *timestamps_secs, + size_t n_timestamps, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out_frames +) { + if (n_timestamps > FLUXER_MAX_NSFW_SAMPLES) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + fluxer_nsfw_frames_reset(out_frames, n_timestamps); + if (!fluxer_av_nsfw_request_valid( + media_data, media_len, decoder_threads, timestamps_secs, + n_timestamps, deadline_monotonic_ms, + max_frame_output_size, out_frames)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + ff_thumbnail_context context; + int status = ff_thumbnail_context_open( + &context, media_data, media_len, FLUXER_AV_INPUT_FORMATS, 0, + deadline_monotonic_ms); + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_nsfw_decoder_open(&context, decoder_threads); + } + AVFrame *candidate = NULL; + if (status == FLUXER_NATIVE_STATUS_OK) { + candidate = av_frame_alloc(); + if (candidate == NULL) { + status = FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + } + struct fluxer_nsfw_target targets[FLUXER_MAX_NSFW_SAMPLES]; + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_nsfw_targets_init( + context.format, context.stream, timestamps_secs, + n_timestamps, deadline_monotonic_ms, targets); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_nsfw_decode_targets( + &context, candidate, targets, n_timestamps, media_len, + deadline_monotonic_ms, + max_frame_output_size, out_frames); + } + + if (candidate != NULL) av_frame_free(&candidate); + ff_thumbnail_context_clear(&context); + if (status != FLUXER_NATIVE_STATUS_OK) { + fluxer_nsfw_frames_free(out_frames, n_timestamps); + } + return status; +} diff --git a/fluxer_media_proxy/src/native_shim/nsfw_webp.c b/fluxer_media_proxy/src/native_shim/nsfw_webp.c new file mode 100644 index 000000000..0cc54f0ce --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/nsfw_webp.c @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +static int fluxer_emit_rgba_nsfw_frame( + const uint8_t *rgba, + int width, + int height, + long long deadline_monotonic_ms, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out +) { + size_t rgba_size = 0; + if (rgba == NULL || out == NULL || deadline_monotonic_ms < 0 || + max_frame_output_size == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int deadline_status = fluxer_native_deadline_status( + deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (ff_validate_rgba_geometry(width, height, &rgba_size) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + VipsImage *image = vips_image_new_from_memory( + rgba, rgba_size, width, height, 4, VIPS_FORMAT_UCHAR); + if (image == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + int rc = ff_fit_frame_image( + &image, FLUXER_NSFW_FRAME_MAX_DIMENSION, FLUXER_NSFW_FRAME_MAX_DIMENSION); + deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) rc = deadline_status; + void *out_buf = NULL; + size_t out_size = 0; + size_t out_capacity = 0; + if (rc == FLUXER_NATIVE_STATUS_OK) { + rc = fluxer_vips_image_write_to_buffer_bounded( + image, ".jpg[Q=65,strip]", deadline_monotonic_ms, + max_frame_output_size, + &out_buf, &out_size, &out_capacity); + } + g_object_unref(image); + if (rc == FLUXER_NATIVE_STATUS_OK && out_buf != NULL && out_size > 0 && + out_capacity >= out_size && out_capacity <= max_frame_output_size) { + out->data = out_buf; + out->len = out_size; + return FLUXER_NATIVE_STATUS_OK; + } + if (out_buf != NULL) g_free(out_buf); + if (rc != FLUXER_NATIVE_STATUS_OK) return rc; + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +struct fluxer_webp_nsfw_request { + const void *data; + size_t len; + int thread_level; + const int *indices; + size_t count; + long long deadline_monotonic_ms; + int max_frames; + size_t max_total_pixels; + size_t max_frame_output_size; + struct fluxer_nsfw_frame_out *outputs; +}; + +struct fluxer_webp_nsfw_selection { + WebPAnimDecoder *decoder; + WebPAnimInfo info; + const int *indices; + size_t count; + size_t next; + long long deadline_monotonic_ms; + size_t max_frame_output_size; + struct fluxer_nsfw_frame_out *outputs; +}; + +static int fluxer_webp_nsfw_request_valid( + const struct fluxer_webp_nsfw_request *request +) { + assert(request != NULL); + if (request->data == NULL) return 0; + if (request->len == 0) return 0; + if (request->indices == NULL) return 0; + if (request->outputs == NULL) return 0; + if (request->count == 0) return 0; + if (request->count > FLUXER_MAX_NSFW_SAMPLES) return 0; + if (request->deadline_monotonic_ms < 0) return 0; + if (request->max_frames <= 0) return 0; + if (request->max_total_pixels == 0) return 0; + if (request->max_frame_output_size == 0) return 0; + if (request->thread_level < 0) return 0; + if (request->thread_level > 1) return 0; + return 1; +} + +static int fluxer_webp_nsfw_info_valid( + const WebPAnimInfo *info, + const struct fluxer_webp_animation_facts *facts, + const struct fluxer_webp_nsfw_request *request +) { + assert(info != NULL); + assert(facts != NULL); + assert(request != NULL); + if (info->canvas_width != facts->canvas_width) return 0; + if (info->canvas_height != facts->canvas_height) return 0; + if (info->frame_count != facts->frame_count) return 0; + return fluxer_nsfw_animation_selection_valid( + request->indices, request->count, (int)info->frame_count); +} + +static int fluxer_webp_open_nsfw_decoder( + const struct fluxer_webp_nsfw_request *request, + WebPAnimDecoder **out_decoder, + WebPAnimInfo *out_info +) { + assert(request != NULL); + assert(out_decoder != NULL); + assert(out_info != NULL); + *out_decoder = NULL; + int status = fluxer_native_deadline_status( + request->deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + struct fluxer_webp_animation_facts facts; + status = fluxer_webp_animation_preflight( + request->data, request->len, request->max_frames, + request->max_total_pixels, request->deadline_monotonic_ms, &facts); + int deadline_status = fluxer_native_deadline_status( + request->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (ff_validate_rgba_geometry( + (int)facts.canvas_width, (int)facts.canvas_height, NULL) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + WebPData input = { .bytes = request->data, .size = request->len }; + WebPAnimDecoderOptions options; + if (!WebPAnimDecoderOptionsInit(&options)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + options.color_mode = MODE_RGBA; + options.use_threads = request->thread_level; + status = fluxer_native_deadline_status(request->deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + WebPAnimDecoder *decoder = WebPAnimDecoderNew(&input, &options); + status = fluxer_native_deadline_status(request->deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) { + if (decoder != NULL) WebPAnimDecoderDelete(decoder); + return status; + } + if (decoder == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int info_valid = WebPAnimDecoderGetInfo(decoder, out_info) && + fluxer_webp_nsfw_info_valid(out_info, &facts, request); + status = fluxer_native_deadline_status(request->deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) { + WebPAnimDecoderDelete(decoder); + return status; + } + if (!info_valid) { + WebPAnimDecoderDelete(decoder); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out_decoder = decoder; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_webp_decode_nsfw_selection( + struct fluxer_webp_nsfw_selection *selection +) { + assert(selection != NULL); + assert(selection->decoder != NULL); + assert(selection->indices != NULL); + assert(selection->outputs != NULL); + assert(selection->count > 0); + for (int frame = 0; frame < (int)selection->info.frame_count; frame++) { + int deadline_status = fluxer_native_deadline_status( + selection->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + uint8_t *rgba = NULL; + int timestamp = 0; + if (!WebPAnimDecoderGetNext( + selection->decoder, &rgba, ×tamp)) { + deadline_status = fluxer_native_deadline_status( + selection->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + deadline_status = fluxer_native_deadline_status( + selection->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + return deadline_status; + } + if (selection->next >= selection->count) continue; + if (frame != selection->indices[selection->next]) continue; + int status = fluxer_emit_rgba_nsfw_frame( + rgba, + (int)selection->info.canvas_width, + (int)selection->info.canvas_height, + selection->deadline_monotonic_ms, + selection->max_frame_output_size, + &selection->outputs[selection->next]); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + selection->next++; + } + if (selection->next != selection->count) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int has_more = WebPAnimDecoderHasMoreFrames(selection->decoder); + int deadline_status = fluxer_native_deadline_status( + selection->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (has_more) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_webp_extract_frames_for_nsfw( + const void *webp_data, + size_t webp_len, + int thread_level, + long long deadline_monotonic_ms, + const int *frame_indices, + size_t n_indices, + int max_frames, + size_t max_total_pixels, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out_frames +) { + if (n_indices > FLUXER_MAX_NSFW_SAMPLES) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + fluxer_nsfw_frames_reset(out_frames, n_indices); + struct fluxer_webp_nsfw_request request = { + .data = webp_data, + .len = webp_len, + .thread_level = thread_level, + .indices = frame_indices, + .count = n_indices, + .deadline_monotonic_ms = deadline_monotonic_ms, + .max_frames = max_frames, + .max_total_pixels = max_total_pixels, + .max_frame_output_size = max_frame_output_size, + .outputs = out_frames, + }; + if (!fluxer_webp_nsfw_request_valid(&request)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + WebPAnimInfo info = {0}; + WebPAnimDecoder *decoder = NULL; + int status = fluxer_webp_open_nsfw_decoder( + &request, &decoder, &info); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + struct fluxer_webp_nsfw_selection selection = { + .decoder = decoder, + .info = info, + .indices = request.indices, + .count = request.count, + .deadline_monotonic_ms = request.deadline_monotonic_ms, + .max_frame_output_size = request.max_frame_output_size, + .outputs = request.outputs, + }; + status = fluxer_webp_decode_nsfw_selection(&selection); + WebPAnimDecoderDelete(decoder); + if (status != FLUXER_NATIVE_STATUS_OK) { + fluxer_nsfw_frames_free(out_frames, n_indices); + } + return status; +} diff --git a/fluxer_media_proxy/src/native_shim/video_frame.c b/fluxer_media_proxy/src/native_shim/video_frame.c new file mode 100644 index 000000000..d70506d70 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/video_frame.c @@ -0,0 +1,855 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +static int ff_display_matrix_supported(const int32_t *matrix) { + assert(matrix != NULL); + const double unit = 65536.0; + const double projective_unit = 1073741824.0; + const double tolerance = 0.001; + double a = (double)matrix[0] / unit; + double b = (double)matrix[1] / unit; + double u = (double)matrix[2] / projective_unit; + double c = (double)matrix[3] / unit; + double d = (double)matrix[4] / unit; + double v = (double)matrix[5] / projective_unit; + double w = (double)matrix[8] / projective_unit; + double first_norm = a * a + c * c; + double second_norm = b * b + d * d; + double dot = a * b + c * d; + double determinant = a * d - b * c; + return isfinite(u) && isfinite(v) && isfinite(w) && + fabs(u) <= tolerance && fabs(v) <= tolerance && + fabs(w - 1.0) <= tolerance && isfinite(first_norm) && + isfinite(second_norm) && isfinite(dot) && isfinite(determinant) && + fabs(first_norm - 1.0) <= tolerance && + fabs(second_norm - 1.0) <= tolerance && fabs(dot) <= tolerance && + fabs(fabs(determinant) - 1.0) <= tolerance; +} + +static int ff_display_matrix_angle( + const AVFrame *frame, + const AVStream *stream, + VipsAngle *out_angle, + int *out_flip_horizontal +) { + assert(out_angle != NULL); + assert(out_flip_horizontal != NULL); + *out_angle = VIPS_ANGLE_D0; + *out_flip_horizontal = 0; + int32_t matrix[9]; + int has_matrix = 0; + const AVFrameSideData *fsd = frame != NULL + ? av_frame_get_side_data(frame, AV_FRAME_DATA_DISPLAYMATRIX) + : NULL; + if (fsd != NULL && fsd->data != NULL && fsd->size >= 9 * sizeof(int32_t)) { + memcpy(matrix, fsd->data, sizeof(matrix)); + has_matrix = 1; + } else if (stream != NULL && stream->codecpar != NULL) { + const AVPacketSideData *psd = av_packet_side_data_get( + stream->codecpar->coded_side_data, stream->codecpar->nb_coded_side_data, + AV_PKT_DATA_DISPLAYMATRIX); + if (psd != NULL && psd->data != NULL && psd->size >= 9 * sizeof(int32_t)) { + memcpy(matrix, psd->data, sizeof(matrix)); + has_matrix = 1; + } + } + if (!has_matrix) return 0; + if (!ff_display_matrix_supported(matrix)) return -1; + double determinant = + ((double)matrix[0] * (double)matrix[4] - + (double)matrix[1] * (double)matrix[3]) / + (65536.0 * 65536.0); + if (determinant < 0.0) { + matrix[0] = -matrix[0]; + matrix[3] = -matrix[3]; + *out_flip_horizontal = 1; + } + double ccw = av_display_rotation_get(matrix); + if (!isfinite(ccw)) return -1; + double turns = -ccw / 90.0; + long quarter = lround(turns); + if (fabs(turns - (double)quarter) > 0.0001) return -1; + quarter %= 4; + if (quarter < 0) quarter += 4; + switch (quarter) { + case 1: *out_angle = VIPS_ANGLE_D90; break; + case 2: *out_angle = VIPS_ANGLE_D180; break; + case 3: *out_angle = VIPS_ANGLE_D270; break; + default: break; + } + return 0; +} + +static int ff_validate_vips_image_bounds(VipsImage *image) { + if (image == NULL) return -1; + size_t ignored = 0; + return ff_validate_rgba_geometry( + vips_image_get_width(image), + vips_image_get_height(image), + &ignored); +} + +static int ff_frame_to_rgba_image( + AVFrame *frame, + int fallback_width, int fallback_height, + int max_width, int max_height, + long long deadline_monotonic_ms, + VipsImage **out +) { + if (out == NULL || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out = NULL; + int width = frame != NULL && frame->width > 0 ? frame->width : fallback_width; + int height = frame != NULL && frame->height > 0 ? frame->height : fallback_height; + if (ff_validate_rgba_geometry(width, height, NULL) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int output_width = width; + int output_height = height; + if (max_width > 0 && max_height > 0) { + double scale = fmin((double)max_width / (double)width, + (double)max_height / (double)height); + if (!isfinite(scale) || scale <= 0.0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (scale < 1.0) { + output_width = (int)fmax(1.0, floor((double)width * scale + 0.5)); + output_height = (int)fmax(1.0, floor((double)height * scale + 0.5)); + } + } + size_t rgba_size = 0; + if (ff_validate_rgba_geometry(output_width, output_height, &rgba_size) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + uint8_t *rgba = (uint8_t *)g_try_malloc(rgba_size); + if (rgba == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + struct SwsContext *sws = NULL; + int convert_status = fluxer_av_frame_convert_to_rgba( + frame, width, height, output_width, output_height, + &sws, deadline_monotonic_ms, rgba); + if (convert_status != FLUXER_NATIVE_STATUS_OK) { + if (sws != NULL) sws_freeContext(sws); + g_free(rgba); + return convert_status; + } + if (sws != NULL) sws_freeContext(sws); + VipsImage *image = vips_image_new_from_memory( + rgba, rgba_size, output_width, output_height, 4, VIPS_FORMAT_UCHAR); + if (image == NULL) { + g_free(rgba); + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (g_signal_connect_swapped( + image, "postclose", G_CALLBACK(g_free), rgba) == 0) { + g_object_unref(image); + g_free(rgba); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out = image; + return FLUXER_NATIVE_STATUS_OK; +} + +struct ff_display_transform { + double hscale; + double vscale; + VipsAngle angle; + int flip_horizontal; +}; + +static int ff_display_transform_init( + AVFormatContext *fmt, + AVStream *stream, + AVFrame *frame, + struct ff_display_transform *transform +) { + if (transform == NULL) return -1; + transform->hscale = 1.0; + transform->vscale = 1.0; + transform->angle = VIPS_ANGLE_D0; + transform->flip_horizontal = 0; + AVRational sar = av_guess_sample_aspect_ratio(fmt, stream, frame); + if (sar.num > 0 && sar.den > 0 && sar.num != sar.den) { + transform->hscale = (double)sar.num / (double)sar.den; + } + if (!isfinite(transform->hscale) || transform->hscale <= 0.0) return -1; + if (!isfinite(transform->vscale) || transform->vscale <= 0.0) return -1; + return ff_display_matrix_angle( + frame, stream, &transform->angle, &transform->flip_horizontal); +} + +static int ff_apply_display_transform( + VipsImage **image, + const struct ff_display_transform *transform +) { + if (image == NULL || *image == NULL || transform == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (transform->hscale != 1.0 || transform->vscale != 1.0) { + double projected_width = + (double)vips_image_get_width(*image) * transform->hscale; + double projected_height = + (double)vips_image_get_height(*image) * transform->vscale; + if (!isfinite(projected_width) || !isfinite(projected_height)) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (projected_width < 1.0 || projected_height < 1.0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (projected_width > (double)FLUXER_MAX_VIDEO_FRAME_DIMENSION || + projected_height > (double)FLUXER_MAX_VIDEO_FRAME_DIMENSION) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (ff_validate_rgba_geometry( + (int)ceil(projected_width), (int)ceil(projected_height), NULL) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + VipsImage *scaled = NULL; + if (vips_resize(*image, &scaled, transform->hscale, + "vscale", transform->vscale, NULL) != 0 || scaled == NULL) { + if (scaled != NULL) g_object_unref(scaled); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (ff_validate_vips_image_bounds(scaled) != 0) { + g_object_unref(scaled); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + g_object_unref(*image); + *image = scaled; + } + if (transform->angle != VIPS_ANGLE_D0) { + VipsImage *rotated = NULL; + if (vips_rot(*image, &rotated, transform->angle, NULL) != 0 || rotated == NULL) { + if (rotated != NULL) g_object_unref(rotated); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (ff_validate_vips_image_bounds(rotated) != 0) { + g_object_unref(rotated); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + g_object_unref(*image); + *image = rotated; + } + if (transform->flip_horizontal) { + VipsImage *flipped = NULL; + if (vips_flip( + *image, &flipped, VIPS_DIRECTION_HORIZONTAL, NULL) != 0 || + flipped == NULL) { + if (flipped != NULL) g_object_unref(flipped); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (ff_validate_vips_image_bounds(flipped) != 0) { + g_object_unref(flipped); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + g_object_unref(*image); + *image = flipped; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_measure_display_geometry( + const struct ff_display_transform *transform, + int source_width, int source_height, + int *out_width, int *out_height +) { + if (transform == NULL || out_width == NULL || out_height == NULL) return -1; + if (ff_validate_rgba_geometry(source_width, source_height, NULL) != 0) return -1; + double projected_width = (double)source_width * transform->hscale; + double projected_height = (double)source_height * transform->vscale; + if (!isfinite(projected_width) || !isfinite(projected_height)) return -1; + if (projected_width < 1.0 || projected_height < 1.0) return -1; + if (projected_width > (double)FLUXER_MAX_VIDEO_FRAME_DIMENSION || + projected_height > (double)FLUXER_MAX_VIDEO_FRAME_DIMENSION) return -1; + int width = (int)floor(projected_width + 0.5); + int height = (int)floor(projected_height + 0.5); + if (transform->angle == VIPS_ANGLE_D90 || transform->angle == VIPS_ANGLE_D270) { + int swap = width; + width = height; + height = swap; + } + *out_width = width; + *out_height = height; + return ff_validate_rgba_geometry(*out_width, *out_height, NULL); +} + +static int ff_prepare_frame_image( + AVFrame *frame, AVCodecContext *dec_ctx, AVFormatContext *fmt, AVStream *stream, + int max_width, int max_height, + long long deadline_monotonic_ms, + int *out_display_width, int *out_display_height, + VipsImage **out_image +); + +int ff_emit_frame_thumbnail( + AVFrame *frame, AVCodecContext *dec_ctx, AVFormatContext *fmt, AVStream *stream, + const char *suffix, int max_width, int max_height, + long long deadline_monotonic_ms, + size_t max_output_size, + int *out_display_width, int *out_display_height, + void **out_buf, size_t *out_size, size_t *out_capacity +) { + if (out_display_width != NULL) *out_display_width = 0; + if (out_display_height != NULL) *out_display_height = 0; + if (out_buf != NULL) *out_buf = NULL; + if (out_size != NULL) *out_size = 0; + if (out_capacity != NULL) *out_capacity = 0; + if (frame == NULL || dec_ctx == NULL || suffix == NULL || + deadline_monotonic_ms < 0 || + max_output_size == 0 || out_buf == NULL || out_size == NULL || + out_capacity == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + VipsImage *image = NULL; + status = ff_prepare_frame_image( + frame, dec_ctx, fmt, stream, max_width, max_height, + deadline_monotonic_ms, + out_display_width, out_display_height, &image); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int write_rc = fluxer_vips_image_write_to_buffer_bounded( + image, suffix, deadline_monotonic_ms, max_output_size, + out_buf, out_size, out_capacity); + g_object_unref(image); + return write_rc; +} + +int ff_fit_frame_image(VipsImage **image, int max_width, int max_height) { + if (image == NULL || *image == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (max_width <= 0 || max_height <= 0) return FLUXER_NATIVE_STATUS_OK; + int image_width = vips_image_get_width(*image); + int image_height = vips_image_get_height(*image); + double scale = fmin((double)max_width / (double)image_width, + (double)max_height / (double)image_height); + if (!isfinite(scale) || scale <= 0.0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (scale >= 1.0) return FLUXER_NATIVE_STATUS_OK; + VipsImage *resized = NULL; + if (vips_resize(*image, &resized, scale, NULL) != 0 || resized == NULL) { + if (resized != NULL) g_object_unref(resized); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + g_object_unref(*image); + *image = resized; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_prepare_frame_image( + AVFrame *frame, AVCodecContext *dec_ctx, AVFormatContext *fmt, AVStream *stream, + int max_width, int max_height, + long long deadline_monotonic_ms, + int *out_display_width, int *out_display_height, + VipsImage **out_image +) { + if (frame == NULL || dec_ctx == NULL || out_image == NULL || + deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out_image = NULL; + int width = frame->width > 0 ? frame->width : dec_ctx->width; + int height = frame->height > 0 ? frame->height : dec_ctx->height; + int display_width = width; + int display_height = height; + int scaled_width = width; + int scaled_height = height; + struct ff_display_transform display_transform; + if (ff_display_transform_init(fmt, stream, frame, &display_transform) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (max_width > 0 || (out_display_width != NULL && out_display_height != NULL)) { + if (ff_measure_display_geometry(&display_transform, width, height, + &display_width, &display_height) != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (out_display_width != NULL && out_display_height != NULL) { + *out_display_width = display_width; + *out_display_height = display_height; + } + } + if (max_width > 0 && max_height > 0) { + double scale = fmin((double)max_width / (double)display_width, + (double)max_height / (double)display_height); + if (!isfinite(scale) || scale <= 0.0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (scale < 1.0) { + scaled_width = (int)fmax(1.0, floor((double)width * scale + 0.5)); + scaled_height = (int)fmax(1.0, floor((double)height * scale + 0.5)); + } + } + VipsImage *image = NULL; + int status = ff_frame_to_rgba_image( + frame, width, height, scaled_width, scaled_height, + deadline_monotonic_ms, &image); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = ff_apply_display_transform(&image, &display_transform); + if (status != FLUXER_NATIVE_STATUS_OK) { + g_object_unref(image); + return status; + } + status = ff_fit_frame_image(&image, max_width, max_height); + if (status != FLUXER_NATIVE_STATUS_OK) { + g_object_unref(image); + return status; + } + *out_image = image; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_emit_frame_rgba( + AVFrame *frame, AVCodecContext *dec_ctx, AVFormatContext *fmt, AVStream *stream, + int max_width, int max_height, + long long deadline_monotonic_ms, + struct fluxer_av_metadata_out *out +) { + if (out == NULL || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + VipsImage *image = NULL; + int status = ff_prepare_frame_image( + frame, dec_ctx, fmt, stream, max_width, max_height, + deadline_monotonic_ms, + &out->display_width, &out->display_height, &image); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + out->rgba_width = vips_image_get_width(image); + out->rgba_height = vips_image_get_height(image); + int bands = vips_image_get_bands(image); + size_t expected_size = 0; + if (bands == 4 && out->rgba_width > 0 && out->rgba_height > 0) { + expected_size = (size_t)out->rgba_width * (size_t)out->rgba_height * 4u; + } + if (expected_size == 0) { + g_object_unref(image); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + status = fluxer_vips_image_write_to_memory_deadline( + image, deadline_monotonic_ms, expected_size, + &out->rgba, &out->rgba_size); + g_object_unref(image); + if (status == FLUXER_NATIVE_STATUS_OK && out->rgba != NULL && + out->rgba_size == expected_size) { + return FLUXER_NATIVE_STATUS_OK; + } + int allocation_failed = out->rgba == NULL; + if (out->rgba != NULL) g_free(out->rgba); + out->rgba = NULL; + out->rgba_size = 0; + out->rgba_width = 0; + out->rgba_height = 0; + out->display_width = 0; + out->display_height = 0; + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return allocation_failed ? FLUXER_NATIVE_STATUS_ALLOCATION_FAILED + : FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +void ff_thumbnail_context_clear(ff_thumbnail_context *context) { + if (context == NULL) return; + if (context->frame != NULL) av_frame_free(&context->frame); + if (context->packet != NULL) av_packet_free(&context->packet); + if (context->decoder != NULL) avcodec_free_context(&context->decoder); + if (context->format != NULL) avformat_close_input(&context->format); + if (context->avio != NULL) { + if (context->avio->buffer != NULL) av_freep(&context->avio->buffer); + avio_context_free(&context->avio); + } else if (context->avio_buffer != NULL) { + av_free(context->avio_buffer); + context->avio_buffer = NULL; + } +} + +static int ff_container_headers_are_complete(const AVFormatContext *format) { + if (format == NULL || format->iformat == NULL) return 0; + if (format->duration <= 0 || format->nb_streams == 0) return 0; + const char *name = format->iformat->name; + if (name == NULL) return 0; + int supported_container = strcmp(name, "matroska,webm") == 0 || + strcmp(name, "mov,mp4,m4a,3gp,3g2,mj2") == 0; + if (!supported_container) return 0; + int media_streams = 0; + for (unsigned int i = 0; i < format->nb_streams; i++) { + const AVStream *stream = format->streams[i]; + if (stream == NULL || stream->codecpar == NULL) return 0; + const AVCodecParameters *parameters = stream->codecpar; + if (parameters->codec_type == AVMEDIA_TYPE_VIDEO) { + if (parameters->codec_id == AV_CODEC_ID_NONE) return 0; + if (parameters->width <= 0 || parameters->height <= 0) return 0; + media_streams++; + } else if (parameters->codec_type == AVMEDIA_TYPE_AUDIO) { + if (parameters->codec_id == AV_CODEC_ID_NONE) return 0; + media_streams++; + } + } + return media_streams > 0; +} + +int ff_thumbnail_context_open( + ff_thumbnail_context *context, + const void *media_data, + size_t media_len, + const char *allowed_formats, + int strict_decode, + long long deadline_monotonic_ms +) { + if (context == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + memset(context, 0, sizeof(*context)); + if (media_data == NULL || media_len == 0 || allowed_formats == NULL || + deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (media_len > (size_t)INT64_MAX) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (strict_decode < 0 || strict_decode > 1) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + context->strict_decode = strict_decode; + context->deadline_monotonic_ms = deadline_monotonic_ms; + int deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + context->reader.data = (const uint8_t *)media_data; + context->reader.len = media_len; + context->avio_buffer = av_malloc(64 * 1024); + if (context->avio_buffer == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + context->avio = avio_alloc_context( + context->avio_buffer, 64 * 1024, 0, &context->reader, + ff_mem_read_packet, NULL, ff_mem_seek); + if (context->avio == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + context->avio_buffer = NULL; + context->format = avformat_alloc_context(); + if (context->format == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + int status = fluxer_restrict_untrusted_av_context(context->format, allowed_formats); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + context->format->pb = context->avio; + context->format->interrupt_callback.callback = fluxer_ffmpeg_interrupt_deadline; + context->format->interrupt_callback.opaque = &context->deadline_monotonic_ms; + context->format->flags |= AVFMT_FLAG_CUSTOM_IO; + context->format->probesize = 5 * 1024 * 1024; + context->format->max_analyze_duration = 5 * AV_TIME_BASE; + if (strict_decode) context->format->error_recognition = AV_EF_EXPLODE; + int open_rc = avformat_open_input(&context->format, NULL, NULL, NULL); + if (open_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + open_rc, context->deadline_monotonic_ms); + } + status = fluxer_prepare_untrusted_av_input(context->format); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (!ff_container_headers_are_complete(context->format)) { + int stream_info_rc = fluxer_find_stream_info_bounded(context->format); + if (stream_info_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + stream_info_rc, context->deadline_monotonic_ms); + } + } + status = fluxer_prepare_untrusted_av_input(context->format); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_thumbnail_context_probe( + const ff_thumbnail_context *context, + struct fluxer_av_metadata_out *out +) { + if (context == NULL || context->format == NULL || out == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + for (unsigned int i = 0; i < context->format->nb_streams; i++) { + int deadline_status = fluxer_native_deadline_status( + context->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + const AVStream *stream = context->format->streams[i]; + if (stream == NULL || stream->codecpar == NULL) continue; + enum AVMediaType type = stream->codecpar->codec_type; + if (type == AVMEDIA_TYPE_VIDEO && !ff_stream_is_attached_picture(stream)) { + out->has_video = 1; + } + if (type == AVMEDIA_TYPE_AUDIO) out->has_audio = 1; + } + if (context->format->duration > 0) { + out->duration_seconds = + (double)context->format->duration / (double)AV_TIME_BASE; + } + return out->has_video || out->has_audio + ? FLUXER_NATIVE_STATUS_OK + : FLUXER_NATIVE_STATUS_UNSUPPORTED; +} + +int ff_thumbnail_decoder_open(ff_thumbnail_context *context, int decoder_threads) { + assert(fluxer_ffmpeg_decoder_threads_valid(decoder_threads)); + if (context == NULL || context->format == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int deadline_status = fluxer_native_deadline_status( + context->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + const AVCodec *codec = NULL; + context->stream_index = ff_find_primary_video_stream(context->format, &codec); + if (context->stream_index < 0) return FLUXER_NATIVE_STATUS_UNSUPPORTED; + context->stream = context->format->streams[context->stream_index]; + if (context->stream == NULL || context->stream->codecpar == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (codec == NULL) codec = avcodec_find_decoder(context->stream->codecpar->codec_id); + if (!fluxer_video_decoder_allowed(context->stream, codec)) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + context->decoder = avcodec_alloc_context3(codec); + if (context->decoder == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + int parameters_rc = avcodec_parameters_to_context( + context->decoder, context->stream->codecpar); + if (parameters_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + parameters_rc, context->deadline_monotonic_ms); + } + context->decoder->max_pixels = (int64_t)FLUXER_MAX_VIDEO_PIXELS; + context->decoder->thread_count = decoder_threads; + if (context->strict_decode) { + context->decoder->err_recognition = AV_EF_CRCCHECK | AV_EF_BITSTREAM | + AV_EF_BUFFER | AV_EF_EXPLODE; + } + int open_rc = avcodec_open2(context->decoder, codec, NULL); + if (open_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + open_rc, context->deadline_monotonic_ms); + } + context->packet = av_packet_alloc(); + context->frame = av_frame_alloc(); + if (context->packet == NULL || context->frame == NULL) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int ff_thumbnail_decode_frame(ff_thumbnail_context *context, int max_packets) { + if (context == NULL || context->format == NULL || context->decoder == NULL || + context->packet == NULL || context->frame == NULL || max_packets <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int packets_seen = 0; + int packet_pending = 0; + int input_exhausted = 0; + int drain_sent = 0; + int send_blocked = 0; + for (;;) { + int deadline_status = fluxer_native_deadline_status( + context->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int receive_rc = avcodec_receive_frame(context->decoder, context->frame); + if (receive_rc == 0) return FLUXER_NATIVE_STATUS_OK; + if (receive_rc != AVERROR(EAGAIN) && receive_rc != AVERROR_EOF) { + return fluxer_native_status_from_av_error_with_deadline( + receive_rc, context->deadline_monotonic_ms); + } + if (receive_rc == AVERROR_EOF) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (send_blocked) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (packet_pending) { + int send_rc = avcodec_send_packet(context->decoder, context->packet); + if (send_rc == AVERROR(EAGAIN)) { + send_blocked = 1; + continue; + } + av_packet_unref(context->packet); + packet_pending = 0; + if (send_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + send_rc, context->deadline_monotonic_ms); + } + continue; + } + if (input_exhausted) { + if (drain_sent) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int send_rc = avcodec_send_packet(context->decoder, NULL); + if (send_rc == AVERROR(EAGAIN)) { + send_blocked = 1; + continue; + } + if (send_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + send_rc, context->deadline_monotonic_ms); + } + drain_sent = 1; + continue; + } + if (packets_seen == max_packets) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + int read_rc = av_read_frame(context->format, context->packet); + if (read_rc == AVERROR_EOF) { + input_exhausted = 1; + continue; + } + if (read_rc < 0) { + return fluxer_native_status_from_av_error_with_deadline( + read_rc, context->deadline_monotonic_ms); + } + packets_seen++; + if (context->packet->stream_index != context->stream_index) { + av_packet_unref(context->packet); + continue; + } + packet_pending = 1; + } +} + +static int ff_thumbnail_decode( + ff_thumbnail_context *context, + const char *suffix, + int max_packets, + int max_width, + int max_height, + size_t max_output_size, + int *out_display_width, + int *out_display_height, + void **out_buf, + size_t *out_size, + size_t *out_capacity +) { + int status = ff_thumbnail_decode_frame(context, max_packets); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + return ff_emit_frame_thumbnail( + context->frame, context->decoder, context->format, context->stream, + suffix, max_width, max_height, context->deadline_monotonic_ms, + max_output_size, + out_display_width, out_display_height, out_buf, out_size, out_capacity); +} + +static int ff_thumbnail_packet_limit(int requested) { + if (requested <= 0) return FLUXER_MAX_VIDEO_THUMBNAIL_PACKETS; + if (requested > FLUXER_MAX_VIDEO_THUMBNAIL_PACKETS) { + return FLUXER_MAX_VIDEO_THUMBNAIL_PACKETS; + } + return requested; +} + +int fluxer_ffmpeg_video_thumbnail_ex( + const void *media_data, + size_t media_len, + int decoder_threads, + long long deadline_monotonic_ms, + const char *suffix, + int max_packets, + int max_width, + int max_height, + size_t max_output_size, + int *out_display_width, + int *out_display_height, + void **out_buf, + size_t *out_size, + size_t *out_capacity +) { + if (out_display_width != NULL) *out_display_width = 0; + if (out_display_height != NULL) *out_display_height = 0; + if (out_buf != NULL) *out_buf = NULL; + if (out_size != NULL) *out_size = 0; + if (out_capacity != NULL) *out_capacity = 0; + if (media_data == NULL || media_len == 0 || suffix == NULL || + deadline_monotonic_ms < 0 || + max_output_size == 0 || out_buf == NULL || out_size == NULL || + out_capacity == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!fluxer_ffmpeg_decoder_threads_valid(decoder_threads)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (max_width < 0 || max_height < 0 || + (max_width > 0) != (max_height > 0) || + (out_display_width == NULL) != (out_display_height == NULL)) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + max_packets = ff_thumbnail_packet_limit(max_packets); + ff_thumbnail_context context; + int rc = ff_thumbnail_context_open( + &context, media_data, media_len, FLUXER_AV_INPUT_FORMATS, 0, + deadline_monotonic_ms); + if (rc == FLUXER_NATIVE_STATUS_OK) { + rc = ff_thumbnail_decoder_open(&context, decoder_threads); + } + if (rc == FLUXER_NATIVE_STATUS_OK) { + rc = ff_thumbnail_decode( + &context, suffix, max_packets, max_width, max_height, max_output_size, + out_display_width, out_display_height, out_buf, out_size, out_capacity); + } + ff_thumbnail_context_clear(&context); + if (rc != FLUXER_NATIVE_STATUS_OK && *out_buf != NULL) { + g_free(*out_buf); + *out_buf = NULL; + *out_size = 0; + *out_capacity = 0; + } + return rc; +} + +static void ff_av_metadata_discard_rgba(struct fluxer_av_metadata_out *out) { + if (out == NULL) return; + if (out->rgba != NULL) g_free(out->rgba); + out->rgba = NULL; + out->rgba_size = 0; + out->rgba_width = 0; + out->rgba_height = 0; + out->display_width = 0; + out->display_height = 0; +} + +int fluxer_av_metadata( + const void *media_data, + size_t media_len, + int decoder_threads, + long long deadline_monotonic_ms, + int max_packets, + int max_width, + int max_height, + struct fluxer_av_metadata_out *out +) { + if (out != NULL) memset(out, 0, sizeof(*out)); + if (media_data == NULL || media_len == 0 || out == NULL || + deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!fluxer_ffmpeg_decoder_threads_valid(decoder_threads)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (max_width < 0 || max_height < 0 || + (max_width > 0) != (max_height > 0)) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + max_packets = ff_thumbnail_packet_limit(max_packets); + ff_thumbnail_context context; + int rc = ff_thumbnail_context_open( + &context, media_data, media_len, FLUXER_AV_INPUT_FORMATS, 0, + deadline_monotonic_ms); + if (rc == FLUXER_NATIVE_STATUS_OK) rc = ff_thumbnail_context_probe(&context, out); + if (rc == FLUXER_NATIVE_STATUS_OK && out->has_video && max_width > 0) { + int frame_rc = ff_thumbnail_decoder_open(&context, decoder_threads); + if (frame_rc == FLUXER_NATIVE_STATUS_OK) { + int64_t frame_count = context.stream->nb_frames; + if (frame_count < 0) { + frame_rc = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } else if (frame_count > INT_MAX) { + frame_rc = FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } else { + out->frame_count = (int)frame_count; + } + } + if (frame_rc == FLUXER_NATIVE_STATUS_OK) { + frame_rc = ff_thumbnail_decode_frame(&context, max_packets); + } + if (frame_rc == FLUXER_NATIVE_STATUS_OK) { + frame_rc = ff_emit_frame_rgba( + context.frame, context.decoder, context.format, context.stream, + max_width, max_height, deadline_monotonic_ms, out); + } + if (frame_rc != FLUXER_NATIVE_STATUS_OK) { + ff_av_metadata_discard_rgba(out); + rc = frame_rc; + } + } + ff_thumbnail_context_clear(&context); + if (rc != FLUXER_NATIVE_STATUS_OK) { + ff_av_metadata_discard_rgba(out); + memset(out, 0, sizeof(*out)); + } + return rc; +} diff --git a/fluxer_media_proxy/src/native_shim/vips_image.c b/fluxer_media_proxy/src/native_shim/vips_image.c new file mode 100644 index 000000000..9a48c2199 --- /dev/null +++ b/fluxer_media_proxy/src/native_shim/vips_image.c @@ -0,0 +1,1044 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#include "native_shim_internal.h" + +const int fluxer_vips_format_uchar = VIPS_FORMAT_UCHAR; +const int fluxer_vips_format_ushort = VIPS_FORMAT_USHORT; +const int fluxer_vips_format_float = VIPS_FORMAT_FLOAT; + +int fluxer_vips_init(const char *argv0) { + if (argv0 == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int rc = vips_init(argv0); + if (rc != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + av_log_set_level(AV_LOG_WARNING); + vips_block_untrusted_set(TRUE); + vips_operation_block_set("VipsForeignLoad", TRUE); + vips_operation_block_set("VipsForeignLoadJpeg", FALSE); + vips_operation_block_set("VipsForeignLoadPng", FALSE); + vips_operation_block_set("VipsForeignLoadWebp", FALSE); + vips_operation_block_set("VipsForeignLoadNsgif", FALSE); + vips_operation_block_set("VipsForeignLoadSvg", FALSE); + vips_operation_block_set("VipsForeignLoadHeif", FALSE); + vips_operation_block_set("VipsForeignLoadJxl", FALSE); + vips_operation_block_set("VipsForeignLoadTiff", FALSE); + return FLUXER_NATIVE_STATUS_OK; +} + +void fluxer_vips_error_clear(void) { + vips_error_clear(); +} + +const char *fluxer_vips_error_buffer(void) { + return vips_error_buffer(); +} + +void fluxer_vips_tune_for_server(int per_pipeline_threads) { + assert(per_pipeline_threads >= 1 && per_pipeline_threads <= FLUXER_MAX_THREADS_PER_PIPELINE); + vips_concurrency_set(per_pipeline_threads); + vips_cache_set_max(0); + vips_cache_set_max_mem(0); + vips_cache_set_max_files(0); + vips_leak_set(FALSE); +} + +int fluxer_vips_probe_animated(const void *buf, size_t len, int *width, int *height, int *pages) { + if (width == NULL || height == NULL || pages == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *width = 0; + *height = 0; + *pages = 0; + if (buf == NULL || len == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + VipsImage *header = vips_image_new_from_buffer(buf, len, "n=1", NULL); + if (header == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + VipsImage *display_header = NULL; + if (vips_autorot(header, &display_header, NULL) != 0 || display_header == NULL) { + if (display_header != NULL) g_object_unref(display_header); + g_object_unref(header); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int display_width = vips_image_get_width(display_header); + int display_height = vips_image_get_height(display_header); + if (display_width <= 0 || display_height <= 0) { + g_object_unref(display_header); + g_object_unref(header); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int n_pages = 1; + if (vips_image_get_typeof(display_header, "n-pages") != 0) { + if (vips_image_get_int(display_header, "n-pages", &n_pages) != 0) { + g_object_unref(display_header); + g_object_unref(header); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } + if (n_pages <= 0) { + g_object_unref(display_header); + g_object_unref(header); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + *width = display_width; + *height = display_height; + *pages = n_pages; + g_object_unref(display_header); + g_object_unref(header); + return FLUXER_NATIVE_STATUS_OK; +} + +VipsImage *fluxer_vips_image_new_from_buffer(const void *buf, size_t len, const char *option_string) { + if (buf == NULL || len == 0 || option_string == NULL) return NULL; + return vips_image_new_from_buffer(buf, len, option_string, NULL); +} + +VipsImage *fluxer_vips_image_new_from_memory(const void *data, size_t size, int width, int height, int bands, int format) { + if (data == NULL || size == 0 || width <= 0 || height <= 0 || bands <= 0) return NULL; + return vips_image_new_from_memory(data, size, width, height, bands, format); +} + +VipsImage *fluxer_vips_image_new_from_memory_copy(const void *data, size_t size, int width, int height, int bands, int format) { + if (data == NULL || size == 0 || width <= 0 || height <= 0 || bands <= 0) return NULL; + return vips_image_new_from_memory_copy(data, size, width, height, bands, format); +} + +int fluxer_vips_image_write_to_buffer(VipsImage *image, const char *suffix, void **buf, size_t *size) { + if (image == NULL || suffix == NULL || buf == NULL || size == NULL) return -1; + return vips_image_write_to_buffer(image, suffix, buf, size, NULL); +} + +int fluxer_vips_image_get_width(VipsImage *image) { + return image == NULL ? 0 : vips_image_get_width(image); +} + +int fluxer_vips_image_get_height(VipsImage *image) { + return image == NULL ? 0 : vips_image_get_height(image); +} + +int fluxer_vips_image_get_orientation_swap(VipsImage *image) { + return image != NULL && vips_image_get_orientation_swap(image); +} + +int fluxer_vips_image_get_bands(VipsImage *image) { + return image == NULL ? 0 : vips_image_get_bands(image); +} + +int fluxer_vips_image_get_format(VipsImage *image) { + return image == NULL ? VIPS_FORMAT_NOTSET : vips_image_get_format(image); +} + +int fluxer_vips_image_has_field(VipsImage *image, const char *field) { + if (image == NULL) return 0; + if (field == NULL) return 0; + return vips_image_get_typeof(image, field) != 0; +} + +int fluxer_vips_image_get_int(VipsImage *image, const char *field, int *out) { + if (out == NULL) return -1; + *out = 0; + if (image == NULL || field == NULL) return -1; + return vips_image_get_int(image, field, out); +} + +void fluxer_vips_set_page_height(VipsImage *image, int page_height) { + if (image != NULL && page_height > 0) { + vips_image_set_int(image, "page-height", page_height); + } +} + +int fluxer_vips_set_animation_loop_count(VipsImage *image, int loop_count) { + if (image == NULL || loop_count < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + vips_image_set_int(image, "loop", loop_count); + int stored_loop_count = -1; + if (vips_image_get_int(image, "loop", &stored_loop_count) != 0 || + stored_loop_count != loop_count) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_vips_operation_status(int rc, VipsImage **out) { + assert(out != NULL); + if (rc == 0 && *out != NULL) return FLUXER_NATIVE_STATUS_OK; + if (*out != NULL) { + g_object_unref(*out); + *out = NULL; + } + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; +} + +struct fluxer_vips_deadline_guard { + VipsImage *image; + long long deadline_monotonic_ms; + gulong eval_handler; + int reached; +}; + +static int fluxer_vips_deadline_guard_start( + struct fluxer_vips_deadline_guard *guard, + VipsImage *image, + long long deadline_monotonic_ms); +static int fluxer_vips_deadline_guard_finish( + struct fluxer_vips_deadline_guard *guard); + +int fluxer_vips_image_copy_memory( + VipsImage *in, + long long deadline_monotonic_ms, + VipsImage **out +) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + if (in == NULL || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + struct fluxer_vips_deadline_guard deadline_guard; + int status = fluxer_vips_deadline_guard_start( + &deadline_guard, in, deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + VipsImage *memory = vips_image_copy_memory(in); + int deadline_status = fluxer_vips_deadline_guard_finish(&deadline_guard); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + if (memory != NULL) g_object_unref(memory); + return deadline_status; + } + if (memory == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = memory; + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_vips_autorot( + VipsImage *in, + long long deadline_monotonic_ms, + VipsImage **out +) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + if (in == NULL || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int rc = vips_autorot(in, out, NULL); + status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) { + if (*out != NULL) { + g_object_unref(*out); + *out = NULL; + } + return status; + } + return fluxer_vips_operation_status(rc, out); +} + +int fluxer_vips_extract_area(VipsImage *in, VipsImage **out, int left, int top, int width, int height) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + if (in == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (left < 0 || top < 0 || width <= 0 || height <= 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int rc = vips_extract_area(in, out, left, top, width, height, NULL); + return fluxer_vips_operation_status(rc, out); +} + +int fluxer_vips_resize(VipsImage *in, VipsImage **out, double scale) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + if (in == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!isfinite(scale) || scale <= 0.0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int rc = vips_resize(in, out, scale, NULL); + return fluxer_vips_operation_status(rc, out); +} + +static int fluxer_vips_copy_optional_int_metadata( + VipsImage *source, + VipsImage *destination, + const char *name +) { + assert(source != NULL); + assert(destination != NULL); + assert(name != NULL); + if (vips_image_get_typeof(source, name) == 0) { + return FLUXER_NATIVE_STATUS_OK; + } + int value = 0; + if (vips_image_get_int(source, name, &value) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + vips_image_set_int(destination, name, value); + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_vips_copy_animation_metadata( + VipsImage *source, + VipsImage *destination, + int n_pages, + int page_height +) { + assert(source != NULL); + assert(destination != NULL); + assert(n_pages > 0); + assert(page_height > 0); + if (vips_image_get_typeof(source, "delay") != 0) { + int *delays = NULL; + int delay_count = 0; + if (vips_image_get_array_int( + source, "delay", &delays, &delay_count) != 0 || + delays == NULL || delay_count != n_pages) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + vips_image_set_array_int(destination, "delay", delays, delay_count); + } + const char *int_fields[] = {"loop", "gif-loop", "gif-delay"}; + for (size_t i = 0; i < sizeof(int_fields) / sizeof(int_fields[0]); i++) { + int status = fluxer_vips_copy_optional_int_metadata( + source, destination, int_fields[i]); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + } + vips_image_set_int(destination, "page-height", page_height); + vips_image_set_int(destination, "n-pages", n_pages); + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_vips_join_animation_pages( + VipsImage *source, + VipsImage **pages, + int n_pages, + int max_pages, + size_t max_total_pixels, + VipsImage **out +) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + if (source == NULL || pages == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (n_pages <= 1 || max_pages <= 0 || n_pages > max_pages || + max_total_pixels == 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (pages[0] == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int page_width = vips_image_get_width(pages[0]); + int page_height = vips_image_get_height(pages[0]); + if (page_width <= 0 || page_height <= 0 || page_height > INT_MAX / n_pages) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int total_height = page_height * n_pages; + if ((size_t)page_width > max_total_pixels / (size_t)total_height) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + for (int i = 1; i < n_pages; i++) { + if (pages[i] == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (vips_image_get_width(pages[i]) != page_width || + vips_image_get_height(pages[i]) != page_height) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + } + VipsImage *joined = NULL; + int rc = vips_arrayjoin(pages, &joined, n_pages, "across", 1, NULL); + int status = fluxer_vips_operation_status(rc, &joined); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (vips_image_get_width(joined) != page_width || + vips_image_get_height(joined) != total_height) { + g_object_unref(joined); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + status = fluxer_vips_copy_animation_metadata( + source, joined, n_pages, page_height); + if (status != FLUXER_NATIVE_STATUS_OK) { + g_object_unref(joined); + return status; + } + *out = joined; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_vips_image_work_bounds_status( + VipsImage *image, + int max_pages, + size_t max_total_pixels +) { + if (image == NULL || max_pages <= 0 || max_total_pixels == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int width = vips_image_get_width(image); + int total_height = vips_image_get_height(image); + if (width <= 0 || total_height <= 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int page_height = total_height; + if (vips_image_get_typeof(image, "page-height") != 0) { + if (vips_image_get_int(image, "page-height", &page_height) != 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } + if (page_height <= 0 || total_height % page_height != 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + int pages = total_height / page_height; + if (pages <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (pages > max_pages) return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + if ((size_t)width > max_total_pixels / (size_t)total_height) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +struct fluxer_vips_thumbnail_request { + const void *data; + size_t len; + VipsImage **output; + int width; + int height; + int pages; + int crop_mode; + int max_pages; + size_t max_total_pixels; +}; + +struct fluxer_vips_center_thumbnail { + VipsImage *image; + int width; + int height; + int has_page_height; +}; + +static int fluxer_vips_thumbnail_request_status( + const struct fluxer_vips_thumbnail_request *request +) { + if (request == NULL || request->data == NULL || request->len == 0 || + request->output == NULL || request->max_pages <= 0 || + request->max_total_pixels == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->width < 0 || request->height < 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (request->pages != 1 && request->pages != -1) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->crop_mode != FLUXER_THUMB_CROP_NONE && + request->crop_mode != FLUXER_THUMB_CROP_CENTRE) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (request->pages == 1 && + request->crop_mode == FLUXER_THUMB_CROP_CENTRE && + (request->width == 0 || request->height == 0)) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static const char *fluxer_vips_thumbnail_options( + const struct fluxer_vips_thumbnail_request *request +) { + assert(request != NULL); + const uint8_t *bytes = request->data; + int is_jpeg = request->len >= 3 && bytes[0] == 0xff && + bytes[1] == 0xd8 && bytes[2] == 0xff; + if (request->pages == -1) { + return is_jpeg + ? "n=-1,access=sequential" + : "n=-1,access=sequential,fail=true"; + } + return is_jpeg ? "access=sequential" : "access=sequential,fail=true"; +} + +static int fluxer_vips_uncropped_thumbnail( + const struct fluxer_vips_thumbnail_request *request, + const char *options, + VipsImage *loaded +) { + assert(request != NULL); + assert(options != NULL); + assert(loaded != NULL); + g_object_unref(loaded); + int target_width = request->width; + int target_height = request->height; + if (target_width == 0) target_width = VIPS_MAX_COORD; + if (target_height == 0) target_height = VIPS_MAX_COORD; + int result = vips_thumbnail_buffer( + (void *)request->data, request->len, request->output, target_width, + "height", target_height, "size", VIPS_SIZE_DOWN, + "no_rotate", FALSE, "option_string", options, NULL); + int status = fluxer_vips_operation_status(result, request->output); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + status = fluxer_vips_image_work_bounds_status( + *request->output, request->max_pages, request->max_total_pixels); + if (status != FLUXER_NATIVE_STATUS_OK) { + g_object_unref(*request->output); + *request->output = NULL; + } + return status; +} + +static int fluxer_vips_center_thumbnail_open( + struct fluxer_vips_center_thumbnail *context, + const struct fluxer_vips_thumbnail_request *request, + VipsImage *loaded +) { + assert(context != NULL); + assert(request != NULL); + assert(loaded != NULL); + VipsImage *oriented = NULL; + int result = vips_autorot(loaded, &oriented, NULL); + g_object_unref(loaded); + if (result != 0 || oriented == NULL) { + if (oriented != NULL) g_object_unref(oriented); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int status = fluxer_vips_image_work_bounds_status( + oriented, request->max_pages, request->max_total_pixels); + if (status != FLUXER_NATIVE_STATUS_OK) { + g_object_unref(oriented); + return status; + } + int width = vips_image_get_width(oriented); + int height = vips_image_get_height(oriented); + int page_height = height; + int has_page_height = + vips_image_get_typeof(oriented, "page-height") != 0; + if (has_page_height && + vips_image_get_int(oriented, "page-height", &page_height) != 0) { + g_object_unref(oriented); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (width <= 0 || height <= 0 || page_height != height) { + g_object_unref(oriented); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + context->image = oriented; + context->width = width; + context->height = height; + context->has_page_height = has_page_height; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_vips_center_thumbnail_resize( + struct fluxer_vips_center_thumbnail *context, + const struct fluxer_vips_thumbnail_request *request +) { + assert(context != NULL); + assert(context->image != NULL); + assert(request != NULL); + double width_scale = (double)request->width / (double)context->width; + double height_scale = (double)request->height / (double)context->height; + double scale = width_scale; + if (height_scale > scale) scale = height_scale; + if (scale > 1.0) scale = 1.0; + if (scale < 1.0) { + VipsImage *resized = NULL; + int result = vips_resize(context->image, &resized, scale, NULL); + if (result != 0 || resized == NULL) { + if (resized != NULL) g_object_unref(resized); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + g_object_unref(context->image); + context->image = resized; + } + context->width = vips_image_get_width(context->image); + context->height = vips_image_get_height(context->image); + if (context->width <= 0 || context->height <= 0) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_vips_center_thumbnail_publish( + struct fluxer_vips_center_thumbnail *context, + const struct fluxer_vips_thumbnail_request *request +) { + assert(context != NULL); + assert(context->image != NULL); + assert(request != NULL); + int final_width = context->width; + int final_height = context->height; + if (final_width > request->width) final_width = request->width; + if (final_height > request->height) final_height = request->height; + if (final_width == context->width && final_height == context->height) { + if (context->has_page_height) { + vips_image_set_int(context->image, "page-height", final_height); + } + *request->output = context->image; + context->image = NULL; + return FLUXER_NATIVE_STATUS_OK; + } + int left = (context->width - final_width) / 2; + int top = (context->height - final_height) / 2; + VipsImage *cropped = NULL; + int result = vips_extract_area( + context->image, &cropped, left, top, final_width, final_height, NULL); + if (result != 0 || cropped == NULL) { + if (cropped != NULL) g_object_unref(cropped); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (context->has_page_height) { + vips_image_set_int(cropped, "page-height", final_height); + } + *request->output = cropped; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_vips_center_thumbnail_run( + const struct fluxer_vips_thumbnail_request *request, + VipsImage *loaded +) { + struct fluxer_vips_center_thumbnail context = {0}; + int status = fluxer_vips_center_thumbnail_open( + &context, request, loaded); + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_vips_center_thumbnail_resize(&context, request); + } + if (status == FLUXER_NATIVE_STATUS_OK) { + status = fluxer_vips_center_thumbnail_publish(&context, request); + } + if (context.image != NULL) g_object_unref(context.image); + return status; +} + +int fluxer_vips_thumbnail_buffer_ex( + const void *buf, size_t len, long long deadline_monotonic_ms, + VipsImage **out, int width, int height, + int n, int crop_mode, int max_pages, size_t max_total_pixels +) { + if (out != NULL) *out = NULL; + if (deadline_monotonic_ms < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + struct fluxer_vips_thumbnail_request request = { + .data = buf, + .len = len, + .output = out, + .width = width, + .height = height, + .pages = n, + .crop_mode = crop_mode, + .max_pages = max_pages, + .max_total_pixels = max_total_pixels, + }; + status = fluxer_vips_thumbnail_request_status(&request); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + const char *options = fluxer_vips_thumbnail_options(&request); + VipsImage *loaded = vips_image_new_from_buffer(buf, len, options, NULL); + if (loaded == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + status = fluxer_vips_image_work_bounds_status( + loaded, max_pages, max_total_pixels); + if (status != FLUXER_NATIVE_STATUS_OK) { + g_object_unref(loaded); + return status; + } + int use_center_crop = n == 1 && crop_mode == FLUXER_THUMB_CROP_CENTRE; + if (use_center_crop) { + status = fluxer_vips_center_thumbnail_run(&request, loaded); + } else { + status = fluxer_vips_uncropped_thumbnail(&request, options, loaded); + } + int deadline_status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + if (*out != NULL) { + g_object_unref(*out); + *out = NULL; + } + return deadline_status; + } + return status; +} + +struct fluxer_vips_write_ctx { + fluxer_vips_write_cb cb; + void *user_data; + long long deadline_monotonic_ms; + int status; +}; + +static void fluxer_vips_deadline_eval( + VipsImage *image, + VipsProgress *progress, + void *user_data +) { + (void)progress; + struct fluxer_vips_deadline_guard *guard = user_data; + if (guard == NULL || guard->reached) return; + if (fluxer_native_deadline_status(guard->deadline_monotonic_ms) == + FLUXER_NATIVE_STATUS_OK) return; + guard->reached = 1; + vips_image_set_kill(image, TRUE); +} + +static int fluxer_vips_deadline_guard_start( + struct fluxer_vips_deadline_guard *guard, + VipsImage *image, + long long deadline_monotonic_ms +) { + if (guard == NULL || image == NULL || deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + memset(guard, 0, sizeof(*guard)); + guard->image = image; + guard->deadline_monotonic_ms = deadline_monotonic_ms; + int status = fluxer_native_deadline_status(deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (deadline_monotonic_ms == 0) return FLUXER_NATIVE_STATUS_OK; + vips_image_set_progress(image, TRUE); + guard->eval_handler = g_signal_connect( + image, "eval", G_CALLBACK(fluxer_vips_deadline_eval), guard); + if (guard->eval_handler == 0) { + vips_image_set_progress(image, FALSE); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_vips_deadline_guard_finish( + struct fluxer_vips_deadline_guard *guard +) { + assert(guard != NULL); + if (guard->eval_handler != 0) { + g_signal_handler_disconnect(guard->image, guard->eval_handler); + vips_image_set_progress(guard->image, FALSE); + vips_image_set_kill(guard->image, FALSE); + } + if (guard->reached) return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + return fluxer_native_deadline_status(guard->deadline_monotonic_ms); +} + +int fluxer_vips_image_write_to_memory_deadline( + VipsImage *image, + long long deadline_monotonic_ms, + size_t max_output_size, + void **out_buf, + size_t *out_size +) { + if (out_buf != NULL) *out_buf = NULL; + if (out_size != NULL) *out_size = 0; + if (image == NULL || deadline_monotonic_ms < 0 || max_output_size == 0 || + out_buf == NULL || out_size == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + struct fluxer_vips_deadline_guard deadline_guard; + int status = fluxer_vips_deadline_guard_start( + &deadline_guard, image, deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + size_t size = 0; + void *buffer = vips_image_write_to_memory(image, &size); + int deadline_status = fluxer_vips_deadline_guard_finish(&deadline_guard); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + if (buffer != NULL) g_free(buffer); + return deadline_status; + } + if (buffer == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + if (size == 0) { + g_free(buffer); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (size > max_output_size) { + g_free(buffer); + return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } + *out_buf = buffer; + *out_size = size; + return FLUXER_NATIVE_STATUS_OK; +} + +static gint64 fluxer_vips_target_write_adapter(VipsTargetCustom *target, const void *bytes, gint64 length, void *gp) { + (void)target; + struct fluxer_vips_write_ctx *c = gp; + int deadline_status = fluxer_native_deadline_status( + c->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) { + c->status = deadline_status; + return -1; + } + if (length < 0) { + c->status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return -1; + } + if (length == 0) return 0; + size_t callback_length = (size_t)length; + if ((gint64)callback_length != length) { + c->status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return -1; + } + if (c->cb(c->user_data, bytes, callback_length) != 0) { + c->status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return -1; + } + return length; +} + +int fluxer_vips_image_write_to_callback( + VipsImage *image, + const char *suffix, + long long deadline_monotonic_ms, + fluxer_vips_write_cb cb, + void *user_data +) { + if (image == NULL || suffix == NULL || cb == NULL || + deadline_monotonic_ms < 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + struct fluxer_vips_deadline_guard deadline_guard; + int status = fluxer_vips_deadline_guard_start( + &deadline_guard, image, deadline_monotonic_ms); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + VipsTargetCustom *target = vips_target_custom_new(); + if (target == NULL) { + fluxer_vips_deadline_guard_finish(&deadline_guard); + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + + struct fluxer_vips_write_ctx ctx = { + .cb = cb, + .user_data = user_data, + .deadline_monotonic_ms = deadline_monotonic_ms, + .status = FLUXER_NATIVE_STATUS_OK, + }; + g_signal_connect(target, "write", G_CALLBACK(fluxer_vips_target_write_adapter), &ctx); + + int rc = vips_image_write_to_target(image, suffix, (VipsTarget *)target, NULL); + g_object_unref(target); + int deadline_status = fluxer_vips_deadline_guard_finish(&deadline_guard); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + if (ctx.status != FLUXER_NATIVE_STATUS_OK) return ctx.status; + if (rc != 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return FLUXER_NATIVE_STATUS_OK; +} + +struct fluxer_vips_bounded_buffer { + uint8_t *data; + size_t len; + size_t capacity; + size_t max_output_size; + int status; +}; + +static int fluxer_vips_bounded_buffer_write(void *user_data, const void *bytes, size_t len) { + if (user_data == NULL) return -1; + struct fluxer_vips_bounded_buffer *buffer = user_data; + if (buffer->status != FLUXER_NATIVE_STATUS_OK) return -1; + if (len == 0) return 0; + if (bytes == NULL) { + buffer->status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + return -1; + } + if (buffer->len > buffer->max_output_size || + len > buffer->max_output_size - buffer->len) { + buffer->status = FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + return -1; + } + size_t required = buffer->len + len; + if (required > buffer->capacity) { + size_t next_capacity = buffer->capacity; + if (next_capacity == 0) { + next_capacity = buffer->max_output_size < 16384u + ? buffer->max_output_size + : 16384u; + } + while (next_capacity < required) { + if (next_capacity > buffer->max_output_size / 2u) { + next_capacity = buffer->max_output_size; + } else { + next_capacity *= 2u; + } + } + if (next_capacity < required || next_capacity > buffer->max_output_size) { + buffer->status = FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + return -1; + } + uint8_t *next = g_try_realloc(buffer->data, next_capacity); + if (next == NULL) { + buffer->status = FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + return -1; + } + buffer->data = next; + buffer->capacity = next_capacity; + } + memcpy(buffer->data + buffer->len, bytes, len); + buffer->len = required; + return 0; +} + +int fluxer_vips_image_write_to_buffer_bounded(VipsImage *image, + const char *suffix, + long long deadline_monotonic_ms, + size_t max_output_size, + void **out_buf, + size_t *out_size, + size_t *out_capacity) { + if (out_buf != NULL) *out_buf = NULL; + if (out_size != NULL) *out_size = 0; + if (out_capacity != NULL) *out_capacity = 0; + if (image == NULL || suffix == NULL || deadline_monotonic_ms < 0 || + max_output_size == 0 || + out_buf == NULL || out_size == NULL || out_capacity == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + struct fluxer_vips_bounded_buffer buffer = { + .max_output_size = max_output_size, + .status = FLUXER_NATIVE_STATUS_OK, + }; + int status = fluxer_vips_image_write_to_callback( + image, suffix, deadline_monotonic_ms, + fluxer_vips_bounded_buffer_write, &buffer); + if (buffer.status != FLUXER_NATIVE_STATUS_OK) status = buffer.status; + if (status == FLUXER_NATIVE_STATUS_OK && + (buffer.data == NULL || buffer.len == 0 || buffer.capacity < buffer.len)) { + status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (status != FLUXER_NATIVE_STATUS_OK) { + if (buffer.data != NULL) g_free(buffer.data); + return status; + } + *out_buf = buffer.data; + *out_size = buffer.len; + *out_capacity = buffer.capacity; + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_vips_image_to_rgba(VipsImage *in, VipsImage **out) { + if (out == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out = NULL; + if (in == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + + VipsBandFormat format = vips_image_get_format(in); + VipsInterpretation interpretation = vips_image_guess_interpretation(in); + VipsImage *scaled = NULL; + VipsImage *depth_normalized = NULL; + if (format == VIPS_FORMAT_USHORT) { + if (interpretation != VIPS_INTERPRETATION_RGB16 && + interpretation != VIPS_INTERPRETATION_GREY16) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + if (vips_cast( + in, &depth_normalized, VIPS_FORMAT_UCHAR, + "shift", TRUE, NULL) != 0 || + depth_normalized == NULL) { + if (depth_normalized != NULL) g_object_unref(depth_normalized); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } else if (format == VIPS_FORMAT_FLOAT) { + if (interpretation == VIPS_INTERPRETATION_B_W) { + if (vips_linear1(in, &scaled, 255.0, 0.0, NULL) != 0 || + scaled == NULL) { + if (scaled != NULL) g_object_unref(scaled); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (vips_cast_uchar(scaled, &depth_normalized, NULL) != 0 || + depth_normalized == NULL) { + if (depth_normalized != NULL) { + g_object_unref(depth_normalized); + } + g_object_unref(scaled); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + g_object_unref(scaled); + } else if (interpretation != VIPS_INTERPRETATION_scRGB) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + } else if (format != VIPS_FORMAT_UCHAR) { + return FLUXER_NATIVE_STATUS_UNSUPPORTED; + } + + VipsImage *colour_input = depth_normalized != NULL ? depth_normalized : in; + VipsImage *srgb = NULL; + if (vips_colourspace( + colour_input, &srgb, VIPS_INTERPRETATION_sRGB, NULL) != 0 || + srgb == NULL) { + if (srgb != NULL) g_object_unref(srgb); + if (depth_normalized != NULL) g_object_unref(depth_normalized); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (depth_normalized != NULL) g_object_unref(depth_normalized); + if (vips_image_get_format(srgb) != VIPS_FORMAT_UCHAR) { + g_object_unref(srgb); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + VipsImage *rgba = NULL; + int bands = vips_image_get_bands(srgb); + if (bands < 4) { + if (vips_addalpha(srgb, &rgba, NULL) != 0 || rgba == NULL) { + if (rgba != NULL) g_object_unref(rgba); + g_object_unref(srgb); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + g_object_unref(srgb); + } else if (bands > 4) { + if (vips_extract_band(srgb, &rgba, 0, "n", 4, NULL) != 0 || + rgba == NULL) { + if (rgba != NULL) g_object_unref(rgba); + g_object_unref(srgb); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + g_object_unref(srgb); + } else { + rgba = srgb; + } + + if (rgba == NULL || vips_image_get_format(rgba) != VIPS_FORMAT_UCHAR || + vips_image_get_bands(rgba) != 4) { + if (rgba != NULL) g_object_unref(rgba); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + + *out = rgba; + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_vips_extract_rgba( + VipsImage *in, + long long deadline_monotonic_ms, + void **out_buf, + size_t *out_size +) { + if (out_buf != NULL) *out_buf = NULL; + if (out_size != NULL) *out_size = 0; + if (in == NULL || deadline_monotonic_ms < 0 || + out_buf == NULL || out_size == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + VipsImage *rgba = NULL; + int status = fluxer_vips_image_to_rgba(in, &rgba); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + int width = vips_image_get_width(rgba); + int height = vips_image_get_height(rgba); + size_t expected_size = 0; + if (ff_validate_rgba_geometry(width, height, &expected_size) != 0) { + g_object_unref(rgba); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + void *buf = NULL; + status = fluxer_vips_image_write_to_memory_deadline( + rgba, deadline_monotonic_ms, expected_size, &buf, out_size); + g_object_unref(rgba); + if (status != FLUXER_NATIVE_STATUS_OK) return status; + if (buf == NULL || *out_size != expected_size) { + if (buf != NULL) g_free(buf); + *out_size = 0; + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + *out_buf = buf; + return FLUXER_NATIVE_STATUS_OK; +} + +void fluxer_vips_unref(VipsImage *image) { + if (image != NULL) { + g_object_unref(image); + } +} + +void fluxer_vips_free(void *mem) { + g_free(mem); +} + +void fluxer_av_free(void *mem) { + if (mem != NULL) av_free(mem); +} + +void fluxer_webp_free(void *mem) { + if (mem != NULL) WebPFree(mem); +} diff --git a/fluxer_media_proxy/src/nsfw.rs b/fluxer_media_proxy/src/nsfw.rs index 811877990..0676d7604 100644 --- a/fluxer_media_proxy/src/nsfw.rs +++ b/fluxer_media_proxy/src/nsfw.rs @@ -1,188 +1,338 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use crate::{ + constants, + metrics::nsfw::NSFWMetrics, + request_log::{Stage, timed_stage}, +}; use base64::{Engine as _, engine::general_purpose}; -use reqwest::Client; +use bytes::Bytes; use serde_json::{Value, json}; -use std::time::Duration; +use std::{sync::Arc, time::Duration}; use thiserror::Error; -#[derive(Clone, Debug)] -pub struct Config { - pub endpoint: String, - pub threshold: f32, - pub timeout_ms: u64, - pub connect_timeout_ms: u64, +pub const NSFW_MAX_FRAME_BYTES: usize = 1024 * 1024; +pub const NSFW_MAX_FRAMES: usize = 3; + +const NSFW_CONNECT_TIMEOUT_MS: u64 = 1_500; +const NSFW_POOL_IDLE_TIMEOUT_SECONDS: u64 = 30; +const NSFW_REQUEST_TIMEOUT_MS: u64 = 5_000; +const NSFW_BATCH_REQUEST_TIMEOUT_MS: u64 = NSFW_REQUEST_TIMEOUT_MS * 3; +const NSFW_PROBABILITY_KEYS: [&str; 4] = ["nsfw_probability", "score", "probability", "nsfw"]; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NSFWThreshold(f32); + +impl NSFWThreshold { + pub fn new(value: f32) -> Result { + if !value.is_finite() || !(0.0..=1.0).contains(&value) { + return Err(InvalidNSFWThreshold); + } + Ok(Self(value)) + } + + fn classifies_as_nsfw(self, probability: f32) -> bool { + probability >= self.0 + } } -impl Config { - pub fn disabled() -> Self { - Self { - endpoint: String::new(), - threshold: 0.85, - timeout_ms: 5_000, - connect_timeout_ms: 1_500, +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +#[error("NSFW threshold must be finite and between zero and one")] +pub struct InvalidNSFWThreshold; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum NSFWPolicy { + Disabled, + Enabled { threshold: NSFWThreshold }, +} + +impl NSFWPolicy { + pub fn enabled(threshold: f32) -> Result { + Ok(Self::Enabled { + threshold: NSFWThreshold::new(threshold)?, + }) + } + + pub fn scan_threshold(self, scan_eligible: bool) -> Option { + if !scan_eligible { + return None; + } + match self { + Self::Disabled => None, + Self::Enabled { threshold } => Some(threshold), } } } -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Result { - pub probability: f32, - pub is_nsfw: bool, -} +#[derive(Clone, Debug, Eq, PartialEq)] +struct NSFWEndpoint(String); -#[derive(Debug, Error, Eq, PartialEq)] -pub enum Error { - #[error("NSFW service is disabled")] - NsfwDisabled, - #[error("NSFW service unavailable")] - NsfwUnavailable, - #[error("invalid NSFW service response")] - InvalidResponse, -} +impl NSFWEndpoint { + fn new(raw: &str) -> Option { + let trimmed = trim_trailing_slash(raw); + if trimmed.is_empty() { + return None; + } + Some(Self(trimmed.to_owned())) + } -pub fn is_enabled(cfg: &Config) -> bool { - !cfg.endpoint.is_empty() -} + fn image_url(&self) -> String { + format!("{}/predict/image", self.0) + } -pub async fn check( - client: &Client, - cfg: &Config, - image_bytes: &[u8], -) -> std::result::Result { - let start = crate::metrics::now_ms(); - let result = check_inner(client, cfg, image_bytes).await; - crate::request_log::record_stage( - crate::request_log::Stage::Nsfw, - (crate::metrics::now_ms() - start).max(0) as u64, - ); - result -} - -async fn check_inner( - client: &Client, - cfg: &Config, - image_bytes: &[u8], -) -> std::result::Result { - if !is_enabled(cfg) { - return Err(Error::NsfwDisabled); + fn images_url(&self) -> String { + format!("{}/predict/images", self.0) } - if image_bytes.is_empty() { - return Err(Error::NsfwUnavailable); - } - let body = json!({ - "base64_data": general_purpose::STANDARD.encode(image_bytes), - }); - let url = format!("{}/predict/image", trim_trailing_slash(&cfg.endpoint)); - let response = client - .post(url) - .timeout(Duration::from_millis(cfg.timeout_ms)) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .json(&body) - .send() - .await - .map_err(|_| Error::NsfwUnavailable)?; - if !response.status().is_success() { - return Err(Error::NsfwUnavailable); - } - let bytes = response.bytes().await.map_err(|_| Error::NsfwUnavailable)?; - let probability = parse_probability(&bytes)?; - Ok(Result { - probability, - is_nsfw: probability >= cfg.threshold, - }) -} - -pub async fn check_buffers( - client: &Client, - cfg: &Config, - frames: &[Vec], -) -> std::result::Result { - let start = crate::metrics::now_ms(); - let result = check_buffers_inner(client, cfg, frames).await; - crate::request_log::record_stage( - crate::request_log::Stage::Nsfw, - (crate::metrics::now_ms() - start).max(0) as u64, - ); - result -} - -async fn check_buffers_inner( - client: &Client, - cfg: &Config, - frames: &[Vec], -) -> std::result::Result { - if !is_enabled(cfg) { - return Err(Error::NsfwDisabled); - } - if frames.is_empty() { - return Err(Error::NsfwUnavailable); - } - if frames.len() == 1 { - return check_inner(client, cfg, &frames[0]).await; - } - if frames.iter().any(Vec::is_empty) { - return Err(Error::NsfwUnavailable); - } - let images = frames - .iter() - .map(|frame| json!({ "base64_data": general_purpose::STANDARD.encode(frame) })) - .collect::>(); - let body = json!({ "images": images }); - let url = format!("{}/predict/images", trim_trailing_slash(&cfg.endpoint)); - let response = client - .post(url) - .timeout(Duration::from_millis(cfg.timeout_ms.saturating_mul(3))) - .header("Content-Type", "application/json") - .header("Accept", "application/json") - .json(&body) - .send() - .await - .map_err(|_| Error::NsfwUnavailable)?; - if !response.status().is_success() { - return Err(Error::NsfwUnavailable); - } - let bytes = response.bytes().await.map_err(|_| Error::NsfwUnavailable)?; - let probabilities = parse_batch_probabilities(&bytes)?; - Ok(verdict_from_frame_probabilities( - &probabilities, - cfg.threshold, - )) } fn trim_trailing_slash(value: &str) -> &str { value.trim_end_matches('/') } -pub fn parse_probability(body: &[u8]) -> std::result::Result { - let value: Value = serde_json::from_slice(body).map_err(|_| Error::InvalidResponse)?; - let object = value.as_object().ok_or(Error::InvalidResponse)?; - for key in ["nsfw_probability", "score", "probability", "nsfw"] { +#[derive(Debug)] +pub struct NSFWScanRequest { + threshold: NSFWThreshold, + frames: Vec>, +} + +impl NSFWScanRequest { + pub fn new( + threshold: NSFWThreshold, + frames: Vec>, + ) -> Result { + validate_nsfw_frames(&frames)?; + Ok(Self { threshold, frames }) + } +} + +fn validate_nsfw_frames(frames: &[Vec]) -> Result<(), InvalidNSFWScanRequest> { + if frames.is_empty() { + return Err(InvalidNSFWScanRequest::Empty); + } + if frames.len() > NSFW_MAX_FRAMES { + return Err(InvalidNSFWScanRequest::TooManyFrames { + count: frames.len(), + }); + } + for (index, frame) in frames.iter().enumerate() { + if frame.is_empty() { + return Err(InvalidNSFWScanRequest::EmptyFrame { index }); + } + if frame.len() > NSFW_MAX_FRAME_BYTES { + return Err(InvalidNSFWScanRequest::FrameTooLarge { + index, + bytes: frame.len(), + }); + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum InvalidNSFWScanRequest { + #[error("NSFW scan request has no images")] + Empty, + #[error("NSFW scan request has {count} images, exceeding the limit of {NSFW_MAX_FRAMES}")] + TooManyFrames { count: usize }, + #[error("NSFW scan request image {index} is empty")] + EmptyFrame { index: usize }, + #[error( + "NSFW scan request image {index} has {bytes} bytes, exceeding the limit of {NSFW_MAX_FRAME_BYTES}" + )] + FrameTooLarge { index: usize, bytes: usize }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct NSFWClassification { + pub probability: f32, + pub is_nsfw: bool, +} + +impl NSFWClassification { + pub fn not_scanned() -> Self { + Self { + probability: 0.0, + is_nsfw: false, + } + } +} + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum NSFWError { + #[error("NSFW service is disabled")] + Disabled, + #[error("NSFW service unavailable")] + Unavailable, + #[error("invalid NSFW service response")] + InvalidResponse, +} + +#[derive(Debug, Error)] +pub enum NSFWClientError { + #[error(transparent)] + InvalidThreshold(#[from] InvalidNSFWThreshold), + #[error("NSFW HTTP client could not be built")] + Transport(#[from] reqwest::Error), +} + +#[derive(Clone)] +struct NSFWTransport { + http: reqwest::Client, + endpoint: NSFWEndpoint, +} + +impl NSFWTransport { + async fn check(&self, frame: &[u8]) -> Result { + let body = json!({ "base64_data": general_purpose::STANDARD.encode(frame) }); + let bytes = self + .post( + self.endpoint.image_url(), + Duration::from_millis(NSFW_REQUEST_TIMEOUT_MS), + &body, + ) + .await?; + parse_probability(&bytes) + } + + async fn check_buffers(&self, frames: &[Vec]) -> Result, NSFWError> { + let images = frames + .iter() + .map(|frame| json!({ "base64_data": general_purpose::STANDARD.encode(frame) })) + .collect::>(); + let body = json!({ "images": images }); + let bytes = self + .post( + self.endpoint.images_url(), + Duration::from_millis(NSFW_BATCH_REQUEST_TIMEOUT_MS), + &body, + ) + .await?; + parse_batch_probabilities(&bytes) + } + + async fn post(&self, url: String, timeout: Duration, body: &Value) -> Result { + let response = self + .http + .post(url) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(body) + .send() + .await + .map_err(|_| NSFWError::Unavailable)?; + if !response.status().is_success() { + return Err(NSFWError::Unavailable); + } + response.bytes().await.map_err(|_| NSFWError::Unavailable) + } +} + +#[derive(Clone)] +pub struct NSFWClient { + transport: Option, + policy: NSFWPolicy, + metrics: Arc, +} + +impl NSFWClient { + pub fn new( + endpoint: &str, + threshold: f32, + metrics: Arc, + ) -> Result { + let policy = NSFWPolicy::enabled(threshold)?; + let Some(endpoint) = NSFWEndpoint::new(endpoint) else { + return Ok(Self { + transport: None, + policy: NSFWPolicy::Disabled, + metrics, + }); + }; + let http = reqwest::Client::builder() + .connect_timeout(Duration::from_millis(NSFW_CONNECT_TIMEOUT_MS)) + .pool_idle_timeout(Duration::from_secs(NSFW_POOL_IDLE_TIMEOUT_SECONDS)) + .user_agent(constants::OUTBOUND_USER_AGENT) + .build()?; + Ok(Self { + transport: Some(NSFWTransport { http, endpoint }), + policy, + metrics, + }) + } + + pub fn disabled() -> Self { + Self { + transport: None, + policy: NSFWPolicy::Disabled, + metrics: Arc::new(NSFWMetrics::new()), + } + } + + pub fn policy(&self) -> NSFWPolicy { + self.policy + } + + pub fn record_declined_scan(&self) { + self.metrics.record_disabled(); + } + + pub async fn check_buffers( + &self, + request: NSFWScanRequest, + ) -> Result { + let outcome = timed_stage(Stage::Nsfw, self.classify(request)).await; + match outcome { + Ok(_) => self.metrics.record_success(), + Err(NSFWError::Disabled) => self.metrics.record_disabled(), + Err(_) => self.metrics.record_failure(), + } + outcome + } + + async fn classify(&self, request: NSFWScanRequest) -> Result { + let transport = self.transport.as_ref().ok_or(NSFWError::Disabled)?; + let NSFWScanRequest { threshold, frames } = request; + if frames.len() == 1 { + let probability = transport.check(&frames[0]).await?; + return Ok(NSFWClassification { + probability, + is_nsfw: threshold.classifies_as_nsfw(probability), + }); + } + let probabilities = transport.check_buffers(&frames).await?; + Ok(verdict_from_frame_probabilities(&probabilities, threshold)) + } +} + +pub fn parse_probability(body: &[u8]) -> Result { + let value: Value = serde_json::from_slice(body).map_err(|_| NSFWError::InvalidResponse)?; + let object = value.as_object().ok_or(NSFWError::InvalidResponse)?; + for key in NSFW_PROBABILITY_KEYS { if let Some(value) = object.get(key) && let Some(score) = number_as_f32(value) { return Ok(score.clamp(0.0, 1.0)); } } - Err(Error::InvalidResponse) + Err(NSFWError::InvalidResponse) } pub const CORROBORATING_FRAMES_REQUIRED: usize = 2; -pub fn parse_batch_probabilities(body: &[u8]) -> std::result::Result, Error> { - let value: Value = serde_json::from_slice(body).map_err(|_| Error::InvalidResponse)?; +pub fn parse_batch_probabilities(body: &[u8]) -> Result, NSFWError> { + let value: Value = serde_json::from_slice(body).map_err(|_| NSFWError::InvalidResponse)?; let predictions = value .get("predictions") .and_then(Value::as_array) - .ok_or(Error::InvalidResponse)?; + .ok_or(NSFWError::InvalidResponse)?; let mut out = Vec::with_capacity(predictions.len()); for item in predictions { let Some(object) = item.as_object() else { continue; }; let mut frame = None; - for key in ["nsfw_probability", "score", "probability", "nsfw"] { + for key in NSFW_PROBABILITY_KEYS { if let Some(score) = object.get(key).and_then(number_as_f32) { let score = score.clamp(0.0, 1.0); frame = Some(frame.map_or(score, |current: f32| current.max(score))); @@ -195,18 +345,21 @@ pub fn parse_batch_probabilities(body: &[u8]) -> std::result::Result, E Ok(out) } -pub fn verdict_from_frame_probabilities(probabilities: &[f32], threshold: f32) -> Result { +pub fn verdict_from_frame_probabilities( + probabilities: &[f32], + threshold: NSFWThreshold, +) -> NSFWClassification { let max = probabilities.iter().copied().fold(0.0f32, f32::max); let over = probabilities .iter() - .filter(|probability| **probability >= threshold) + .filter(|probability| threshold.classifies_as_nsfw(**probability)) .count(); let required = if probabilities.len() >= CORROBORATING_FRAMES_REQUIRED { CORROBORATING_FRAMES_REQUIRED } else { 1 }; - Result { + NSFWClassification { probability: max, is_nsfw: over >= required, } @@ -219,6 +372,11 @@ fn number_as_f32(value: &Value) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::metrics::Metrics; + + fn threshold() -> NSFWThreshold { + NSFWThreshold::new(0.85).expect("0.85 is a valid threshold") + } #[test] fn parse_batch_probabilities_reads_every_frame() { @@ -234,49 +392,66 @@ mod tests { .is_empty() ); assert_eq!( - Err(Error::InvalidResponse), + Err(NSFWError::InvalidResponse), parse_batch_probabilities(br#"{}"#) ); assert_eq!( - Err(Error::InvalidResponse), + Err(NSFWError::InvalidResponse), parse_batch_probabilities(b"not-json") ); } #[test] fn single_high_frame_does_not_flag_a_multi_frame_scan() { - let verdict = verdict_from_frame_probabilities(&[0.02, 0.99, 0.03], 0.95); + let verdict = verdict_from_frame_probabilities( + &[0.02, 0.99, 0.03], + NSFWThreshold::new(0.95).unwrap(), + ); assert!(!verdict.is_nsfw); assert!((verdict.probability - 0.99).abs() < 0.001); } #[test] fn two_high_frames_flag_a_multi_frame_scan() { - assert!(verdict_from_frame_probabilities(&[0.02, 0.99, 0.97], 0.95).is_nsfw); + assert!( + verdict_from_frame_probabilities( + &[0.02, 0.99, 0.97], + NSFWThreshold::new(0.95).unwrap() + ) + .is_nsfw + ); } #[test] fn a_lone_frame_still_flags_on_its_own() { - assert!(verdict_from_frame_probabilities(&[0.99], 0.95).is_nsfw); - assert!(!verdict_from_frame_probabilities(&[0.94], 0.95).is_nsfw); + assert!( + verdict_from_frame_probabilities(&[0.99], NSFWThreshold::new(0.95).unwrap()).is_nsfw + ); + assert!( + !verdict_from_frame_probabilities(&[0.94], NSFWThreshold::new(0.95).unwrap()).is_nsfw + ); } #[test] fn an_empty_scan_never_flags() { - let verdict = verdict_from_frame_probabilities(&[], 0.95); + let verdict = verdict_from_frame_probabilities(&[], NSFWThreshold::new(0.95).unwrap()); assert!(!verdict.is_nsfw); assert!((verdict.probability - 0.0).abs() < 0.001); } #[test] fn frames_at_the_threshold_count_as_over() { - assert!(verdict_from_frame_probabilities(&[0.95, 0.95], 0.95).is_nsfw); + assert!( + verdict_from_frame_probabilities(&[0.95, 0.95], NSFWThreshold::new(0.95).unwrap()) + .is_nsfw + ); } #[test] fn disabled_when_endpoint_empty() { - let cfg = Config::disabled(); - assert!(!is_enabled(&cfg)); + let client = NSFWClient::new("", 0.85, Arc::new(NSFWMetrics::new())).unwrap(); + assert_eq!(NSFWPolicy::Disabled, client.policy()); + assert_eq!(None, client.policy().scan_threshold(true)); } #[test] @@ -293,8 +468,11 @@ mod tests { assert!((parse_probability(br#"{"score":0.99}"#).unwrap() - 0.99).abs() < 0.001); assert!((parse_probability(br#"{"probability":1}"#).unwrap() - 1.0).abs() < 0.001); assert!((parse_probability(br#"{"nsfw":0}"#).unwrap() - 0.0).abs() < 0.001); - assert_eq!(Err(Error::InvalidResponse), parse_probability(br#"{}"#)); - assert_eq!(Err(Error::InvalidResponse), parse_probability(b"not-json")); + assert_eq!(Err(NSFWError::InvalidResponse), parse_probability(br#"{}"#)); + assert_eq!( + Err(NSFWError::InvalidResponse), + parse_probability(b"not-json") + ); } #[test] @@ -302,4 +480,70 @@ mod tests { assert!((parse_probability(br#"{"score":2.5}"#).unwrap() - 1.0).abs() < 0.001); assert!((parse_probability(br#"{"score":-0.3}"#).unwrap() - 0.0).abs() < 0.001); } + + #[test] + fn a_threshold_outside_the_unit_interval_is_rejected() { + assert_eq!(Err(InvalidNSFWThreshold), NSFWThreshold::new(1.5)); + assert_eq!(Err(InvalidNSFWThreshold), NSFWThreshold::new(-0.1)); + assert_eq!(Err(InvalidNSFWThreshold), NSFWThreshold::new(f32::NAN)); + assert!(threshold().classifies_as_nsfw(0.85)); + assert!(!threshold().classifies_as_nsfw(0.849)); + } + + #[test] + fn a_scan_request_bounds_its_frames() { + assert_eq!( + InvalidNSFWScanRequest::Empty, + NSFWScanRequest::new(threshold(), Vec::new()).unwrap_err() + ); + assert_eq!( + InvalidNSFWScanRequest::EmptyFrame { index: 1 }, + NSFWScanRequest::new(threshold(), vec![vec![1], Vec::new()]).unwrap_err() + ); + assert_eq!( + InvalidNSFWScanRequest::FrameTooLarge { + index: 0, + bytes: NSFW_MAX_FRAME_BYTES + 1, + }, + NSFWScanRequest::new(threshold(), vec![vec![1; NSFW_MAX_FRAME_BYTES + 1]]).unwrap_err() + ); + assert!(NSFWScanRequest::new(threshold(), vec![vec![1]; NSFW_MAX_FRAMES]).is_ok()); + } + + #[tokio::test] + async fn a_four_frame_request_is_rejected_before_any_http_call() { + let metrics = Metrics::new(); + let client = NSFWClient::new("http://127.0.0.1:9/", 0.85, metrics.nsfw()).unwrap(); + assert_eq!( + InvalidNSFWScanRequest::TooManyFrames { count: 4 }, + NSFWScanRequest::new(threshold(), vec![vec![1]; 4]).unwrap_err() + ); + let rendered = metrics.render(); + assert!(rendered.contains("fluxer_media_proxy_nsfw_calls_failed_total 0")); + + let reachable = NSFWScanRequest::new(threshold(), vec![vec![1]]).unwrap(); + assert_eq!( + Err(NSFWError::Unavailable), + client.check_buffers(reachable).await + ); + let rendered = metrics.render(); + assert!(rendered.contains("fluxer_media_proxy_nsfw_calls_failed_total 1")); + assert!(rendered.contains("fluxer_media_proxy_nsfw_calls_ok_total 0")); + } + + #[tokio::test] + async fn a_disabled_client_records_a_disabled_call() { + let metrics = Metrics::new(); + let client = NSFWClient::new("", 0.85, metrics.nsfw()).unwrap(); + let request = NSFWScanRequest::new(threshold(), vec![vec![1]]).unwrap(); + assert_eq!( + Err(NSFWError::Disabled), + client.check_buffers(request).await + ); + assert!( + metrics + .render() + .contains("fluxer_media_proxy_nsfw_calls_disabled_total 1") + ); + } } diff --git a/fluxer_media_proxy/src/output_format.rs b/fluxer_media_proxy/src/output_format.rs index f842d0cff..224b1b66a 100644 --- a/fluxer_media_proxy/src/output_format.rs +++ b/fluxer_media_proxy/src/output_format.rs @@ -1,6 +1,80 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::constants::{self, AssetExtension, AssetKind}; +use crate::{ + asset_size, + constants::{AssetExtension, AssetKind}, +}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum OutputFormat { + PNG, + JPEG, + WebP, + GIF, + APNG, +} + +impl OutputFormat { + pub const fn from_source_extension(extension: AssetExtension) -> Option { + match extension { + AssetExtension::Png => Some(Self::PNG), + AssetExtension::Jpeg => Some(Self::JPEG), + AssetExtension::Webp => Some(Self::WebP), + AssetExtension::Gif => Some(Self::GIF), + AssetExtension::Apng => Some(Self::APNG), + AssetExtension::Avif + | AssetExtension::Heic + | AssetExtension::Heif + | AssetExtension::Jxl + | AssetExtension::Svg => None, + } + } + + pub const fn coerce_from_extension(extension: AssetExtension) -> Self { + match Self::from_source_extension(extension) { + Some(format) => format, + None => Self::WebP, + } + } + + pub const fn as_asset_extension(self) -> AssetExtension { + match self { + Self::PNG => AssetExtension::Png, + Self::JPEG => AssetExtension::Jpeg, + Self::WebP => AssetExtension::Webp, + Self::GIF => AssetExtension::Gif, + Self::APNG => AssetExtension::Apng, + } + } + + pub const fn mime(self) -> &'static str { + match self { + Self::PNG => "image/png", + Self::JPEG => "image/jpeg", + Self::WebP => "image/webp", + Self::GIF => "image/gif", + Self::APNG => "image/apng", + } + } + + pub const fn extension(self) -> &'static str { + match self { + Self::PNG => "png", + Self::JPEG => "jpeg", + Self::WebP => "webp", + Self::GIF => "gif", + Self::APNG => "apng", + } + } + + pub const fn cache_serialization(self) -> &'static str { + self.extension() + } + + pub const fn supports_animation(self) -> bool { + matches!(self, Self::WebP | Self::GIF | Self::APNG) + } +} #[derive(Clone, Copy, Debug)] pub struct Input { @@ -12,43 +86,27 @@ pub struct Input { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct OutputSelection { - pub format: AssetExtension, + pub format: OutputFormat, pub size: Option, pub reason: &'static str, } pub fn is_output_format_supported(ext: AssetExtension) -> bool { - !matches!( - ext, - AssetExtension::Avif - | AssetExtension::Heic - | AssetExtension::Heif - | AssetExtension::Jxl - | AssetExtension::Svg - ) -} - -pub fn can_encode_to(ext: AssetExtension) -> bool { - is_output_format_supported(ext) + OutputFormat::from_source_extension(ext).is_some() } pub fn coerce_unsupported_format(ext: AssetExtension) -> AssetExtension { - if can_encode_to(ext) { - ext - } else { - AssetExtension::Webp - } + OutputFormat::coerce_from_extension(ext).as_asset_extension() } pub fn select_url_variant(input: Input) -> OutputSelection { let requested = input.manual_format_override.unwrap_or(input.original); - let output = coerce_unsupported_format(requested); OutputSelection { - format: output, + format: OutputFormat::coerce_from_extension(requested), size: input .requested_size - .map(|size| constants::clamp_size(size, input.kind)), - reason: if output == requested { + .map(|size| asset_size::clamp_size(size, input.kind)), + reason: if is_output_format_supported(requested) { "url" } else { "url-coerced" @@ -68,7 +126,7 @@ mod tests { requested_size: Some(128), manual_format_override: None, }); - assert_eq!(AssetExtension::Png, r.format); + assert_eq!(OutputFormat::PNG, r.format); assert_eq!("url", r.reason); } @@ -80,7 +138,7 @@ mod tests { requested_size: Some(128), manual_format_override: None, }); - assert_eq!(AssetExtension::Webp, r.format); + assert_eq!(OutputFormat::WebP, r.format); assert_eq!("url-coerced", r.reason); } @@ -92,7 +150,7 @@ mod tests { requested_size: Some(128), manual_format_override: None, }); - assert_eq!(AssetExtension::Webp, r.format); + assert_eq!(OutputFormat::WebP, r.format); assert_eq!("url-coerced", r.reason); } @@ -104,7 +162,7 @@ mod tests { requested_size: Some(128), manual_format_override: Some(AssetExtension::Png), }); - assert_eq!(AssetExtension::Png, r.format); + assert_eq!(OutputFormat::PNG, r.format); assert_eq!("url", r.reason); } @@ -116,7 +174,50 @@ mod tests { requested_size: Some(256), manual_format_override: Some(AssetExtension::Svg), }); - assert_eq!(AssetExtension::Webp, r.format); + assert_eq!(OutputFormat::WebP, r.format); assert_eq!("url-coerced", r.reason); } + + #[test] + fn encodable_extensions_round_trip_through_the_output_format() { + for extension in [ + AssetExtension::Png, + AssetExtension::Jpeg, + AssetExtension::Webp, + AssetExtension::Gif, + AssetExtension::Apng, + ] { + let format = OutputFormat::from_source_extension(extension).expect("encodable"); + assert!(is_output_format_supported(extension)); + assert_eq!(extension, format.as_asset_extension()); + assert_eq!(extension.mime(), format.mime()); + assert_eq!(extension.name(), format.extension()); + assert_eq!(format.extension(), format.cache_serialization()); + assert_eq!(extension, coerce_unsupported_format(extension)); + } + for extension in [ + AssetExtension::Avif, + AssetExtension::Heic, + AssetExtension::Heif, + AssetExtension::Jxl, + AssetExtension::Svg, + ] { + assert!(!is_output_format_supported(extension)); + assert_eq!(None, OutputFormat::from_source_extension(extension)); + assert_eq!( + OutputFormat::WebP, + OutputFormat::coerce_from_extension(extension) + ); + assert_eq!(AssetExtension::Webp, coerce_unsupported_format(extension)); + } + } + + #[test] + fn only_the_animation_containers_support_animation() { + assert!(OutputFormat::WebP.supports_animation()); + assert!(OutputFormat::GIF.supports_animation()); + assert!(OutputFormat::APNG.supports_animation()); + assert!(!OutputFormat::PNG.supports_animation()); + assert!(!OutputFormat::JPEG.supports_animation()); + } } diff --git a/fluxer_media_proxy/src/percent_decode.rs b/fluxer_media_proxy/src/percent_decode.rs new file mode 100644 index 000000000..49ada58c0 --- /dev/null +++ b/fluxer_media_proxy/src/percent_decode.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use thiserror::Error; + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum PercentDecodeError { + #[error("percent-encoded value is invalid")] + InvalidEncoding, + #[error("percent-decoded value length overflowed")] + LengthOverflow, + #[error("percent-decoded value allocation failed")] + AllocationFailed, +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn decode_bytes(input: &str) -> Result, PercentDecodeError> { + let bytes = input.as_bytes(); + let mut decoded = Vec::new(); + decoded + .try_reserve_exact(bytes.len()) + .map_err(|_| PercentDecodeError::AllocationFailed)?; + let mut index = 0; + while index < bytes.len() { + let value = bytes[index]; + if value == b'%' { + let escape_start = index + .checked_add(1) + .ok_or(PercentDecodeError::LengthOverflow)?; + let escape_end = index + .checked_add(2) + .ok_or(PercentDecodeError::LengthOverflow)?; + if escape_end >= bytes.len() { + return Err(PercentDecodeError::InvalidEncoding); + } + let (Some(high), Some(low)) = + (hex_value(bytes[escape_start]), hex_value(bytes[escape_end])) + else { + return Err(PercentDecodeError::InvalidEncoding); + }; + let decoded_value = (high << 4) | low; + if decoded_value == b'/' { + return Err(PercentDecodeError::InvalidEncoding); + } + decoded.push(decoded_value); + index = index + .checked_add(3) + .ok_or(PercentDecodeError::LengthOverflow)?; + continue; + } + decoded.push(value); + index = index + .checked_add(1) + .ok_or(PercentDecodeError::LengthOverflow)?; + } + Ok(decoded) +} + +pub fn decode_utf8(input: &str) -> Result { + String::from_utf8(decode_bytes(input)?).map_err(|_| PercentDecodeError::InvalidEncoding) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{disposition, external_path, test_fixtures::ADVERSARIAL_TEXT_INPUTS}; + + #[test] + fn percent_decode_accepts_utf8_and_rejects_bad_or_path_changing_escapes() { + assert_eq!(decode_utf8("hello%20world"), Ok("hello world".to_owned())); + assert_eq!(decode_utf8("%C3%A9"), Ok("é".to_owned())); + for invalid in ["%", "%0", "%GG", "%2F", "%2f", "%FF"] { + assert_eq!( + decode_utf8(invalid), + Err(PercentDecodeError::InvalidEncoding), + "accepted {invalid}" + ); + } + } + + #[test] + fn strictness_does_not_leak_into_the_lossy_external_path_decoder() { + assert_eq!("a/b", external_path::percent_decode_string("a%2Fb", false)); + assert_eq!("a b", external_path::percent_decode_string("a+b", true)); + assert_eq!("a%", external_path::percent_decode_string("a%", false)); + assert_eq!( + "\u{fffd}", + external_path::percent_decode_string("%FF", false) + ); + } + + #[test] + fn text_parsers_reject_adversarial_input_without_panicking() { + for text in ADVERSARIAL_TEXT_INPUTS { + if let Ok(decoded) = decode_utf8(text) { + assert_eq!( + decoded.contains('/'), + text.contains('/'), + "decoding changed the path structure of {text}" + ); + } + for decision in [ + disposition::Decision::Inline, + disposition::Decision::Attachment, + ] { + assert!( + disposition::header(decision, Some(text)).is_ok(), + "disposition rejected {text}" + ); + } + } + } +} diff --git a/fluxer_media_proxy/src/policy_tests/adversarial.rs b/fluxer_media_proxy/src/policy_tests/adversarial.rs new file mode 100644 index 000000000..7f616ef8b --- /dev/null +++ b/fluxer_media_proxy/src/policy_tests/adversarial.rs @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + asset_hash::{self, AssetHash}, + disposition::{self, Decision, PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES}, + query::Query, + test_fixtures::ADVERSARIAL_TEXT_INPUTS, + upload_relay::token::token_from_query, +}; + +const RELAY_TOKEN_BYTES_MAX: usize = 16 * 1024; + +#[test] +fn adversarial_text_never_escapes_the_asset_hash_digest_contract() { + for text in ADVERSARIAL_TEXT_INPUTS { + let parsed = AssetHash::parse(text); + assert!( + text.ends_with(parsed.digest()), + "the digest is not a suffix of {text}" + ); + assert_eq!( + parsed.is_animated(), + text.starts_with("a_"), + "the animation flag disagrees with {text}" + ); + assert_eq!( + text.len() - parsed.digest().len(), + if parsed.is_animated() { 2 } else { 0 }, + "the digest dropped bytes from {text}" + ); + assert_eq!(parsed.digest(), asset_hash::strip_animation_prefix(text)); + } +} + +#[test] +fn adversarial_query_text_yields_at_most_one_relay_token() { + for text in ADVERSARIAL_TEXT_INPUTS { + let query = Query::parse(text); + for key in ["t", "token", ""] { + let Ok(token) = token_from_query(query.get(key)) else { + continue; + }; + assert!(!token.is_empty(), "an empty token was accepted from {text}"); + assert!( + !token.contains('&'), + "a multi-parameter token was accepted from {text}" + ); + assert!( + token.len() <= RELAY_TOKEN_BYTES_MAX, + "an unbounded token was accepted from {text}" + ); + } + if let Ok(token) = token_from_query(Some(text)) { + assert!(!token.is_empty(), "an empty token was accepted from {text}"); + assert!(token.len() <= RELAY_TOKEN_BYTES_MAX); + } + } +} + +#[test] +fn adversarial_filenames_never_break_the_content_disposition_header() { + for text in ADVERSARIAL_TEXT_INPUTS { + for (decision, directive) in [ + (Decision::Inline, "inline"), + (Decision::Attachment, "attachment"), + ] { + let value = disposition::header(decision, Some(text)) + .expect("disposition header") + .into_header_value(); + let rendered = value.to_str().expect("the disposition header is ascii"); + assert!( + rendered.len() <= PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES, + "unbounded disposition for {text}" + ); + assert!( + !rendered.contains(['\r', '\n', '\0']), + "the disposition for {text} can split the header block" + ); + assert!( + rendered.starts_with(directive), + "the disposition for {text} lost its directive" + ); + if text.is_empty() { + assert_eq!(rendered, directive); + continue; + } + assert_eq!( + rendered.matches("; filename=\"").count(), + 1, + "the disposition for {text} repeated the filename parameter" + ); + let quoted = rendered + .strip_prefix(directive) + .and_then(|rest| rest.strip_prefix("; filename=\"")) + .and_then(|rest| rest.split('"').next()) + .expect("a terminated quoted filename"); + assert!( + !quoted.contains(['\\', '/']), + "the ascii fallback for {text} kept a path or escape character" + ); + } + } +} diff --git a/fluxer_media_proxy/src/policy_tests/headers.rs b/fluxer_media_proxy/src/policy_tests/headers.rs new file mode 100644 index 000000000..a7d641609 --- /dev/null +++ b/fluxer_media_proxy/src/policy_tests/headers.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + disposition::{self, Decision}, + http_headers, + range::ByteRange, +}; +use http::{HeaderMap, header}; + +fn disposition_string(decision: Decision, filename: Option<&str>) -> String { + disposition::header(decision, filename) + .expect("disposition header") + .into_header_value() + .to_str() + .expect("disposition header is ascii") + .to_owned() +} + +#[test] +fn disposition_policy_blocks_scriptable_documents() { + assert_eq!(disposition::decide("image/png", false), Decision::Inline); + assert_eq!( + disposition::decide("video/webm; codecs=vp9", false), + Decision::Inline + ); + assert_eq!( + disposition::decide("image/svg+xml", false), + Decision::Attachment + ); + assert_eq!( + disposition::decide("application/pdf; charset=binary", false), + Decision::Attachment + ); + assert_eq!(disposition::decide("image/png", true), Decision::Attachment); +} + +#[test] +fn disposition_filename_uses_ascii_and_rfc5987_forms() { + assert_eq!( + disposition_string(Decision::Attachment, Some("photo.png")), + "attachment; filename=\"photo.png\"" + ); + assert_eq!( + disposition_string(Decision::Attachment, Some("résumé/\".png")), + "attachment; filename=\"r__sum____.png\"; filename*=UTF-8''r%C3%A9sum%C3%A9%2F%22.png" + ); + assert_eq!(disposition_string(Decision::Inline, Some("")), "inline"); +} + +#[test] +fn public_media_headers_never_negotiate_a_conditional_validator() { + let mut responses = Vec::new(); + for content_type in ["image/png", "video/mp4", "audio/mpeg", "image/svg+xml"] { + for byte_range in [None, Some(ByteRange { start: 10, end: 19 })] { + let mut headers = HeaderMap::new(); + http_headers::add_media_headers(&mut headers, 100, content_type, byte_range); + responses.push(headers); + } + } + let mut unsatisfiable = HeaderMap::new(); + http_headers::add_unsatisfiable_headers(&mut unsatisfiable, 4096); + responses.push(unsatisfiable); + let mut security_only = HeaderMap::new(); + http_headers::add_security_headers(&mut security_only); + responses.push(security_only); + + for headers in &responses { + for negotiated in ["etag", "if-none-match", "if-modified-since", "age"] { + assert!( + headers.get(negotiated).is_none(), + "{negotiated} was negotiated on a public media response" + ); + } + if let Some(vary) = headers.get(header::VARY) { + assert_eq!(vary, "Accept-Encoding"); + } + } +} diff --git a/fluxer_media_proxy/src/policy_tests/mod.rs b/fluxer_media_proxy/src/policy_tests/mod.rs new file mode 100644 index 000000000..30b5379b4 --- /dev/null +++ b/fluxer_media_proxy/src/policy_tests/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod adversarial; +mod headers; +mod parsers; diff --git a/fluxer_media_proxy/src/policy_tests/parsers.rs b/fluxer_media_proxy/src/policy_tests/parsers.rs new file mode 100644 index 000000000..0910de7ee --- /dev/null +++ b/fluxer_media_proxy/src/policy_tests/parsers.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + asset_hash::AssetHash, + constants::{DEFAULT_IMAGE_SIZE, parse_image_size}, + image_quality::ImageQuality, + image_transform::EncodeEffort, + query::Query, +}; + +#[test] +fn asset_hash_parsing_stays_lenient_about_the_digest_shape() { + for raw in [ + "", + "deadbee", + "deadbeef0", + "DEADBEEF", + "gggggggg", + "0123abcd", + "a-deadbeef", + ] { + let parsed = AssetHash::parse(raw); + assert_eq!(parsed.digest(), raw, "the digest rewrote {raw}"); + assert!(!parsed.is_animated(), "{raw} was read as animated"); + } + for (raw, digest) in [ + ("a_deadbeef", "deadbeef"), + ("a_DEADBEEF", "DEADBEEF"), + ("a_gggggggg", "gggggggg"), + ("a_", ""), + ] { + let parsed = AssetHash::parse(raw); + assert_eq!(parsed.digest(), digest, "the digest rewrote {raw}"); + assert!(parsed.is_animated(), "{raw} lost its animation prefix"); + } +} + +#[test] +fn malformed_query_values_fall_back_instead_of_failing_the_request() { + for raw in ["", "-1", "1e3", "99999999999", "128px", " 128"] { + assert_eq!( + parse_image_size(Some(raw)), + DEFAULT_IMAGE_SIZE, + "size {raw} did not fall back" + ); + } + // A value that parses is snapped up the ladder, never dropped to the default: the old era + // pinned this so an off-ladder request can never be served fewer pixels than it asked for. + assert_eq!(parse_image_size(Some("0")), 16); + assert_eq!(parse_image_size(Some("777")), 1024); + assert_eq!(parse_image_size(Some("256")), 256); + assert_eq!(parse_image_size(None), DEFAULT_IMAGE_SIZE); + + for raw in ["", " ", "LOW", "lossy", "%FF", "high "] { + assert_eq!( + ImageQuality::parse_lenient(raw), + ImageQuality::High, + "quality {raw} did not fall back" + ); + } + assert_eq!(ImageQuality::parse_lenient("low"), ImageQuality::Low); + + for raw in ["", "-1", "nine", "10.5", "256"] { + assert_eq!( + EncodeEffort::parse_lenient(raw), + None, + "effort {raw} was accepted" + ); + } + assert_eq!( + EncodeEffort::parse_lenient("250").map(EncodeEffort::get), + Some(9) + ); + + let query = Query::parse("animated=yes&download=TRUE&passthrough=1&empty="); + assert!(!query.bool_value("animated", false)); + assert!(!query.bool_value("animated", true)); + assert!(query.bool_value("download", false)); + assert!(query.bool_value("passthrough", false)); + assert!(!query.bool_value("empty", true)); + assert!(query.bool_value("missing", true)); + assert!(!query.bool_value("missing", false)); +} diff --git a/fluxer_media_proxy/src/public_net_policy.rs b/fluxer_media_proxy/src/public_net_policy.rs deleted file mode 100644 index aa76e1112..000000000 --- a/fluxer_media_proxy/src/public_net_policy.rs +++ /dev/null @@ -1,450 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use reqwest::dns::{Addrs, Name, Resolve, Resolving}; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}; -use thiserror::Error; -use tokio::net::lookup_host; - -const MAX_URL_LEN: usize = 8192; - -#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] -pub enum Error { - #[error("invalid URL")] - InvalidUrl, - #[error("blocked URL")] - BlockedUrl, - #[error("DNS lookup failed")] - DnsLookupFailed, - #[error("host resolved to no address")] - HostResolvedToNoAddress, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct ParsedUrl<'a> { - pub scheme: &'a str, - pub authority: &'a str, - pub host: &'a str, - pub path_query: &'a str, -} - -fn contains_ctl(value: &str) -> bool { - value.bytes().any(|ch| ch < 0x20 || ch == 0x7f) -} - -pub fn parse_url(url: &str) -> Result, Error> { - if url.is_empty() || url.len() > MAX_URL_LEN || contains_ctl(url) { - return Err(Error::InvalidUrl); - } - let scheme_end = url.find("://").ok_or(Error::InvalidUrl)?; - let scheme = &url[..scheme_end]; - if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) { - return Err(Error::BlockedUrl); - } - let mut rest = &url[scheme_end + 3..]; - let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); - if authority_end == 0 { - return Err(Error::InvalidUrl); - } - let authority = &rest[..authority_end]; - if authority.contains('@') { - return Err(Error::BlockedUrl); - } - rest = &rest[authority_end..]; - let host = if let Some(after_bracket) = authority.strip_prefix('[') { - let close = after_bracket.find(']').ok_or(Error::InvalidUrl)?; - let host = &after_bracket[..close]; - let suffix = &after_bracket[close + 1..]; - if !suffix.is_empty() { - let port = suffix.strip_prefix(':').ok_or(Error::InvalidUrl)?; - validate_port(port)?; - } - host - } else if let Some(colon) = authority.find(':') { - if authority[colon + 1..].contains(':') { - return Err(Error::InvalidUrl); - } - validate_port(&authority[colon + 1..])?; - &authority[..colon] - } else { - authority - }; - if host.is_empty() { - return Err(Error::InvalidUrl); - } - let path_query = if rest.is_empty() { - "/" - } else { - &rest[..rest.find('#').unwrap_or(rest.len())] - }; - Ok(ParsedUrl { - scheme, - authority, - host, - path_query, - }) -} - -fn validate_port(raw: &str) -> Result<(), Error> { - if raw.is_empty() { - return Err(Error::InvalidUrl); - } - let port = raw.parse::().map_err(|_| Error::InvalidUrl)?; - if port == 0 { - return Err(Error::InvalidUrl); - } - Ok(()) -} - -fn normalize_host(raw: &str) -> Result { - let trimmed = raw.trim_matches([' ', '\t', '\r', '\n']); - let without_dot = trimmed.strip_suffix('.').unwrap_or(trimmed); - if without_dot.is_empty() { - return Err(Error::InvalidUrl); - } - Ok(without_dot.to_ascii_lowercase()) -} - -fn ipv4_to_u32(ip: Ipv4Addr) -> u32 { - u32::from_be_bytes(ip.octets()) -} - -fn ipv4_in(ip: Ipv4Addr, prefix: Ipv4Addr, bits: u32) -> bool { - let mask = if bits == 0 { - 0 - } else { - u32::MAX << (32 - bits) - }; - (ipv4_to_u32(ip) & mask) == (ipv4_to_u32(prefix) & mask) -} - -fn ipv6_to_u128(ip: Ipv6Addr) -> u128 { - u128::from_be_bytes(ip.octets()) -} - -fn ipv6_in(ip: Ipv6Addr, prefix: Ipv6Addr, bits: u32) -> bool { - let mask = if bits == 0 { - 0 - } else { - u128::MAX << (128 - bits) - }; - (ipv6_to_u128(ip) & mask) == (ipv6_to_u128(prefix) & mask) -} - -fn blocked_ipv4(ip: Ipv4Addr) -> bool { - ipv4_in(ip, Ipv4Addr::new(0, 0, 0, 0), 8) - || ipv4_in(ip, Ipv4Addr::new(10, 0, 0, 0), 8) - || ipv4_in(ip, Ipv4Addr::new(100, 64, 0, 0), 10) - || ipv4_in(ip, Ipv4Addr::new(127, 0, 0, 0), 8) - || ipv4_in(ip, Ipv4Addr::new(169, 254, 0, 0), 16) - || ipv4_in(ip, Ipv4Addr::new(172, 16, 0, 0), 12) - || ipv4_in(ip, Ipv4Addr::new(192, 0, 0, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(192, 0, 2, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(192, 88, 99, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(192, 168, 0, 0), 16) - || ipv4_in(ip, Ipv4Addr::new(198, 18, 0, 0), 15) - || ipv4_in(ip, Ipv4Addr::new(198, 51, 100, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(203, 0, 113, 0), 24) - || ipv4_in(ip, Ipv4Addr::new(224, 0, 0, 0), 4) - || ipv4_in(ip, Ipv4Addr::new(240, 0, 0, 0), 4) - || ip == Ipv4Addr::new(255, 255, 255, 255) -} - -fn embedded_ipv4(ip: Ipv6Addr) -> Option { - let octets = ip.octets(); - if ipv6_in(ip, Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0, 0), 96) - || ipv6_in(ip, Ipv6Addr::UNSPECIFIED, 96) - { - return Some(Ipv4Addr::new( - octets[12], octets[13], octets[14], octets[15], - )); - } - if ipv6_in(ip, Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0), 16) { - return Some(Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5])); - } - None -} - -fn blocked_ipv6(ip: Ipv6Addr) -> bool { - if let Some(mapped) = ip.to_ipv4_mapped() { - return blocked_ipv4(mapped); - } - if let Some(embedded) = embedded_ipv4(ip) { - return blocked_ipv4(embedded); - } - ip.is_unspecified() - || ip.is_loopback() - || ipv6_in(ip, Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0), 32) - || ipv6_in(ip, Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7) - || ipv6_in(ip, Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), 10) - || ipv6_in(ip, Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8) -} - -pub fn is_blocked_ip_literal(raw: &str) -> bool { - match raw.parse::() { - Ok(IpAddr::V4(ip)) => blocked_ipv4(ip), - Ok(IpAddr::V6(ip)) => blocked_ipv6(ip), - Err(_) => true, - } -} - -fn validate_resolved_ip(ip: IpAddr) -> Result<(), Error> { - match ip { - IpAddr::V4(ip) if blocked_ipv4(ip) => Err(Error::BlockedUrl), - IpAddr::V6(ip) if blocked_ipv6(ip) => Err(Error::BlockedUrl), - _ => Ok(()), - } -} - -pub struct PinnedDnsResolver; - -impl Resolve for PinnedDnsResolver { - fn resolve(&self, name: Name) -> Resolving { - Box::pin(async move { - let host = name.as_str().to_owned(); - let resolved: Vec = lookup_host((host.as_str(), 0)).await?.collect(); - if resolved.is_empty() { - return Err(Box::new(Error::HostResolvedToNoAddress) - as Box); - } - for addr in &resolved { - validate_resolved_ip(addr.ip())?; - } - Ok(Box::new(resolved.into_iter()) as Addrs) - }) - } -} - -pub fn is_valid_public_hostname(host: &str) -> bool { - if host.is_empty() || host.len() > 253 || !host.contains('.') { - return false; - } - let mut last = ""; - for label in host.split('.') { - if label.is_empty() || label.len() > 63 { - return false; - } - let bytes = label.as_bytes(); - if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() { - return false; - } - if !bytes - .iter() - .all(|b| b.is_ascii_alphanumeric() || *b == b'-') - { - return false; - } - last = label; - } - !last.bytes().all(|b| b.is_ascii_digit()) -} - -pub fn validate_url(url: &str) -> Result<(), Error> { - let parsed = parse_url(url)?; - let host = normalize_host(parsed.host)?; - if let Ok(ip) = host.parse::() { - return match ip { - IpAddr::V4(ip) if blocked_ipv4(ip) => Err(Error::BlockedUrl), - IpAddr::V6(ip) if blocked_ipv6(ip) => Err(Error::BlockedUrl), - _ => Ok(()), - }; - } - if !is_valid_public_hostname(&host) { - return Err(Error::BlockedUrl); - } - let mut seen = false; - for addr in (host.as_str(), 80) - .to_socket_addrs() - .map_err(|_| Error::DnsLookupFailed)? - { - seen = true; - match addr.ip() { - IpAddr::V4(ip) if blocked_ipv4(ip) => return Err(Error::BlockedUrl), - IpAddr::V6(ip) if blocked_ipv6(ip) => return Err(Error::BlockedUrl), - _ => {} - } - } - if seen { - Ok(()) - } else { - Err(Error::HostResolvedToNoAddress) - } -} - -pub fn resolve_redirect(base_url: &str, location: &str) -> Result { - if location.is_empty() || location.len() > MAX_URL_LEN || contains_ctl(location) { - return Err(Error::InvalidUrl); - } - let fragment = location.find('#').unwrap_or(location.len()); - let loc = location[..fragment].trim_matches([' ', '\t', '\r', '\n']); - if loc - .get(..7) - .is_some_and(|s| s.eq_ignore_ascii_case("http://")) - || loc - .get(..8) - .is_some_and(|s| s.eq_ignore_ascii_case("https://")) - { - return Ok(loc.to_owned()); - } - let base = parse_url(base_url)?; - if loc.starts_with("//") { - return Ok(format!("{}:{loc}", base.scheme)); - } - if loc.starts_with('/') { - return Ok(format!("{}://{}{}", base.scheme, base.authority, loc)); - } - let q = base.path_query.find('?').unwrap_or(base.path_query.len()); - let base_path = &base.path_query[..q]; - if loc.starts_with('?') { - return Ok(format!( - "{}://{}{}{}", - base.scheme, base.authority, base_path, loc - )); - } - let slash = base_path.rfind('/').unwrap_or(0); - let prefix = if slash == 0 { - "/" - } else { - &base_path[..slash + 1] - }; - let joined = format!("{prefix}{loc}"); - Ok(format!( - "{}://{}{}", - base.scheme, - base.authority, - remove_dot_segments(&joined) - )) -} - -fn remove_dot_segments(path_query: &str) -> String { - let q = path_query.find('?').unwrap_or(path_query.len()); - let path = &path_query[..q]; - let query = &path_query[q..]; - let mut segments = Vec::new(); - for segment in path.split('/') { - if segment.is_empty() || segment == "." { - continue; - } - if segment == ".." { - segments.pop(); - } else { - segments.push(segment); - } - } - let mut out = String::from("/"); - out.push_str(&segments.join("/")); - if path.len() > 1 && path.ends_with('/') && !out.ends_with('/') { - out.push('/'); - } - out.push_str(query); - out -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn blocks_private_and_special_ip_literals() { - assert!(is_blocked_ip_literal("127.0.0.1")); - assert!(is_blocked_ip_literal("10.1.2.3")); - assert!(is_blocked_ip_literal("::1")); - assert!(is_blocked_ip_literal("::ffff:192.168.1.1")); - assert!(!is_blocked_ip_literal("8.8.8.8")); - assert!(!is_blocked_ip_literal("2606:4700:4700::1111")); - } - - #[test] - fn validates_public_host_syntax() { - assert!(is_valid_public_hostname("example.com")); - assert!(is_valid_public_hostname("xn--bcher-kva.example")); - assert!(!is_valid_public_hostname("localhost")); - assert!(!is_valid_public_hostname("example")); - assert!(!is_valid_public_hostname("bad_name.example")); - assert!(!is_valid_public_hostname("example.123")); - } - - #[test] - fn resolves_relative_redirects() { - assert_eq!( - "https://example.com/a/d?y=2", - resolve_redirect("https://example.com/a/b/c?x=1", "../d?y=2#ignored").unwrap() - ); - assert_eq!( - "https://example.com/z", - resolve_redirect("https://example.com/a/b/c", "/z").unwrap() - ); - } - - #[test] - fn blocks_every_documented_ipv4_ssrf_range() { - for ip in [ - "0.0.0.0", - "10.0.0.1", - "100.64.0.1", - "127.0.0.1", - "169.254.169.254", - "172.16.5.5", - "192.0.0.1", - "192.0.2.5", - "192.88.99.5", - "192.168.1.1", - "198.18.0.1", - "198.51.100.5", - "203.0.113.5", - "224.0.0.1", - "240.0.0.1", - "255.255.255.255", - ] { - assert!(is_blocked_ip_literal(ip), "{ip}"); - } - } - - #[test] - fn blocks_every_documented_ipv6_ssrf_range() { - for ip in [ - "::", - "::1", - "2001:db8::1", - "fc00::1", - "fd00::1", - "fe80::1", - "ff00::1", - "64:ff9b::a9fe:a9fe", - "::7f00:1", - "2002:a9fe:a9fe::", - ] { - assert!(is_blocked_ip_literal(ip), "{ip}"); - } - } - - #[test] - fn rejects_urls_with_userinfo() { - assert_eq!( - Err(Error::BlockedUrl), - validate_url("https://user:pass@example.com/") - ); - } - - #[test] - fn rejects_non_http_s_schemes() { - assert_eq!(Err(Error::BlockedUrl), validate_url("file:///etc/passwd")); - assert_eq!( - Err(Error::BlockedUrl), - validate_url("gopher://example.com/") - ); - } - - #[test] - fn redirect_that_returns_to_same_url_is_allowed() { - assert_eq!( - "https://example.com/path", - resolve_redirect("https://example.com/path", "/path").unwrap() - ); - } - - #[test] - fn redirect_with_dot_dot_cannot_escape_host() { - let r = resolve_redirect("https://example.com/a", "../../../etc").unwrap(); - assert!(r.starts_with("https://example.com/")); - } -} diff --git a/fluxer_media_proxy/src/public_net_policy/ip_tables.rs b/fluxer_media_proxy/src/public_net_policy/ip_tables.rs new file mode 100644 index 000000000..98ce08538 --- /dev/null +++ b/fluxer_media_proxy/src/public_net_policy/ip_tables.rs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +const NON_PUBLIC_IPV4_SUBNETS: &[(Ipv4Addr, u32)] = &[ + (Ipv4Addr::UNSPECIFIED, 8), + (Ipv4Addr::new(10, 0, 0, 0), 8), + (Ipv4Addr::new(100, 64, 0, 0), 10), + (Ipv4Addr::new(127, 0, 0, 0), 8), + (Ipv4Addr::new(169, 254, 0, 0), 16), + (Ipv4Addr::new(172, 16, 0, 0), 12), + (Ipv4Addr::new(192, 0, 0, 0), 24), + (Ipv4Addr::new(192, 0, 2, 0), 24), + (Ipv4Addr::new(192, 88, 99, 0), 24), + (Ipv4Addr::new(192, 168, 0, 0), 16), + (Ipv4Addr::new(198, 18, 0, 0), 15), + (Ipv4Addr::new(198, 51, 100, 0), 24), + (Ipv4Addr::new(203, 0, 113, 0), 24), + (Ipv4Addr::new(224, 0, 0, 0), 4), + (Ipv4Addr::new(240, 0, 0, 0), 4), +]; + +const PUBLIC_IPV4_SPECIAL_USE_EXCEPTIONS: &[(Ipv4Addr, u32)] = &[ + (Ipv4Addr::new(192, 0, 0, 9), 32), + (Ipv4Addr::new(192, 0, 0, 10), 32), +]; + +const IPV6_GLOBAL_UNICAST_SUBNETS: &[(Ipv6Addr, u32)] = + &[(Ipv6Addr::new(0x2000, 0, 0, 0, 0, 0, 0, 0), 3)]; + +const NON_PUBLIC_IPV6_SUBNETS: &[(Ipv6Addr, u32)] = &[ + (Ipv6Addr::UNSPECIFIED, 96), + (Ipv6Addr::new(0x0064, 0xff9b, 1, 0, 0, 0, 0, 0), 48), + (Ipv6Addr::new(0x0100, 0, 0, 0, 0, 0, 0, 0), 63), + (Ipv6Addr::new(0x2001, 0, 0, 0, 0, 0, 0, 0), 23), + (Ipv6Addr::new(0x2001, 0x0db8, 0, 0, 0, 0, 0, 0), 32), + (Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0), 16), + (Ipv6Addr::new(0x3fff, 0, 0, 0, 0, 0, 0, 0), 20), + (Ipv6Addr::new(0x5f00, 0, 0, 0, 0, 0, 0, 0), 16), + (Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7), + (Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 0), 10), + (Ipv6Addr::new(0xfec0, 0, 0, 0, 0, 0, 0, 0), 10), + (Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0, 0), 8), +]; + +const PUBLIC_IPV6_SPECIAL_USE_EXCEPTIONS: &[(Ipv6Addr, u32)] = &[ + (Ipv6Addr::new(0x2001, 1, 0, 0, 0, 0, 0, 1), 128), + (Ipv6Addr::new(0x2001, 1, 0, 0, 0, 0, 0, 2), 128), + (Ipv6Addr::new(0x2001, 1, 0, 0, 0, 0, 0, 3), 128), + (Ipv6Addr::new(0x2001, 3, 0, 0, 0, 0, 0, 0), 32), + (Ipv6Addr::new(0x2001, 4, 0x0112, 0, 0, 0, 0, 0), 48), + (Ipv6Addr::new(0x2001, 0x20, 0, 0, 0, 0, 0, 0), 28), + (Ipv6Addr::new(0x2001, 0x30, 0, 0, 0, 0, 0, 0), 28), +]; + +fn ipv4_to_u32(address: Ipv4Addr) -> u32 { + u32::from_be_bytes(address.octets()) +} + +fn ipv4_in(address: Ipv4Addr, prefix: Ipv4Addr, bits: u32) -> bool { + let mask = if bits == 0 { + 0 + } else { + u32::MAX << (32 - bits) + }; + (ipv4_to_u32(address) & mask) == (ipv4_to_u32(prefix) & mask) +} + +fn ipv6_to_u128(address: Ipv6Addr) -> u128 { + u128::from_be_bytes(address.octets()) +} + +fn ipv6_in(address: Ipv6Addr, prefix: Ipv6Addr, bits: u32) -> bool { + let mask = if bits == 0 { + 0 + } else { + u128::MAX << (128 - bits) + }; + (ipv6_to_u128(address) & mask) == (ipv6_to_u128(prefix) & mask) +} + +fn is_blocked_ipv4(address: Ipv4Addr) -> bool { + if PUBLIC_IPV4_SPECIAL_USE_EXCEPTIONS + .iter() + .any(|(prefix, bits)| ipv4_in(address, *prefix, *bits)) + { + return false; + } + NON_PUBLIC_IPV4_SUBNETS + .iter() + .any(|(prefix, bits)| ipv4_in(address, *prefix, *bits)) +} + +fn embedded_ipv4(address: Ipv6Addr) -> Option { + if !ipv6_in(address, Ipv6Addr::new(0x0064, 0xff9b, 0, 0, 0, 0, 0, 0), 96) { + return None; + } + let octets = address.octets(); + Some(Ipv4Addr::new( + octets[12], octets[13], octets[14], octets[15], + )) +} + +fn is_blocked_ipv6(address: Ipv6Addr) -> bool { + if let Some(mapped) = address.to_ipv4_mapped() { + return is_blocked_ipv4(mapped); + } + if let Some(embedded) = embedded_ipv4(address) { + return is_blocked_ipv4(embedded); + } + if PUBLIC_IPV6_SPECIAL_USE_EXCEPTIONS + .iter() + .any(|(prefix, bits)| ipv6_in(address, *prefix, *bits)) + { + return false; + } + if !IPV6_GLOBAL_UNICAST_SUBNETS + .iter() + .any(|(prefix, bits)| ipv6_in(address, *prefix, *bits)) + { + return true; + } + NON_PUBLIC_IPV6_SUBNETS + .iter() + .any(|(prefix, bits)| ipv6_in(address, *prefix, *bits)) +} + +pub(super) fn is_public_ip(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => !is_blocked_ipv4(address), + IpAddr::V6(address) => !is_blocked_ipv6(address), + } +} + +pub fn is_blocked_ip_literal(raw: &str) -> bool { + match raw.parse::() { + Ok(address) => !is_public_ip(address), + Err(_) => true, + } +} diff --git a/fluxer_media_proxy/src/public_net_policy/mod.rs b/fluxer_media_proxy/src/public_net_policy/mod.rs new file mode 100644 index 000000000..87ee6f540 --- /dev/null +++ b/fluxer_media_proxy/src/public_net_policy/mod.rs @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod ip_tables; +mod resolver; + +#[cfg(test)] +mod tests; + +pub use ip_tables::is_blocked_ip_literal; +pub use resolver::{PinnedDnsResolver, is_pinned_dns_failure}; + +use std::net::IpAddr; +use thiserror::Error; +use url::Url; + +const MAX_URL_LEN: usize = 8192; +const MAX_PUBLIC_HOSTNAME_BYTES: usize = 253; +const MAX_PUBLIC_HOSTNAME_LABEL_BYTES: usize = 63; +const ALLOWED_PUBLIC_URL_PORTS: [u16; 2] = [80, 443]; + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum Error { + #[error("invalid URL")] + InvalidUrl, + #[error("blocked URL")] + BlockedUrl, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ParsedUrl<'a> { + pub scheme: &'a str, + pub authority: &'a str, + pub host: &'a str, + pub port: Option, + pub path_query: &'a str, + pub fragment: Option<&'a str>, +} + +fn contains_ctl(value: &str) -> bool { + value.bytes().any(|ch| ch < 0x20 || ch == 0x7f) +} + +pub fn parse_url(url: &str) -> Result, Error> { + if url.is_empty() || url.len() > MAX_URL_LEN || contains_ctl(url) { + return Err(Error::InvalidUrl); + } + let scheme_end = url.find("://").ok_or(Error::InvalidUrl)?; + let scheme = &url[..scheme_end]; + if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) { + return Err(Error::BlockedUrl); + } + let mut rest = &url[scheme_end + 3..]; + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + if authority_end == 0 { + return Err(Error::InvalidUrl); + } + let authority = &rest[..authority_end]; + if authority.contains('@') { + return Err(Error::BlockedUrl); + } + rest = &rest[authority_end..]; + let mut port = None; + let host = if let Some(after_bracket) = authority.strip_prefix('[') { + let close = after_bracket.find(']').ok_or(Error::InvalidUrl)?; + let host = &after_bracket[..close]; + let suffix = &after_bracket[close + 1..]; + if !suffix.is_empty() { + let raw = suffix.strip_prefix(':').ok_or(Error::InvalidUrl)?; + port = Some(parse_port(raw)?); + } + host + } else if let Some(colon) = authority.find(':') { + if authority[colon + 1..].contains(':') { + return Err(Error::InvalidUrl); + } + port = Some(parse_port(&authority[colon + 1..])?); + &authority[..colon] + } else { + authority + }; + if host.is_empty() { + return Err(Error::InvalidUrl); + } + let (path_query, fragment) = if rest.is_empty() { + ("/", None) + } else { + match rest.find('#') { + Some(hash) => (&rest[..hash], Some(&rest[hash + 1..])), + None => (rest, None), + } + }; + Ok(ParsedUrl { + scheme, + authority, + host, + port, + path_query, + fragment, + }) +} + +fn parse_port(raw: &str) -> Result { + if raw.is_empty() { + return Err(Error::InvalidUrl); + } + let port = raw.parse::().map_err(|_| Error::InvalidUrl)?; + if port == 0 { + return Err(Error::InvalidUrl); + } + Ok(port) +} + +fn default_port_for_scheme(scheme: &str) -> u16 { + if scheme.eq_ignore_ascii_case("https") { + 443 + } else { + 80 + } +} + +fn normalize_host(raw: &str) -> Result { + let trimmed = raw.trim_matches([' ', '\t', '\r', '\n']); + let without_dot = trimmed.strip_suffix('.').unwrap_or(trimmed); + if without_dot.is_empty() { + return Err(Error::InvalidUrl); + } + Ok(without_dot.to_ascii_lowercase()) +} + +pub fn is_valid_public_hostname(host: &str) -> bool { + if host.is_empty() || host.len() > MAX_PUBLIC_HOSTNAME_BYTES || !host.contains('.') { + return false; + } + let mut last = ""; + for label in host.split('.') { + if label.is_empty() || label.len() > MAX_PUBLIC_HOSTNAME_LABEL_BYTES { + return false; + } + let bytes = label.as_bytes(); + if !bytes[0].is_ascii_alphanumeric() || !bytes[bytes.len() - 1].is_ascii_alphanumeric() { + return false; + } + if !bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') + { + return false; + } + last = label; + } + !last.bytes().all(|byte| byte.is_ascii_digit()) +} + +pub fn validate_url(url: &str) -> Result<(), Error> { + let parsed = parse_url(url)?; + if parsed.fragment.is_some() { + return Err(Error::BlockedUrl); + } + let port = parsed + .port + .unwrap_or_else(|| default_port_for_scheme(parsed.scheme)); + if !ALLOWED_PUBLIC_URL_PORTS.contains(&port) { + return Err(Error::BlockedUrl); + } + let host = normalize_host(parsed.host)?; + if let Ok(address) = host.parse::() { + return if ip_tables::is_public_ip(address) { + Ok(()) + } else { + Err(Error::BlockedUrl) + }; + } + if !is_valid_public_hostname(&host) { + return Err(Error::BlockedUrl); + } + Ok(()) +} + +pub fn resolve_redirect(base_url: &str, location: &str) -> Result { + if location.is_empty() || location.len() > MAX_URL_LEN || contains_ctl(location) { + return Err(Error::InvalidUrl); + } + let fragment = location.find('#').unwrap_or(location.len()); + let loc = location[..fragment].trim_matches([' ', '\t', '\r', '\n']); + if loc + .get(..7) + .is_some_and(|s| s.eq_ignore_ascii_case("http://")) + || loc + .get(..8) + .is_some_and(|s| s.eq_ignore_ascii_case("https://")) + { + return Ok(loc.to_owned()); + } + let base = parse_url(base_url)?; + if loc.starts_with("//") { + return Ok(format!("{}:{loc}", base.scheme)); + } + if loc.starts_with('/') { + return Ok(format!("{}://{}{}", base.scheme, base.authority, loc)); + } + let q = base.path_query.find('?').unwrap_or(base.path_query.len()); + let base_path = &base.path_query[..q]; + if loc.starts_with('?') { + return Ok(format!( + "{}://{}{}{}", + base.scheme, base.authority, base_path, loc + )); + } + let slash = base_path.rfind('/').unwrap_or(0); + let prefix = if slash == 0 { + "/" + } else { + &base_path[..slash + 1] + }; + let joined = format!("{prefix}{loc}"); + Ok(format!( + "{}://{}{}", + base.scheme, + base.authority, + remove_dot_segments(&joined) + )) +} + +fn remove_dot_segments(path_query: &str) -> String { + let q = path_query.find('?').unwrap_or(path_query.len()); + let path = &path_query[..q]; + let query = &path_query[q..]; + let mut segments = Vec::new(); + for segment in path.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + segments.pop(); + } else { + segments.push(segment); + } + } + let mut out = String::from("/"); + out.push_str(&segments.join("/")); + if path.len() > 1 && path.ends_with('/') && !out.ends_with('/') { + out.push('/'); + } + out.push_str(query); + out +} + +pub fn external_url_for_log(value: &str) -> String { + let Ok(mut parsed) = Url::parse(value) else { + return "[invalid-url]".to_owned(); + }; + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.set_path("/[redacted]"); + parsed.set_query(None); + parsed.set_fragment(None); + parsed.to_string() +} diff --git a/fluxer_media_proxy/src/public_net_policy/resolver.rs b/fluxer_media_proxy/src/public_net_policy/resolver.rs new file mode 100644 index 000000000..62c8b8782 --- /dev/null +++ b/fluxer_media_proxy/src/public_net_policy/resolver.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::ip_tables::is_public_ip; +use reqwest::dns::{Addrs, Name, Resolve, Resolving}; +use std::net::SocketAddr; +use thiserror::Error; +use tokio::net::lookup_host; + +pub(super) const MAX_PUBLIC_DNS_ADDRESSES: usize = 64; + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub(super) enum ResolveError { + #[error("host lookup failed")] + LookupFailed, + #[error("host resolved to no address")] + HostResolvedToNoAddress, + #[error("host resolved to a blocked network address")] + BlockedAddress, + #[error("host resolved to too many network addresses")] + TooManyAddresses, +} + +pub(super) fn screen_resolved_addresses( + addresses: impl Iterator, +) -> Result, ResolveError> { + let mut screened = Vec::with_capacity(MAX_PUBLIC_DNS_ADDRESSES); + for address in addresses { + if screened.len() == MAX_PUBLIC_DNS_ADDRESSES { + return Err(ResolveError::TooManyAddresses); + } + if !is_public_ip(address.ip()) { + return Err(ResolveError::BlockedAddress); + } + screened.push(address); + } + if screened.is_empty() { + return Err(ResolveError::HostResolvedToNoAddress); + } + Ok(screened) +} + +pub struct PinnedDnsResolver; + +impl Resolve for PinnedDnsResolver { + fn resolve(&self, name: Name) -> Resolving { + Box::pin(async move { + let host = name.as_str().to_owned(); + let looked_up = lookup_host((host.as_str(), 0)) + .await + .map_err(|_| ResolveError::LookupFailed)?; + let resolved = screen_resolved_addresses(looked_up)?; + Ok(Box::new(resolved.into_iter()) as Addrs) + }) + } +} + +// reqwest and the hyper connector box this rejection behind errors of their own, so the source +// chain is the only way back to the decision the resolver made. +pub fn is_pinned_dns_failure(error: &(dyn std::error::Error + 'static)) -> bool { + let mut current = Some(error); + while let Some(error) = current { + if error.downcast_ref::().is_some() { + return true; + } + current = std::error::Error::source(error); + } + false +} diff --git a/fluxer_media_proxy/src/public_net_policy/tests.rs b/fluxer_media_proxy/src/public_net_policy/tests.rs new file mode 100644 index 000000000..e2cd48391 --- /dev/null +++ b/fluxer_media_proxy/src/public_net_policy/tests.rs @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::ip_tables::is_public_ip; +use super::resolver::{MAX_PUBLIC_DNS_ADDRESSES, ResolveError, screen_resolved_addresses}; +use super::*; +use std::net::SocketAddr; + +fn public(literal: &str) -> bool { + is_public_ip(literal.parse().expect("test address literal parses")) +} + +fn socket_addresses(literal: &str, count: usize) -> Vec { + let address = literal.parse().expect("test address literal parses"); + (0..count).map(|_| SocketAddr::new(address, 443)).collect() +} + +#[test] +fn blocks_private_and_special_ip_literals() { + assert!(is_blocked_ip_literal("127.0.0.1")); + assert!(is_blocked_ip_literal("10.1.2.3")); + assert!(is_blocked_ip_literal("::1")); + assert!(is_blocked_ip_literal("::ffff:192.168.1.1")); + assert!(!is_blocked_ip_literal("8.8.8.8")); + assert!(!is_blocked_ip_literal("2606:4700:4700::1111")); +} + +#[test] +fn validates_public_host_syntax() { + assert!(is_valid_public_hostname("example.com")); + assert!(is_valid_public_hostname("xn--bcher-kva.example")); + assert!(!is_valid_public_hostname("localhost")); + assert!(!is_valid_public_hostname("example")); + assert!(!is_valid_public_hostname("bad_name.example")); + assert!(!is_valid_public_hostname("example.123")); +} + +#[test] +fn resolves_relative_redirects() { + assert_eq!( + "https://example.com/a/d?y=2", + resolve_redirect("https://example.com/a/b/c?x=1", "../d?y=2#ignored").unwrap() + ); + assert_eq!( + "https://example.com/z", + resolve_redirect("https://example.com/a/b/c", "/z").unwrap() + ); +} + +#[test] +fn blocks_every_documented_ipv4_ssrf_range() { + for ip in [ + "0.0.0.0", + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", + "172.16.5.5", + "192.0.0.1", + "192.0.2.5", + "192.88.99.5", + "192.168.1.1", + "198.18.0.1", + "198.51.100.5", + "203.0.113.5", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255", + ] { + assert!(is_blocked_ip_literal(ip), "{ip}"); + } +} + +#[test] +fn blocks_every_documented_ipv6_ssrf_range() { + for ip in [ + "::", + "::1", + "2001:db8::1", + "fc00::1", + "fd00::1", + "fe80::1", + "ff00::1", + "64:ff9b::a9fe:a9fe", + "::7f00:1", + "2002:a9fe:a9fe::", + ] { + assert!(is_blocked_ip_literal(ip), "{ip}"); + } +} + +#[test] +fn rejects_urls_with_userinfo() { + assert_eq!( + Err(Error::BlockedUrl), + validate_url("https://user:pass@example.com/") + ); +} + +#[test] +fn rejects_non_http_s_schemes() { + assert_eq!(Err(Error::BlockedUrl), validate_url("file:///etc/passwd")); + assert_eq!( + Err(Error::BlockedUrl), + validate_url("gopher://example.com/") + ); +} + +#[test] +fn redirect_that_returns_to_same_url_is_allowed() { + assert_eq!( + "https://example.com/path", + resolve_redirect("https://example.com/path", "/path").unwrap() + ); +} + +#[test] +fn redirect_with_dot_dot_cannot_escape_host() { + let r = resolve_redirect("https://example.com/a", "../../../etc").unwrap(); + assert!(r.starts_with("https://example.com/")); +} + +#[test] +fn logged_urls_keep_only_scheme_host_and_port() { + assert_eq!( + "https://cdn.example.com/[redacted]", + external_url_for_log("https://agent:hunter2@cdn.example.com/rooms/private?token=abc#f") + ); + assert_eq!( + "http://cdn.example.com:8080/[redacted]", + external_url_for_log("http://cdn.example.com:8080/a/b/c") + ); + assert_eq!("[invalid-url]", external_url_for_log("not a url")); + assert_eq!("[invalid-url]", external_url_for_log("")); +} + +#[test] +fn ipv6_outside_global_unicast_is_blocked_by_default() { + for literal in [ + "100::1", + "1fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", + "2001::1", + "2002::1", + "3fff::1", + "4000::", + "5f00::1", + "fec0::1", + "ff02::1", + ] { + assert!(!public(literal), "{literal}"); + } + for literal in [ + "2000::", + "2001:200::1", + "2001:db7:ffff:ffff:ffff:ffff:ffff:ffff", + "2001:db9::1", + "2003::1", + "2606:4700:4700::1111", + "2a00:1450:4001:800::200e", + "3fff:1000::1", + ] { + assert!(public(literal), "{literal}"); + } +} + +#[test] +fn globally_reachable_special_use_addresses_stay_reachable() { + for literal in ["192.0.0.9", "192.0.0.10", "2001:1::1", "2001:20::1"] { + assert!(public(literal), "{literal}"); + } + for literal in ["192.0.0.8", "192.0.0.11", "2001:1::4", "2001:40::1"] { + assert!(!public(literal), "{literal}"); + } +} + +#[test] +fn ipv4_written_in_ipv6_notation_is_revalidated_against_the_v4_table() { + for literal in [ + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "::ffff:169.254.169.254", + "::93.184.216.34", + "64:ff9b::10.0.0.1", + "64:ff9b::169.254.169.254", + "2002:5db8:d822::", + ] { + assert!(!public(literal), "{literal}"); + } + for literal in ["::ffff:93.184.216.34", "64:ff9b::93.184.216.34"] { + assert!(public(literal), "{literal}"); + } +} + +#[test] +fn unparseable_ip_literals_fail_closed() { + assert!(is_blocked_ip_literal("")); + assert!(is_blocked_ip_literal("example.com")); + assert!(is_blocked_ip_literal("127.0.0.1:80")); + assert!(is_blocked_ip_literal("0x7f000001")); +} + +#[test] +fn public_urls_are_restricted_to_the_standard_web_ports() { + assert_eq!(Ok(()), validate_url("https://example.com/a")); + assert_eq!(Ok(()), validate_url("https://example.com:443/a")); + assert_eq!(Ok(()), validate_url("http://example.com:80/a")); + for url in [ + "https://example.com:8080/a", + "http://example.com:8080/a", + "https://example.com:22/a", + "https://[2606:4700:4700::1111]:8443/a", + ] { + assert_eq!(Err(Error::BlockedUrl), validate_url(url), "{url}"); + } + for url in ["https://example.com:0/a", "https://example.com:99999/a"] { + assert_eq!(Err(Error::InvalidUrl), validate_url(url), "{url}"); + } +} + +#[test] +fn urls_carrying_a_fragment_are_rejected() { + assert_eq!( + Err(Error::BlockedUrl), + validate_url("https://example.com/a#section") + ); + assert_eq!( + Err(Error::BlockedUrl), + validate_url("https://example.com#section") + ); + assert_eq!(Ok(()), validate_url("https://example.com/a")); + let next = resolve_redirect("https://example.com/a/b", "/next#anchor").unwrap(); + assert_eq!("https://example.com/next", next); + assert_eq!(Ok(()), validate_url(&next)); +} + +#[test] +fn url_validation_accepts_public_syntax_without_resolving_dns() { + assert_eq!( + Ok(()), + validate_url("https://this-host-does-not-exist.invalid/a") + ); + assert_eq!( + Err(Error::BlockedUrl), + validate_url("https://169.254.169.254/latest/meta-data/") + ); + assert_eq!(Err(Error::BlockedUrl), validate_url("http://localhost/a")); +} + +#[test] +fn resolved_address_sets_are_bounded_and_fail_closed() { + assert_eq!( + Err(ResolveError::HostResolvedToNoAddress), + screen_resolved_addresses(std::iter::empty()) + ); + let at_limit = socket_addresses("93.184.216.34", MAX_PUBLIC_DNS_ADDRESSES); + assert_eq!( + Ok(at_limit.clone()), + screen_resolved_addresses(at_limit.into_iter()) + ); + let over_limit = socket_addresses("93.184.216.34", MAX_PUBLIC_DNS_ADDRESSES + 1); + assert_eq!( + Err(ResolveError::TooManyAddresses), + screen_resolved_addresses(over_limit.into_iter()) + ); + let mut mixed = socket_addresses("93.184.216.34", 2); + mixed.extend(socket_addresses("169.254.169.254", 1)); + assert_eq!( + Err(ResolveError::BlockedAddress), + screen_resolved_addresses(mixed.into_iter()) + ); +} + +#[test] +fn a_resolver_rejection_is_recognisable_however_deeply_the_transport_wraps_it() { + assert!(is_pinned_dns_failure(&ResolveError::BlockedAddress)); + assert!(is_pinned_dns_failure(&ResolveError::LookupFailed)); + let wrapped = anyhow::Error::new(ResolveError::HostResolvedToNoAddress).context("dns error"); + assert!(is_pinned_dns_failure(wrapped.as_ref())); + assert!(!is_pinned_dns_failure(&std::io::Error::other( + "tcp connect error" + ))); +} diff --git a/fluxer_media_proxy/src/query.rs b/fluxer_media_proxy/src/query.rs index 1609ff64a..a1ce5aaad 100644 --- a/fluxer_media_proxy/src/query.rs +++ b/fluxer_media_proxy/src/query.rs @@ -57,3 +57,119 @@ impl Query { .unwrap_or(default_value) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_fixtures::ADVERSARIAL_TEXT_INPUTS; + + fn target<'a>(path: &'a str, query: &'a str) -> Target<'a> { + Target { path, query } + } + + #[test] + fn split_target_cuts_at_the_first_question_mark() { + assert_eq!( + target("/avatars/1/a.png", "size=64"), + split_target("/avatars/1/a.png?size=64") + ); + assert_eq!( + target("/avatars/1/a.png", ""), + split_target("/avatars/1/a.png") + ); + assert_eq!(target("", ""), split_target("")); + assert_eq!(target("", "size=64"), split_target("?size=64")); + assert_eq!(target("/a", ""), split_target("/a?")); + assert_eq!(target("/a", "b?c=1"), split_target("/a?b?c=1")); + } + + #[test] + fn adversarial_targets_split_without_losing_or_duplicating_bytes() { + for text in ADVERSARIAL_TEXT_INPUTS { + for raw in [ + (*text).to_owned(), + format!("/{text}"), + format!("/media/{text}?v={text}"), + format!("{text}?{text}"), + ] { + let split = split_target(&raw); + assert!(!split.path.contains('?'), "{raw}"); + assert!(raw.starts_with(split.path), "{raw}"); + assert!(raw.ends_with(split.query), "{raw}"); + assert_eq!( + raw.len(), + split.path.len() + split.query.len() + usize::from(raw.contains('?')), + "{raw}" + ); + } + } + } + + #[test] + fn query_parse_decodes_pairs_and_keeps_the_first_value_for_a_repeated_key() { + let query = Query::parse("size=64&size=128&download&flag=&a+b=c+d&%2F=%2F"); + assert_eq!(Some("64"), query.get("size")); + assert_eq!(Some(""), query.get("download")); + assert_eq!(Some(""), query.get("flag")); + assert_eq!(Some("c d"), query.get("a b")); + assert_eq!(Some("/"), query.get("/")); + assert_eq!(None, query.get("missing")); + assert_eq!(6, query.pairs.len()); + } + + #[test] + fn query_parse_skips_empty_fields_and_tolerates_broken_escapes() { + let query = Query::parse("&&a=1&&&b=%zz&c=%&d=%FF&e=%C3%A9"); + assert_eq!(Some("1"), query.get("a")); + assert_eq!(Some("%zz"), query.get("b")); + assert_eq!(Some("%"), query.get("c")); + assert_eq!(Some("\u{fffd}"), query.get("d")); + assert_eq!(Some("\u{e9}"), query.get("e")); + assert_eq!(None, query.get("")); + assert_eq!(5, query.pairs.len()); + assert_eq!(0, Query::parse("&&&").pairs.len()); + assert_eq!(0, Query::parse("").pairs.len()); + } + + #[test] + fn bool_value_accepts_only_true_and_one() { + let query = Query::parse("a=true&b=TRUE&c=1&d=yes&e=&f=0&g"); + assert!(query.bool_value("a", false)); + assert!(query.bool_value("b", false)); + assert!(query.bool_value("c", false)); + assert!(!query.bool_value("d", true)); + assert!(!query.bool_value("e", true)); + assert!(!query.bool_value("f", true)); + assert!(!query.bool_value("g", true)); + assert!(query.bool_value("missing", true)); + assert!(!query.bool_value("missing", false)); + } + + #[test] + fn adversarial_query_text_parses_into_one_pair_per_non_empty_field() { + for text in ADVERSARIAL_TEXT_INPUTS { + for raw in [ + (*text).to_owned(), + format!("size={text}"), + format!("{text}={text}"), + format!("{text}&{text}"), + format!("&{text}&&size=64&"), + ] { + let query = Query::parse(&raw); + assert_eq!( + raw.split('&').filter(|field| !field.is_empty()).count(), + query.pairs.len(), + "{raw}" + ); + for (key, _) in &query.pairs { + let first = query + .pairs + .iter() + .find_map(|(k, v)| (k == key).then_some(v.as_str())); + assert_eq!(first, query.get(key), "{raw}"); + } + assert_eq!(None, query.get("definitely-absent"), "{raw}"); + } + } + } +} diff --git a/fluxer_media_proxy/src/range.rs b/fluxer_media_proxy/src/range.rs index c51ee2f87..ebfaf7370 100644 --- a/fluxer_media_proxy/src/range.rs +++ b/fluxer_media_proxy/src/range.rs @@ -7,9 +7,11 @@ pub struct ByteRange { } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct ParsedRange { - pub range: Option, - pub unsatisfiable: bool, +pub enum RangeSelection { + #[default] + Full, + Partial(ByteRange), + Unsatisfiable, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -26,94 +28,96 @@ pub enum RequestRange<'a> { Unsatisfiable, } -pub fn parse_range(header: Option<&str>, file_size: usize) -> ParsedRange { +pub fn parse_range(header: Option<&str>, file_size: usize) -> RangeSelection { let Some(raw) = header else { - return ParsedRange::default(); + return RangeSelection::Full; }; let trimmed = raw.trim_matches([' ', '\t']); + // The unit is matched the way classify_request_range matches it, which is what decides + // whether a GET forwards the Range upstream at all. Accepting more units here alone would + // answer 206 on a HEAD whose GET serves the whole body. let Some(spec) = trimmed.strip_prefix("bytes=") else { - return ParsedRange::default(); + return RangeSelection::Full; }; if spec.contains(',') { - return ParsedRange::default(); + return RangeSelection::Full; } let Some(dash) = spec.find('-') else { - return ParsedRange::default(); + return RangeSelection::Full; }; + let end_offset = dash + .checked_add(1) + .expect("range delimiter offset must fit usize"); let start_part = &spec[..dash]; - let end_part = &spec[dash + 1..]; + let end_part = &spec[end_offset..]; if start_part.is_empty() && end_part.is_empty() { - return ParsedRange::default(); - } - if file_size == 0 { - return ParsedRange { - range: None, - unsatisfiable: true, - }; + return RangeSelection::Full; } + // A range-spec that does not parse is ignored outright and the full resource is served + // (RFC 9110 s14.2). That check has to happen before the zero-length shortcut below, or a + // malformed spec against an empty object reports 416 instead of 200. if !start_part.is_empty() { - let Ok(start) = start_part.parse::() else { - return ParsedRange::default(); + let Some(start) = parse_decimal_usize(start_part) else { + return RangeSelection::Full; }; - let requested_end = if end_part.is_empty() { - file_size - 1 - } else if let Ok(end) = end_part.parse::() { - end + let parsed_end = if end_part.is_empty() { + None + } else if let Some(end) = parse_decimal_usize(end_part) { + Some(end) } else { - return ParsedRange::default(); + return RangeSelection::Full; }; - if start >= file_size || requested_end < start { - return ParsedRange { - range: None, - unsatisfiable: true, - }; + if file_size == 0 { + return RangeSelection::Unsatisfiable; } - return ParsedRange { - range: Some(ByteRange { - start, - end: requested_end.min(file_size - 1), - }), - unsatisfiable: false, - }; + let last_byte = file_size + .checked_sub(1) + .expect("nonempty media resource must have a last byte"); + let requested_end = parsed_end.unwrap_or(last_byte); + if start >= file_size || requested_end < start { + return RangeSelection::Unsatisfiable; + } + return RangeSelection::Partial(ByteRange { + start, + end: requested_end.min(last_byte), + }); } - let Ok(suffix_len) = end_part.parse::() else { - return ParsedRange::default(); + let Some(suffix_len) = parse_decimal_usize(end_part) else { + return RangeSelection::Full; }; + if file_size == 0 { + return RangeSelection::Unsatisfiable; + } + let last_byte = file_size + .checked_sub(1) + .expect("nonempty media resource must have a last byte"); if suffix_len == 0 { - return ParsedRange { - range: None, - unsatisfiable: true, - }; + return RangeSelection::Unsatisfiable; } let resolved_len = suffix_len.min(file_size); - ParsedRange { - range: Some(ByteRange { - start: file_size - resolved_len, - end: file_size - 1, - }), - unsatisfiable: false, - } + RangeSelection::Partial(ByteRange { + start: file_size + .checked_sub(resolved_len) + .expect("resolved suffix length must not exceed the media resource"), + end: last_byte, + }) } pub fn parse_bounded_request_range(header: Option<&str>, max_len: usize) -> Option { - let raw = header?; - let spec = raw.trim_matches([' ', '\t']).strip_prefix("bytes=")?; + let raw = header?.trim_matches([' ', '\t']); + let spec = raw.strip_prefix("bytes=")?; if spec.contains(',') { return None; } let dash = spec.find('-')?; - let start_part = spec[..dash].trim_matches([' ', '\t']); - let end_part = spec[dash + 1..].trim_matches([' ', '\t']); - if start_part.is_empty() || end_part.is_empty() { - return None; - } - let start = start_part.parse::().ok()?; - let end = end_part.parse::().ok()?; + let end_offset = dash.checked_add(1)?; + let start = parse_decimal_usize(spec[..dash].trim_matches([' ', '\t']))?; + let end = parse_decimal_usize(spec[end_offset..].trim_matches([' ', '\t']))?; if end < start { return None; } - let len = end - start + 1; - if len == 0 || len > max_len { + let len = end.checked_sub(start)?.checked_add(1)?; + if len > max_len { return None; } Some(ByteRange { start, end }) @@ -166,22 +170,23 @@ pub fn parse_unsatisfiable_content_range(header: Option<&str>) -> Option } pub fn parse_content_range(header: Option<&str>) -> Option { - let raw = header?; - let spec = raw.trim_matches([' ', '\t']).strip_prefix("bytes ")?; + let raw = header?.trim_matches([' ', '\t']); + let (unit, spec) = raw.split_once(' ')?; + if !unit.eq_ignore_ascii_case("bytes") { + return None; + } let dash = spec.find('-')?; - let slash = spec[dash + 1..].find('/')? + dash + 1; - let start = spec[..dash] - .trim_matches([' ', '\t']) - .parse::() - .ok()?; - let end = spec[dash + 1..slash] - .trim_matches([' ', '\t']) - .parse::() - .ok()?; + let range_end_start = dash.checked_add(1)?; + let slash = spec[range_end_start..] + .find('/')? + .checked_add(range_end_start)?; + let start = parse_decimal_usize(spec[..dash].trim_matches([' ', '\t']))?; + let end = parse_decimal_usize(spec[range_end_start..slash].trim_matches([' ', '\t']))?; if end < start { return None; } - let size_part = spec[slash + 1..].trim_matches([' ', '\t']); + let size_start = slash.checked_add(1)?; + let size_part = spec[size_start..].trim_matches([' ', '\t']); if size_part.is_empty() { return None; } @@ -192,7 +197,7 @@ pub fn parse_content_range(header: Option<&str>) -> Option { size: None, }); } - let size = size_part.parse::().ok()?; + let size = parse_decimal_usize(size_part)?; if size == 0 || end >= size { return None; } @@ -203,74 +208,185 @@ pub fn parse_content_range(header: Option<&str>) -> Option { }) } +pub(crate) fn parse_decimal_usize(raw: &str) -> Option { + if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + raw.parse().ok() +} + #[cfg(test)] mod tests { use super::*; + use crate::test_fixtures::ADVERSARIAL_RANGE_HEADERS; #[test] fn range_parser() { assert_eq!( - Some(ByteRange { start: 0, end: 9 }), - parse_range(Some("bytes=0-9"), 100).range + RangeSelection::Partial(ByteRange { start: 0, end: 9 }), + parse_range(Some("bytes=0-9"), 100) ); assert_eq!( - Some(ByteRange { start: 95, end: 99 }), - parse_range(Some("bytes=-5"), 100).range + RangeSelection::Partial(ByteRange { start: 95, end: 99 }), + parse_range(Some("bytes=-5"), 100) + ); + assert_eq!( + RangeSelection::Unsatisfiable, + parse_range(Some("bytes=100-200"), 100) ); - assert!(parse_range(Some("bytes=100-200"), 100).unsatisfiable); } #[test] fn open_ended_range() { assert_eq!( - Some(ByteRange { start: 50, end: 99 }), - parse_range(Some("bytes=50-"), 100).range + RangeSelection::Partial(ByteRange { start: 50, end: 99 }), + parse_range(Some("bytes=50-"), 100) ); } #[test] fn suffix_larger_than_file_clamps_to_whole_file() { assert_eq!( - Some(ByteRange { start: 0, end: 99 }), - parse_range(Some("bytes=-9999"), 100).range + RangeSelection::Partial(ByteRange { start: 0, end: 99 }), + parse_range(Some("bytes=-9999"), 100) ); } #[test] fn zero_length_suffix_is_unsatisfiable() { - assert!(parse_range(Some("bytes=-0"), 100).unsatisfiable); + assert_eq!( + RangeSelection::Unsatisfiable, + parse_range(Some("bytes=-0"), 100) + ); + } + + #[test] + fn a_malformed_spec_is_ignored_even_against_a_zero_length_object() { + // RFC 9110 s14.2: an unparsable range-spec is ignored and the full resource is served. + // The zero-length shortcut must not pre-empt that check. The old era reached the same + // answer on GET by classifying the spec before it ever consulted the length; only its + // HEAD branch went through this parser and reported 416, which is the bug being closed. + for raw in [ + "bytes=abc-def", + "bytes=--", + "bytes=1-2-3", + "bytes=x-y", + "bytes=-abc", + ] { + assert_eq!( + RangeSelection::Full, + parse_range(Some(raw), 0), + "{raw} against an empty object must be ignored, not 416" + ); + assert_eq!( + RangeSelection::Full, + parse_range(Some(raw), 100), + "{raw} against a sized object must be ignored" + ); + } + // A well-formed spec against an empty object stays unsatisfiable. + for raw in ["bytes=0-9", "bytes=5-", "bytes=-5", "bytes=10-5"] { + assert_eq!( + RangeSelection::Unsatisfiable, + parse_range(Some(raw), 0), + "{raw} against an empty object must stay unsatisfiable" + ); + } } #[test] fn missing_or_malformed_range_falls_through_to_no_range() { - assert_eq!(None, parse_range(None, 100).range); - assert!(!parse_range(None, 100).unsatisfiable); - assert_eq!(None, parse_range(Some("rows=0-9"), 100).range); - assert_eq!(None, parse_range(Some("bytes="), 100).range); - assert_eq!(None, parse_range(Some("bytes=abc-def"), 100).range); + assert_eq!(RangeSelection::Full, parse_range(None, 100)); + assert_eq!(RangeSelection::Full, parse_range(Some("rows=0-9"), 100)); + assert_eq!(RangeSelection::Full, parse_range(Some("bytes="), 100)); + assert_eq!( + RangeSelection::Full, + parse_range(Some("bytes=abc-def"), 100) + ); + assert_eq!(RangeSelection::Full, parse_range(Some("bytes=1"), 100)); + assert_eq!(RangeSelection::Full, parse_range(Some("bytes= x-y"), 100)); } #[test] fn multi_range_not_supported() { - assert_eq!(None, parse_range(Some("bytes=0-1, 2-3"), 100).range); + assert_eq!( + RangeSelection::Full, + parse_range(Some("bytes=0-1, 2-3"), 100) + ); } #[test] fn reversed_start_end_is_unsatisfiable() { - assert!(parse_range(Some("bytes=10-5"), 100).unsatisfiable); + assert_eq!( + RangeSelection::Unsatisfiable, + parse_range(Some("bytes=10-5"), 100) + ); } #[test] fn empty_file_is_unsatisfiable_for_any_byte_range() { - assert!(parse_range(Some("bytes=0-9"), 0).unsatisfiable); - assert!(parse_range(Some("bytes=-5"), 0).unsatisfiable); + assert_eq!( + RangeSelection::Unsatisfiable, + parse_range(Some("bytes=0-9"), 0) + ); + assert_eq!( + RangeSelection::Unsatisfiable, + parse_range(Some("bytes=-5"), 0) + ); } #[test] fn end_past_eof_clamps() { assert_eq!( - Some(ByteRange { start: 0, end: 99 }), - parse_range(Some("bytes=0-9999"), 100).range + RangeSelection::Partial(ByteRange { start: 0, end: 99 }), + parse_range(Some("bytes=0-9999"), 100) + ); + } + + #[test] + fn a_request_range_unit_is_matched_exactly_but_a_response_unit_is_not() { + // Every request-side parser has to agree with classify_request_range, which forwards + // only a lowercase "bytes=" spec. A HEAD that read an uppercase unit as a range would + // report 206 and a partial length for an object its own GET serves whole. + assert_eq!(RangeSelection::Full, parse_range(Some("BYTES=0-1"), 100)); + assert_eq!(RangeSelection::Full, parse_range(Some("Bytes=0-1"), 100)); + assert_eq!(RangeSelection::Full, parse_range(Some("items=0-1"), 100)); + assert_eq!( + RequestRange::Absent, + classify_request_range(Some("BYTES=0-1")) + ); + assert_eq!(None, parse_bounded_request_range(Some("BYTES=0-1"), 32)); + // The response side reads the object store's own reply rather than client input, so a + // lenient unit there only rescues a Content-Range the strict match would drop. + assert_eq!( + Some(ContentRange { + start: 0, + end: 1, + size: Some(2) + }), + parse_content_range(Some("Bytes 0-1/2")) + ); + assert_eq!(None, parse_content_range(Some("items 0-1/2"))); + } + + #[test] + fn only_ascii_digits_are_accepted_as_range_offsets() { + // A deliberate divergence from origin/main, which parsed offsets with usize::from_str + // and so accepted the leading sign that RFC 9110's first-pos = 1*DIGIT forbids. The old + // era already served "bytes=+5-" whole on GET, because the spec reached the object store + // verbatim and was ignored there, and only its HEAD branch answered 206. The strict + // parse is what makes the two agree. + assert_eq!(None, parse_decimal_usize("")); + assert_eq!(None, parse_decimal_usize("+5")); + assert_eq!(None, parse_decimal_usize(" 5")); + assert_eq!(None, parse_decimal_usize("5_0")); + assert_eq!(Some(50), parse_decimal_usize("50")); + assert_eq!(RangeSelection::Full, parse_range(Some("bytes=+5-"), 100)); + assert_eq!(RangeSelection::Full, parse_range(Some("bytes=-+5"), 100)); + assert_eq!( + RequestRange::Forwardable("bytes=+5-"), + classify_request_range(Some("bytes=+5-")), + "forwarding is unchanged: the object store still settles the spec it always saw" ); } @@ -288,6 +404,10 @@ mod tests { None, parse_bounded_request_range(Some("bytes=0-1, 2-3"), 32) ); + assert_eq!( + None, + parse_bounded_request_range(Some("bytes=0-18446744073709551615"), usize::MAX) + ); } #[test] @@ -298,7 +418,10 @@ mod tests { classify_request_range(Some(raw)), "range={raw} must reach the upstream" ); - assert!(parse_range(Some(raw), 100).range.is_some()); + assert!(matches!( + parse_range(Some(raw), 100), + RangeSelection::Partial(_) + )); } assert_eq!( RequestRange::Forwardable("bytes=0-9"), @@ -315,8 +438,8 @@ mod tests { classify_request_range(Some(raw)), "range={raw} is unsatisfiable at every size" ); - assert!(parse_range(Some(raw), 100).unsatisfiable); - assert!(parse_range(Some(raw), 1).unsatisfiable); + assert_eq!(RangeSelection::Unsatisfiable, parse_range(Some(raw), 100)); + assert_eq!(RangeSelection::Unsatisfiable, parse_range(Some(raw), 1)); } for raw in [ "rows=0-9", @@ -331,8 +454,7 @@ mod tests { classify_request_range(Some(raw)), "range={raw} must not reach the upstream" ); - assert_eq!(None, parse_range(Some(raw), 100).range); - assert!(!parse_range(Some(raw), 100).unsatisfiable); + assert_eq!(RangeSelection::Full, parse_range(Some(raw), 100)); } assert_eq!(RequestRange::Absent, classify_request_range(None)); } @@ -377,5 +499,42 @@ mod tests { assert_eq!(None, parse_content_range(Some("bytes */100"))); assert_eq!(None, parse_content_range(Some("bytes 10-9/100"))); assert_eq!(None, parse_content_range(Some("bytes 0-100/100"))); + assert_eq!(None, parse_content_range(Some("bytes 0-0/0"))); + assert_eq!(None, parse_content_range(Some("bytes 0-0/"))); + assert_eq!( + None, + parse_content_range(Some("bytes 0-0/184467440737095516160")) + ); + } + + #[test] + fn range_parsing_stays_within_the_declared_file_size_on_adversarial_headers() { + for header in ADVERSARIAL_RANGE_HEADERS { + for file_size in [0_usize, 1, 100, usize::from(u16::MAX)] { + if let RangeSelection::Partial(selected) = parse_range(Some(header), file_size) { + assert!(file_size > 0, "partial range for empty file from {header}"); + assert!( + selected.start <= selected.end, + "inverted range from {header}" + ); + assert!(selected.end < file_size, "range beyond file from {header}"); + } + assert!( + parse_bounded_request_range(Some(header), file_size) + .is_none_or(|selected| selected.start <= selected.end + && selected.end - selected.start < file_size) + ); + } + if let Some(parsed) = parse_content_range(Some(header)) { + assert!( + parsed.start <= parsed.end, + "inverted content range {header}" + ); + if let Some(size) = parsed.size { + assert!(size > 0, "zero-sized content range {header}"); + assert!(parsed.end < size, "content range beyond size {header}"); + } + } + } } } diff --git a/fluxer_media_proxy/src/request_log/failure.rs b/fluxer_media_proxy/src/request_log/failure.rs new file mode 100644 index 000000000..5a0630c7c --- /dev/null +++ b/fluxer_media_proxy/src/request_log/failure.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt; + +#[derive(Clone, Debug)] +pub struct ErrorReason { + pub code: &'static str, + pub source: Option, +} + +impl ErrorReason { + pub fn new(code: &'static str) -> Self { + Self { code, source: None } + } + + pub fn with_source(code: &'static str, source: impl fmt::Debug) -> Self { + Self { + code, + source: Some(format!("{source:?}")), + } + } + + pub fn with_message(code: &'static str, message: impl Into) -> Self { + Self { + code, + source: Some(message.into()), + } + } +} diff --git a/fluxer_media_proxy/src/request_log.rs b/fluxer_media_proxy/src/request_log/mod.rs similarity index 50% rename from fluxer_media_proxy/src/request_log.rs rename to fluxer_media_proxy/src/request_log/mod.rs index afae8a6f8..895094080 100644 --- a/fluxer_media_proxy/src/request_log.rs +++ b/fluxer_media_proxy/src/request_log/mod.rs @@ -1,24 +1,33 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -use crate::metrics::{self, RequestKind}; +mod failure; +mod stage; + +pub use failure::ErrorReason; +pub use stage::{Stage, record_stage, timed_stage}; + +use crate::{ + metrics::{ + self, + request::{RequestKind, RequestMetrics}, + }, + public_net_policy::external_url_for_log, +}; use axum::{ - extract::Request, - http::{HeaderMap, StatusCode, header}, + extract::{Request, State}, + http::{HeaderMap, Method, StatusCode, header}, middleware::Next, response::Response, }; use rand::RngExt; -use std::{ - fmt, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, -}; +use stage::{StageTimingSnapshot, StageTimings}; +use std::{future::Future, sync::Arc, time::Instant}; use tracing::{Level, event}; const ID_ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const ID_LEN: usize = 12; +const TARGET_LOG_BYTES_MAX: usize = 512; +const HEADER_LOG_BYTES_MAX: usize = 256; #[derive(Clone, Debug)] pub struct RequestId(pub String); @@ -39,80 +48,36 @@ impl RequestId { } } -#[derive(Debug, Default)] -pub struct StageTimings { - fetch_ms: AtomicU64, - transform_ms: AtomicU64, - nsfw_ms: AtomicU64, +struct RequestObservation { + id: RequestId, + method: Method, + kind: RequestKind, + path: String, + query: String, + referer: Option, + user_agent: Option, } -#[derive(Clone, Copy, Debug)] -pub enum Stage { - Fetch, - Transform, - Nsfw, -} - -impl StageTimings { - pub fn add(&self, stage: Stage, ms: u64) { - let slot = match stage { - Stage::Fetch => &self.fetch_ms, - Stage::Transform => &self.transform_ms, - Stage::Nsfw => &self.nsfw_ms, - }; - slot.fetch_add(ms, Ordering::Relaxed); - } - - pub fn snapshot(&self) -> (u64, u64, u64) { - ( - self.fetch_ms.load(Ordering::Relaxed), - self.transform_ms.load(Ordering::Relaxed), - self.nsfw_ms.load(Ordering::Relaxed), - ) - } -} - -tokio::task_local! { - static STAGES: Arc; -} - -pub fn record_stage(stage: Stage, ms: u64) { - let _ = STAGES.try_with(|s| s.add(stage, ms)); -} - -pub async fn timed_stage(stage: Stage, fut: F) -> T -where - F: std::future::Future, -{ - let start = metrics::now_ms(); - let out = fut.await; - let elapsed = (metrics::now_ms() - start).max(0) as u64; - record_stage(stage, elapsed); - out -} - -#[derive(Clone, Debug)] -pub struct ErrorReason { - pub code: &'static str, - pub source: Option, -} - -impl ErrorReason { - pub fn new(code: &'static str) -> Self { - Self { code, source: None } - } - - pub fn with_source(code: &'static str, source: impl fmt::Debug) -> Self { +impl RequestObservation { + fn new( + id: RequestId, + method: Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> Self { Self { - code, - source: Some(format!("{source:?}")), - } - } - - pub fn with_message(code: &'static str, message: impl Into) -> Self { - Self { - code, - source: Some(message.into()), + kind: classify_route(path), + path: clip(path, TARGET_LOG_BYTES_MAX), + query: query + .map(|query| clip(query, TARGET_LOG_BYTES_MAX)) + .unwrap_or_default(), + referer: header_str(headers, header::REFERER) + .map(|value| clip(&external_url_for_log(value), HEADER_LOG_BYTES_MAX)), + user_agent: header_str(headers, header::USER_AGENT) + .map(|value| clip(value, HEADER_LOG_BYTES_MAX)), + id, + method, } } } @@ -156,29 +121,72 @@ pub fn classify_route(path: &str) -> RequestKind { RequestKind::Other } -pub async fn trace(mut req: Request, next: Next) -> Response { +pub async fn trace( + State(metrics): State>, + mut req: Request, + next: Next, +) -> Response { let id = RequestId::generate(); - let method = req.method().clone(); - let path = req.uri().path().to_owned(); - let query = req.uri().query().map(ToOwned::to_owned); - let kind = classify_route(&path); - let referer = header_str(req.headers(), header::REFERER); - let user_agent = header_str(req.headers(), header::USER_AGENT); + let observation = RequestObservation::new( + id.clone(), + req.method().clone(), + req.uri().path(), + req.uri().query(), + req.headers(), + ); + req.extensions_mut().insert(id); + observe(metrics.as_ref(), observation, next.run(req)).await +} - req.extensions_mut().insert(id.clone()); +pub async fn trace_public_request( + metrics: &RequestMetrics, + id: RequestId, + method: Method, + path_and_query: &str, + headers: &HeaderMap, + future: F, +) -> Response +where + F: Future, +{ + let (path, query) = match path_and_query.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (path_and_query, None), + }; + let observation = RequestObservation::new(id, method, path, query, headers); + observe(metrics, observation, future).await +} + +async fn observe( + metrics: &RequestMetrics, + observation: RequestObservation, + future: F, +) -> Response +where + F: Future, +{ + let RequestObservation { + id, + method, + kind, + path, + query, + referer, + user_agent, + } = observation; let stages = Arc::new(StageTimings::default()); - req.extensions_mut().insert(stages.clone()); - let started_ms = metrics::now_ms(); - let response = STAGES.scope(stages.clone(), next.run(req)).await; - let elapsed_ms = (metrics::now_ms() - started_ms).max(0) as u64; - let (fetch_ms, transform_ms, nsfw_ms) = stages.snapshot(); + let started = Instant::now(); + let response = stage::scope(Arc::clone(&stages), future).await; + let elapsed_ms = metrics::duration_millis(started.elapsed()); + let StageTimingSnapshot { + fetch_ms, + transform_ms, + nsfw_ms, + } = stages.snapshot(); let status = response.status(); let reason = response.extensions().get::().cloned(); - metrics::GLOBAL.record_request_with_duration(kind, status.as_u16(), elapsed_ms); - - let path_for_log = clip(&path, 512); - let query_for_log = query.as_deref().map(|q| clip(q, 512)).unwrap_or_default(); + metrics.record_request_with_duration(kind, status.as_u16(), elapsed_ms); if status.is_success() || status.is_redirection() { if !matches!(kind, RequestKind::Health | RequestKind::Other) { @@ -187,8 +195,8 @@ pub async fn trace(mut req: Request, next: Next) -> Response { req = %id.as_str(), kind = kind.label(), method = %method, - path = %path_for_log, - query = %query_for_log, + path = %path, + query = %query, status = status.as_u16(), duration_ms = elapsed_ms, fetch_ms, @@ -205,10 +213,7 @@ pub async fn trace(mut req: Request, next: Next) -> Response { } else { Level::WARN }; - let (code, source) = match reason { - Some(r) => (r.code, r.source.unwrap_or_default()), - None => (default_reason(status), String::new()), - }; + let (code, source) = log_reason(reason, status); match level { Level::ERROR => event!( @@ -216,8 +221,8 @@ pub async fn trace(mut req: Request, next: Next) -> Response { req = %id.as_str(), kind = kind.label(), method = %method, - path = %path_for_log, - query = %query_for_log, + path = %path, + query = %query, status = status.as_u16(), duration_ms = elapsed_ms, fetch_ms, @@ -234,8 +239,8 @@ pub async fn trace(mut req: Request, next: Next) -> Response { req = %id.as_str(), kind = kind.label(), method = %method, - path = %path_for_log, - query = %query_for_log, + path = %path, + query = %query, status = status.as_u16(), duration_ms = elapsed_ms, fetch_ms, @@ -249,18 +254,32 @@ pub async fn trace(mut req: Request, next: Next) -> Response { response } -fn header_str(headers: &HeaderMap, name: header::HeaderName) -> Option { - headers - .get(name) - .and_then(|v| v.to_str().ok()) - .map(|s| clip(s, 256)) +fn header_str(headers: &HeaderMap, name: header::HeaderName) -> Option<&str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +fn log_reason(reason: Option, status: StatusCode) -> (&'static str, String) { + match reason { + Some(reason) => ( + reason.code, + reason + .source + .map(|source| clip(&source, TARGET_LOG_BYTES_MAX)) + .unwrap_or_default(), + ), + None => (default_reason(status), String::new()), + } } fn clip(value: &str, max: usize) -> String { if value.len() <= max { return value.to_owned(); } - let mut out = value[..max].to_owned(); + let mut end = max; + while !value.is_char_boundary(end) { + end -= 1; + } + let mut out = value[..end].to_owned(); out.push('~'); out } @@ -272,6 +291,7 @@ fn default_reason(status: StatusCode) -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::metrics::Metrics; use axum::{Router, body::Body, middleware, response::IntoResponse, routing::get}; use tower::ServiceExt; @@ -279,6 +299,16 @@ mod tests { id.len() == ID_LEN && id.bytes().all(|c| ID_ALPHABET.contains(&c)) } + fn observation_with_headers(headers: HeaderMap) -> RequestObservation { + RequestObservation::new( + RequestId::generate(), + Method::GET, + "/attachments/1/2/a.png", + Some("size=128"), + &headers, + ) + } + #[test] fn request_id_is_stable_length_and_alphabet() { let id = RequestId::generate(); @@ -307,12 +337,70 @@ mod tests { assert_eq!(RequestKind::Other, classify_route("/unknown")); } + #[test] + fn clip_never_splits_a_multibyte_character() { + let key = "\u{597d}".repeat(8); + assert_eq!(24, key.len()); + assert_eq!("\u{597d}\u{597d}\u{597d}~", clip(&key, 10)); + assert_eq!("\u{597d}\u{597d}\u{597d}\u{597d}~", clip(&key, 12)); + assert_eq!(key, clip(&key, 24)); + } + + #[test] + fn a_long_error_source_is_clipped_to_the_target_bound() { + let long = ErrorReason::with_message("storage_error", "k".repeat(4096)); + let (code, source) = log_reason(Some(long), StatusCode::NOT_FOUND); + assert_eq!("storage_error", code); + assert_eq!(TARGET_LOG_BYTES_MAX + 1, source.len()); + assert!(source.ends_with('~')); + assert_eq!( + ("Not Found", String::new()), + log_reason(None, StatusCode::NOT_FOUND) + ); + assert_eq!( + ("storage_error", "key=a".to_owned()), + log_reason( + Some(ErrorReason::with_message("storage_error", "key=a")), + StatusCode::NOT_FOUND + ) + ); + } + #[test] fn clip_truncates_with_marker() { assert_eq!("abc", clip("abc", 8)); assert_eq!("abcdefgh~", clip("abcdefghIJ", 8)); } + #[test] + fn credentialed_referer_is_redacted_before_it_reaches_the_log() { + let mut headers = HeaderMap::new(); + headers.insert( + header::REFERER, + "https://agent:hunter2@cdn.example.com/rooms/private?token=abc#fragment" + .parse() + .unwrap(), + ); + headers.insert(header::USER_AGENT, "curl/8.0".parse().unwrap()); + let observation = observation_with_headers(headers); + assert_eq!( + Some("https://cdn.example.com/[redacted]".to_owned()), + observation.referer + ); + assert_eq!(Some("curl/8.0".to_owned()), observation.user_agent); + } + + #[test] + fn unparsable_referer_collapses_to_a_marker_and_paths_stay_raw() { + let mut headers = HeaderMap::new(); + headers.insert(header::REFERER, "not a url".parse().unwrap()); + let observation = observation_with_headers(headers); + assert_eq!(Some("[invalid-url]".to_owned()), observation.referer); + assert_eq!("/attachments/1/2/a.png", observation.path); + assert_eq!("size=128", observation.query); + assert_eq!(RequestKind::Attachment, observation.kind); + } + #[tokio::test] async fn middleware_inserts_request_id_into_extensions() { async fn handler(req: Request) -> impl IntoResponse { @@ -325,7 +413,10 @@ mod tests { } let app = Router::new() .route("/", get(handler)) - .layer(middleware::from_fn(trace)); + .layer(middleware::from_fn_with_state( + Arc::new(RequestMetrics::new()), + trace, + )); let resp = app .oneshot( axum::http::Request::builder() @@ -350,9 +441,10 @@ mod tests { .insert(ErrorReason::with_message("transcode_failed", "vips OOM")); resp } + let metrics = Metrics::new(); let app = Router::new() .route("/", get(handler)) - .layer(middleware::from_fn(trace)); + .layer(middleware::from_fn_with_state(metrics.request(), trace)); let resp = app .oneshot( axum::http::Request::builder() @@ -366,5 +458,32 @@ mod tests { let reason = resp.extensions().get::().unwrap(); assert_eq!("transcode_failed", reason.code); assert_eq!(Some("vips OOM".to_owned()), reason.source); + let rendered = metrics.render(); + assert!(rendered.contains("fluxer_media_proxy_requests_5xx_total{kind=\"other\"} 1\n")); + assert!(rendered.contains("fluxer_media_proxy_request_duration_ms_count 1\n")); + } + + #[tokio::test] + async fn trace_public_request_observes_a_non_axum_request() { + let metrics = Metrics::new(); + let response = trace_public_request( + metrics.request().as_ref(), + RequestId::generate(), + Method::GET, + "/attachments/1/2/a.png?size=128", + &HeaderMap::new(), + async { + record_stage(Stage::Fetch, 3); + record_stage(Stage::Transform, 5); + record_stage(Stage::Nsfw, 7); + Response::new(Body::empty()) + }, + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + let rendered = metrics.render(); + assert!( + rendered.contains("fluxer_media_proxy_requests_2xx_total{kind=\"attachment\"} 1\n") + ); } } diff --git a/fluxer_media_proxy/src/request_log/stage.rs b/fluxer_media_proxy/src/request_log/stage.rs new file mode 100644 index 000000000..57842fb6e --- /dev/null +++ b/fluxer_media_proxy/src/request_log/stage.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::metrics; +use std::{ + future::Future, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Instant, +}; + +#[derive(Clone, Copy, Debug)] +pub enum Stage { + Fetch, + Transform, + Nsfw, +} + +#[derive(Debug, Default)] +pub(super) struct StageTimings { + fetch_ms: AtomicU64, + transform_ms: AtomicU64, + nsfw_ms: AtomicU64, +} + +#[derive(Clone, Copy, Debug)] +pub(super) struct StageTimingSnapshot { + pub(super) fetch_ms: u64, + pub(super) transform_ms: u64, + pub(super) nsfw_ms: u64, +} + +impl StageTimings { + fn add(&self, stage: Stage, milliseconds: u64) { + let slot = match stage { + Stage::Fetch => &self.fetch_ms, + Stage::Transform => &self.transform_ms, + Stage::Nsfw => &self.nsfw_ms, + }; + slot.fetch_add(milliseconds, Ordering::Relaxed); + } + + pub(super) fn snapshot(&self) -> StageTimingSnapshot { + StageTimingSnapshot { + fetch_ms: self.fetch_ms.load(Ordering::Relaxed), + transform_ms: self.transform_ms.load(Ordering::Relaxed), + nsfw_ms: self.nsfw_ms.load(Ordering::Relaxed), + } + } +} + +tokio::task_local! { + static STAGES: Arc; +} + +pub fn record_stage(stage: Stage, milliseconds: u64) { + let _ = STAGES.try_with(|stages| stages.add(stage, milliseconds)); +} + +pub async fn timed_stage(stage: Stage, future: F) -> T +where + F: Future, +{ + let started = Instant::now(); + let output = future.await; + record_stage(stage, metrics::duration_millis(started.elapsed())); + output +} + +pub(super) async fn scope(stages: Arc, future: F) -> F::Output +where + F: Future, +{ + STAGES.scope(stages, future).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn each_stage_accumulates_into_its_own_slot() { + let stages = Arc::new(StageTimings::default()); + scope(Arc::clone(&stages), async { + record_stage(Stage::Fetch, 3); + record_stage(Stage::Fetch, 4); + record_stage(Stage::Transform, 5); + record_stage(Stage::Nsfw, 6); + }) + .await; + let snapshot = stages.snapshot(); + assert_eq!(7, snapshot.fetch_ms); + assert_eq!(5, snapshot.transform_ms); + assert_eq!(6, snapshot.nsfw_ms); + } + + #[tokio::test] + async fn recording_outside_a_scope_is_a_no_op() { + record_stage(Stage::Fetch, 9); + let stages = Arc::new(StageTimings::default()); + let snapshot = scope(Arc::clone(&stages), async { stages.snapshot() }).await; + assert_eq!(0, snapshot.fetch_ms); + } + + #[tokio::test] + async fn timed_stage_charges_the_elapsed_time_to_the_stage() { + let stages = Arc::new(StageTimings::default()); + let value = scope(Arc::clone(&stages), async { + timed_stage(Stage::Transform, async { + tokio::time::sleep(std::time::Duration::from_millis(12)).await; + "done" + }) + .await + }) + .await; + assert_eq!("done", value); + let snapshot = stages.snapshot(); + assert!(snapshot.transform_ms >= 10); + assert_eq!(0, snapshot.fetch_ms); + } +} diff --git a/fluxer_media_proxy/src/response_body_limit.rs b/fluxer_media_proxy/src/response_body_limit.rs new file mode 100644 index 000000000..69834b7bd --- /dev/null +++ b/fluxer_media_proxy/src/response_body_limit.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const RESPONSE_BODY_CHUNK_BYTES_MIN: u64 = 4 * 1024; +const RESPONSE_BODY_CHUNK_OVERHEAD_MAX: u64 = 16; +pub(crate) const RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX: usize = 512 * 1024; + +pub(crate) fn response_body_chunk_limit(maximum_bytes: u64) -> u64 { + maximum_bytes + .div_ceil(RESPONSE_BODY_CHUNK_BYTES_MIN) + .checked_add(RESPONSE_BODY_CHUNK_OVERHEAD_MAX) + .expect("u64 response length chunk limit must fit u64") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn chunk_limit_adds_overhead_to_ceiled_minimum_chunk_count() { + assert_eq!(response_body_chunk_limit(0), 16); + assert_eq!(response_body_chunk_limit(1), 17); + assert_eq!(response_body_chunk_limit(4096), 17); + assert_eq!(response_body_chunk_limit(4097), 18); + } + + #[test] + fn transport_chunk_maximum_is_512_kib() { + assert_eq!(RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX, 512 * 1024); + } +} diff --git a/fluxer_media_proxy/src/secret.rs b/fluxer_media_proxy/src/secret.rs new file mode 100644 index 000000000..67fa2ba72 --- /dev/null +++ b/fluxer_media_proxy/src/secret.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::fmt; +use std::sync::atomic::{Ordering, compiler_fence}; + +const REDACTED: &str = "[REDACTED]"; + +fn zero(bytes: &mut [u8]) { + for byte in bytes.iter_mut() { + unsafe { std::ptr::write_volatile(byte, 0) }; + } + compiler_fence(Ordering::SeqCst); +} + +#[derive(Clone, Default)] +pub struct SecretString(String); + +impl SecretString { + pub fn new(value: String) -> Self { + Self(value) + } + + pub fn expose(&self) -> &str { + self.0.as_str() + } + + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl Drop for SecretString { + fn drop(&mut self) { + zero(unsafe { self.0.as_mut_vec() }); + } +} + +impl fmt::Debug for SecretString { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(REDACTED) + } +} + +#[derive(Clone, Default)] +pub struct SecretBytes(Vec); + +impl SecretBytes { + pub fn new(value: Vec) -> Self { + Self(value) + } + + pub fn expose(&self) -> &[u8] { + self.0.as_slice() + } +} + +impl Drop for SecretBytes { + fn drop(&mut self) { + zero(self.0.as_mut_slice()); + } +} + +impl fmt::Debug for SecretBytes { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(REDACTED) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zeroing_overwrites_every_byte_in_place() { + let mut buffer = [1u8, 2, 3, 4, 5]; + zero(&mut buffer); + assert_eq!([0u8; 5], buffer); + } + + #[test] + fn secrets_expose_their_value_without_printing_it() { + let string = SecretString::new("hunter2".to_owned()); + assert_eq!("hunter2", string.expose()); + assert_eq!(b"hunter2", string.as_bytes()); + assert!(!string.is_empty()); + assert_eq!("[REDACTED]", format!("{string:?}")); + + let bytes = SecretBytes::new(vec![7u8; 32]); + assert_eq!(&[7u8; 32][..], bytes.expose()); + assert_eq!("[REDACTED]", format!("{bytes:?}")); + } + + #[test] + fn empty_secrets_report_themselves_empty() { + assert!(SecretString::default().is_empty()); + assert!(SecretString::new(String::new()).is_empty()); + assert!(SecretBytes::default().expose().is_empty()); + } + + #[test] + fn cloning_a_secret_keeps_the_original_readable_after_the_clone_drops() { + let original = SecretString::new("shared".to_owned()); + drop(original.clone()); + assert_eq!("shared", original.expose()); + } +} diff --git a/fluxer_media_proxy/src/server.rs b/fluxer_media_proxy/src/server.rs deleted file mode 100644 index e24ec2ea1..000000000 --- a/fluxer_media_proxy/src/server.rs +++ /dev/null @@ -1,5541 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::{ - asset_hash, - bunny_ip_gate::{self, BunnyIpGate}, - byte_cache::Cache, - coalescer::{ByteCoalescer, CoalescerError}, - config::{Config, DeploymentMode}, - constants::{self, AssetExtension, AssetKind}, - disposition, external_path, http_client, http_headers, media_process, metrics, mime, - output_format, public_net_policy, range, - request_log::{self, ErrorReason, Stage}, - signing, - spool::{SpoolError, spool_to_temp}, - storage::{RelayBody, RelayPutOptions, StorageError, Store, StreamObject}, - timed_semaphore::TimedSemaphore, - upload_relay, -}; -use anyhow::Context as _; -use axum::{ - Router, - body::{Body, to_bytes}, - extract::{ConnectInfo, Path, Query, State}, - http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}, - middleware, - response::Response, - routing::{any, get, post, put}, -}; -use base64::{Engine as _, engine::general_purpose}; -use bytes::Bytes; -use http_body::Frame; -use http_body_util::BodyExt; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use std::{ - borrow::Cow, - collections::HashMap, - net::SocketAddr, - pin::Pin, - sync::{Arc, OnceLock}, - task::{Context, Poll}, - time::{Duration, Instant}, -}; -use tokio::net::TcpListener; -use tracing::{info, warn}; - -#[derive(Clone)] -struct AppState { - cfg: Config, - store: Store, - client: http_client::HttpClient, - nsfw_client: reqwest::Client, - transform_cache: Arc, - external_hints: ExternalHintCache, - coalescer: Arc, - native_transform_admissions: TimedSemaphore, - native_transforms: TimedSemaphore, -} - -pub async fn run(cfg: Config) -> anyhow::Result<()> { - metrics::init_global(); - media_process::warmup_vips()?; - let addr: SocketAddr = format!("{}:{}", cfg.bind_host, cfg.port).parse()?; - let state = Arc::new(AppState { - store: Store::try_new(cfg.clone())?, - client: http_client::build(http_client::Options { - connect_timeout_ms: cfg.socket_io_timeout_ms.max(1), - timeout_ms: cfg.socket_io_timeout_ms.max(1), - restrict_to_public: true, - ..http_client::Options::default() - })?, - nsfw_client: reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_millis(1_500)) - .pool_idle_timeout(std::time::Duration::from_secs(30)) - .user_agent(constants::OUTBOUND_USER_AGENT) - .build()?, - transform_cache: Arc::new(Cache::new( - cfg.transform_cache_capacity_bytes, - cfg.transform_cache_max_entry_bytes, - cfg.transform_cache_ttl_ms, - )), - external_hints: new_external_hint_cache(&cfg), - coalescer: Arc::new(ByteCoalescer::new()), - native_transform_admissions: TimedSemaphore::new(transform_admission_capacity(&cfg)), - native_transforms: TimedSemaphore::new(cfg.max_native_transforms), - cfg, - }); - let bunny_gate = if state.cfg.bunny_ip_gate_enabled { - let gate = Arc::new(BunnyIpGate::new( - bunny_ip_gate::build_refresh_client()?, - state.cfg.bunny_ip_gate_trusted_proxies.clone(), - )); - let count = gate - .refresh_once() - .await - .context("initial bunny ip allowlist fetch failed")?; - info!( - count, - trusted_proxies = state.cfg.bunny_ip_gate_trusted_proxies.len(), - refresh_secs = state.cfg.bunny_ip_gate_refresh_secs, - "bunny ip gate enabled" - ); - Arc::clone(&gate) - .spawn_background_refresher(Duration::from_secs(state.cfg.bunny_ip_gate_refresh_secs)); - Some(gate) - } else { - None - }; - if let Some(read_endpoint) = state.cfg.s3_read_endpoint.as_deref() { - info!( - endpoint = read_endpoint, - bucket = state.cfg.s3_read_bucket, - style = ?state.cfg.s3_read_bucket_style, - signed = state.cfg.s3_read_signed, - "object body reads served from the S3 read endpoint" - ); - } - let mut router = Router::new() - .route("/_health", get(health)) - .route("/_metrics", get(metrics_handler)) - .route("/_metadata", post(metadata_handler)) - .route("/_thumbnail", post(thumbnail_handler)) - .route("/_frames", post(frames_handler)) - .route("/v1/relay/{*key}", put(relay_put).options(relay_options)) - .fallback(any(catch_all)) - .layer(middleware::from_fn(add_version_header)) - .layer(middleware::from_fn(request_log::trace)); - if let Some(gate) = bunny_gate { - router = router.layer(middleware::from_fn_with_state( - gate, - bunny_ip_gate::gate_middleware, - )); - } - router = router.layer(middleware::from_fn_with_state( - state.cfg.mode, - add_security_header_middleware, - )); - let app = router.with_state(state); - let listener = TcpListener::bind(addr).await?; - info!(%addr, "media proxy listening"); - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown_signal()) - .await?; - Ok(()) -} - -async fn shutdown_signal() { - let ctrl_c = async { - let _ = tokio::signal::ctrl_c().await; - }; - #[cfg(unix)] - let terminate = async { - let Ok(mut sigterm) = - tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - else { - return; - }; - sigterm.recv().await; - }; - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - tokio::select! { - _ = ctrl_c => {}, - _ = terminate => {}, - } -} - -async fn health() -> &'static str { - "OK" -} - -fn build_version() -> &'static str { - static BUILD_VERSION: OnceLock = OnceLock::new(); - BUILD_VERSION - .get_or_init(|| { - std::env::var("BUILD_VERSION") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "dev".to_owned()) - }) - .as_str() -} - -async fn add_version_header(request: Request, next: middleware::Next) -> Response { - let mut response = next.run(request).await; - if let Ok(value) = HeaderValue::from_str(build_version()) { - response.headers_mut().insert("x-fluxer-version", value); - } - response -} - -async fn add_security_header_middleware( - State(mode): State, - request: Request, - next: middleware::Next, -) -> Response { - let mut response = next.run(request).await; - let headers = response.headers_mut(); - http_headers::add_security_headers(headers); - if mode == DeploymentMode::Static { - headers.remove("X-Robots-Tag"); - } - response -} - -fn is_loopback_peer(peer: &SocketAddr) -> bool { - peer.ip().to_canonical().is_loopback() -} - -async fn metrics_handler(ConnectInfo(peer): ConnectInfo) -> Response { - if !is_loopback_peer(&peer) { - let mut denied = Response::new(Body::from("FORBIDDEN")); - *denied.status_mut() = StatusCode::FORBIDDEN; - http_headers::add_security_headers(denied.headers_mut()); - denied - .headers_mut() - .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")); - return denied; - } - let mut response = Response::new(Body::from(metrics::render())); - http_headers::add_security_headers(response.headers_mut()); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"), - ); - response - .headers_mut() - .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); - response -} - -#[derive(Debug, Deserialize)] -struct MetadataRequest { - version: Option, - #[serde(rename = "type")] - typ: String, - nsfw: String, - base64: Option, - upload_filename: Option, - filename: Option, - bucket: Option, - key: Option, - url: Option, - with_base64: Option, -} - -#[derive(Debug, Deserialize)] -struct FramesRequest { - version: Option, - #[serde(rename = "type")] - typ: String, - base64: Option, - upload_filename: Option, - filename: Option, - bucket: Option, - key: Option, - url: Option, -} - -impl FramesRequest { - fn into_metadata_request(self) -> MetadataRequest { - MetadataRequest { - version: self.version, - typ: self.typ, - nsfw: "allow".to_owned(), - base64: self.base64, - upload_filename: self.upload_filename, - filename: self.filename, - bucket: self.bucket, - key: self.key, - url: self.url, - with_base64: None, - } - } -} - -async fn metadata_handler( - State(app): State>, - headers: HeaderMap, - request: Request, -) -> Response { - if !check_internal_auth(&headers, &app.cfg.secret_key) { - return text(StatusCode::UNAUTHORIZED, "Unauthorized"); - } - let body = match read_limited_body(request).await { - Ok(body) => body, - Err(status) => return text(status, status.canonical_reason().unwrap_or("Bad Request")), - }; - let req: MetadataRequest = match serde_json::from_slice::(&body) { - Ok(req) if req.version == Some(2) => req, - _ => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let should_scan_nsfw = match req.nsfw.as_str() { - "block" | "flag" => true, - "allow" => false, - _ => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let mut input = match load_metadata_input(&app, &req).await { - Ok(input) => input, - Err(status) => return text(status, status.canonical_reason().unwrap_or("Bad Request")), - }; - if req.with_base64.unwrap_or(false) && metadata_input_is_svg(&input) { - input = match rasterize_metadata_svg(&app, input).await { - Ok(input) => input, - Err(response) => return response, - }; - } - let json = match media_process::metadata_json_with_options( - &input.data, - &input.filename, - media_process::MetadataOptions { - placeholder: true, - nsfw: if should_scan_nsfw { - nsfw_config(&app) - } else { - crate::nsfw::Config::disabled() - }, - }, - &app.nsfw_client, - ) - .await - { - Ok(json) => json, - Err(err) => { - return text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "metadata_extraction_failed", - format!("filename={} err={err:?}", input.filename), - ); - } - }; - let mut value: serde_json::Value = - serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!({})); - if req.with_base64.unwrap_or(false) { - value["base64"] = serde_json::Value::String(general_purpose::STANDARD.encode(&input.data)); - } - json_response(StatusCode::OK, value.to_string()) -} - -struct InputData { - data: Bytes, - filename: String, -} - -fn metadata_input_is_svg(input: &InputData) -> bool { - mime::sniff(&input.data).mime == "image/svg+xml" - || image_extension_from_filename(&input.filename) == Some(AssetExtension::Svg) -} - -fn replace_image_extension(filename: &str, ext: AssetExtension) -> String { - let last_slash = filename.rfind('/').map(|idx| idx + 1).unwrap_or(0); - let last_dot = filename[last_slash..] - .rfind('.') - .map(|idx| last_slash + idx); - match last_dot { - Some(idx) => format!("{}.{}", &filename[..idx], ext.name()), - None => format!("{}.{}", filename, ext.name()), - } -} - -#[allow(clippy::result_large_err)] -async fn rasterize_metadata_svg( - app: &Arc, - input: InputData, -) -> Result { - let options = media_process::ImageOptions { - format: AssetExtension::Webp, - quality: "lossless".to_owned(), - animated: false, - deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64), - max_encode_frames: Some(app.cfg.max_encode_frames), - max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms), - ..Default::default() - }; - match run_transform(app, input.data, options).await { - Ok(media) => Ok(InputData { - data: media.bytes.into(), - filename: replace_image_extension(&input.filename, AssetExtension::Webp), - }), - Err(err) if transform_error_is_timeout(&err) => Err(text_with_source( - StatusCode::GATEWAY_TIMEOUT, - "Gateway Timeout", - "metadata_svg_rasterize_timeout", - input.filename, - )), - Err(err) => Err(text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "metadata_svg_rasterize_failed", - format!("filename={} err={err:?}", input.filename), - )), - } -} - -async fn load_metadata_input( - app: &AppState, - req: &MetadataRequest, -) -> Result { - match req.typ.as_str() { - "base64" => { - let raw = req.base64.as_deref().ok_or(StatusCode::BAD_REQUEST)?; - let b64 = raw.rsplit_once(',').map(|(_, v)| v).unwrap_or(raw); - if b64.len() > constants::MAX_INTERNAL_REQUEST_BODY_BYTES { - warn!(reason = "metadata_base64_too_large", len = b64.len()); - return Err(StatusCode::BAD_REQUEST); - } - let data = general_purpose::STANDARD.decode(b64).map_err(|err| { - warn!(reason = "metadata_base64_decode", ?err); - StatusCode::BAD_REQUEST - })?; - if data.len() > constants::MAX_MEDIA_PROXY_BYTES { - warn!(reason = "metadata_decoded_too_large", len = data.len()); - return Err(StatusCode::BAD_REQUEST); - } - Ok(InputData { - data: Bytes::from(data), - filename: req - .filename - .clone() - .unwrap_or_else(|| "inline.bin".to_owned()), - }) - } - "upload" => { - let key = req - .upload_filename - .as_deref() - .ok_or(StatusCode::BAD_REQUEST)?; - let object = app - .store - .read_object(&app.cfg.bucket_uploads, key) - .await - .map_err(|err| { - warn!(reason = "metadata_upload_read", key, %err); - StatusCode::BAD_REQUEST - })?; - Ok(InputData { - data: object.data, - filename: req.filename.clone().unwrap_or_else(|| key.to_owned()), - }) - } - "s3" => { - let bucket = req.bucket.as_deref().ok_or(StatusCode::BAD_REQUEST)?; - let key = req.key.as_deref().ok_or(StatusCode::BAD_REQUEST)?; - let object = app.store.read_object(bucket, key).await.map_err(|err| { - warn!(reason = "metadata_s3_read", bucket, key, %err); - StatusCode::BAD_REQUEST - })?; - Ok(InputData { - data: object.data, - filename: req.filename.clone().unwrap_or_else(|| key.to_owned()), - }) - } - "external" => { - let url = req.url.as_deref().ok_or(StatusCode::BAD_REQUEST)?; - let fetched = fetch_external(app, url).await.map_err(|err| { - warn!(reason = "metadata_external_fetch", url, ?err); - match err { - ExternalFetchError::BlockedUrl => StatusCode::BAD_REQUEST, - ExternalFetchError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - ExternalFetchError::TooManyRedirects | ExternalFetchError::FetchFailed => { - StatusCode::BAD_GATEWAY - } - } - })?; - if !fetched.status.is_success() { - warn!( - reason = "metadata_external_status", - url, - status = fetched.status.as_u16() - ); - return Err(map_internal_metadata_upstream_status(fetched.status)); - } - let data = fetched - .body - .into_buffered(&fetched.url) - .await - .map_err(|err| { - warn!(reason = "metadata_external_body", url, ?err); - match err { - ExternalFetchError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - _ => StatusCode::BAD_GATEWAY, - } - })?; - Ok(InputData { - data, - filename: req - .filename - .clone() - .unwrap_or_else(|| url_filename(&fetched.url)), - }) - } - _ => Err(StatusCode::BAD_REQUEST), - } -} - -#[derive(Debug, Deserialize)] -struct UploadFileRequest { - upload_filename: String, -} - -async fn thumbnail_handler( - State(app): State>, - headers: HeaderMap, - request: Request, -) -> Response { - if !check_internal_auth(&headers, &app.cfg.secret_key) { - return text(StatusCode::UNAUTHORIZED, "Unauthorized"); - } - let body = match read_limited_body(request).await { - Ok(body) => body, - Err(status) => return text(status, "Bad Request"), - }; - let req: UploadFileRequest = match serde_json::from_slice(&body) { - Ok(req) => req, - Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let object = match app - .store - .read_object(&app.cfg.bucket_uploads, &req.upload_filename) - .await - { - Ok(object) => object, - Err(err) => return storage_error_response(&req.upload_filename, err), - }; - let media = if mime::category(&object.content_type) == Some(mime::Category::Video) { - match media_process::extract_video_thumbnail(&object.data, AssetExtension::Webp) { - Ok(media) => media, - Err(err) => { - return text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "video_thumbnail_failed", - err, - ); - } - } - } else { - let options = media_process::ImageOptions { - width: Some(512), - height: Some(512), - format: AssetExtension::Webp, - cover_crop: false, - ..Default::default() - }; - match run_transform(&app, object.data.clone(), options).await { - Ok(media) => media, - Err(err) => { - return text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "image_thumbnail_failed", - err, - ); - } - } - }; - media_response( - Method::GET, - media.bytes.into(), - media.content_type, - None, - None, - ) -} - -async fn frames_handler( - State(app): State>, - headers: HeaderMap, - request: Request, -) -> Response { - if !check_internal_auth(&headers, &app.cfg.secret_key) { - return text(StatusCode::UNAUTHORIZED, "Unauthorized"); - } - let body = match read_limited_body(request).await { - Ok(body) => body, - Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let req: FramesRequest = match serde_json::from_slice::(&body) { - Ok(req) if req.version.is_none_or(|v| v == 2) => req, - _ => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let req = req.into_metadata_request(); - let input = match load_metadata_input(&app, &req).await { - Ok(input) => input, - Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - match media_process::extract_video_thumbnail(&input.data, AssetExtension::Jpeg) { - Ok(frame) => { - let encoded = general_purpose::STANDARD.encode(frame.bytes); - json_response( - StatusCode::OK, - format!( - "{{\"frames\":[{{\"timestamp\":0,\"mime_type\":\"image/jpeg\",\"base64\":\"{}\"}}]}}", - encoded - ), - ) - } - Err(_) => json_response(StatusCode::OK, "{\"frames\":[]}".to_owned()), - } -} - -async fn relay_options() -> Response { - let mut response = Response::new(Body::empty()); - *response.status_mut() = StatusCode::NO_CONTENT; - http_headers::add_security_headers(response.headers_mut()); - relay_cors(response.headers_mut()); - response.headers_mut().insert( - header::ACCESS_CONTROL_MAX_AGE, - HeaderValue::from_static("600"), - ); - response -} - -async fn relay_put( - State(app): State>, - Path(key): Path, - Query(params): Query>, - headers: HeaderMap, - request: Request, -) -> Response { - if app.cfg.mode != DeploymentMode::Upload { - return text(StatusCode::NOT_FOUND, "Not Found"); - } - let key = external_path::percent_decode_string(&key, false); - let Some(token_raw) = params.get("t") else { - return relay_error(upload_relay::RelayError::MissingToken); - }; - let token = match upload_relay::decode_token( - token_raw, - &app.cfg.upload_relay_secret, - upload_relay::now_unix(), - ) { - Ok(token) => token, - Err(err) => return relay_error(upload_relay::map_token_error(err)), - }; - let part_number = - match upload_relay::query_part_number(params.get("partNumber").map(String::as_str)) { - Ok(part_number) => part_number, - Err(err) => return relay_error(err), - }; - let content_length = headers - .get(header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()); - if let Err(err) = upload_relay::validate_relay_request( - &token, - upload_relay::RelayRequest { - uploads_bucket: &app.cfg.bucket_uploads, - request_key: &key, - request_method: request.method(), - query_upload_id: params.get("uploadId").map(String::as_str), - query_part_number: part_number, - content_length, - max_body_bytes: app.cfg.upload_relay_max_body_bytes, - }, - ) { - return relay_error(err); - } - let (body, body_length, client_failure) = match content_length { - Some(declared) => { - let (tx, rx) = tokio::sync::mpsc::channel(RELAY_STREAM_BUFFER_FRAMES); - let failure = Arc::new(OnceLock::new()); - spawn_relay_body_feeder(request.into_body(), declared, tx, Arc::clone(&failure)); - (RelayBody::Streamed(rx), declared, Some(failure)) - } - None => { - let body_length_limit = token.mb.min(app.cfg.upload_relay_max_body_bytes); - let spooled = match spool_to_temp( - request.into_body(), - content_length, - body_length_limit, - &app.cfg.upload_relay_spool_dir, - app.cfg.upload_relay_spool_chunk_bytes, - app.cfg.upload_relay_spool_max_total_bytes, - ) - .await - { - Ok(spooled) => spooled, - Err(SpoolError::PayloadTooLarge) => { - return relay_error(upload_relay::RelayError::PayloadTooLarge); - } - Err(SpoolError::PayloadShortRead) | Err(SpoolError::Body(_)) => { - return relay_error(upload_relay::RelayError::ClientUploadFailed); - } - Err(SpoolError::BudgetExhausted) => { - return relay_error(upload_relay::RelayError::UpstreamRetryable); - } - Err(SpoolError::Io(_)) => { - return relay_error(upload_relay::RelayError::InternalError); - } - }; - let (file, spooled_length) = spooled.into_parts(); - (RelayBody::Spooled(file), spooled_length, None) - } - }; - let content_type = token.ct.clone().or_else(|| { - headers - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .map(ToOwned::to_owned) - }); - let timeout_ms = if client_failure.is_some() { - app.cfg.upload_relay_s3_timeout_ms.saturating_add( - body_length - .div_ceil(RELAY_STREAM_MIN_CLIENT_BYTES_PER_SEC) - .saturating_mul(1000), - ) - } else { - app.cfg.upload_relay_s3_timeout_ms - }; - let options = RelayPutOptions { - body, - content_length: body_length, - content_type, - upload_id: params.get("uploadId").cloned(), - part_number, - timeout_ms, - }; - match app - .store - .relay_put_object(&app.cfg.bucket_uploads, &key, options) - .await - { - Ok(etag) => { - let mut response = Response::new(Body::empty()); - *response.status_mut() = StatusCode::OK; - http_headers::add_security_headers(response.headers_mut()); - relay_cors(response.headers_mut()); - if let Some(etag) = etag { - response.headers_mut().insert( - header::ETAG, - HeaderValue::from_str(&etag).unwrap_or_else(|_| HeaderValue::from_static("")), - ); - } - response - } - Err(err) => { - if let Some(client_err) = client_failure.as_ref().and_then(|failure| failure.get()) { - return relay_error(*client_err); - } - warn!(error = %err, "upload relay upstream S3 PUT failed"); - relay_error(upload_relay::RelayError::UpstreamS3Error) - } - } -} - -const RELAY_STREAM_BUFFER_FRAMES: usize = 8; -const RELAY_STREAM_MIN_CLIENT_BYTES_PER_SEC: u64 = 16 * 1024; - -fn spawn_relay_body_feeder( - mut body: Body, - declared_length: u64, - tx: tokio::sync::mpsc::Sender>, - failure: Arc>, -) { - tokio::spawn(async move { - let mut written: u64 = 0; - while let Some(frame_result) = body.frame().await { - let frame = match frame_result { - Ok(frame) => frame, - Err(_) => { - let _ = failure.set(upload_relay::RelayError::ClientUploadFailed); - let _ = tx - .send(Err(std::io::Error::new( - std::io::ErrorKind::ConnectionAborted, - "client body read failed", - ))) - .await; - return; - } - }; - let Ok(chunk) = frame.into_data() else { - continue; - }; - if chunk.is_empty() { - continue; - } - let next = written.saturating_add(chunk.len() as u64); - if next > declared_length { - let _ = failure.set(upload_relay::RelayError::PayloadTooLarge); - let _ = tx - .send(Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "payload exceeded declared length", - ))) - .await; - return; - } - written = next; - if tx.send(Ok(chunk)).await.is_err() { - return; - } - } - if written != declared_length { - let _ = failure.set(upload_relay::RelayError::ClientUploadFailed); - let _ = tx - .send(Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "payload shorter than declared length", - ))) - .await; - } - }); -} - -async fn catch_all( - State(app): State>, - Query(params): Query>, - request: Request, -) -> Response { - let method = request.method().clone(); - if method != Method::GET && method != Method::HEAD { - return text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed"); - } - let path = request.uri().path().to_owned(); - if app.cfg.mode == DeploymentMode::Static { - let key = decode_storage_key(&path); - return serve_stored_raw( - &app, - method, - &app.cfg.bucket_static, - &key, - request.headers(), - ) - .await; - } - if let Some(rest) = path.strip_prefix("/external/") { - return serve_external(&app, method, rest, ¶ms, request.headers()).await; - } - if path.starts_with("/attachments/") { - let key = decode_storage_key(&path); - return serve_attachment(&app, method, &key, ¶ms, request.headers()).await; - } - if path.starts_with("/themes/") && path.ends_with(".css") { - let key = decode_storage_key(&path); - return serve_stored_with_override( - &app, - method, - &app.cfg.bucket_cdn, - &key, - "text/css; charset=utf-8", - request.headers(), - ) - .await; - } - if let Some(key) = parse_entrance_sound_path(&path) { - return serve_stored_raw(&app, method, &app.cfg.bucket_cdn, &key, request.headers()).await; - } - if let Some(asset) = parse_guild_member_asset_path(&path) { - return serve_asset_image(&app, method, asset, ¶ms, request.headers()).await; - } - if let Some(asset) = parse_simple_asset_path(&path, AssetKind::Emoji) { - return serve_asset_image(&app, method, asset, ¶ms, request.headers()).await; - } - if let Some(asset) = parse_simple_asset_path(&path, AssetKind::Sticker) { - return serve_asset_image(&app, method, asset, ¶ms, request.headers()).await; - } - if let Some(asset) = parse_standard_asset_path(&path) { - return serve_asset_image(&app, method, asset, ¶ms, request.headers()).await; - } - text(StatusCode::NOT_FOUND, "Not Found") -} - -struct ParsedAssetPath { - storage_key: String, - original_ext: AssetExtension, - hash: String, - kind: AssetKind, - forced_output_format: Option, -} - -fn parse_standard_asset_path(path: &str) -> Option { - let mut parts = path.trim_start_matches('/').split('/'); - let prefix = parts.next()?; - let id = parts.next()?; - let filename = parts.next()?; - if parts.next().is_some() { - return None; - } - let kind = match prefix { - "avatars" => AssetKind::Avatar, - "icons" => AssetKind::GuildIcon, - "branding" => AssetKind::GuildIcon, - "banners" => AssetKind::Banner, - "splashes" => AssetKind::Splash, - "embed-splashes" => AssetKind::EmbedSplash, - _ => return None, - }; - if id.is_empty() { - return None; - } - let parsed = parse_asset_filename(filename)?; - let storage_hash = asset_hash::strip_animation_prefix(parsed.hash); - Some(ParsedAssetPath { - storage_key: format!("{prefix}/{id}/{storage_hash}"), - original_ext: parsed.ext, - hash: parsed.hash.to_owned(), - kind, - forced_output_format: None, - }) -} - -fn parse_guild_member_asset_path(path: &str) -> Option { - let mut parts = path.trim_start_matches('/').split('/'); - if parts.next()? != "guilds" { - return None; - } - let guild_id = parts.next()?; - if parts.next()? != "users" { - return None; - } - let user_id = parts.next()?; - let prefix = parts.next()?; - let filename = parts.next()?; - if guild_id.is_empty() || user_id.is_empty() || parts.next().is_some() { - return None; - } - let kind = match prefix { - "avatars" => AssetKind::Avatar, - "banners" => AssetKind::Banner, - _ => return None, - }; - let parsed = parse_asset_filename(filename)?; - let storage_hash = asset_hash::strip_animation_prefix(parsed.hash); - Some(ParsedAssetPath { - storage_key: format!("guilds/{guild_id}/users/{user_id}/{prefix}/{storage_hash}"), - original_ext: parsed.ext, - hash: parsed.hash.to_owned(), - kind, - forced_output_format: None, - }) -} - -fn parse_simple_asset_path(path: &str, kind: AssetKind) -> Option { - let expected_prefix = match kind { - AssetKind::Emoji => "emojis", - AssetKind::Sticker => "stickers", - _ => return None, - }; - let mut parts = path.trim_start_matches('/').split('/'); - let prefix = parts.next()?; - if prefix != expected_prefix { - return None; - } - let filename = parts.next()?; - if parts.next().is_some() { - return None; - } - let parsed = parse_asset_filename(filename)?; - let id_no_ext = filename.split_once('.')?.0; - if id_no_ext.is_empty() { - return None; - } - Some(ParsedAssetPath { - storage_key: format!("{prefix}/{id_no_ext}"), - original_ext: parsed.ext, - hash: parsed.hash.to_owned(), - kind, - forced_output_format: (kind == AssetKind::Sticker).then_some(AssetExtension::Webp), - }) -} - -fn parse_entrance_sound_path(path: &str) -> Option { - let mut parts = path.trim_start_matches('/').split('/'); - if parts.next()? != "entrance-sounds" { - return None; - } - let user_id = parts.next()?; - let filename = parts.next()?; - if parts.next().is_some() { - return None; - } - if user_id.is_empty() || !user_id.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - let (hash, ext) = filename.split_once('.')?; - if hash.is_empty() || !hash.bytes().all(|b| b.is_ascii_alphanumeric()) { - return None; - } - if !matches!(ext, "mp3" | "ogg" | "m4a" | "wav") { - return None; - } - Some(format!("entrance-sounds/{user_id}/{filename}")) -} - -struct ParsedAssetFilename<'a> { - hash: &'a str, - ext: AssetExtension, -} - -fn parse_asset_filename(filename: &str) -> Option> { - let (hash, ext_raw) = filename.split_once('.')?; - if hash.is_empty() || ext_raw.is_empty() || ext_raw.contains('.') { - return None; - } - if !hash.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { - return None; - } - if !ext_raw.bytes().all(|b| b.is_ascii_alphanumeric()) { - return None; - } - Some(ParsedAssetFilename { - hash, - ext: AssetExtension::parse(ext_raw)?, - }) -} - -fn asset_filename_hint(asset: &ParsedAssetPath) -> String { - let hash = asset_hash::strip_animation_prefix(&asset.hash); - format!("{hash}.{}", asset.original_ext.name()) -} - -const EXTERNAL_SNIFF_PREFIX_BYTES: usize = 8192; -const EXTERNAL_HINT_CACHE_ENTRIES: u64 = 4096; - -type ExternalHintCache = moka::sync::Cache; - -#[derive(Clone)] -struct ExternalHint { - url: String, - content_type: String, - source_format: Option, -} - -fn new_external_hint_cache(cfg: &Config) -> ExternalHintCache { - moka::sync::Cache::builder() - .max_capacity(EXTERNAL_HINT_CACHE_ENTRIES) - .time_to_live(Duration::from_millis(cfg.transform_cache_ttl_ms.max(1))) - .build() -} - -fn external_sniffed_content_type(data: &[u8], filename: &str, content_type: String) -> String { - let prefix = &data[..data.len().min(EXTERNAL_SNIFF_PREFIX_BYTES)]; - if mime::sniff(prefix).mime == "image/svg+xml" { - return "image/svg+xml".to_owned(); - } - if content_type_is_trustworthy(&content_type) { - return content_type; - } - mime::detect(prefix, filename, Some(&content_type)) -} - -fn external_hint(url: &str, filename: &str, content_type: &str, data: &[u8]) -> ExternalHint { - let content_type = external_sniffed_content_type(data, filename, content_type.to_owned()); - let source_format = if extension_from_mime(mime::sniff(data).mime) == Some(AssetExtension::Apng) - { - Some(AssetExtension::Apng) - } else { - extension_from_mime(&content_type).or_else(|| image_extension_from_filename(filename)) - }; - ExternalHint { - url: url.to_owned(), - content_type, - source_format, - } -} - -fn external_cached_transform( - app: &Arc, - url: &str, - method: &Method, - params: &HashMap, - headers: &HeaderMap, -) -> Option { - let hint = app.external_hints.get(url)?; - let explicit_requested_format = explicit_output_format(params).ok()?; - let width = parse_optional_dimension_param(params, "width").ok()?; - let height = parse_optional_dimension_param(params, "height").ok()?; - let animated = animated_param(params, false); - let filename = url_filename(&hint.url); - let (format, quality, response_content_type) = match mime::category(&hint.content_type) { - Some(mime::Category::Video) => { - let format = output_format::coerce_unsupported_format(explicit_requested_format?); - let quality = params - .get("quality") - .cloned() - .unwrap_or_else(|| "high".to_owned()); - (format, quality, format.mime().to_owned()) - } - Some(mime::Category::Image) => { - let requested_format = explicit_requested_format.unwrap_or_else(|| { - external_default_output_extension(&filename, &hint.content_type) - }); - let requested_supported_format = - output_format::coerce_unsupported_format(requested_format); - let format = effective_animated_image_output_format( - hint.source_format, - requested_supported_format, - animated, - ); - let quality = params.get("quality").cloned().unwrap_or_else(|| { - default_transform_quality( - format, - animated, - transform_static_quality_default(hint.source_format), - ) - .to_owned() - }); - let response_content_type = transform_response_content_type( - explicit_requested_format, - requested_format, - format, - &hint.content_type, - ) - .to_owned(); - (format, quality, response_content_type) - } - _ => return None, - }; - let cached = app - .transform_cache - .get(&transform_cache_key(TransformCacheKeyInput { - route: TransformRoute::External, - cache_identity: &hint.url, - width, - height, - format, - quality: &quality, - animated, - effort: None, - }))?; - metrics::GLOBAL - .transform_cache_hits - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let disposition = content_disposition_header( - &response_content_type, - bool_param(params, "download", false), - Some(&filename), - ); - Some(media_response( - method.clone(), - cached, - &response_content_type, - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - Some(disposition), - )) -} - -async fn external_head_passthrough( - app: &AppState, - url: &str, - params: &HashMap, -) -> Option { - let fetched = fetch_external_head(app, url).await.ok()?; - if fetched.status != StatusCode::OK || is_svg_content_type(&fetched.content_type) { - return None; - } - let total_len = usize::try_from(fetched.content_length?).ok()?; - if total_len > constants::MAX_MEDIA_PROXY_BYTES { - return None; - } - let filename = url_filename(&fetched.url); - let content_type = if content_type_is_trustworthy(&fetched.content_type) { - fetched.content_type - } else { - mime::detect(&[], &filename, Some(&fetched.content_type)) - }; - let disposition = content_disposition_header( - &content_type, - bool_param(params, "download", false), - Some(&filename), - ); - Some(passthrough_head_response( - &content_type, - total_len, - None, - Some(disposition), - )) -} - -async fn serve_external( - app: &Arc, - method: Method, - rest: &str, - params: &HashMap, - headers: &HeaderMap, -) -> Response { - let Some((sig, proxy_path)) = rest.split_once('/') else { - return text(StatusCode::BAD_REQUEST, "Bad Request"); - }; - if !signing::verify_signature(proxy_path, sig, app.cfg.secret_key.as_bytes()) { - return text(StatusCode::UNAUTHORIZED, "Unauthorized"); - } - let url = match external_path::reconstruct_original_url(proxy_path) { - Ok(url) => url, - Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let url_ext_is_svg = - image_extension_from_filename(&url_filename(&url)) == Some(AssetExtension::Svg); - let wants_transform = url_ext_is_svg - || params.contains_key("width") - || params.contains_key("height") - || params.contains_key("format") - || params.contains_key("quality") - || animated_param(params, false); - let client_range = headers - .get(header::RANGE) - .and_then(|v| v.to_str().ok()) - .and_then(|raw| raw.strip_prefix("bytes=")) - .filter(|rv| !rv.is_empty() && rv.bytes().all(|b| b.is_ascii_graphic())); - let forward_range = if wants_transform { None } else { client_range }; - let allow_stream = !wants_transform; - if wants_transform - && let Some(cached) = external_cached_transform(app, &url, &method, params, headers) - { - return cached; - } - if method == Method::HEAD - && !wants_transform - && client_range.is_none() - && let Some(response) = external_head_passthrough(app, &url, params).await - { - return response; - } - let mut fetched = match fetch_external_with_range(app, &url, forward_range, allow_stream).await - { - Ok(fetched) if fetched.status.is_success() => fetched, - Ok(fetched) => { - return text_with_source( - map_upstream_status(fetched.status), - "Upstream fetch failed", - "external_upstream_status", - format!("url={url} upstream_status={}", fetched.status.as_u16()), - ); - } - Err(ExternalFetchError::BlockedUrl) => { - return text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "external_blocked_url", - &url, - ); - } - Err(ExternalFetchError::PayloadTooLarge) => { - return text_with_source( - StatusCode::PAYLOAD_TOO_LARGE, - "Payload Too Large", - "external_payload_too_large", - &url, - ); - } - Err(err @ ExternalFetchError::TooManyRedirects) - | Err(err @ ExternalFetchError::FetchFailed) => { - return text_with_source( - StatusCode::BAD_GATEWAY, - "Bad Gateway", - "external_fetch_failed", - format!("url={url} err={err:?}"), - ); - } - }; - if forward_range.is_some() - && fetched.status == StatusCode::PARTIAL_CONTENT - && is_svg_content_type(&fetched.content_type) - { - fetched = match fetch_external_with_range(app, &url, None, false).await { - Ok(fetched) if fetched.status.is_success() => fetched, - Ok(fetched) => { - return text_with_source( - map_upstream_status(fetched.status), - "Upstream fetch failed", - "external_upstream_status", - format!("url={url} upstream_status={}", fetched.status.as_u16()), - ); - } - Err(ExternalFetchError::BlockedUrl) => { - return text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "external_blocked_url", - &url, - ); - } - Err(ExternalFetchError::PayloadTooLarge) => { - return text_with_source( - StatusCode::PAYLOAD_TOO_LARGE, - "Payload Too Large", - "external_payload_too_large", - &url, - ); - } - Err(err @ ExternalFetchError::TooManyRedirects) - | Err(err @ ExternalFetchError::FetchFailed) => { - return text_with_source( - StatusCode::BAD_GATEWAY, - "Bad Gateway", - "external_fetch_failed", - format!("url={url} err={err:?}"), - ); - } - }; - } - let filename = url_filename(&fetched.url); - let requested_download = bool_param(params, "download", false); - if forward_range.is_some() && fetched.status == StatusCode::PARTIAL_CONTENT { - let disposition = - content_disposition_header(&fetched.content_type, requested_download, Some(&filename)); - return external_partial_response(method, fetched, Some(disposition)); - } - let FetchedExternal { - url: fetched_url, - body, - content_type, - content_length, - .. - } = fetched; - let data = match body { - ExternalBody::Streaming { response, prefix } => { - let content_type = external_sniffed_content_type(&prefix, &filename, content_type); - let disposition = - content_disposition_header(&content_type, requested_download, Some(&filename)); - return external_streaming_response( - method, - response, - prefix, - content_length, - &content_type, - Some(disposition), - &fetched_url, - ); - } - ExternalBody::Buffered(data) => data, - }; - app.external_hints.insert( - url, - external_hint(&fetched_url, &filename, &content_type, &data), - ); - serve_bytes_or_transform( - app, - ServeBytesRequest { - method, - data, - content_type, - cache_identity: &fetched_url, - filename: &filename, - route: TransformRoute::External, - params, - headers, - }, - ) - .await -} - -struct FetchedExternal { - url: String, - status: StatusCode, - body: ExternalBody, - content_type: String, - content_length: Option, - content_range: Option, -} - -enum ExternalBody { - Buffered(Bytes), - Streaming { - response: reqwest::Response, - prefix: Bytes, - }, -} - -impl ExternalBody { - async fn into_buffered(self, url: &str) -> Result { - match self { - Self::Buffered(data) => Ok(data), - Self::Streaming { response, prefix } => { - buffer_external_response(response, prefix, url).await - } - } - } -} - -fn external_should_stream(allow_stream: bool, content_type: &str) -> bool { - allow_stream && !is_svg_content_type(content_type) -} - -fn external_source_is_svg(prefix: &[u8], filename: &str, content_type: &str) -> bool { - image_extension_from_filename(filename) == Some(AssetExtension::Svg) - || is_svg_content_type(&external_sniffed_content_type( - prefix, - filename, - content_type.to_owned(), - )) -} - -#[derive(Debug)] -struct ExternalStreamOverrun; - -impl std::fmt::Display for ExternalStreamOverrun { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("external stream exceeded the media proxy size cap") - } -} - -impl std::error::Error for ExternalStreamOverrun {} - -struct ExternalStreamBody { - url: String, - prefix: Option, - upstream: reqwest::Body, - remaining: usize, -} - -impl http_body::Body for ExternalStreamBody { - type Data = Bytes; - type Error = Box; - - fn poll_frame( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll, Self::Error>>> { - let this = self.get_mut(); - if let Some(prefix) = this.prefix.take() - && !prefix.is_empty() - { - return Poll::Ready(Some(Ok(Frame::data(prefix)))); - } - let frame = match Pin::new(&mut this.upstream).poll_frame(cx) { - Poll::Pending => return Poll::Pending, - Poll::Ready(None) => return Poll::Ready(None), - Poll::Ready(Some(Err(err))) => return Poll::Ready(Some(Err(err.into()))), - Poll::Ready(Some(Ok(frame))) => frame, - }; - let Some(len) = frame.data_ref().map(Bytes::len) else { - return Poll::Ready(Some(Ok(frame))); - }; - let Some(remaining) = this.remaining.checked_sub(len) else { - this.remaining = 0; - warn!(url = %this.url, "external stream exceeded the size cap"); - metrics::GLOBAL - .external_stream_overruns - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - return Poll::Ready(Some(Err(Box::new(ExternalStreamOverrun)))); - }; - this.remaining = remaining; - Poll::Ready(Some(Ok(frame))) - } -} - -fn external_stream_body( - method: &Method, - response: reqwest::Response, - prefix: Bytes, - url: &str, -) -> Body { - if *method == Method::HEAD { - return Body::empty(); - } - let remaining = constants::MAX_MEDIA_PROXY_BYTES.saturating_sub(prefix.len()); - let (_, upstream) = http::Response::from(response).into_parts(); - Body::new(ExternalStreamBody { - url: url.to_owned(), - prefix: Some(prefix), - upstream, - remaining, - }) -} - -fn external_partial_response( - method: Method, - fetched: FetchedExternal, - disposition: Option, -) -> Response { - let FetchedExternal { - url, - body, - content_type, - content_length, - content_range, - .. - } = fetched; - let (body, body_len) = match body { - ExternalBody::Buffered(data) => { - let body_len = Some(data.len() as u64); - let body = if method == Method::HEAD { - Body::empty() - } else { - Body::from(data) - }; - (body, body_len) - } - ExternalBody::Streaming { response, prefix } => ( - external_stream_body(&method, response, prefix, &url), - content_length, - ), - }; - let mut response = Response::new(body); - *response.status_mut() = StatusCode::PARTIAL_CONTENT; - http_headers::add_media_headers( - response.headers_mut(), - body_len - .and_then(|len| usize::try_from(len).ok()) - .unwrap_or(constants::MAX_MEDIA_PROXY_BYTES), - &content_type, - None, - ); - if let Some(body_len) = body_len { - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); - } - if let Some(cr) = content_range.as_deref() - && let Ok(value) = HeaderValue::from_str(cr) - { - response.headers_mut().insert(header::CONTENT_RANGE, value); - } - if let Some(disposition) = disposition - && let Ok(value) = HeaderValue::from_str(&disposition) - { - response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } - response -} - -fn external_streaming_response( - method: Method, - response: reqwest::Response, - prefix: Bytes, - content_length: Option, - content_type: &str, - disposition: Option, - url: &str, -) -> Response { - let body = external_stream_body(&method, response, prefix, url); - let mut http_response = Response::new(body); - *http_response.status_mut() = StatusCode::OK; - http_headers::add_media_headers( - http_response.headers_mut(), - content_length - .and_then(|len| usize::try_from(len).ok()) - .unwrap_or(constants::MAX_MEDIA_PROXY_BYTES), - content_type, - None, - ); - if let Some(content_length) = content_length { - http_response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(content_length)); - } - if let Some(disposition) = disposition - && let Ok(value) = HeaderValue::from_str(&disposition) - { - http_response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } - http_response -} - -#[derive(Debug)] -enum ExternalFetchError { - BlockedUrl, - PayloadTooLarge, - TooManyRedirects, - FetchFailed, -} - -async fn fetch_external(app: &AppState, url: &str) -> Result { - fetch_external_with_range(app, url, None, false).await -} - -async fn fetch_external_head( - app: &AppState, - url: &str, -) -> Result { - fetch_external_timed(app, url, Method::HEAD, None, false).await -} - -async fn fetch_external_with_range( - app: &AppState, - url: &str, - range: Option<&str>, - allow_stream: bool, -) -> Result { - fetch_external_timed(app, url, Method::GET, range, allow_stream).await -} - -async fn fetch_external_timed( - app: &AppState, - url: &str, - method: Method, - range: Option<&str>, - allow_stream: bool, -) -> Result { - let start_ms = metrics::now_ms(); - let result = fetch_external_inner(app, url, method, range, allow_stream).await; - request_log::record_stage(Stage::Fetch, (metrics::now_ms() - start_ms).max(0) as u64); - result -} - -async fn fetch_external_inner( - app: &AppState, - url: &str, - method: Method, - range: Option<&str>, - allow_stream: bool, -) -> Result { - let mut current_url = url.to_owned(); - let mut visited: Vec = Vec::new(); - for _ in 0..=5 { - if visited.iter().any(|seen| seen == ¤t_url) { - warn!(url = %current_url, "redirect loop detected"); - return Err(ExternalFetchError::TooManyRedirects); - } - visited.push(current_url.clone()); - if let Err(err) = public_net_policy::validate_url(¤t_url) { - warn!(?err, url = %current_url, "blocked external fetch"); - metrics::GLOBAL - .blocked_url_attempts - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - return Err(ExternalFetchError::BlockedUrl); - } - let mut request = app.client.request(method.clone(), ¤t_url); - if let Some(rv) = range { - request = request.header(header::RANGE, format!("bytes={rv}")); - } - let response = request.send().await.map_err(|err| { - warn!(url = %current_url, %err, "external send failed"); - ExternalFetchError::FetchFailed - })?; - let status = response.status(); - if is_redirect_status(status) { - let Some(location) = response - .headers() - .get(header::LOCATION) - .and_then(|value| value.to_str().ok()) - else { - warn!(url = %current_url, status = status.as_u16(), "redirect missing Location"); - return Err(ExternalFetchError::FetchFailed); - }; - current_url = - public_net_policy::resolve_redirect(¤t_url, location).map_err(|err| { - warn!(url = %current_url, %location, ?err, "redirect target blocked"); - ExternalFetchError::BlockedUrl - })?; - continue; - } - return external_fetched_from_response(response, current_url, allow_stream).await; - } - Err(ExternalFetchError::TooManyRedirects) -} - -async fn external_fetched_from_response( - mut response: reqwest::Response, - url: String, - allow_stream: bool, -) -> Result { - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_owned(); - let content_range = response - .headers() - .get(header::CONTENT_RANGE) - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned); - let content_length = response - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()) - .or_else(|| response.content_length()); - if let Some(len) = content_length - && len > constants::MAX_MEDIA_PROXY_BYTES as u64 - { - warn!(url = %url, len, "external payload too large"); - return Err(ExternalFetchError::PayloadTooLarge); - } - let mut prefix = Bytes::new(); - if status.is_success() && external_should_stream(allow_stream, &content_type) { - prefix = external_body_prefix(&mut response, &url).await?; - if !external_source_is_svg(&prefix, &url_filename(&url), &content_type) { - return Ok(FetchedExternal { - url, - status, - body: ExternalBody::Streaming { response, prefix }, - content_type, - content_length, - content_range, - }); - } - } - let data = buffer_external_response(response, prefix, &url).await?; - Ok(FetchedExternal { - url, - status, - body: ExternalBody::Buffered(data), - content_type, - content_length, - content_range, - }) -} - -async fn external_body_prefix( - response: &mut reqwest::Response, - url: &str, -) -> Result { - let mut prefix: Vec = Vec::new(); - while prefix.len() < EXTERNAL_SNIFF_PREFIX_BYTES { - let chunk = response.chunk().await.map_err(|err| { - warn!(url = %url, %err, "external body read failed"); - ExternalFetchError::FetchFailed - })?; - let Some(chunk) = chunk else { - break; - }; - prefix.extend_from_slice(&chunk); - } - Ok(Bytes::from(prefix)) -} - -async fn buffer_external_response( - mut response: reqwest::Response, - prefix: Bytes, - url: &str, -) -> Result { - let initial_capacity = response - .content_length() - .map(|len| len.min(constants::MAX_MEDIA_PROXY_BYTES as u64) as usize) - .unwrap_or(0) - .saturating_add(prefix.len()) - .min(constants::MAX_MEDIA_PROXY_BYTES); - let mut buf: Vec = Vec::with_capacity(initial_capacity); - buf.extend_from_slice(&prefix); - while let Some(chunk) = response.chunk().await.map_err(|err| { - warn!(url = %url, %err, "external body read failed"); - ExternalFetchError::FetchFailed - })? { - let Some(next_len) = buf - .len() - .checked_add(chunk.len()) - .filter(|len| *len <= constants::MAX_MEDIA_PROXY_BYTES) - else { - warn!(url = %url, "external payload too large"); - return Err(ExternalFetchError::PayloadTooLarge); - }; - debug_assert!(next_len <= constants::MAX_MEDIA_PROXY_BYTES); - buf.extend_from_slice(&chunk); - } - Ok(Bytes::from(buf)) -} - -fn map_upstream_status(status: StatusCode) -> StatusCode { - match status.as_u16() { - 400 => StatusCode::BAD_REQUEST, - 401 => StatusCode::UNAUTHORIZED, - 403 => StatusCode::FORBIDDEN, - 404 => StatusCode::NOT_FOUND, - 405 => StatusCode::METHOD_NOT_ALLOWED, - 406 => StatusCode::NOT_ACCEPTABLE, - 408 => StatusCode::REQUEST_TIMEOUT, - 409 => StatusCode::CONFLICT, - 410 => StatusCode::GONE, - 411 => StatusCode::LENGTH_REQUIRED, - 412 => StatusCode::PRECONDITION_FAILED, - 413 => StatusCode::PAYLOAD_TOO_LARGE, - 414 => StatusCode::URI_TOO_LONG, - 415 => StatusCode::UNSUPPORTED_MEDIA_TYPE, - 416 => StatusCode::RANGE_NOT_SATISFIABLE, - 428 => StatusCode::from_u16(428).expect("428 is a valid status code"), - 429 => StatusCode::TOO_MANY_REQUESTS, - _ => StatusCode::BAD_GATEWAY, - } -} - -fn map_internal_metadata_upstream_status(status: StatusCode) -> StatusCode { - match status.as_u16() { - 429 => StatusCode::SERVICE_UNAVAILABLE, - _ => map_upstream_status(status), - } -} - -fn is_redirect_status(status: StatusCode) -> bool { - matches!( - status, - StatusCode::MOVED_PERMANENTLY - | StatusCode::FOUND - | StatusCode::SEE_OTHER - | StatusCode::TEMPORARY_REDIRECT - | StatusCode::PERMANENT_REDIRECT - ) -} - -async fn serve_asset_image( - app: &Arc, - method: Method, - asset: ParsedAssetPath, - params: &HashMap, - headers: &HeaderMap, -) -> Response { - let requested_download = bool_param(params, "download", false); - let asset_filename = asset_filename_hint(&asset); - let size = constants::parse_image_size(params.get("size").map(String::as_str)); - let selected = output_format::select_url_variant(output_format::Input { - kind: asset.kind, - original: asset.original_ext, - requested_size: Some(size), - manual_format_override: asset - .forced_output_format - .or_else(|| asset_manual_format_override(params, asset.original_ext)), - }); - let animated = asset_wants_animated(params, &asset.hash); - let object = - match read_cdn_object_with_fallback(app, &asset.storage_key, asset.original_ext).await { - Ok(object) => object, - Err(err) => return storage_error_response(&asset.storage_key, err), - }; - let sniffed_source = mime::sniff(&object.data); - let sniffed_source_ext = extension_from_mime(sniffed_source.mime); - let source_format = if sniffed_source_ext == Some(AssetExtension::Apng) { - AssetExtension::Apng - } else { - extension_from_mime(&object.content_type) - .or(sniffed_source_ext) - .unwrap_or(asset.original_ext) - }; - let serve_content_type = if object.content_type.is_empty() - || object - .content_type - .eq_ignore_ascii_case("application/octet-stream") - || extension_from_mime(&object.content_type).is_none() - { - source_format.mime().to_owned() - } else { - object.content_type.clone() - }; - let out_ext = - effective_animated_image_output_format(Some(source_format), selected.format, animated); - let quality = params - .get("quality") - .cloned() - .unwrap_or_else(|| default_transform_quality(out_ext, animated, "high").to_owned()); - let width = selected.size; - let height = selected.size; - if same_format_loaded_image_request_can_use_original( - sniffed_source, - OriginalImageRequest { - source_ext: Some(source_format), - explicit_out_ext: asset_manual_format_override(params, asset.original_ext), - out_ext, - width, - height, - has_quality: params.contains_key("quality"), - effort: None, - animated, - }, - ) { - return media_response( - method, - object.data, - &serve_content_type, - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - Some(content_disposition_header( - &serve_content_type, - requested_download, - Some(&asset_filename), - )), - ); - } - let options = media_process::ImageOptions { - width, - height, - format: out_ext, - quality: quality.clone(), - animated, - effort_override: None, - cover_crop: matches!(asset.kind, AssetKind::Emoji | AssetKind::Sticker), - deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64), - max_encode_frames: Some(app.cfg.max_encode_frames), - max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms), - }; - let cache_key = asset_transform_cache_key( - &asset.storage_key, - selected.size, - out_ext, - &quality, - animated, - ); - if let Some(cached) = app.transform_cache.get(&cache_key) { - metrics::GLOBAL - .transform_cache_hits - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - return media_response( - method, - cached, - out_ext.mime(), - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - Some(content_disposition_header( - out_ext.mime(), - requested_download, - Some(&asset_filename), - )), - ); - } - metrics::GLOBAL - .transform_cache_misses - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let coalescer_deadline = deadline_instant(options.deadline_ms); - let transformed = match app - .coalescer - .run_once_until(cache_key.clone(), coalescer_deadline, || { - let app = app.clone(); - let data = object.data.clone(); - let options = options.clone(); - async move { - coalesced_work_result(run_transform(&app, data, options).await) - .map(|media| media.bytes) - } - }) - .await - { - Ok(bytes) => bytes, - Err(CoalescerError::RequestTimeout) => { - return text_with_reason( - StatusCode::GATEWAY_TIMEOUT, - "Gateway Timeout", - "coalescer_timeout_asset_image", - ); - } - Err(CoalescerError::WorkFailed) => { - let src_ct = object.content_type.as_str(); - let src_is_displayable = src_ct.starts_with("image/") - && src_ct != "image/avif" - && src_ct != "image/heic" - && src_ct != "image/heif" - && source_format != AssetExtension::Svg - && !is_svg_content_type(src_ct); - if !src_is_displayable { - return text_with_source( - StatusCode::INTERNAL_SERVER_ERROR, - "Transcode Failed", - "transcode_failed", - format!( - "asset key={} src_ct={} out={} size={:?} animated={}", - asset.storage_key, - src_ct, - out_ext.name(), - selected.size, - animated, - ), - ); - } - return media_response( - method, - object.data, - &object.content_type, - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - None, - ); - } - }; - app.transform_cache.put(cache_key, transformed.clone()); - media_response( - method, - transformed, - out_ext.mime(), - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - Some(content_disposition_header( - out_ext.mime(), - requested_download, - Some(&asset_filename), - )), - ) -} - -async fn read_cdn_object_with_fallback( - app: &Arc, - key: &str, - original_ext: AssetExtension, -) -> Result { - match app.store.read_object(&app.cfg.bucket_cdn, key).await { - Ok(object) => Ok(object), - Err(StorageError::NotFound) => { - let fallback_key = format!("{key}.{}", original_ext.name()); - app.store - .read_object(&app.cfg.bucket_cdn, &fallback_key) - .await - } - Err(err) => Err(err), - } -} - -async fn serve_attachment( - app: &Arc, - method: Method, - key: &str, - params: &HashMap, - headers: &HeaderMap, -) -> Response { - let filename = filename_from_storage_key(key); - let animated = animated_param(params, false); - let wants_transform = params.contains_key("width") - || params.contains_key("height") - || params.contains_key("format") - || params.contains_key("quality") - || animated; - if !wants_transform { - return serve_stored_passthrough_stream( - app, - method, - &app.cfg.bucket_cdn, - key, - headers, - PassthroughDisposition::Attachment { - requested_download: bool_param(params, "download", false), - filename, - }, - ) - .await; - } - let object = match app.store.read_object(&app.cfg.bucket_cdn, key).await { - Ok(object) => object, - Err(err) => return storage_error_response(key, err), - }; - serve_bytes_or_transform( - app, - ServeBytesRequest { - method, - data: object.data, - content_type: object.content_type, - cache_identity: key, - filename, - route: TransformRoute::Attachment, - params, - headers, - }, - ) - .await -} - -async fn serve_stored_raw( - app: &Arc, - method: Method, - bucket: &str, - key: &str, - headers: &HeaderMap, -) -> Response { - serve_stored_passthrough_stream( - app, - method, - bucket, - key, - headers, - PassthroughDisposition::None, - ) - .await -} - -enum PassthroughDisposition<'a> { - None, - Attachment { - requested_download: bool, - filename: &'a str, - }, -} - -async fn serve_stored_passthrough_stream( - app: &Arc, - method: Method, - bucket: &str, - key: &str, - headers: &HeaderMap, - disposition: PassthroughDisposition<'_>, -) -> Response { - if method == Method::HEAD { - return serve_stored_passthrough_head(app, bucket, key, headers, &disposition).await; - } - if app.cfg.mode == DeploymentMode::Mp - && image_extension_from_filename(key) == Some(AssetExtension::Svg) - { - return serve_stored_passthrough_svg(app, method, bucket, key, headers, &disposition).await; - } - let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); - let forwarded_range = match range::classify_request_range(range_header) { - range::RequestRange::Absent => None, - range::RequestRange::Forwardable(value) => Some(value), - range::RequestRange::Unsatisfiable => { - return passthrough_unsatisfiable_response(app, bucket, key, None).await; - } - }; - let object = match app.store.stream_object(bucket, key, forwarded_range).await { - Ok(object) => object, - Err(err) => return storage_error_response(key, err), - }; - if object.status == StatusCode::RANGE_NOT_SATISFIABLE { - return passthrough_unsatisfiable_response(app, bucket, key, object.total_length).await; - } - let content_type = passthrough_content_type(&object.content_type, key); - if app.cfg.mode == DeploymentMode::Mp && is_svg_content_type(&content_type) { - return serve_stored_passthrough_svg(app, method, bucket, key, headers, &disposition).await; - } - let total_len = match passthrough_total_len(app, bucket, key, object.total_length).await { - Ok(value) => value, - Err(err) => return storage_error_response(key, err), - }; - if total_len > constants::MAX_MEDIA_PROXY_BYTES { - return storage_error_response(key, StorageError::StreamTooLong); - } - streaming_media_response( - method, - object, - total_len, - &content_type, - passthrough_disposition_header(&disposition, &content_type), - ) -} - -async fn serve_stored_passthrough_head( - app: &Arc, - bucket: &str, - key: &str, - headers: &HeaderMap, - disposition: &PassthroughDisposition<'_>, -) -> Response { - let head = match app.store.head_object(bucket, key).await { - Ok(head) => head, - Err(err) => return storage_error_response(key, err), - }; - if head.content_length > constants::MAX_MEDIA_PROXY_BYTES as u64 { - return storage_error_response(key, StorageError::StreamTooLong); - } - let content_type = passthrough_content_type(&head.content_type, key); - if app.cfg.mode == DeploymentMode::Mp - && (is_svg_content_type(&content_type) - || image_extension_from_filename(key) == Some(AssetExtension::Svg)) - { - return serve_stored_passthrough_svg(app, Method::HEAD, bucket, key, headers, disposition) - .await; - } - let total_len = match usize::try_from(head.content_length) { - Ok(value) => value, - Err(_) => return storage_error_response(key, StorageError::StreamTooLong), - }; - let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); - let parsed_range = range::parse_range(range_header, total_len); - if parsed_range.unsatisfiable { - return unsatisfiable_response(total_len); - } - passthrough_head_response( - &content_type, - total_len, - parsed_range.range, - passthrough_disposition_header(disposition, &content_type), - ) -} - -async fn serve_stored_passthrough_svg( - app: &Arc, - method: Method, - bucket: &str, - key: &str, - headers: &HeaderMap, - disposition: &PassthroughDisposition<'_>, -) -> Response { - let object = match app.store.read_object(bucket, key).await { - Ok(object) => object, - Err(err) => return storage_error_response(key, err), - }; - let cache_identity = format!("{bucket}/{key}"); - serve_stored_svg_rasterized( - app, - method, - object.data, - &cache_identity, - headers, - disposition, - ) - .await -} - -async fn passthrough_total_len( - app: &Arc, - bucket: &str, - key: &str, - known: Option, -) -> Result { - let total = match known { - Some(value) => value, - None => app.store.head_object(bucket, key).await?.content_length, - }; - usize::try_from(total).map_err(|_| StorageError::StreamTooLong) -} - -async fn passthrough_unsatisfiable_response( - app: &Arc, - bucket: &str, - key: &str, - known_total: Option, -) -> Response { - match passthrough_total_len(app, bucket, key, known_total).await { - Ok(total_len) => unsatisfiable_response(total_len), - Err(err) => storage_error_response(key, err), - } -} - -fn unsatisfiable_response(total_len: usize) -> Response { - let mut response = Response::new(Body::empty()); - *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; - http_headers::add_unsatisfiable_headers(response.headers_mut(), total_len); - response -} - -fn passthrough_content_type(source_content_type: &str, key: &str) -> String { - let extension_mime = mime::extension_mime(key); - if extension_mime == Some("audio/mp4") - && mime::normalize(Some(source_content_type)) == Some("video/mp4") - { - return "audio/mp4".to_owned(); - } - if content_type_is_trustworthy(source_content_type) { - source_content_type.to_owned() - } else { - extension_mime - .or_else(|| { - mime::normalize(Some(source_content_type)).filter(|value| { - !value.is_empty() && !value.eq_ignore_ascii_case("application/octet-stream") - }) - }) - .unwrap_or("application/octet-stream") - .to_owned() - } -} - -fn passthrough_disposition_header( - disposition: &PassthroughDisposition<'_>, - content_type: &str, -) -> Option { - match disposition { - PassthroughDisposition::None => None, - PassthroughDisposition::Attachment { - requested_download, - filename, - } => Some(content_disposition_header( - content_type, - *requested_download, - Some(filename), - )), - } -} - -async fn serve_stored_svg_rasterized( - app: &Arc, - method: Method, - data: Bytes, - cache_identity: &str, - headers: &HeaderMap, - disposition: &PassthroughDisposition<'_>, -) -> Response { - let format = AssetExtension::Webp; - let quality = "lossless".to_owned(); - let options = media_process::ImageOptions { - format, - quality: quality.clone(), - animated: false, - deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64), - max_encode_frames: Some(app.cfg.max_encode_frames), - max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms), - ..Default::default() - }; - let cache_key = transform_cache_key(TransformCacheKeyInput { - route: TransformRoute::Stored, - cache_identity, - width: None, - height: None, - format, - quality: &quality, - animated: false, - effort: None, - }); - if let Some(cached) = app.transform_cache.get(&cache_key) { - metrics::GLOBAL - .transform_cache_hits - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - return media_response( - method, - cached, - format.mime(), - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - passthrough_disposition_header(disposition, format.mime()), - ); - } - metrics::GLOBAL - .transform_cache_misses - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let coalescer_deadline = deadline_instant(options.deadline_ms); - let transformed = match app - .coalescer - .run_once_until(cache_key.clone(), coalescer_deadline, || { - let app = app.clone(); - let data = data.clone(); - let options = options.clone(); - async move { - coalesced_work_result(run_transform(&app, data, options).await) - .map(|media| media.bytes) - } - }) - .await - { - Ok(bytes) => bytes, - Err(CoalescerError::RequestTimeout) => { - return text_with_reason( - StatusCode::GATEWAY_TIMEOUT, - "Gateway Timeout", - "coalescer_timeout_svg_rasterize", - ); - } - Err(CoalescerError::WorkFailed) => { - return text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "svg_rasterize_failed", - cache_identity, - ); - } - }; - app.transform_cache.put(cache_key, transformed.clone()); - media_response( - method, - transformed, - format.mime(), - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - passthrough_disposition_header(disposition, format.mime()), - ) -} - -fn passthrough_head_response( - content_type: &str, - total_len: usize, - byte_range: Option, - disposition: Option, -) -> Response { - let body_len = byte_range.map(|r| r.end - r.start + 1).unwrap_or(total_len); - let mut response = Response::new(Body::empty()); - *response.status_mut() = if byte_range.is_some() { - StatusCode::PARTIAL_CONTENT - } else { - StatusCode::OK - }; - http_headers::add_media_headers(response.headers_mut(), total_len, content_type, byte_range); - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); - if let Some(disposition) = disposition - && let Ok(value) = HeaderValue::from_str(&disposition) - { - response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } - response -} - -fn streaming_media_response( - method: Method, - object: StreamObject, - total_len: usize, - content_type: &str, - disposition: Option, -) -> Response { - let status = if object.status == StatusCode::PARTIAL_CONTENT { - StatusCode::PARTIAL_CONTENT - } else { - StatusCode::OK - }; - let effective_byte_range = if status == StatusCode::PARTIAL_CONTENT { - object.byte_range - } else { - None - }; - let expected_body_len = effective_byte_range - .map(|r| r.end - r.start + 1) - .unwrap_or(total_len); - let body_len = object - .content_length - .and_then(|value| usize::try_from(value).ok()) - .unwrap_or(expected_body_len); - let response_content_type = if content_type.is_empty() { - object.content_type.as_str() - } else { - content_type - }; - let mut response = if method == Method::HEAD { - Response::new(Body::empty()) - } else { - Response::new(object.body) - }; - *response.status_mut() = status; - http_headers::add_media_headers( - response.headers_mut(), - total_len, - response_content_type, - effective_byte_range, - ); - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); - if let Some(disposition) = disposition - && let Ok(value) = HeaderValue::from_str(&disposition) - { - response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } - response -} - -async fn serve_stored_with_override( - app: &Arc, - method: Method, - bucket: &str, - key: &str, - content_type: &str, - headers: &HeaderMap, -) -> Response { - let object = match app.store.read_object(bucket, key).await { - Ok(object) => object, - Err(err) => return storage_error_response(key, err), - }; - media_response( - method, - object.data, - content_type, - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - None, - ) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TransformRoute { - Attachment, - External, - Stored, -} - -struct ServeBytesRequest<'a> { - method: Method, - data: Bytes, - content_type: String, - cache_identity: &'a str, - filename: &'a str, - route: TransformRoute, - params: &'a HashMap, - headers: &'a HeaderMap, -} - -async fn serve_bytes_or_transform(app: &Arc, request: ServeBytesRequest<'_>) -> Response { - let ServeBytesRequest { - method, - data, - content_type, - cache_identity, - filename, - route, - params, - headers, - } = request; - let animated = animated_param(params, false); - let sniffed_prefix = mime::sniff(&data[..data.len().min(8192)]); - let content_type = if sniffed_prefix.mime == "image/svg+xml" { - "image/svg+xml".to_owned() - } else if content_type_is_trustworthy(&content_type) { - content_type - } else { - mime::detect(&data[..data.len().min(8192)], filename, Some(&content_type)) - }; - let source_is_svg = is_svg_content_type(&content_type) - || image_extension_from_filename(filename) == Some(AssetExtension::Svg); - let wants_transform = source_is_svg - || params.contains_key("width") - || params.contains_key("height") - || params.contains_key("format") - || params.contains_key("quality") - || animated; - let requested_download = bool_param(params, "download", false); - - if !wants_transform { - let disposition = - content_disposition_header(&content_type, requested_download, Some(filename)); - return media_response( - method, - data, - &content_type, - headers.get(header::RANGE).and_then(|v| v.to_str().ok()), - Some(disposition), - ); - } - - let explicit_requested_format = match explicit_output_format(params) { - Ok(format) => format, - Err(()) => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let width = match parse_optional_dimension_param(params, "width") { - Ok(width) => width, - Err(()) => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let height = match parse_optional_dimension_param(params, "height") { - Ok(height) => height, - Err(()) => return text(StatusCode::BAD_REQUEST, "Bad Request"), - }; - let media_kind = mime::category(&content_type); - let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); - - if media_kind == Some(mime::Category::Video) { - let Some(requested_format) = explicit_requested_format else { - if route == TransformRoute::Attachment { - return text(StatusCode::BAD_REQUEST, "Bad Request"); - } - let disposition = - content_disposition_header(&content_type, requested_download, Some(filename)); - return media_response(method, data, &content_type, range_header, Some(disposition)); - }; - let format = output_format::coerce_unsupported_format(requested_format); - let quality = params - .get("quality") - .cloned() - .unwrap_or_else(|| "high".to_owned()); - let cache_key = transform_cache_key(TransformCacheKeyInput { - route, - cache_identity, - width, - height, - format, - quality: &quality, - animated, - effort: None, - }); - if let Some(cached) = app.transform_cache.get(&cache_key) { - metrics::GLOBAL - .transform_cache_hits - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let disposition = - content_disposition_header(format.mime(), requested_download, Some(filename)); - return media_response( - method, - cached, - format.mime(), - range_header, - Some(disposition), - ); - } - metrics::GLOBAL - .transform_cache_misses - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let deadline_ms = Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64); - let coalescer_deadline = deadline_instant(deadline_ms); - let transformed = match app - .coalescer - .run_once_until(cache_key.clone(), coalescer_deadline, || { - let app = app.clone(); - let data = data.clone(); - let quality = quality.clone(); - async move { - coalesced_work_result( - run_video_transform( - &app, - data, - format, - width, - height, - quality, - deadline_ms, - ) - .await, - ) - .map(|media| media.bytes) - } - }) - .await - { - Ok(bytes) => bytes, - Err(CoalescerError::RequestTimeout) => { - return text_with_reason( - StatusCode::GATEWAY_TIMEOUT, - "Gateway Timeout", - "coalescer_timeout_video", - ); - } - Err(CoalescerError::WorkFailed) => { - return text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "video_transform_failed", - format!( - "fmt={} w={:?} h={:?} q={}", - format.name(), - width, - height, - quality - ), - ); - } - }; - app.transform_cache.put(cache_key, transformed.clone()); - let disposition = - content_disposition_header(format.mime(), requested_download, Some(filename)); - return media_response( - method, - transformed, - format.mime(), - range_header, - Some(disposition), - ); - } - - if media_kind != Some(mime::Category::Image) { - if route == TransformRoute::Attachment && explicit_requested_format.is_some() { - return text(StatusCode::BAD_REQUEST, "Bad Request"); - } - let disposition = - content_disposition_header(&content_type, requested_download, Some(filename)); - return media_response(method, data, &content_type, range_header, Some(disposition)); - } - - let sniffed_source = mime::sniff(&data); - let sniffed_source_format = extension_from_mime(sniffed_source.mime); - let source_format = if sniffed_source_format == Some(AssetExtension::Apng) { - Some(AssetExtension::Apng) - } else { - extension_from_mime(&content_type).or_else(|| image_extension_from_filename(filename)) - }; - let default_out_ext = match route { - TransformRoute::Attachment => { - image_extension_from_filename(filename).unwrap_or(AssetExtension::Webp) - } - TransformRoute::External => external_default_output_extension(filename, &content_type), - TransformRoute::Stored => { - image_extension_from_filename(filename).unwrap_or(AssetExtension::Webp) - } - }; - let requested_format = explicit_requested_format.unwrap_or(default_out_ext); - let requested_supported_format = output_format::coerce_unsupported_format(requested_format); - let format = - effective_animated_image_output_format(source_format, requested_supported_format, animated); - let response_content_type = transform_response_content_type( - explicit_requested_format, - requested_format, - format, - &content_type, - ); - let quality = params.get("quality").cloned().unwrap_or_else(|| { - default_transform_quality( - format, - animated, - transform_static_quality_default(source_format), - ) - .to_owned() - }); - let effort = (route == TransformRoute::Attachment) - .then(|| parse_effort(params)) - .flatten(); - if same_format_loaded_image_request_can_use_original( - sniffed_source, - OriginalImageRequest { - source_ext: source_format, - explicit_out_ext: explicit_requested_format, - out_ext: format, - width, - height, - has_quality: params.contains_key("quality"), - effort, - animated, - }, - ) { - let serve_ct = if content_type.is_empty() - || content_type.eq_ignore_ascii_case("application/octet-stream") - || extension_from_mime(&content_type).is_none() - { - source_format - .map(|ext| ext.mime().to_owned()) - .unwrap_or_else(|| content_type.clone()) - } else { - content_type.clone() - }; - let disposition = content_disposition_header(&serve_ct, requested_download, Some(filename)); - return media_response(method, data, &serve_ct, range_header, Some(disposition)); - } - let options = media_process::ImageOptions { - width, - height, - format, - quality: quality.clone(), - animated, - effort_override: effort, - cover_crop: params.contains_key("width") && params.contains_key("height"), - deadline_ms: Some(metrics::now_ms() + app.cfg.transform_timeout_ms as i64), - max_encode_frames: Some(app.cfg.max_encode_frames), - max_encode_duration_ms: Some(app.cfg.max_encode_duration_ms), - }; - let cache_key = transform_cache_key(TransformCacheKeyInput { - route, - cache_identity, - width: options.width, - height: options.height, - format: options.format, - quality: &options.quality, - animated: options.animated, - effort, - }); - if let Some(cached) = app.transform_cache.get(&cache_key) { - metrics::GLOBAL - .transform_cache_hits - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let disposition = - content_disposition_header(response_content_type, requested_download, Some(filename)); - return media_response( - method, - cached, - response_content_type, - range_header, - Some(disposition), - ); - } - metrics::GLOBAL - .transform_cache_misses - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let coalescer_deadline = deadline_instant(options.deadline_ms); - let media = match app - .coalescer - .run_once_until(cache_key.clone(), coalescer_deadline, || { - let app = app.clone(); - let data = data.clone(); - let options = options.clone(); - async move { - coalesced_work_result(run_transform(&app, data, options).await) - .map(|media| media.bytes) - } - }) - .await - { - Ok(bytes) => { - app.transform_cache.put(cache_key, bytes.clone()); - ProcessedBytes { - bytes, - content_type: response_content_type.to_owned(), - } - } - Err(CoalescerError::RequestTimeout) => { - return text_with_reason( - StatusCode::GATEWAY_TIMEOUT, - "Gateway Timeout", - "coalescer_timeout_image", - ); - } - Err(CoalescerError::WorkFailed) => { - return text_with_source( - StatusCode::BAD_REQUEST, - "Bad Request", - "image_transform_failed", - format!( - "route={:?} cache_identity={} fmt={} w={:?} h={:?} q={} animated={}", - route, - cache_identity, - options.format.name(), - options.width, - options.height, - options.quality, - options.animated, - ), - ); - } - }; - let disposition = - content_disposition_header(&media.content_type, requested_download, Some(filename)); - media_response( - method, - media.bytes, - &media.content_type, - range_header, - Some(disposition), - ) -} - -struct ProcessedBytes { - bytes: Bytes, - content_type: String, -} - -async fn run_transform( - app: &Arc, - data: Bytes, - options: media_process::ImageOptions, -) -> anyhow::Result { - let deadline = deadline_instant(options.deadline_ms); - let _admission = app.native_transform_admissions.try_wait()?; - let wait_start = metrics::now_ms(); - let _permit = app.native_transforms.wait_until(deadline).await?; - let waited = (metrics::now_ms() - wait_start).max(0) as u64; - metrics::GLOBAL.native_transform_wait.observe(waited); - let start = metrics::now_ms(); - let result = - tokio::task::spawn_blocking(move || media_process::transform_image(&data, &options)) - .await??; - let elapsed = (metrics::now_ms() - start).max(0) as u64; - metrics::GLOBAL.transform_image_duration.observe(elapsed); - request_log::record_stage(Stage::Transform, elapsed); - Ok(result) -} - -async fn run_video_transform( - app: &Arc, - data: Bytes, - format: AssetExtension, - width: Option, - height: Option, - quality: String, - deadline_ms: Option, -) -> anyhow::Result { - let deadline = deadline_instant(deadline_ms); - let _admission = app.native_transform_admissions.try_wait()?; - let wait_start = metrics::now_ms(); - let _permit = app.native_transforms.wait_until(deadline).await?; - let waited = (metrics::now_ms() - wait_start).max(0) as u64; - metrics::GLOBAL.native_transform_wait.observe(waited); - let start = metrics::now_ms(); - let result = tokio::task::spawn_blocking(move || { - let thumbnail = media_process::extract_video_thumbnail(&data, format)?; - if width.is_none() && height.is_none() { - return Ok(thumbnail); - } - media_process::transform_image( - &thumbnail.bytes, - &media_process::ImageOptions { - width, - height, - format, - quality, - animated: false, - deadline_ms, - ..Default::default() - }, - ) - }) - .await??; - let elapsed = (metrics::now_ms() - start).max(0) as u64; - metrics::GLOBAL.transform_image_duration.observe(elapsed); - request_log::record_stage(Stage::Transform, elapsed); - Ok(result) -} - -fn coalesced_work_result(result: anyhow::Result) -> anyhow::Result { - match result { - Ok(value) => Ok(value), - Err(error) if transform_error_is_timeout(&error) => { - Err(anyhow::Error::new(CoalescerError::RequestTimeout)) - } - Err(error) => Err(error), - } -} - -fn transform_error_is_timeout(error: &anyhow::Error) -> bool { - error.downcast_ref::() - == Some(&media_process::MediaError::RequestTimeout) - || error.downcast_ref::() - == Some(&crate::timed_semaphore::TimedSemaphoreError::RequestTimeout) -} - -fn transform_admission_capacity(cfg: &Config) -> usize { - cfg.max_native_transforms + cfg.worker_queue_capacity -} - -fn media_response( - method: Method, - data: Bytes, - content_type: &str, - range_header: Option<&str>, - disposition: Option, -) -> Response { - let total_len = data.len(); - let parsed_range = range::parse_range(range_header, data.len()); - if parsed_range.unsatisfiable { - let mut response = Response::new(Body::empty()); - *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; - http_headers::add_unsatisfiable_headers(response.headers_mut(), data.len()); - return response; - } - let (status, body_bytes, byte_range) = if let Some(r) = parsed_range.range { - let bytes = data.slice(r.start..=r.end); - (StatusCode::PARTIAL_CONTENT, bytes, Some(r)) - } else { - (StatusCode::OK, data, None) - }; - let mut response = if method == Method::HEAD { - Response::new(Body::empty()) - } else { - Response::new(Body::from(body_bytes.clone())) - }; - *response.status_mut() = status; - http_headers::add_media_headers(response.headers_mut(), total_len, content_type, byte_range); - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(body_bytes.len())); - if let Some(disposition) = disposition - && let Ok(value) = HeaderValue::from_str(&disposition) - { - response - .headers_mut() - .insert(header::CONTENT_DISPOSITION, value); - } - response -} - -fn check_internal_auth(headers: &HeaderMap, secret: &str) -> bool { - let Some(auth) = headers - .get(header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - else { - return false; - }; - let expected = format!("Bearer {secret}"); - if auth.len() != expected.len() { - return false; - } - auth.bytes() - .zip(expected.bytes()) - .fold(0u8, |diff, (a, b)| diff | (a ^ b)) - == 0 -} - -async fn read_limited_body(request: Request) -> Result { - to_bytes( - request.into_body(), - constants::MAX_INTERNAL_REQUEST_BODY_BYTES + 1, - ) - .await - .map_err(|_| StatusCode::BAD_REQUEST) - .and_then(|body| { - if body.len() > constants::MAX_INTERNAL_REQUEST_BODY_BYTES { - Err(StatusCode::PAYLOAD_TOO_LARGE) - } else { - Ok(body) - } - }) -} - -fn storage_status(err: &StorageError) -> StatusCode { - match err { - StorageError::NotFound => StatusCode::NOT_FOUND, - StorageError::ReadOnlyStorage => StatusCode::FORBIDDEN, - StorageError::InvalidBucket | StorageError::InvalidKey => StatusCode::BAD_REQUEST, - StorageError::StreamTooLong => StatusCode::PAYLOAD_TOO_LARGE, - _ => StatusCode::BAD_GATEWAY, - } -} - -fn storage_error_response(key: &str, err: StorageError) -> Response { - let status = storage_status(&err); - let body = if status == StatusCode::NOT_FOUND { - "Not Found" - } else { - canonical_reason_str(status) - }; - text_with_source( - status, - body, - "storage_error", - format!("key={key} err={err}"), - ) -} - -fn relay_error(err: upload_relay::RelayError) -> Response { - let status = match err { - upload_relay::RelayError::MissingToken - | upload_relay::RelayError::InvalidToken - | upload_relay::RelayError::RelayTokenExpired => StatusCode::UNAUTHORIZED, - upload_relay::RelayError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, - upload_relay::RelayError::UpstreamRetryable => StatusCode::SERVICE_UNAVAILABLE, - upload_relay::RelayError::UpstreamS3Error => StatusCode::BAD_GATEWAY, - _ => StatusCode::BAD_REQUEST, - }; - let mut response = text(status, status.canonical_reason().unwrap_or("Bad Request")); - relay_cors(response.headers_mut()); - response -} - -fn relay_cors(headers: &mut HeaderMap) { - headers.insert( - header::ACCESS_CONTROL_ALLOW_ORIGIN, - HeaderValue::from_static("*"), - ); - headers.insert( - header::ACCESS_CONTROL_ALLOW_METHODS, - HeaderValue::from_static("PUT, OPTIONS"), - ); - headers.insert( - header::ACCESS_CONTROL_ALLOW_HEADERS, - HeaderValue::from_static( - "Content-Type, Content-Length, Authorization, X-Fluxer-Features, X-Client-Context", - ), - ); - headers.insert( - header::ACCESS_CONTROL_EXPOSE_HEADERS, - HeaderValue::from_static("ETag, X-Fluxer-Version"), - ); -} - -fn text(status: StatusCode, body: &str) -> Response { - text_inner(status, body, None) -} - -fn text_with_source( - status: StatusCode, - body: &str, - code: &'static str, - source: impl std::fmt::Debug, -) -> Response { - text_inner(status, body, Some(ErrorReason::with_source(code, source))) -} - -fn text_with_reason(status: StatusCode, body: &str, code: &'static str) -> Response { - text_inner(status, body, Some(ErrorReason::new(code))) -} - -const ERROR_CACHE_CONTROL: &str = "no-store"; - -fn text_inner(status: StatusCode, body: &str, reason: Option) -> Response { - let mut response = Response::new(Body::from(body.to_owned())); - *response.status_mut() = status; - http_headers::add_security_headers(response.headers_mut()); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response.headers_mut().insert( - header::X_CONTENT_TYPE_OPTIONS, - HeaderValue::from_static("nosniff"), - ); - if status.is_client_error() || status.is_server_error() { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static(ERROR_CACHE_CONTROL), - ); - response - .extensions_mut() - .insert(reason.unwrap_or_else(|| ErrorReason::new(canonical_reason_str(status)))); - } - response -} - -fn canonical_reason_str(status: StatusCode) -> &'static str { - status.canonical_reason().unwrap_or("error") -} - -fn json_response(status: StatusCode, body: String) -> Response { - let mut response = Response::new(Body::from(body)); - *response.status_mut() = status; - http_headers::add_security_headers(response.headers_mut()); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/json"), - ); - if status.is_client_error() || status.is_server_error() { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static(ERROR_CACHE_CONTROL), - ); - } - response -} - -fn decode_storage_key(path: &str) -> String { - external_path::percent_decode_string(path.trim_start_matches('/'), false) -} - -fn bool_param(params: &HashMap, key: &str, default_value: bool) -> bool { - params - .get(key) - .map(|raw| raw.eq_ignore_ascii_case("true") || raw == "1") - .unwrap_or(default_value) -} - -fn animated_param(params: &HashMap, default_value: bool) -> bool { - params - .get("animated") - .map(|raw| raw.eq_ignore_ascii_case("true") || raw == "1") - .unwrap_or(default_value) -} - -fn explicit_output_format(params: &HashMap) -> Result, ()> { - let Some(raw) = params.get("format") else { - return Ok(None); - }; - AssetExtension::parse(raw).map(Some).ok_or(()) -} - -#[cfg(test)] -fn parse_dimension(raw: Option<&str>) -> Option { - raw.and_then(|v| v.parse::().ok()) - .filter(|v| *v > 0 && *v <= constants::Limits::image_dimension()) -} - -fn parse_optional_dimension_param( - params: &HashMap, - key: &str, -) -> Result, ()> { - let Some(raw) = params.get(key) else { - return Ok(None); - }; - if raw.is_empty() { - return Err(()); - } - let value = raw.parse::().map_err(|_| ())?; - if value == 0 || value > constants::Limits::image_dimension() { - return Err(()); - } - Ok(Some(value)) -} - -fn parse_effort(params: &HashMap) -> Option { - let raw = params.get("effort")?; - if raw.is_empty() { - return None; - } - raw.parse::().ok().map(|value| value.min(9)) -} - -fn deadline_instant(deadline_ms: Option) -> Option { - let deadline_ms = deadline_ms?; - let remaining_ms = deadline_ms.saturating_sub(metrics::now_ms()).max(0) as u64; - Some(Instant::now() + Duration::from_millis(remaining_ms)) -} - -fn effective_animated_image_output_format( - source_ext: Option, - requested_out_ext: AssetExtension, - animated: bool, -) -> AssetExtension { - if animated - && source_ext == Some(AssetExtension::Apng) - && requested_out_ext == AssetExtension::Png - { - return AssetExtension::Apng; - } - if animated - && source_ext == Some(AssetExtension::Gif) - && requested_out_ext == AssetExtension::Webp - { - return AssetExtension::Gif; - } - requested_out_ext -} - -fn default_transform_quality(format: AssetExtension, animated: bool, static_default: &str) -> &str { - if animated && format == AssetExtension::Webp { - "auto" - } else { - static_default - } -} - -fn transform_static_quality_default(source_ext: Option) -> &'static str { - if matches!( - source_ext, - Some(AssetExtension::Jpeg | AssetExtension::Heic | AssetExtension::Heif) - ) { - "high" - } else { - "lossless" - } -} - -fn is_v1_asset_manual_format(ext: AssetExtension) -> bool { - matches!( - ext, - AssetExtension::Png - | AssetExtension::Jpeg - | AssetExtension::Webp - | AssetExtension::Gif - | AssetExtension::Apng - | AssetExtension::Avif - ) -} - -fn asset_manual_format_override( - params: &HashMap, - url_ext: AssetExtension, -) -> Option { - let raw = params.get("format").or_else(|| params.get("fmt")); - if let Some(raw) = raw { - if raw.eq_ignore_ascii_case("auto") { - return None; - } - if let Some(parsed) = AssetExtension::parse(raw) - && is_v1_asset_manual_format(parsed) - { - return Some(parsed); - } - } - is_v1_asset_manual_format(url_ext).then_some(url_ext) -} - -fn asset_wants_animated(params: &HashMap, hash: &str) -> bool { - animated_param(params, asset_hash::has_animation_prefix(hash)) -} - -fn animated_image_request_can_use_original( - source_ext: AssetExtension, - explicit_out_ext: Option, - out_ext: AssetExtension, - width: Option, - height: Option, - animated: bool, -) -> bool { - if !animated || width.is_some() || height.is_some() { - return false; - } - if !matches!( - source_ext, - AssetExtension::Gif | AssetExtension::Webp | AssetExtension::Apng - ) { - return false; - } - let requested = explicit_out_ext.unwrap_or(out_ext); - requested == source_ext -} - -fn same_format_image_request_base_allows_original( - source_ext: AssetExtension, - explicit_out_ext: Option, - out_ext: AssetExtension, - has_quality: bool, - effort: Option, -) -> bool { - if effort.is_some() { - return false; - } - if !output_format::is_output_format_supported(source_ext) { - return false; - } - if out_ext != source_ext { - return false; - } - let _ = explicit_out_ext; - if has_quality && source_ext != AssetExtension::Gif { - return false; - } - true -} - -#[derive(Clone, Copy)] -struct OriginalImageRequest { - source_ext: Option, - explicit_out_ext: Option, - out_ext: AssetExtension, - width: Option, - height: Option, - has_quality: bool, - effort: Option, - animated: bool, -} - -fn same_format_loaded_image_request_can_use_original( - sniffed: mime::SniffInfo, - request: OriginalImageRequest, -) -> bool { - let Some(source_ext) = request.source_ext else { - return false; - }; - if animated_image_request_can_use_original( - source_ext, - request.explicit_out_ext, - request.out_ext, - request.width, - request.height, - request.animated, - ) { - return true; - } - if !same_format_image_request_base_allows_original( - source_ext, - request.explicit_out_ext, - request.out_ext, - request.has_quality, - request.effort, - ) { - return false; - } - if !(request.animated - || request.explicit_out_ext.is_some() - || request.has_quality - || request.width.is_some() - || request.height.is_some()) - { - return false; - } - if sniffed.width == 0 || sniffed.height == 0 { - return false; - } - if let Some(target_w) = request.width - && target_w < sniffed.width - { - return false; - } - if let Some(target_h) = request.height - && target_h < sniffed.height - { - return false; - } - true -} - -fn content_type_is_trustworthy(content_type: &str) -> bool { - if content_type.is_empty() { - return false; - } - if content_type.eq_ignore_ascii_case("application/octet-stream") { - return false; - } - matches!( - mime::category(content_type), - Some(mime::Category::Image | mime::Category::Video | mime::Category::Audio) - ) -} - -fn is_svg_content_type(content_type: &str) -> bool { - mime::normalize(Some(content_type)) - .is_some_and(|value| value.eq_ignore_ascii_case("image/svg+xml")) -} - -fn extension_from_mime(content_type: &str) -> Option { - match mime::normalize(Some(content_type))? { - "image/jpeg" => Some(AssetExtension::Jpeg), - "image/png" => Some(AssetExtension::Png), - "image/webp" => Some(AssetExtension::Webp), - "image/gif" => Some(AssetExtension::Gif), - "image/apng" => Some(AssetExtension::Apng), - "image/avif" => Some(AssetExtension::Avif), - "image/heic" => Some(AssetExtension::Heic), - "image/heif" => Some(AssetExtension::Heif), - "image/jxl" => Some(AssetExtension::Jxl), - "image/svg+xml" => Some(AssetExtension::Svg), - _ => None, - } -} - -fn image_extension_from_filename(filename: &str) -> Option { - AssetExtension::parse(extension_of(filename)?) -} - -fn external_default_output_extension(filename: &str, content_type: &str) -> AssetExtension { - extension_from_mime(content_type) - .or_else(|| image_extension_from_filename(filename)) - .unwrap_or(AssetExtension::Webp) -} - -fn transform_response_content_type( - explicit_out_ext: Option, - requested_out_ext: AssetExtension, - out_ext: AssetExtension, - fallback_content_type: &str, -) -> &str { - if explicit_out_ext.is_some() - || out_ext != requested_out_ext - || is_svg_content_type(fallback_content_type) - { - out_ext.mime() - } else { - fallback_content_type - } -} - -struct TransformCacheKeyInput<'a> { - route: TransformRoute, - cache_identity: &'a str, - width: Option, - height: Option, - format: AssetExtension, - quality: &'a str, - animated: bool, - effort: Option, -} - -fn asset_transform_cache_key( - storage_key: &str, - size: Option, - out_ext: AssetExtension, - quality: &str, - animated: bool, -) -> String { - format!( - "asset:{storage_key}:{}:{}:{quality}:{animated}", - size.unwrap_or(0), - out_ext.name() - ) -} - -fn transform_cache_key(input: TransformCacheKeyInput<'_>) -> String { - let prefix = match input.route { - TransformRoute::Attachment => "attachment", - TransformRoute::External => "external", - TransformRoute::Stored => "stored", - }; - let identity = match input.route { - TransformRoute::Attachment | TransformRoute::Stored => input.cache_identity.to_owned(), - TransformRoute::External => sha256_hex(input.cache_identity.as_bytes()), - }; - format!( - "{prefix}:{identity}|w={}|h={}|fmt={}|q={}|anim={}|effort={}", - input.width.unwrap_or(0), - input.height.unwrap_or(0), - input.format.name(), - input.quality, - input.animated, - input.effort.unwrap_or(255), - ) -} - -fn sha256_hex(data: &[u8]) -> String { - hex::encode(Sha256::digest(data)) -} - -fn last_segment(value: &str) -> &str { - value - .rsplit('/') - .next() - .filter(|s| !s.is_empty()) - .unwrap_or("file.bin") -} - -fn filename_from_storage_key(key: &str) -> &str { - last_segment(key) -} - -fn strip_query_fragment(value: &str) -> &str { - let query = value.find('?').unwrap_or(value.len()); - let fragment = value.find('#').unwrap_or(value.len()); - &value[..query.min(fragment)] -} - -fn url_filename(url: &str) -> String { - let clean = strip_query_fragment(url); - let filename = last_segment(clean); - if filename.is_empty() { - "external.bin".to_owned() - } else { - filename.to_owned() - } -} - -fn extension_of(filename: &str) -> Option<&str> { - filename.rsplit_once('.').map(|(_, ext)| ext) -} - -fn content_disposition_header( - content_type: &str, - requested_download: bool, - filename: Option<&str>, -) -> String { - let filename = filename - .map(|name| download_filename_for_content_type(name, content_type, requested_download)); - disposition::format_header( - disposition::decide(content_type, requested_download), - filename.as_deref(), - ) -} - -fn download_filename_for_content_type<'a>( - filename: &'a str, - content_type: &str, - requested_download: bool, -) -> Cow<'a, str> { - if !requested_download || filename.is_empty() { - return Cow::Borrowed(filename); - } - let Some(expected_ext) = extension_from_mime(content_type) else { - return Cow::Borrowed(filename); - }; - if image_extension_from_filename(filename) == Some(expected_ext) { - return Cow::Borrowed(filename); - } - let ext = expected_ext.name(); - let Some((stem, _)) = filename.rsplit_once('.') else { - return Cow::Owned(format!("{filename}.{ext}")); - }; - if stem.is_empty() { - Cow::Owned(format!("{filename}.{ext}")) - } else { - Cow::Owned(format!("{stem}.{ext}")) - } -} - -fn nsfw_config(app: &AppState) -> crate::nsfw::Config { - crate::nsfw::Config { - endpoint: app.cfg.nsfw_service_endpoint.clone(), - threshold: app.cfg.nsfw_threshold, - timeout_ms: 5_000, - connect_timeout_ms: 1_500, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use base64::engine::general_purpose::STANDARD; - - fn test_peer(value: &str) -> SocketAddr { - value.parse().expect("valid socket address") - } - - #[test] - fn metrics_guard_accepts_loopback_peers() { - assert!(is_loopback_peer(&test_peer("127.0.0.1:5000"))); - assert!(is_loopback_peer(&test_peer("127.0.0.2:5000"))); - assert!(is_loopback_peer(&test_peer("[::1]:5000"))); - assert!(is_loopback_peer(&test_peer("[::ffff:127.0.0.1]:5000"))); - } - - #[test] - fn metrics_guard_rejects_remote_peers() { - assert!(!is_loopback_peer(&test_peer("8.8.8.8:5000"))); - assert!(!is_loopback_peer(&test_peer("10.0.0.5:5000"))); - assert!(!is_loopback_peer(&test_peer("172.18.0.4:5000"))); - assert!(!is_loopback_peer(&test_peer("[fe80::1]:5000"))); - assert!(!is_loopback_peer(&test_peer("[::ffff:8.8.8.8]:5000"))); - } - - fn avatar_cache_key_for_requested_size(raw: &str) -> String { - let size = constants::parse_image_size(Some(raw)); - let selected = output_format::select_url_variant(output_format::Input { - kind: AssetKind::Avatar, - original: AssetExtension::Webp, - requested_size: Some(size), - manual_format_override: None, - }); - asset_transform_cache_key( - "avatars/852813040100737024/hash", - selected.size, - selected.format, - "high", - false, - ) - } - - #[test] - fn lossy_source_transform_defaults_to_lossy_output() { - for lossy in [ - AssetExtension::Jpeg, - AssetExtension::Heic, - AssetExtension::Heif, - ] { - assert_eq!( - "high", - default_transform_quality( - AssetExtension::Webp, - false, - transform_static_quality_default(Some(lossy)) - ), - "{} source must not be re-encoded losslessly", - lossy.name() - ); - } - for lossless in [ - AssetExtension::Png, - AssetExtension::Apng, - AssetExtension::Gif, - AssetExtension::Avif, - AssetExtension::Webp, - ] { - assert_eq!( - "lossless", - default_transform_quality( - AssetExtension::Webp, - false, - transform_static_quality_default(Some(lossless)) - ), - "{} source must keep the lossless default", - lossless.name() - ); - } - assert_eq!( - "lossless", - default_transform_quality( - AssetExtension::Webp, - false, - transform_static_quality_default(None) - ) - ); - assert_eq!( - "auto", - default_transform_quality( - AssetExtension::Webp, - true, - transform_static_quality_default(Some(AssetExtension::Jpeg)) - ) - ); - } - const TRANSFORM_FIXTURE_JPEG_B64: &str = "/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wAARCAAbADADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwCLyfakMB9Kd/a8B+7Y3B/Kj+1rcplrO5B9lFe77VHgcnmQtD61XeP2q3PqlnHCZPs9ycYBAjB5P44qtPqUC3QgjtpZcjkqORxnp/8AXqfbw7lqjNq5WeP2qB4TV5b2OQgfY5F3EhS5Cg468n60yxheaaR1QGFizHL8/gOKmWJhFlRoSktC0tlcEZa5Ix7mmTw3W1Y4b2NVByxOQW+pweKLm6mjVtj4x04Bq/YSvPp8MkjbmYHJ6ZrzalRuNzWlT5XcoxWZkRXkkEjR/wDPLkdf7vrU7Wz6ltaUynyycK4Ix71fUc4ycD3rL8TzyxaJIsblQ7qjY7g9axdRvQ6Iwv1Kt/qul6aDGG+0SLwUiIIGfU9PyrJHiooT5dhGsZ52lufrWckafZAdozj/ABqrdHBwOwFPmbNY0oLof//Z"; - const TRANSFORM_FIXTURE_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwEAIAAACI8LKTAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAGYktHRP///////wlY99wAAAAHdElNRQfqCBUDKiB6/yyRAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI2LTA4LTIxVDAzOjQyOjMyKzAwOjAwHW75GgAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNi0wOC0yMVQwMzo0MjozMiswMDowMGwzQaYAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjYtMDgtMjFUMDM6NDI6MzIrMDA6MDA7JmB5AAABR0lEQVRo3u3boRYBURCA4X/O2RUoyhYKRaYoZF3SPQLPsd5Al3SZopAVCmWLQlmBJ1gTZ8J8T/CfPbNz94aV75fwR/JZQbqwzvBL3i2oP6wz/JLnBpoz6wy/pBhCdrTO8Evue2iPrTP8ktsUOlvrDL/kcoVe1zrDrzjmFXIqYZBaZ/iVfHJIl9YZfskhg1FhneFXUq6gFjuokuz6MDlbZ/gVE6SIY16RlDnU4hSrFBOkkPUL5g3rDL9ighTxgBSxpBUxQYr4UFTEbV4RE6SIHaSIU0wRE6SQeQPWL+sMv+IVU8QrpogJUsQEKWRyhl3fOsOvuGooZFTAIbPO8Ct2kEIGKZxK6wy/4javkF4XLlfrDL+ks4Xb1DrDL2mP4b63zvBLsiMUQ+sMv6Q5g+fGOsMvqT/g3bLO8EvSBZS5dYZfAhC/RFX7AbalYE7qXJRZAAAAAElFTkSuQmCC"; - const TRANSFORM_FIXTURE_MP4_B64: &str = "AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAMybW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAAMgAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAlx0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAAMgAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAAAwAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAADIAAAAAAABAAAAAAHUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAACABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABf21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAT9zdGJsAAAAv3N0c2QAAAAAAAAAAQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAMABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UR7ARAAAAMAEAAAAwFA8SJZYAEABmjr4OSyLP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAACT0AAAAAAAAAAYc3R0cwAAAAAAAAABAAAAAgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAABxzdHNjAAAAAAAAAAEAAAABAAAAAgAAAAEAAAAcc3RzegAAAAAAAAAAAAAAAgAAA6YAAAAMAAAAFHN0Y28AAAAAAAAAAQAAA2IAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjYyLjEyLjEwMgAAAAhmcmVlAAADum1kYXQAAAKuBgX//6rcRem95tlIt5Ys2CDZI+7veDI2NCAtIGNvcmUgMTY1IHIzMjIyIGIzNTYwNWEgLSBILjI2NC9NUEVHLTQgQVZDIGNvZGVjIC0gQ29weWxlZnQgMjAwMy0yMDI1IC0gaHR0cDovL3d3dy52aWRlb2xhbi5vcmcveDI2NC5odG1sIC0gb3B0aW9uczogY2FiYWM9MSByZWY9MyBkZWJsb2NrPTE6MDowIGFuYWx5c2U9MHgzOjB4MTEzIG1lPWhleCBzdWJtZT03IHBzeT0xIHBzeV9yZD0xLjAwOjAuMDAgbWl4ZWRfcmVmPTEgbWVfcmFuZ2U9MTYgY2hyb21hX21lPTEgdHJlbGxpcz0xIDh4OGRjdD0xIGNxbT0wIGRlYWR6b25lPTIxLDExIGZhc3RfcHNraXA9MSBjaHJvbWFfcXBfb2Zmc2V0PS0yIHRocmVhZHM9MSBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD0xIGJfYmlhcz0wIGRpcmVjdD0xIHdlaWdodGI9MSBvcGVuX2dvcD0wIHdlaWdodHA9MiBrZXlpbnQ9MjUwIGtleWludF9taW49MTAgc2NlbmVjdXQ9NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD00MCByYz1jcmYgbWJ0cmVlPTEgY3JmPTQwLjAgcWNvbXA9MC42MCBxcG1pbj0wIHFwbWF4PTY5IHFwc3RlcD00IGlwX3JhdGlvPTEuNDAgYXE9MToxLjAwAIAAAADwZYiEAHfOEV8JdDdl6J63yJem6w2D7J9l+nMGcbbyHNpf1RSO+5/egDVWmxn+lFqcntWl2ur6nHe18p73gwfozgPsGKPdA2PIl5OfSx5aTLti3+xINRFg0nFB2J/3DMbcRNRkylQAh0s9Xkm4GU3dOB4ec/yLiY8QloMlsc6H424xJ0LOG/4eoCdscXPOq48R85joaWOcYikCoSmgRa+SbMvql5Q4lBojSvTes7zId5gUKA53scjIpt43LUoIrxlJ4+z2aGeBg4bQRBGyKeyT/PHbUUn/+akrQTU1isl1/vfCvWc29OAaPcKn+qH6pjyjAAAACEGaIWxCX4GA"; - - async fn transform_qualities_reaching_the_encoder( - fixture_b64: &str, - content_type: &str, - filename: &str, - ) -> Vec { - let tmp = tempfile::tempdir().unwrap(); - let cfg = - upload_relay_test_config(tmp.path(), tmp.path(), b"01234567890123456789012345678901"); - let app = test_app_state(cfg); - let params = HashMap::from([ - ("format".to_owned(), "webp".to_owned()), - ("width".to_owned(), "32".to_owned()), - ]); - let headers = HeaderMap::new(); - let response = serve_bytes_or_transform( - &app, - ServeBytesRequest { - method: Method::GET, - data: Bytes::from(STANDARD.decode(fixture_b64).unwrap()), - content_type: content_type.to_owned(), - cache_identity: filename, - filename, - route: TransformRoute::Attachment, - params: ¶ms, - headers: &headers, - }, - ) - .await; - assert_eq!( - StatusCode::OK, - response.status(), - "{filename} did not reach the transform path" - ); - ["lossless", "high", "auto", "low"] - .into_iter() - .filter(|quality| { - app.transform_cache - .get(&transform_cache_key(TransformCacheKeyInput { - route: TransformRoute::Attachment, - cache_identity: filename, - width: Some(32), - height: None, - format: AssetExtension::Webp, - quality, - animated: false, - effort: None, - })) - .is_some() - }) - .map(str::to_owned) - .collect() - } - - #[tokio::test] - async fn image_transform_route_sends_lossy_sources_to_a_lossy_encode() { - assert_eq!( - vec!["high".to_owned()], - transform_qualities_reaching_the_encoder( - TRANSFORM_FIXTURE_JPEG_B64, - "image/jpeg", - "photo.jpg" - ) - .await, - "a jpeg attachment must not be re-encoded losslessly" - ); - assert_eq!( - vec!["lossless".to_owned()], - transform_qualities_reaching_the_encoder( - TRANSFORM_FIXTURE_PNG_B64, - "image/png", - "art.png" - ) - .await, - "a png attachment must keep the lossless encode" - ); - } - - #[tokio::test] - async fn video_poster_transform_route_sends_decoded_frames_to_a_lossy_encode() { - assert_eq!( - vec!["high".to_owned()], - transform_qualities_reaching_the_encoder( - TRANSFORM_FIXTURE_MP4_B64, - "video/mp4", - "clip.mp4" - ) - .await, - "a decoded video frame is already lossy and must not be re-encoded losslessly" - ); - } - - #[test] - fn requested_sizes_off_the_ladder_share_the_cache_key_of_the_rung_they_snap_to() { - let canonical = avatar_cache_key_for_requested_size("1024"); - assert_eq!( - "asset:avatars/852813040100737024/hash:1024:webp:high:false", - canonical - ); - for raw in ["641", "700", "1000", "1023", "1024"] { - assert_eq!( - canonical, - avatar_cache_key_for_requested_size(raw), - "size={raw} minted a second cache key" - ); - } - let floor = avatar_cache_key_for_requested_size("128"); - assert_eq!( - "asset:avatars/852813040100737024/hash:128:webp:high:false", - floor - ); - for raw in ["1", "17", "20", "100", "128"] { - assert_eq!( - floor, - avatar_cache_key_for_requested_size(raw), - "size={raw} minted a second cache key" - ); - } - } - - #[test] - fn requested_sizes_on_different_rungs_keep_distinct_cache_keys() { - assert_ne!( - avatar_cache_key_for_requested_size("300"), - avatar_cache_key_for_requested_size("512") - ); - assert_ne!( - avatar_cache_key_for_requested_size("300"), - avatar_cache_key_for_requested_size("1000") - ); - } - - async fn robots_header_for(mode: DeploymentMode) -> Option { - use axum::{body::Body, http::Request, routing::get}; - let router = Router::new() - .route( - "/probe", - get(|| async { - let mut response = Response::new(Body::empty()); - http_headers::add_media_headers(response.headers_mut(), 0, "text/plain", None); - response - }), - ) - .layer(middleware::from_fn_with_state( - mode, - add_security_header_middleware, - )); - let response = tower::ServiceExt::oneshot( - router, - Request::builder() - .uri("/probe") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - response - .headers() - .get("X-Robots-Tag") - .map(|v| v.to_str().unwrap().to_owned()) - } - - #[tokio::test] - async fn static_mode_does_not_set_robots_tag() { - assert_eq!(robots_header_for(DeploymentMode::Static).await, None); - } - - #[tokio::test] - async fn media_and_upload_modes_still_set_robots_tag() { - assert_eq!( - robots_header_for(DeploymentMode::Mp).await.as_deref(), - Some(http_headers::ROBOTS) - ); - assert_eq!( - robots_header_for(DeploymentMode::Upload).await.as_deref(), - Some(http_headers::ROBOTS) - ); - } - - fn upload_relay_test_config( - storage_root: &std::path::Path, - spool_dir: &std::path::Path, - relay_secret: &[u8], - ) -> Config { - Config::load_from_iter([ - ( - "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), - "secret".to_owned(), - ), - ("FLUXER_MEDIA_PROXY_MODE".to_owned(), "upload".to_owned()), - ( - "FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(), - "local".to_owned(), - ), - ( - "FLUXER_MEDIA_PROXY_STORAGE_ROOT".to_owned(), - storage_root.display().to_string(), - ), - ( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64".to_owned(), - base64::Engine::encode(&STANDARD, relay_secret), - ), - ( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES".to_owned(), - "4096".to_owned(), - ), - ( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR".to_owned(), - spool_dir.display().to_string(), - ), - ( - "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES".to_owned(), - (1u64 << 30).to_string(), - ), - ]) - .unwrap() - } - - fn test_app_state(cfg: Config) -> Arc { - Arc::new(AppState { - store: Store::new(cfg.clone()), - client: http_client::build_default(), - nsfw_client: reqwest::Client::new(), - transform_cache: Arc::new(Cache::new( - cfg.transform_cache_capacity_bytes, - cfg.transform_cache_max_entry_bytes, - cfg.transform_cache_ttl_ms, - )), - external_hints: new_external_hint_cache(&cfg), - coalescer: Arc::new(ByteCoalescer::new()), - native_transform_admissions: TimedSemaphore::new(transform_admission_capacity(&cfg)), - native_transforms: TimedSemaphore::new(cfg.max_native_transforms), - cfg, - }) - } - - fn static_mode_test_config(storage_root: &std::path::Path) -> Config { - Config::load_from_iter([ - ( - "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), - "secret".to_owned(), - ), - ("FLUXER_MEDIA_PROXY_MODE".to_owned(), "static".to_owned()), - ( - "FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(), - "local".to_owned(), - ), - ( - "FLUXER_MEDIA_PROXY_STORAGE_ROOT".to_owned(), - storage_root.display().to_string(), - ), - ]) - .unwrap() - } - - async fn static_mode_cache_control( - key: &str, - body: &[u8], - content_type: &str, - ) -> Option { - use axum::{body::Body, http::Request}; - let tmp = tempfile::tempdir().unwrap(); - let cfg = static_mode_test_config(&tmp.path().canonicalize().unwrap()); - let bucket = cfg.bucket_static.clone(); - let state = test_app_state(cfg); - state - .store - .write_object(&bucket, key, body, content_type) - .await - .unwrap(); - let router = Router::new() - .fallback(any(catch_all)) - .with_state(Arc::clone(&state)); - let response = tower::ServiceExt::oneshot( - router, - Request::builder() - .uri(format!("/{key}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(StatusCode::OK, response.status(), "key={key}"); - response - .headers() - .get(header::CACHE_CONTROL) - .map(|value| value.to_str().unwrap().to_owned()) - } - - #[tokio::test] - async fn static_mode_caches_every_asset_forever() { - assert_eq!( - Some("public, max-age=31536000".to_owned()), - static_mode_cache_control("avatars/0.png", b"\x89PNG\r\n\x1a\n", "image/png").await - ); - assert_eq!( - Some("public, max-age=31536000".to_owned()), - static_mode_cache_control("web/favicon.ico", b"icon", "image/x-icon").await - ); - assert_eq!( - Some("public, max-age=31536000".to_owned()), - static_mode_cache_control("emoji/1f600.svg", b"", "image/svg+xml").await - ); - assert_eq!( - Some("public, max-age=31536000".to_owned()), - static_mode_cache_control("web/NOTICE.md", b"notice", "text/markdown").await - ); - } - - #[tokio::test] - async fn static_mode_omits_expires_and_relies_on_cache_control() { - use axum::{body::Body, http::Request}; - let tmp = tempfile::tempdir().unwrap(); - let cfg = static_mode_test_config(&tmp.path().canonicalize().unwrap()); - let bucket = cfg.bucket_static.clone(); - let state = test_app_state(cfg); - state - .store - .write_object(&bucket, "avatars/0.png", b"png", "image/png") - .await - .unwrap(); - let router = Router::new() - .fallback(any(catch_all)) - .with_state(Arc::clone(&state)); - let response = tower::ServiceExt::oneshot( - router, - Request::builder() - .uri("/avatars/0.png") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(StatusCode::OK, response.status()); - assert!(response.headers().get(header::EXPIRES).is_none()); - assert_eq!( - "public, max-age=31536000", - response.headers().get("CDN-Cache-Control").unwrap() - ); - } - - #[test] - fn entrance_sound_path_parses_valid_keys() { - assert_eq!( - parse_entrance_sound_path("/entrance-sounds/1130650140672000000/eb417d05ad2e14c4.wav"), - Some("entrance-sounds/1130650140672000000/eb417d05ad2e14c4.wav".to_owned()) - ); - for ext in ["mp3", "ogg", "m4a", "wav"] { - assert_eq!( - parse_entrance_sound_path(&format!("/entrance-sounds/42/abc123.{ext}")), - Some(format!("entrance-sounds/42/abc123.{ext}")) - ); - } - } - - #[test] - fn entrance_sound_path_rejects_invalid_keys() { - assert_eq!( - parse_entrance_sound_path("/entrance-sounds/42/abc.flac"), - None - ); - assert_eq!( - parse_entrance_sound_path("/entrance-sounds/abc/abc.wav"), - None - ); - assert_eq!( - parse_entrance_sound_path("/entrance-sounds/42/abc.wav/x"), - None - ); - assert_eq!( - parse_entrance_sound_path("/entrance-sounds/42/../secret.wav"), - None - ); - assert_eq!(parse_entrance_sound_path("/entrance-sounds/42"), None); - assert_eq!(parse_entrance_sound_path("/entrance-sounds//abc.wav"), None); - assert_eq!(parse_entrance_sound_path("/avatars/42/abc.wav"), None); - } - - #[test] - fn internal_auth_uses_bearer_secret() { - let mut headers = HeaderMap::new(); - headers.insert( - header::AUTHORIZATION, - HeaderValue::from_static("Bearer secret"), - ); - assert!(check_internal_auth(&headers, "secret")); - assert!(!check_internal_auth(&headers, "other")); - } - - #[test] - fn dimensions_are_bounded() { - assert_eq!(Some(128), parse_dimension(Some("128"))); - assert_eq!(None, parse_dimension(Some("0"))); - assert_eq!(None, parse_dimension(Some("999999999"))); - } - - #[test] - fn metadata_base64_svg_detection_uses_bytes_or_filename() { - let svg_bytes = InputData { - data: Bytes::from_static(br#""#), - filename: "upload.bin".to_owned(), - }; - assert!(metadata_input_is_svg(&svg_bytes)); - - let svg_filename = InputData { - data: Bytes::from_static(b"not svg"), - filename: "icons/logo.svg".to_owned(), - }; - assert!(metadata_input_is_svg(&svg_filename)); - - let png_filename = InputData { - data: Bytes::from_static(b"not svg"), - filename: "icons/logo.png".to_owned(), - }; - assert!(!metadata_input_is_svg(&png_filename)); - } - - #[test] - fn replace_image_extension_only_changes_last_path_segment() { - assert_eq!( - "avatars/user.icon.webp", - replace_image_extension("avatars/user.icon.svg", AssetExtension::Webp) - ); - assert_eq!( - "avatars.v1/user.webp", - replace_image_extension("avatars.v1/user", AssetExtension::Webp) - ); - } - - #[tokio::test] - async fn metadata_base64_svg_rasterizes_to_webp_bytes() { - let tmp = tempfile::tempdir().unwrap(); - let cfg = - upload_relay_test_config(tmp.path(), tmp.path(), b"01234567890123456789012345678901"); - let app = test_app_state(cfg); - let input = InputData { - data: Bytes::from_static( - br#""#, - ), - filename: "icons/logo.svg".to_owned(), - }; - let raster = match rasterize_metadata_svg(&app, input).await { - Ok(raster) => raster, - Err(response) => panic!("unexpected status {}", response.status()), - }; - - assert_eq!("icons/logo.webp", raster.filename); - assert_eq!("image/webp", mime::sniff(&raster.data).mime); - } - - type PassthroughOriginRequests = Arc)>>>; - - fn parse_origin_range(raw: &str) -> Option<(usize, usize)> { - let spec = raw.strip_prefix("bytes=")?; - let (start, end) = spec.split_once('-')?; - Some((start.parse().ok()?, end.parse().ok()?)) - } - - async fn passthrough_origin( - body: &'static [u8], - answer_416_with_total: bool, - ) -> (String, PassthroughOriginRequests) { - use axum::body::Body; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let seen: PassthroughOriginRequests = Arc::new(tokio::sync::Mutex::new(Vec::new())); - let handler = Arc::clone(&seen); - let router = Router::new().fallback(any(move |request: axum::extract::Request| { - let seen = Arc::clone(&handler); - async move { - let (parts, _body) = request.into_parts(); - let range = parts - .headers - .get(header::RANGE) - .and_then(|value| value.to_str().ok()) - .map(ToOwned::to_owned); - seen.lock().await.push((parts.method, range.clone())); - let total = body.len(); - let mut response = Response::new(Body::empty()); - response - .headers_mut() - .insert(header::CONTENT_TYPE, HeaderValue::from_static("image/png")); - match range.as_deref().and_then(parse_origin_range) { - None => { - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(total)); - *response.body_mut() = Body::from(body); - } - Some((start, end)) if start < total => { - let end = end.min(total - 1); - *response.status_mut() = StatusCode::PARTIAL_CONTENT; - response.headers_mut().insert( - header::CONTENT_RANGE, - HeaderValue::from_str(&format!("bytes {start}-{end}/{total}")).unwrap(), - ); - response - .headers_mut() - .insert(header::CONTENT_LENGTH, HeaderValue::from(end - start + 1)); - *response.body_mut() = Body::from(&body[start..=end]); - } - Some(_) => { - *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; - if answer_416_with_total { - response.headers_mut().insert( - header::CONTENT_RANGE, - HeaderValue::from_str(&format!("bytes */{total}")).unwrap(), - ); - } - } - } - response - } - })); - tokio::spawn(async move { - axum::serve(listener, router).await.unwrap(); - }); - (format!("http://{addr}"), seen) - } - - fn s3_mode_test_config(endpoint: &str) -> Config { - Config::load_from_iter([ - ( - "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), - "secret".to_owned(), - ), - ("FLUXER_MEDIA_PROXY_MODE".to_owned(), "mp".to_owned()), - ( - "FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(), - "s3".to_owned(), - ), - ("FLUXER_S3_ENDPOINT".to_owned(), endpoint.to_owned()), - ( - "FLUXER_S3_ACCESS_KEY_ID".to_owned(), - "AKIAIOSFODNN7EXAMPLE".to_owned(), - ), - ( - "FLUXER_S3_SECRET_ACCESS_KEY".to_owned(), - "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_owned(), - ), - ]) - .unwrap() - } - - async fn passthrough_request(cfg: Config, range: Option<&str>) -> Response { - use axum::{body::Body, http::Request}; - let state = test_app_state(cfg); - let router = Router::new() - .fallback(any(catch_all)) - .with_state(Arc::clone(&state)); - let mut request = Request::builder().uri("/attachments/1/2/pic.png"); - if let Some(range) = range { - request = request.header(header::RANGE, range); - } - tower::ServiceExt::oneshot(router, request.body(Body::empty()).unwrap()) - .await - .unwrap() - } - - #[tokio::test] - async fn passthrough_get_serves_the_whole_object_in_one_round_trip() { - let (endpoint, seen) = passthrough_origin(b"0123456789", true).await; - let response = passthrough_request(s3_mode_test_config(&endpoint), None).await; - - assert_eq!(StatusCode::OK, response.status()); - assert_eq!( - "10", - response.headers().get(header::CONTENT_LENGTH).unwrap() - ); - assert_eq!( - "image/png", - response.headers().get(header::CONTENT_TYPE).unwrap() - ); - assert!(response.headers().get(header::CONTENT_RANGE).is_none()); - let body = axum::body::to_bytes(response.into_body(), 64) - .await - .unwrap(); - assert_eq!(b"0123456789", &body[..]); - assert_eq!(vec![(Method::GET, None)], seen.lock().await.clone()); - } - - #[tokio::test] - async fn passthrough_ranged_get_reuses_the_upstream_content_range() { - let (endpoint, seen) = passthrough_origin(b"0123456789", true).await; - let response = passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=2-5")).await; - - assert_eq!(StatusCode::PARTIAL_CONTENT, response.status()); - assert_eq!( - "bytes 2-5/10", - response.headers().get(header::CONTENT_RANGE).unwrap() - ); - assert_eq!("4", response.headers().get(header::CONTENT_LENGTH).unwrap()); - let body = axum::body::to_bytes(response.into_body(), 64) - .await - .unwrap(); - assert_eq!(b"2345", &body[..]); - assert_eq!( - vec![(Method::GET, Some("bytes=2-5".to_owned()))], - seen.lock().await.clone() - ); - } - - #[tokio::test] - async fn passthrough_unsatisfiable_range_reuses_the_upstream_total() { - let (endpoint, seen) = passthrough_origin(b"0123456789", true).await; - let response = - passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=20-30")).await; - - assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, response.status()); - assert_eq!( - "bytes */10", - response.headers().get(header::CONTENT_RANGE).unwrap() - ); - assert_eq!( - vec![(Method::GET, Some("bytes=20-30".to_owned()))], - seen.lock().await.clone() - ); - } - - #[tokio::test] - async fn passthrough_falls_back_to_a_head_when_the_upstream_416_omits_the_total() { - let (endpoint, seen) = passthrough_origin(b"0123456789", false).await; - let response = - passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=20-30")).await; - - assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, response.status()); - assert_eq!( - "bytes */10", - response.headers().get(header::CONTENT_RANGE).unwrap() - ); - assert_eq!( - vec![ - (Method::GET, Some("bytes=20-30".to_owned())), - (Method::HEAD, None) - ], - seen.lock().await.clone() - ); - } - - #[tokio::test] - async fn passthrough_ignores_a_malformed_range_instead_of_forwarding_it() { - let (endpoint, seen) = passthrough_origin(b"0123456789", true).await; - let response = - passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=abc-def")).await; - - assert_eq!(StatusCode::OK, response.status()); - assert_eq!(vec![(Method::GET, None)], seen.lock().await.clone()); - } - - #[tokio::test] - async fn passthrough_answers_a_reversed_range_without_asking_for_the_body() { - let (endpoint, seen) = passthrough_origin(b"0123456789", true).await; - let response = - passthrough_request(s3_mode_test_config(&endpoint), Some("bytes=10-5")).await; - - assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, response.status()); - assert_eq!( - "bytes */10", - response.headers().get(header::CONTENT_RANGE).unwrap() - ); - assert_eq!(vec![(Method::HEAD, None)], seen.lock().await.clone()); - } - - #[tokio::test] - async fn passthrough_get_never_touches_the_write_endpoint() { - let (origin, origin_seen) = passthrough_origin(b"0123456789", true).await; - let (read, read_seen) = passthrough_origin(b"0123456789", true).await; - let mut cfg = s3_mode_test_config(&origin); - cfg.s3_read_endpoint = Some(read); - let response = passthrough_request(cfg, None).await; - - assert_eq!(StatusCode::OK, response.status()); - assert!(origin_seen.lock().await.is_empty()); - assert_eq!(vec![(Method::GET, None)], read_seen.lock().await.clone()); - } - - #[tokio::test] - async fn passthrough_head_stays_a_single_round_trip() { - use axum::{body::Body, http::Request}; - let (endpoint, seen) = passthrough_origin(b"0123456789", true).await; - let state = test_app_state(s3_mode_test_config(&endpoint)); - let router = Router::new() - .fallback(any(catch_all)) - .with_state(Arc::clone(&state)); - let response = tower::ServiceExt::oneshot( - router, - Request::builder() - .method(Method::HEAD) - .uri("/attachments/1/2/pic.png") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - - assert_eq!(StatusCode::OK, response.status()); - assert_eq!( - "10", - response.headers().get(header::CONTENT_LENGTH).unwrap() - ); - assert_eq!(vec![(Method::HEAD, None)], seen.lock().await.clone()); - } - - #[test] - fn passthrough_content_type_preserves_non_media_metadata() { - assert_eq!( - "application/zip", - passthrough_content_type("application/zip", "downloads/app.zip") - ); - } - - #[test] - fn passthrough_content_type_prefers_known_extension_over_bad_metadata() { - assert_eq!( - "image/png", - passthrough_content_type("text/plain", "image.png") - ); - } - - #[test] - fn passthrough_content_type_prefers_m4a_extension_over_mp4_metadata() { - assert_eq!( - "audio/mp4", - passthrough_content_type("video/mp4", "track.m4a") - ); - } - - #[test] - fn asset_size_query_is_clamped_by_kind() { - let params = HashMap::from([("size".to_owned(), "4096".to_owned())]); - let size = constants::parse_image_size(params.get("size").map(String::as_str)); - let selected = output_format::select_url_variant(output_format::Input { - kind: AssetKind::Avatar, - original: AssetExtension::Webp, - requested_size: Some(size), - manual_format_override: asset_manual_format_override(¶ms, AssetExtension::Webp), - }); - assert_eq!(Some(1024), selected.size); - } - - #[test] - fn standard_asset_path_strips_virtual_animation_prefix_and_extension() { - let parsed = - parse_standard_asset_path("/avatars/1216100949629702144/a_d2f35261.webp").unwrap(); - assert_eq!("avatars/1216100949629702144/d2f35261", parsed.storage_key); - assert_eq!(AssetExtension::Webp, parsed.original_ext); - assert_eq!(AssetKind::Avatar, parsed.kind); - assert!(asset_wants_animated(&HashMap::new(), &parsed.hash)); - assert!(!asset_wants_animated( - &HashMap::from([("animated".to_owned(), "false".to_owned())]), - &parsed.hash - )); - } - - #[test] - fn guild_member_and_simple_asset_paths_match_v1_storage_keys() { - let guild = - parse_guild_member_asset_path("/guilds/1/users/2/avatars/a_memberhash.gif").unwrap(); - assert_eq!("guilds/1/users/2/avatars/memberhash", guild.storage_key); - assert_eq!(AssetKind::Avatar, guild.kind); - assert_eq!(AssetExtension::Gif, guild.original_ext); - assert!(guild.forced_output_format.is_none()); - - let emoji = - parse_simple_asset_path("/emojis/1501314428688998182.webp", AssetKind::Emoji).unwrap(); - assert_eq!("emojis/1501314428688998182", emoji.storage_key); - assert_eq!(AssetKind::Emoji, emoji.kind); - assert!(emoji.forced_output_format.is_none()); - - let sticker = - parse_simple_asset_path("/stickers/1501314428688998182.png", AssetKind::Sticker) - .unwrap(); - assert_eq!("stickers/1501314428688998182", sticker.storage_key); - assert_eq!(Some(AssetExtension::Webp), sticker.forced_output_format); - } - - #[test] - fn asset_manual_format_override_is_v1_compatible() { - assert_eq!( - Some(AssetExtension::Webp), - asset_manual_format_override(&HashMap::new(), AssetExtension::Webp) - ); - assert_eq!( - None, - asset_manual_format_override( - &HashMap::from([("format".to_owned(), "auto".to_owned())]), - AssetExtension::Webp - ) - ); - assert_eq!( - Some(AssetExtension::Png), - asset_manual_format_override( - &HashMap::from([("fmt".to_owned(), "png".to_owned())]), - AssetExtension::Webp - ) - ); - assert_eq!( - Some(AssetExtension::Webp), - asset_manual_format_override( - &HashMap::from([("format".to_owned(), "svg".to_owned())]), - AssetExtension::Webp - ) - ); - } - - #[test] - fn text_responses_set_nosniff_header() { - let response = text(StatusCode::NOT_FOUND, "Not Found"); - assert_eq!( - "nosniff", - response - .headers() - .get(header::X_CONTENT_TYPE_OPTIONS) - .unwrap() - .to_str() - .unwrap() - ); - assert_eq!( - http_headers::STRICT_TRANSPORT_SECURITY, - response - .headers() - .get("strict-transport-security") - .unwrap() - .to_str() - .unwrap() - ); - assert!( - response - .headers() - .contains_key(header::CONTENT_SECURITY_POLICY) - ); - assert!(response.headers().contains_key("permissions-policy")); - } - - #[test] - fn external_should_stream_covers_unknown_length_and_unlabelled_bodies() { - assert!(external_should_stream(true, "video/mp4")); - assert!(external_should_stream(true, "application/octet-stream")); - assert!(external_should_stream(true, "")); - assert!(!external_should_stream(false, "video/mp4")); - assert!(!external_should_stream(true, "image/svg+xml")); - assert!(!external_should_stream( - true, - "image/svg+xml; charset=utf-8" - )); - } - - #[test] - fn external_source_is_svg_matches_the_transform_paths_svg_test() { - let padded_svg = { - let mut data = b""); - data - }; - assert!( - !mime::sniff(&padded_svg[..8192.min(padded_svg.len())]) - .mime - .eq("image/svg+xml") - ); - assert!(external_source_is_svg( - &padded_svg, - "logo.svg", - "application/octet-stream" - )); - assert!(external_source_is_svg( - b"", - "logo", - "application/octet-stream" - )); - assert!(external_source_is_svg( - b"\x89PNG\r\n\x1a\n", - "logo.svg", - "image/png" - )); - assert!(!external_source_is_svg( - b"\x89PNG\r\n\x1a\n", - "logo.png", - "image/png" - )); - assert!(!external_source_is_svg(b"", "clip.mp4", "")); - } - - #[tokio::test] - async fn external_streaming_response_passes_body_through() { - let upstream = reqwest::Response::from( - http::Response::builder() - .status(StatusCode::OK) - .body("streamed bytes") - .unwrap(), - ); - let response = external_streaming_response( - Method::GET, - upstream, - Bytes::new(), - Some(14), - "video/mp4", - Some("inline; filename=\"clip.mp4\"".to_owned()), - "https://cdn.example/clip.mp4", - ); - - assert_eq!(StatusCode::OK, response.status()); - assert_eq!( - "14", - response - .headers() - .get(header::CONTENT_LENGTH) - .unwrap() - .to_str() - .unwrap() - ); - assert_eq!( - "video/mp4", - response - .headers() - .get(header::CONTENT_TYPE) - .unwrap() - .to_str() - .unwrap() - ); - let body = to_bytes(response.into_body(), 64).await.unwrap(); - assert_eq!(b"streamed bytes", body.as_ref()); - } - - #[tokio::test] - async fn external_streaming_response_emits_the_sniff_prefix_then_the_rest() { - let upstream = reqwest::Response::from( - http::Response::builder() - .status(StatusCode::OK) - .body("rest of the body") - .unwrap(), - ); - let response = external_streaming_response( - Method::GET, - upstream, - Bytes::from_static(b"prefix "), - None, - "application/octet-stream", - None, - "https://cdn.example/blob.bin", - ); - - assert_eq!(StatusCode::OK, response.status()); - assert!( - response.headers().get(header::CONTENT_LENGTH).is_none(), - "an upstream without a Content-Length must stream without inventing one" - ); - let body = to_bytes(response.into_body(), 64).await.unwrap(); - assert_eq!(b"prefix rest of the body", body.as_ref()); - } - - const STREAM_TEST_CHUNK_BYTES: usize = 1024 * 1024; - const STREAM_TEST_URL: &str = "https://cdn.example/clip.mp4"; - - struct TestUpstreamBody { - chunks: std::collections::VecDeque, - polls: Arc, - } - - impl http_body::Body for TestUpstreamBody { - type Data = Bytes; - type Error = std::io::Error; - - fn poll_frame( - self: Pin<&mut Self>, - _cx: &mut Context<'_>, - ) -> Poll, Self::Error>>> { - let this = self.get_mut(); - this.polls - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - Poll::Ready(this.chunks.pop_front().map(|chunk| Ok(Frame::data(chunk)))) - } - } - - fn test_chunks(total: usize) -> std::collections::VecDeque { - let filler = Bytes::from(vec![b'v'; STREAM_TEST_CHUNK_BYTES]); - let mut chunks = std::collections::VecDeque::new(); - let mut remaining = total; - while remaining > 0 { - let take = remaining.min(STREAM_TEST_CHUNK_BYTES); - chunks.push_back(filler.slice(..take)); - remaining -= take; - } - chunks - } - - fn test_upstream( - total: usize, - declared: Option, - polls: Arc, - ) -> reqwest::Response { - let mut builder = http::Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "video/mp4"); - if let Some(declared) = declared { - builder = builder.header(header::CONTENT_LENGTH, declared.to_string()); - } - reqwest::Response::from( - builder - .body(reqwest::Body::wrap(TestUpstreamBody { - chunks: test_chunks(total), - polls, - })) - .unwrap(), - ) - } - - async fn drain_counting(body: Body) -> Result { - let mut body = body; - let mut delivered = 0usize; - while let Some(frame) = body.frame().await { - let frame = frame?; - if let Some(data) = frame.data_ref() { - delivered += data.len(); - } - } - Ok(delivered) - } - - async fn external_stream_delivered(total: usize) -> Result { - let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let fetched = external_fetched_from_response( - test_upstream(total, None, polls), - STREAM_TEST_URL.to_owned(), - true, - ) - .await - .expect("an upstream without a declared length must be admitted"); - assert_eq!( - None, fetched.content_length, - "the fixture must exercise the undeclared length path" - ); - let ExternalBody::Streaming { response, prefix } = fetched.body else { - panic!("an undeclared length passthrough must stream"); - }; - let response = external_streaming_response( - Method::GET, - response, - prefix, - None, - "video/mp4", - None, - &fetched.url, - ); - assert_eq!(StatusCode::OK, response.status()); - drain_counting(response.into_body()).await - } - - #[tokio::test] - async fn an_undeclared_length_stream_under_the_cap_is_delivered_whole() { - let total = constants::MAX_MEDIA_PROXY_BYTES - STREAM_TEST_CHUNK_BYTES; - assert_eq!(total, external_stream_delivered(total).await.unwrap()); - } - - #[tokio::test] - async fn an_undeclared_length_stream_exactly_at_the_cap_is_delivered_whole() { - let total = constants::MAX_MEDIA_PROXY_BYTES; - assert_eq!(total, external_stream_delivered(total).await.unwrap()); - } - - #[tokio::test] - async fn an_undeclared_length_stream_past_the_cap_fails_the_transfer() { - let total = constants::MAX_MEDIA_PROXY_BYTES + 1; - let err = external_stream_delivered(total) - .await - .expect_err("a body past the cap must not be delivered as a complete response"); - assert!( - err.to_string() - .contains("exceeded the media proxy size cap"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn a_declared_length_past_the_cap_is_rejected_before_the_body_is_read() { - let polls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let declared = constants::MAX_MEDIA_PROXY_BYTES as u64 + 1; - let outcome = external_fetched_from_response( - test_upstream(STREAM_TEST_CHUNK_BYTES, Some(declared), Arc::clone(&polls)), - STREAM_TEST_URL.to_owned(), - true, - ) - .await; - assert!( - matches!(outcome, Err(ExternalFetchError::PayloadTooLarge)), - "a declared length past the cap must be refused up front" - ); - assert_eq!( - 0, - polls.load(std::sync::atomic::Ordering::Relaxed), - "an oversized declared length must be refused without reading the body" - ); - } - - async fn raw_stream_transfer(budget: usize, total: usize) -> Vec { - let chunks = test_chunks(total); - let app = Router::new().route( - "/probe", - get(move || { - let chunks = chunks.clone(); - async move { - let mut response = Response::new(Body::new(ExternalStreamBody { - url: STREAM_TEST_URL.to_owned(), - prefix: None, - upstream: reqwest::Body::wrap(TestUpstreamBody { - chunks, - polls: Arc::new(std::sync::atomic::AtomicUsize::new(0)), - }), - remaining: budget, - })); - *response.status_mut() = StatusCode::OK; - response - } - }), - ); - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, app).await }); - let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); - tokio::io::AsyncWriteExt::write_all( - &mut stream, - b"GET /probe HTTP/1.1\r\nHost: proxy\r\nConnection: close\r\n\r\n", - ) - .await - .unwrap(); - let mut raw = Vec::new(); - tokio::io::AsyncReadExt::read_to_end(&mut stream, &mut raw) - .await - .unwrap(); - server.abort(); - raw - } - - #[tokio::test] - async fn a_stream_within_its_budget_completes_the_chunked_transfer() { - let raw = raw_stream_transfer(STREAM_TEST_CHUNK_BYTES, STREAM_TEST_CHUNK_BYTES).await; - assert!(raw.starts_with(b"HTTP/1.1 200 OK")); - assert!( - raw.ends_with(b"0\r\n\r\n"), - "a stream inside the cap must terminate the chunked body" - ); - } - - #[tokio::test] - async fn a_stream_past_its_budget_never_completes_the_chunked_transfer() { - let raw = raw_stream_transfer(STREAM_TEST_CHUNK_BYTES, STREAM_TEST_CHUNK_BYTES + 1).await; - assert!( - !raw.ends_with(b"0\r\n\r\n"), - "an overrun must not look like a complete body to the client" - ); - } - - #[test] - fn external_sniffed_content_type_recovers_mislabelled_bodies() { - assert_eq!( - "image/svg+xml", - external_sniffed_content_type( - b"", - "logo", - "application/octet-stream".to_owned() - ) - ); - assert_eq!( - "video/mp4", - external_sniffed_content_type(b"", "clip.mp4", "application/octet-stream".to_owned()) - ); - assert_eq!( - "video/webm", - external_sniffed_content_type(b"", "clip.mp4", "video/webm".to_owned()) - ); - } - - #[tokio::test] - async fn external_transform_probe_hits_the_entry_the_transform_path_wrote() { - let tmp = tempfile::tempdir().unwrap(); - let cfg = - upload_relay_test_config(tmp.path(), tmp.path(), b"01234567890123456789012345678901"); - let app = test_app_state(cfg); - let url = "https://cdn.example.test/photo.jpg"; - let params = HashMap::from([("width".to_owned(), "32".to_owned())]); - let headers = HeaderMap::new(); - let data = Bytes::from(STANDARD.decode(TRANSFORM_FIXTURE_JPEG_B64).unwrap()); - let filename = url_filename(url); - - assert!( - external_cached_transform(&app, url, &Method::GET, ¶ms, &headers).is_none(), - "an unseen url must not resolve to a cached transform" - ); - - let response = serve_bytes_or_transform( - &app, - ServeBytesRequest { - method: Method::GET, - data: data.clone(), - content_type: "image/jpeg".to_owned(), - cache_identity: url, - filename: &filename, - route: TransformRoute::External, - params: ¶ms, - headers: &headers, - }, - ) - .await; - assert_eq!(StatusCode::OK, response.status()); - let transformed = to_bytes(response.into_body(), 1 << 20).await.unwrap(); - - app.external_hints.insert( - url.to_owned(), - external_hint(url, &filename, "image/jpeg", &data), - ); - let probed = external_cached_transform(&app, url, &Method::GET, ¶ms, &headers) - .expect("the probe must reach the entry the transform path wrote"); - - assert_eq!(StatusCode::OK, probed.status()); - assert_eq!( - "image/jpeg", - probed.headers().get(header::CONTENT_TYPE).unwrap() - ); - assert_eq!( - transformed, - to_bytes(probed.into_body(), 1 << 20).await.unwrap() - ); - } - - #[tokio::test] - async fn external_partial_response_streams_partial_body() { - let upstream = reqwest::Response::from( - http::Response::builder() - .status(StatusCode::PARTIAL_CONTENT) - .body("abcd") - .unwrap(), - ); - let fetched = FetchedExternal { - url: "https://media.example.test/clip.webm".to_owned(), - status: StatusCode::PARTIAL_CONTENT, - body: ExternalBody::Streaming { - response: upstream, - prefix: Bytes::new(), - }, - content_type: "video/webm".to_owned(), - content_length: Some(4), - content_range: Some("bytes 0-3/10".to_owned()), - }; - let response = external_partial_response(Method::GET, fetched, None); - - assert_eq!(StatusCode::PARTIAL_CONTENT, response.status()); - assert_eq!( - "bytes 0-3/10", - response - .headers() - .get(header::CONTENT_RANGE) - .unwrap() - .to_str() - .unwrap() - ); - assert_eq!( - "4", - response - .headers() - .get(header::CONTENT_LENGTH) - .unwrap() - .to_str() - .unwrap() - ); - let body = to_bytes(response.into_body(), 64).await.unwrap(); - assert_eq!(b"abcd", body.as_ref()); - } - - fn cache_control_of(response: &Response) -> &str { - response - .headers() - .get(header::CACHE_CONTROL) - .expect("cache-control is always set") - .to_str() - .expect("cache-control is ASCII") - } - - #[tokio::test] - async fn external_streaming_response_caches_forever() { - for (content_type, expected) in [ - ("video/mp4", "public, max-age=31536000, no-transform"), - ("image/webp", "public, max-age=31536000"), - ] { - let upstream = reqwest::Response::from( - http::Response::builder() - .status(StatusCode::OK) - .body("streamed bytes") - .unwrap(), - ); - let response = external_streaming_response( - Method::GET, - upstream, - Bytes::new(), - Some(14), - content_type, - None, - "https://cdn.example/clip.mp4", - ); - - assert_eq!( - expected, - cache_control_of(&response), - "content_type={content_type}" - ); - } - } - - #[test] - fn external_partial_response_caches_forever() { - let fetched = FetchedExternal { - url: "https://media.example.test/clip.webm".to_owned(), - status: StatusCode::PARTIAL_CONTENT, - body: ExternalBody::Buffered(Bytes::from_static(b"abcd")), - content_type: "video/webm".to_owned(), - content_length: Some(4), - content_range: Some("bytes 0-3/10".to_owned()), - }; - let response = external_partial_response(Method::GET, fetched, None); - - assert_eq!( - "public, max-age=31536000, no-transform", - cache_control_of(&response) - ); - } - - #[test] - fn stored_media_responses_cache_forever() { - let response = media_response( - Method::GET, - Bytes::from_static(b"stored bytes"), - "image/webp", - None, - None, - ); - assert_eq!("public, max-age=31536000", cache_control_of(&response)); - - let streamable = media_response( - Method::GET, - Bytes::from_static(b"stored bytes"), - "video/mp4", - None, - None, - ); - assert_eq!( - "public, max-age=31536000, no-transform", - cache_control_of(&streamable) - ); - - let head = passthrough_head_response("image/webp", 12, None, None); - assert_eq!("public, max-age=31536000", cache_control_of(&head)); - } - - #[test] - fn external_partial_response_uses_media_headers() { - let fetched = FetchedExternal { - url: "https://media.example.test/clip.webm".to_owned(), - status: StatusCode::PARTIAL_CONTENT, - body: ExternalBody::Buffered(Bytes::from_static(b"abcd")), - content_type: "video/webm".to_owned(), - content_length: Some(4), - content_range: Some("bytes 0-3/10".to_owned()), - }; - let response = external_partial_response( - Method::GET, - fetched, - Some("inline; filename=\"clip.webm\"".to_owned()), - ); - - assert_eq!(StatusCode::PARTIAL_CONTENT, response.status()); - assert_eq!( - "*", - response - .headers() - .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) - .unwrap() - .to_str() - .unwrap() - ); - assert_eq!( - "video/webm", - response - .headers() - .get(header::CONTENT_TYPE) - .unwrap() - .to_str() - .unwrap() - ); - assert_eq!( - "bytes 0-3/10", - response - .headers() - .get(header::CONTENT_RANGE) - .unwrap() - .to_str() - .unwrap() - ); - assert_eq!( - "inline; filename=\"clip.webm\"", - response - .headers() - .get(header::CONTENT_DISPOSITION) - .unwrap() - .to_str() - .unwrap() - ); - assert!( - response - .headers() - .contains_key(header::CONTENT_SECURITY_POLICY) - ); - assert!(response.headers().contains_key("strict-transport-security")); - assert!(response.headers().contains_key("permissions-policy")); - assert!( - response - .headers() - .contains_key(header::X_CONTENT_TYPE_OPTIONS) - ); - assert!(response.headers().contains_key("CDN-Cache-Control")); - } - - #[test] - fn relay_cors_allows_client_context_headers() { - let mut headers = HeaderMap::new(); - relay_cors(&mut headers); - let allow_headers = headers - .get(header::ACCESS_CONTROL_ALLOW_HEADERS) - .unwrap() - .to_str() - .unwrap(); - - assert!( - allow_headers - .split(',') - .any(|name| name.trim().eq_ignore_ascii_case("x-fluxer-features")) - ); - assert!( - allow_headers - .split(',') - .any(|name| name.trim().eq_ignore_ascii_case("x-client-context")) - ); - } - - #[tokio::test] - async fn relay_put_accepts_unknown_content_length_body() { - let tmp = tempfile::tempdir().unwrap(); - let tmp_root = tmp.path().canonicalize().unwrap(); - let storage_root = tmp_root.join("storage"); - let spool_dir = tmp_root.join("spool"); - tokio::fs::create_dir_all(&spool_dir).await.unwrap(); - let relay_secret = [7u8; 32]; - let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); - let key = "guild/diagnostics.txt"; - let token = upload_relay::encode_token( - &upload_relay::TokenPayload { - b: "uploads".to_owned(), - k: key.to_owned(), - m: upload_relay::TokenMethod::Put, - u: None, - p: None, - ct: Some("text/plain".to_owned()), - mb: 4096, - e: upload_relay::now_unix() + 60, - }, - &relay_secret, - ) - .unwrap(); - let body = Bytes::from_static(b"diagnostics bundle"); - let request = Request::builder() - .method(Method::PUT) - .body(Body::from(body.clone())) - .unwrap(); - assert!(request.headers().get(header::CONTENT_LENGTH).is_none()); - - let response = relay_put( - State(test_app_state(cfg)), - Path(key.to_owned()), - Query(HashMap::from([("t".to_owned(), token)])), - HeaderMap::new(), - request, - ) - .await; - - assert_eq!(StatusCode::OK, response.status()); - let stored = tokio::fs::read(storage_root.join("uploads").join(key)) - .await - .unwrap(); - assert_eq!(body.as_ref(), stored.as_slice()); - } - - fn relay_test_token(key: &str, relay_secret: &[u8]) -> String { - upload_relay::encode_token( - &upload_relay::TokenPayload { - b: "uploads".to_owned(), - k: key.to_owned(), - m: upload_relay::TokenMethod::Put, - u: None, - p: None, - ct: Some("application/octet-stream".to_owned()), - mb: 4096, - e: upload_relay::now_unix() + 60, - }, - relay_secret, - ) - .unwrap() - } - - fn content_length_headers(declared: u64) -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert( - header::CONTENT_LENGTH, - HeaderValue::from_str(&declared.to_string()).unwrap(), - ); - headers - } - - #[tokio::test] - async fn relay_put_streams_known_length_body_without_spooling() { - let tmp = tempfile::tempdir().unwrap(); - let tmp_root = tmp.path().canonicalize().unwrap(); - let storage_root = tmp_root.join("storage"); - let spool_dir = tmp_root.join("spool"); - tokio::fs::create_dir_all(&spool_dir).await.unwrap(); - let relay_secret = [7u8; 32]; - let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); - let key = "guild/streamed.bin"; - let token = relay_test_token(key, &relay_secret); - let body = Bytes::from_static(b"streamed straight through"); - - let response = relay_put( - State(test_app_state(cfg)), - Path(key.to_owned()), - Query(HashMap::from([("t".to_owned(), token)])), - content_length_headers(body.len() as u64), - Request::builder() - .method(Method::PUT) - .body(Body::from(body.clone())) - .unwrap(), - ) - .await; - - assert_eq!(StatusCode::OK, response.status()); - let stored = tokio::fs::read(storage_root.join("uploads").join(key)) - .await - .unwrap(); - assert_eq!(body.as_ref(), stored.as_slice()); - let mut spool_entries = tokio::fs::read_dir(&spool_dir).await.unwrap(); - assert!(spool_entries.next_entry().await.unwrap().is_none()); - } - - #[tokio::test] - async fn relay_put_rejects_streaming_body_longer_than_declared() { - let tmp = tempfile::tempdir().unwrap(); - let tmp_root = tmp.path().canonicalize().unwrap(); - let storage_root = tmp_root.join("storage"); - let spool_dir = tmp_root.join("spool"); - tokio::fs::create_dir_all(&spool_dir).await.unwrap(); - let relay_secret = [7u8; 32]; - let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); - let key = "guild/overrun.bin"; - let token = relay_test_token(key, &relay_secret); - - let response = relay_put( - State(test_app_state(cfg)), - Path(key.to_owned()), - Query(HashMap::from([("t".to_owned(), token)])), - content_length_headers(4), - Request::builder() - .method(Method::PUT) - .body(Body::from(Bytes::from_static(b"way past four bytes"))) - .unwrap(), - ) - .await; - - assert_eq!(StatusCode::PAYLOAD_TOO_LARGE, response.status()); - assert!( - tokio::fs::try_exists(storage_root.join("uploads").join(key)) - .await - .ok() - != Some(true) - ); - } - - #[test] - fn error_responses_declare_an_explicit_no_store_policy() { - for status in [ - StatusCode::NOT_FOUND, - StatusCode::BAD_GATEWAY, - StatusCode::INTERNAL_SERVER_ERROR, - ] { - let response = text(status, "nope"); - assert_eq!( - response.headers().get(header::CACHE_CONTROL).unwrap(), - "no-store", - "status {status} must not be cacheable" - ); - } - } - - #[test] - fn successful_text_responses_are_left_to_the_media_cache_policy() { - let response = text(StatusCode::OK, "fine"); - assert!(response.headers().get(header::CACHE_CONTROL).is_none()); - } - - #[tokio::test] - async fn relay_put_rejects_streaming_body_shorter_than_declared() { - let tmp = tempfile::tempdir().unwrap(); - let tmp_root = tmp.path().canonicalize().unwrap(); - let storage_root = tmp_root.join("storage"); - let spool_dir = tmp_root.join("spool"); - tokio::fs::create_dir_all(&spool_dir).await.unwrap(); - let relay_secret = [7u8; 32]; - let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); - let key = "guild/short.bin"; - let token = relay_test_token(key, &relay_secret); - - let response = relay_put( - State(test_app_state(cfg)), - Path(key.to_owned()), - Query(HashMap::from([("t".to_owned(), token)])), - content_length_headers(32), - Request::builder() - .method(Method::PUT) - .body(Body::from(Bytes::from_static(b"tiny"))) - .unwrap(), - ) - .await; - - assert_eq!(StatusCode::BAD_REQUEST, response.status()); - assert!( - tokio::fs::try_exists(storage_root.join("uploads").join(key)) - .await - .ok() - != Some(true) - ); - } - - #[test] - fn upstream_status_is_clamped_to_known_whitelist() { - assert_eq!( - StatusCode::NOT_FOUND, - map_upstream_status(StatusCode::NOT_FOUND) - ); - assert_eq!( - StatusCode::TOO_MANY_REQUESTS, - map_upstream_status(StatusCode::TOO_MANY_REQUESTS) - ); - assert_eq!( - StatusCode::from_u16(428).unwrap(), - map_upstream_status(StatusCode::from_u16(428).unwrap()) - ); - assert_eq!( - StatusCode::BAD_GATEWAY, - map_upstream_status(StatusCode::from_u16(451).unwrap()) - ); - assert_eq!( - StatusCode::BAD_GATEWAY, - map_upstream_status(StatusCode::INTERNAL_SERVER_ERROR) - ); - assert_eq!( - StatusCode::BAD_GATEWAY, - map_upstream_status(StatusCode::SERVICE_UNAVAILABLE) - ); - } - - #[test] - fn internal_metadata_does_not_surface_origin_429() { - assert_eq!( - StatusCode::SERVICE_UNAVAILABLE, - map_internal_metadata_upstream_status(StatusCode::TOO_MANY_REQUESTS) - ); - assert_eq!( - StatusCode::NOT_FOUND, - map_internal_metadata_upstream_status(StatusCode::NOT_FOUND) - ); - } - - #[test] - fn asset_filename_rejects_unstable_shapes() { - assert!(parse_asset_filename("hash.extra.png").is_none()); - assert!(parse_asset_filename("hash-with-dash.png").is_none()); - assert!(parse_asset_filename(".png").is_none()); - assert!(parse_asset_filename("hash.").is_none()); - } - - #[test] - fn animated_gif_requests_downgrade_webp_output_to_gif() { - assert_eq!( - AssetExtension::Apng, - effective_animated_image_output_format( - Some(AssetExtension::Apng), - AssetExtension::Png, - true - ) - ); - assert_eq!( - AssetExtension::Png, - effective_animated_image_output_format( - Some(AssetExtension::Apng), - AssetExtension::Png, - false - ) - ); - assert_eq!( - AssetExtension::Gif, - effective_animated_image_output_format( - Some(AssetExtension::Gif), - AssetExtension::Webp, - true - ) - ); - assert_eq!( - AssetExtension::Webp, - effective_animated_image_output_format( - Some(AssetExtension::Gif), - AssetExtension::Webp, - false - ) - ); - assert_eq!( - AssetExtension::Webp, - effective_animated_image_output_format( - Some(AssetExtension::Webp), - AssetExtension::Webp, - true - ) - ); - assert_eq!( - AssetExtension::Png, - effective_animated_image_output_format( - Some(AssetExtension::Png), - AssetExtension::Png, - true - ) - ); - assert_eq!( - AssetExtension::Png, - effective_animated_image_output_format( - Some(AssetExtension::Png), - AssetExtension::Png, - false - ) - ); - assert!(animated_param( - &HashMap::from([("animated".to_owned(), "true".to_owned())]), - false - )); - assert!(!animated_param( - &HashMap::from([("animatd".to_owned(), "true".to_owned())]), - false - )); - } - - #[test] - fn attachment_and_external_query_helpers_match_v1_edges() { - for name in [ - "clip.mp4", - "clip.m4v", - "clip.webm", - "clip.mov", - "clip.ogv", - "clip.mkv", - "clip.3gp", - "clip.avi", - "clip.flv", - "clip.ts", - "clip.mpg", - "clip.mpeg", - "clip.wmv", - ] { - assert_eq!(None, image_extension_from_filename(name)); - } - assert_eq!( - AssetExtension::Gif, - external_default_output_extension("welcome.png", "image/gif") - ); - assert_eq!( - AssetExtension::Png, - external_default_output_extension("welcome.png", "application/octet-stream") - ); - assert_eq!( - "image/gif", - transform_response_content_type( - None, - AssetExtension::Gif, - AssetExtension::Gif, - "image/gif" - ) - ); - assert_eq!( - "image/webp", - transform_response_content_type( - None, - AssetExtension::Heic, - AssetExtension::Webp, - "image/heic" - ) - ); - assert_eq!( - "image/webp", - transform_response_content_type( - None, - AssetExtension::Webp, - AssetExtension::Webp, - "image/svg+xml; charset=utf-8" - ) - ); - assert_eq!( - "image/gif", - transform_response_content_type( - Some(AssetExtension::Webp), - AssetExtension::Webp, - AssetExtension::Gif, - "image/gif" - ) - ); - assert_eq!( - "file.png", - url_filename("https://example.test/a/file.png?x=1#frag") - ); - assert_eq!( - "photo.png", - filename_from_storage_key("attachments/123/456/photo.png") - ); - assert_eq!( - "file.bin", - filename_from_storage_key("attachments/123/456/") - ); - assert!( - explicit_output_format(&HashMap::from([("format".to_owned(), "auto".to_owned())])) - .is_err() - ); - } - - #[test] - fn explicit_download_disposition_uses_response_image_extension() { - assert_eq!( - "attachment; filename=\"welcome.gif\"", - content_disposition_header("image/gif", true, Some("welcome.png")) - ); - assert_eq!( - "attachment; filename=\"welcome.gif\"", - content_disposition_header("image/gif", true, Some("welcome")) - ); - assert_eq!( - "attachment; filename=\"photo.jpg\"", - content_disposition_header("image/jpeg", true, Some("photo.jpg")) - ); - assert_eq!( - "attachment; filename=\"photo.jpg\"", - content_disposition_header( - "image/jpeg", - true, - Some(filename_from_storage_key("attachments/123/456/photo.jpg")) - ) - ); - assert_eq!( - "inline; filename=\"welcome.png\"", - content_disposition_header("image/gif", false, Some("welcome.png")) - ); - } - - #[test] - fn same_format_gif_noop_requests_use_original_bytes() { - let gif_header = mime::sniff(b"GIF89a\x2c\x01\xe1\x00"); - assert!(same_format_loaded_image_request_can_use_original( - gif_header, - OriginalImageRequest { - source_ext: Some(AssetExtension::Gif), - explicit_out_ext: None, - out_ext: AssetExtension::Gif, - width: Some(300), - height: Some(225), - has_quality: false, - effort: None, - animated: true, - } - )); - assert!(same_format_loaded_image_request_can_use_original( - gif_header, - OriginalImageRequest { - source_ext: Some(AssetExtension::Gif), - explicit_out_ext: None, - out_ext: AssetExtension::Gif, - width: Some(301), - height: None, - has_quality: false, - effort: None, - animated: true, - } - )); - assert!(!same_format_loaded_image_request_can_use_original( - gif_header, - OriginalImageRequest { - source_ext: Some(AssetExtension::Gif), - explicit_out_ext: None, - out_ext: AssetExtension::Gif, - width: Some(299), - height: None, - has_quality: false, - effort: None, - animated: true, - } - )); - assert!(!same_format_loaded_image_request_can_use_original( - gif_header, - OriginalImageRequest { - source_ext: Some(AssetExtension::Gif), - explicit_out_ext: Some(AssetExtension::Webp), - out_ext: AssetExtension::Webp, - width: None, - height: None, - has_quality: false, - effort: None, - animated: true, - } - )); - assert!(!same_format_loaded_image_request_can_use_original( - gif_header, - OriginalImageRequest { - source_ext: Some(AssetExtension::Gif), - explicit_out_ext: Some(AssetExtension::Webp), - out_ext: AssetExtension::Webp, - width: Some(300), - height: None, - has_quality: false, - effort: None, - animated: false, - } - )); - assert!(!same_format_loaded_image_request_can_use_original( - gif_header, - OriginalImageRequest { - source_ext: Some(AssetExtension::Gif), - explicit_out_ext: None, - out_ext: AssetExtension::Gif, - width: Some(300), - height: None, - has_quality: false, - effort: Some(1), - animated: false, - } - )); - } -} diff --git a/fluxer_media_proxy/src/server/asset_path.rs b/fluxer_media_proxy/src/server/asset_path.rs new file mode 100644 index 000000000..638caa792 --- /dev/null +++ b/fluxer_media_proxy/src/server/asset_path.rs @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + asset_hash::AssetHash, + constants::{AssetExtension, AssetKind}, + external_path, + storage::safe_key, +}; +use std::fmt; + +pub(in crate::server) struct ParsedAssetPath { + pub(in crate::server) storage_key: String, + pub(in crate::server) original_ext: AssetExtension, + pub(in crate::server) hash: String, + pub(in crate::server) kind: AssetKind, + pub(in crate::server) forced_output_format: Option, +} + +pub(in crate::server) fn parse_standard_asset_path(path: &str) -> Option { + let mut parts = canonical_public_path(path)?.split('/'); + let prefix = parts.next()?; + let owner_key = parts.next()?; + let filename = parts.next()?; + if parts.next().is_some() { + return None; + } + let kind = match prefix { + "avatars" => AssetKind::Avatar, + "icons" => AssetKind::GuildIcon, + "branding" => AssetKind::GuildIcon, + "banners" => AssetKind::Banner, + "splashes" => AssetKind::Splash, + "embed-splashes" => AssetKind::EmbedSplash, + _ => return None, + }; + if !valid_asset_owner_key(prefix, owner_key) { + return None; + } + let parsed = parse_asset_filename(filename)?; + let storage_hash = AssetHash::parse(parsed.hash).digest(); + Some(ParsedAssetPath { + storage_key: format!("{prefix}/{owner_key}/{storage_hash}"), + original_ext: parsed.ext, + hash: parsed.hash.to_owned(), + kind, + forced_output_format: None, + }) +} + +pub(in crate::server) fn parse_guild_member_asset_path(path: &str) -> Option { + let mut parts = canonical_public_path(path)?.split('/'); + if parts.next()? != "guilds" { + return None; + } + let guild_id = SnowflakeId::parse(parts.next()?)?; + if parts.next()? != "users" { + return None; + } + let user_id = SnowflakeId::parse(parts.next()?)?; + let prefix = parts.next()?; + let filename = parts.next()?; + if parts.next().is_some() { + return None; + } + let kind = match prefix { + "avatars" => AssetKind::Avatar, + "banners" => AssetKind::Banner, + _ => return None, + }; + let parsed = parse_asset_filename(filename)?; + let storage_hash = AssetHash::parse(parsed.hash).digest(); + Some(ParsedAssetPath { + storage_key: format!("guilds/{guild_id}/users/{user_id}/{prefix}/{storage_hash}"), + original_ext: parsed.ext, + hash: parsed.hash.to_owned(), + kind, + forced_output_format: None, + }) +} + +pub(in crate::server) fn parse_simple_asset_path( + path: &str, + kind: AssetKind, +) -> Option { + let expected_prefix = match kind { + AssetKind::Emoji => "emojis", + AssetKind::Sticker => "stickers", + _ => return None, + }; + let mut parts = canonical_public_path(path)?.split('/'); + let prefix = parts.next()?; + if prefix != expected_prefix { + return None; + } + let filename = parts.next()?; + if parts.next().is_some() { + return None; + } + let parsed = parse_asset_filename(filename)?; + let id = SnowflakeId::parse(parsed.hash)?; + Some(ParsedAssetPath { + storage_key: format!("{prefix}/{id}"), + original_ext: parsed.ext, + hash: parsed.hash.to_owned(), + kind, + forced_output_format: (kind == AssetKind::Sticker).then_some(AssetExtension::Webp), + }) +} + +pub(in crate::server) fn parse_entrance_sound_path(path: &str) -> Option { + let mut parts = canonical_public_path(path)?.split('/'); + if parts.next()? != "entrance-sounds" { + return None; + } + let user_id = parts.next()?; + let filename = parts.next()?; + if parts.next().is_some() { + return None; + } + if user_id.is_empty() || !user_id.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let (hash, ext) = filename.split_once('.')?; + if hash.is_empty() || !hash.bytes().all(|b| b.is_ascii_alphanumeric()) { + return None; + } + if !matches!(ext, "mp3" | "ogg" | "m4a" | "wav") { + return None; + } + Some(format!("entrance-sounds/{user_id}/{filename}")) +} + +struct ParsedAssetFilename<'a> { + hash: &'a str, + ext: AssetExtension, +} + +fn parse_asset_filename(filename: &str) -> Option> { + let (hash, ext_raw) = filename.split_once('.')?; + if hash.is_empty() || ext_raw.is_empty() || ext_raw.contains('.') { + return None; + } + if !hash.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + return None; + } + if !ext_raw.bytes().all(|b| b.is_ascii_alphanumeric()) { + return None; + } + Some(ParsedAssetFilename { + hash, + ext: AssetExtension::parse(ext_raw)?, + }) +} + +pub(in crate::server) fn asset_filename_hint(asset: &ParsedAssetPath) -> String { + let hash = AssetHash::parse(&asset.hash).digest(); + format!("{hash}.{}", asset.original_ext.name()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SnowflakeId(u64); + +impl SnowflakeId { + fn parse(raw: &str) -> Option { + if raw.starts_with('0') || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let value = raw.parse::().ok()?; + (value > 0).then_some(Self(value)) + } +} + +impl fmt::Display for SnowflakeId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +fn valid_asset_owner_key(prefix: &str, owner_key: &str) -> bool { + if prefix == "branding" { + return !owner_key.is_empty() + && owner_key.len() <= ASSET_OWNER_KEY_MAX_BYTES + && owner_key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')); + } + SnowflakeId::parse(owner_key).is_some() +} + +const ASSET_OWNER_KEY_MAX_BYTES: usize = 128; + +fn canonical_public_path(path: &str) -> Option<&str> { + let relative_path = path.strip_prefix('/')?; + if relative_path.starts_with('/') { + return None; + } + Some(relative_path) +} + +#[derive(Debug, thiserror::Error)] +pub(in crate::server) enum StorageKeyDecodeError { + #[error("decoded storage key is invalid")] + InvalidKey, +} + +pub(in crate::server) fn decode_storage_key(path: &str) -> Result { + let key = external_path::percent_decode_string(path.trim_start_matches('/'), false); + safe_key(&key).map_err(|_| StorageKeyDecodeError::InvalidKey)?; + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::params::asset_wants_animated; + use std::collections::HashMap; + + #[test] + fn entrance_sound_path_parses_valid_keys() { + assert_eq!( + parse_entrance_sound_path("/entrance-sounds/1130650140672000000/eb417d05ad2e14c4.wav"), + Some("entrance-sounds/1130650140672000000/eb417d05ad2e14c4.wav".to_owned()) + ); + for ext in ["mp3", "ogg", "m4a", "wav"] { + assert_eq!( + parse_entrance_sound_path(&format!("/entrance-sounds/42/abc123.{ext}")), + Some(format!("entrance-sounds/42/abc123.{ext}")) + ); + } + } + + #[test] + fn entrance_sound_path_rejects_invalid_keys() { + assert_eq!( + parse_entrance_sound_path("/entrance-sounds/42/abc.flac"), + None + ); + assert_eq!( + parse_entrance_sound_path("/entrance-sounds/abc/abc.wav"), + None + ); + assert_eq!( + parse_entrance_sound_path("/entrance-sounds/42/abc.wav/x"), + None + ); + assert_eq!( + parse_entrance_sound_path("/entrance-sounds/42/../secret.wav"), + None + ); + assert_eq!(parse_entrance_sound_path("/entrance-sounds/42"), None); + assert_eq!(parse_entrance_sound_path("/entrance-sounds//abc.wav"), None); + assert_eq!(parse_entrance_sound_path("/avatars/42/abc.wav"), None); + } + + #[test] + fn standard_asset_path_strips_virtual_animation_prefix_and_extension() { + let parsed = + parse_standard_asset_path("/avatars/1216100949629702144/a_d2f35261.webp").unwrap(); + assert_eq!("avatars/1216100949629702144/d2f35261", parsed.storage_key); + assert_eq!(AssetExtension::Webp, parsed.original_ext); + assert_eq!(AssetKind::Avatar, parsed.kind); + assert!(asset_wants_animated(&HashMap::new(), &parsed.hash)); + assert!(!asset_wants_animated( + &HashMap::from([("animated".to_owned(), "false".to_owned())]), + &parsed.hash + )); + } + + #[test] + fn guild_member_and_simple_asset_paths_match_v1_storage_keys() { + let guild = + parse_guild_member_asset_path("/guilds/1/users/2/avatars/a_memberhash.gif").unwrap(); + assert_eq!("guilds/1/users/2/avatars/memberhash", guild.storage_key); + assert_eq!(AssetKind::Avatar, guild.kind); + assert_eq!(AssetExtension::Gif, guild.original_ext); + assert!(guild.forced_output_format.is_none()); + + let emoji = + parse_simple_asset_path("/emojis/1501314428688998182.webp", AssetKind::Emoji).unwrap(); + assert_eq!("emojis/1501314428688998182", emoji.storage_key); + assert_eq!(AssetKind::Emoji, emoji.kind); + assert!(emoji.forced_output_format.is_none()); + + let sticker = + parse_simple_asset_path("/stickers/1501314428688998182.png", AssetKind::Sticker) + .unwrap(); + assert_eq!("stickers/1501314428688998182", sticker.storage_key); + assert_eq!(Some(AssetExtension::Webp), sticker.forced_output_format); + } + + #[test] + fn asset_filename_rejects_unstable_shapes() { + assert!(parse_asset_filename("hash.extra.png").is_none()); + assert!(parse_asset_filename("hash-with-dash.png").is_none()); + assert!(parse_asset_filename(".png").is_none()); + assert!(parse_asset_filename("hash.").is_none()); + } + + #[test] + fn asset_paths_reject_noncanonical_owners_extensions_and_prefixes() { + for invalid in [ + "avatars/1216100949629702144/deadbeef.png", + "//avatars/1216100949629702144/deadbeef.png", + "/avatars/0/deadbeef.png", + "/avatars/01/deadbeef.png", + "/avatars/1216100949629702144/deadbeef.exe", + "/avatars/1216100949629702144/deadbeef.png/extra", + ] { + assert!( + parse_standard_asset_path(invalid).is_none(), + "accepted {invalid}" + ); + } + assert!(parse_standard_asset_path("/branding/fluxer-wordmark/deadbeef.png").is_some()); + assert!(parse_guild_member_asset_path("/guilds/01/users/2/avatars/h.gif").is_none()); + assert!(parse_simple_asset_path("/emojis/012.webp", AssetKind::Emoji).is_none()); + } + + #[test] + fn storage_keys_decode_the_way_the_old_era_decoded_them() { + assert_eq!( + "attachments/1216100949629702144/1216100949629702145/name/with-slash.png", + decode_storage_key( + "/attachments/1216100949629702144/1216100949629702145/name%2Fwith-slash.png" + ) + .expect("an encoded slash still resolves to a live object") + ); + assert_eq!( + "attachments/1/a%.png", + decode_storage_key("/attachments/1/a%.png").expect("malformed escapes stay literal") + ); + assert_eq!( + "attachments/1/\u{fffd}.png", + decode_storage_key("/attachments/1/%FF.png").expect("non utf8 decodes lossily") + ); + assert_eq!( + "attachments/1/a\\b.png", + decode_storage_key("/attachments/1/a%5Cb.png") + .expect("a backslash is an ordinary key byte the old era served") + ); + for traversal in [ + "/attachments/%2E%2E/photo.png", + "/attachments/1/..%2Fsecret.png", + "/attachments/1/%2E%2E%2Fsecret.png", + "/attachments/1/a%00b.png", + "/attachments%2F%2Fphoto.png", + ] { + assert!( + matches!( + decode_storage_key(traversal), + Err(StorageKeyDecodeError::InvalidKey) + ), + "safe_key accepted {traversal}" + ); + } + } + + #[test] + fn storage_keys_reject_path_changing_escapes_and_empty_components() { + assert_eq!( + "attachments/1/2/photo.png", + decode_storage_key("/attachments/1/2/photo.png").expect("valid storage key") + ); + assert_eq!( + "attachments/1/a b.png", + decode_storage_key("/attachments/1/a%20b.png").expect("valid storage key") + ); + assert_eq!( + "attachments/1/photo.png", + decode_storage_key("/attachments%2F1%2Fphoto.png").expect("valid storage key") + ); + assert!(matches!( + decode_storage_key("/attachments/%2E%2E/photo.png"), + Err(StorageKeyDecodeError::InvalidKey) + )); + assert!(matches!( + decode_storage_key("/attachments//photo.png"), + Err(StorageKeyDecodeError::InvalidKey) + )); + assert!(decode_storage_key("/").is_err()); + } +} diff --git a/fluxer_media_proxy/src/server/download_stream.rs b/fluxer_media_proxy/src/server/download_stream.rs new file mode 100644 index 000000000..25598ceef --- /dev/null +++ b/fluxer_media_proxy/src/server/download_stream.rs @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::metrics::external::ExternalMetrics; +use axum::body::{Body, BodyDataStream}; +use bytes::Bytes; +use futures_util::StreamExt as _; +use std::{ + io, + sync::Arc, + time::{Duration, Instant}, +}; + +const PASSTHROUGH_STREAM_MIN_BYTES_PER_SEC: u64 = 16 * 1024; +const IDLE_TIMEOUT_MESSAGE: &str = "passthrough stream idle timeout"; +const TOTAL_TIMEOUT_MESSAGE: &str = "passthrough stream total timeout"; +const TRUNCATED_MESSAGE: &str = "passthrough stream ended before Content-Length"; +const OVERRUN_MESSAGE: &str = "passthrough stream exceeded Content-Length"; + +#[derive(Clone)] +pub(in crate::server) struct DownloadStreamPolicy { + idle_timeout: Duration, + overruns: Option>, +} + +struct ActiveDownloadStream { + source: BodyDataStream, + remaining: usize, + allows_early_end: bool, + idle_timeout: Duration, + total_deadline: Option, + overruns: Option>, +} + +type GuardedFrame = Option<(Result, Option)>; + +impl DownloadStreamPolicy { + pub(in crate::server) fn for_passthrough(socket_io_timeout_ms: u64) -> Self { + Self { + idle_timeout: Duration::from_millis(socket_io_timeout_ms.max(1)), + overruns: None, + } + } + + // Only the external route feeds the overrun counter: a stored object that outruns its own + // Content-Length is a store bug, not the misbehaving-origin signal that series tracks. + pub(in crate::server) fn for_external( + socket_io_timeout_ms: u64, + overruns: Arc, + ) -> Self { + let mut policy = Self::for_passthrough(socket_io_timeout_ms); + policy.overruns = Some(overruns); + policy + } + + pub(in crate::server) fn guard(self, body: Body, expected_length: usize) -> Body { + self.guarded(body, expected_length, false) + } + + pub(in crate::server) fn guard_capped(self, body: Body, max_length: usize) -> Body { + self.guarded(body, max_length, true) + } + + fn guarded(self, body: Body, remaining: usize, allows_early_end: bool) -> Body { + let transfer_seconds = u64::try_from(remaining) + .unwrap_or(u64::MAX) + .div_ceil(PASSTHROUGH_STREAM_MIN_BYTES_PER_SEC); + let total_timeout = self + .idle_timeout + .saturating_add(Duration::from_secs(transfer_seconds)); + let stream = ActiveDownloadStream { + source: body.into_data_stream(), + remaining, + allows_early_end, + idle_timeout: self.idle_timeout, + total_deadline: Instant::now().checked_add(total_timeout), + overruns: self.overruns, + }; + Body::from_stream(futures_util::stream::unfold( + Some(stream), + |stream: Option| async move { stream?.next().await }, + )) + } +} + +impl ActiveDownloadStream { + async fn next(mut self) -> GuardedFrame { + if self.remaining == 0 { + return self.finish().await; + } + match self.next_frame().await { + Ok(Some(Ok(chunk))) => { + if chunk.len() > self.remaining { + return self.overrun(); + } + self.remaining -= chunk.len(); + Some((Ok(chunk), Some(self))) + } + Ok(Some(Err(error))) => fail(io::Error::other(error)), + Ok(None) if self.allows_early_end => None, + Ok(None) => fail(io::Error::new( + io::ErrorKind::UnexpectedEof, + TRUNCATED_MESSAGE, + )), + Err(error) => fail(error), + } + } + + async fn finish(mut self) -> GuardedFrame { + match self.next_frame().await { + Ok(None) => None, + Ok(Some(Ok(_))) => self.overrun(), + Ok(Some(Err(error))) => fail(io::Error::other(error)), + Err(error) => fail(error), + } + } + + fn overrun(&self) -> GuardedFrame { + if let Some(overruns) = &self.overruns { + overruns.record_stream_overrun(); + } + fail(io::Error::new(io::ErrorKind::InvalidData, OVERRUN_MESSAGE)) + } + + async fn next_frame(&mut self) -> Result>, io::Error> { + let now = Instant::now(); + if let Some(total_deadline) = self.total_deadline + && now >= total_deadline + { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + TOTAL_TIMEOUT_MESSAGE, + )); + } + let (deadline, message) = self.next_deadline(now); + match deadline { + Some(deadline) => tokio::time::timeout_at(deadline.into(), self.source.next()) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, message)), + None => Ok(self.source.next().await), + } + } + + fn next_deadline(&self, now: Instant) -> (Option, &'static str) { + let idle_deadline = now.checked_add(self.idle_timeout); + match (idle_deadline, self.total_deadline) { + (Some(idle), Some(total)) if idle < total => (Some(idle), IDLE_TIMEOUT_MESSAGE), + (_, Some(total)) => (Some(total), TOTAL_TIMEOUT_MESSAGE), + (idle, None) => (idle, IDLE_TIMEOUT_MESSAGE), + } + } +} + +fn fail(error: io::Error) -> GuardedFrame { + Some((Err(error), None)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metrics::Metrics; + use http_body_util::BodyExt as _; + + fn body_from_chunks(chunks: Vec<&'static [u8]>) -> Body { + Body::from_stream(futures_util::stream::iter( + chunks + .into_iter() + .map(|chunk| Ok::<_, io::Error>(Bytes::from_static(chunk))), + )) + } + + async fn collect_error(body: Body) -> io::Error { + let mut error = body + .collect() + .await + .map(|collected| collected.to_bytes()) + .expect_err("the guarded stream fails") + .into_inner(); + loop { + error = match error.downcast::() { + Ok(error) => return *error, + Err(error) => error + .downcast::() + .expect("the guarded stream fails with an io error") + .into_inner(), + }; + } + } + + #[tokio::test] + async fn a_stream_matching_its_content_length_passes_through_unchanged() { + let guarded = DownloadStreamPolicy::for_passthrough(30_000) + .guard(body_from_chunks(vec![b"he", b"llo"]), 5); + let collected = guarded + .collect() + .await + .expect("the guarded stream ends") + .to_bytes(); + assert_eq!(&b"hello"[..], &collected[..]); + } + + #[tokio::test] + async fn a_stream_that_ends_before_its_content_length_fails_with_unexpected_eof() { + let guarded = + DownloadStreamPolicy::for_passthrough(30_000).guard(body_from_chunks(vec![b"hel"]), 5); + assert_eq!( + io::ErrorKind::UnexpectedEof, + collect_error(guarded).await.kind() + ); + } + + #[tokio::test] + async fn a_stream_that_overruns_its_content_length_fails_with_invalid_data() { + let guarded = DownloadStreamPolicy::for_passthrough(30_000) + .guard(body_from_chunks(vec![b"hello", b"world"]), 5); + assert_eq!( + io::ErrorKind::InvalidData, + collect_error(guarded).await.kind() + ); + } + + #[tokio::test] + async fn a_chunk_larger_than_the_remaining_content_length_fails_with_invalid_data() { + let guarded = DownloadStreamPolicy::for_passthrough(30_000) + .guard(body_from_chunks(vec![b"hello world"]), 5); + assert_eq!( + io::ErrorKind::InvalidData, + collect_error(guarded).await.kind() + ); + } + + #[tokio::test] + async fn a_stalled_stream_fails_at_its_idle_timeout() { + let stalled = + Body::from_stream(futures_util::stream::pending::>()); + let guarded = DownloadStreamPolicy::for_passthrough(1).guard(stalled, 5); + assert_eq!(io::ErrorKind::TimedOut, collect_error(guarded).await.kind()); + } + + #[tokio::test] + async fn an_external_stream_that_sends_past_its_content_length_is_counted() { + let metrics = Metrics::new(); + let guarded = DownloadStreamPolicy::for_external(30_000, metrics.external()) + .guard(body_from_chunks(vec![b"hello", b"world"]), 5); + assert_eq!( + io::ErrorKind::InvalidData, + collect_error(guarded).await.kind() + ); + assert!( + metrics + .render() + .contains("fluxer_media_proxy_external_stream_overruns_total 1\n") + ); + } + + #[tokio::test] + async fn an_external_chunk_past_the_remaining_content_length_is_counted() { + let metrics = Metrics::new(); + let guarded = DownloadStreamPolicy::for_external(30_000, metrics.external()) + .guard(body_from_chunks(vec![b"hello world"]), 5); + assert_eq!( + io::ErrorKind::InvalidData, + collect_error(guarded).await.kind() + ); + assert!( + metrics + .render() + .contains("fluxer_media_proxy_external_stream_overruns_total 1\n") + ); + } + + #[tokio::test] + async fn a_well_behaved_external_stream_leaves_the_overrun_counter_alone() { + let metrics = Metrics::new(); + let guarded = DownloadStreamPolicy::for_external(30_000, metrics.external()) + .guard(body_from_chunks(vec![b"he", b"llo"]), 5); + let collected = guarded + .collect() + .await + .expect("the guarded stream ends") + .to_bytes(); + assert_eq!(&b"hello"[..], &collected[..]); + assert!( + metrics + .render() + .contains("fluxer_media_proxy_external_stream_overruns_total 0\n") + ); + } + + #[tokio::test] + async fn a_capped_stream_that_ends_before_its_cap_passes_through_unchanged() { + let guarded = DownloadStreamPolicy::for_passthrough(30_000) + .guard_capped(body_from_chunks(vec![b"he", b"llo"]), 1024); + let collected = guarded + .collect() + .await + .expect("the guarded stream ends") + .to_bytes(); + assert_eq!(&b"hello"[..], &collected[..]); + } + + #[tokio::test] + async fn a_capped_stream_that_exceeds_its_cap_fails_with_invalid_data() { + let guarded = DownloadStreamPolicy::for_passthrough(30_000) + .guard_capped(body_from_chunks(vec![b"hello", b"world"]), 5); + assert_eq!( + io::ErrorKind::InvalidData, + collect_error(guarded).await.kind() + ); + } + + #[tokio::test] + async fn a_stalled_capped_stream_still_fails_at_its_idle_timeout() { + let stalled = + Body::from_stream(futures_util::stream::pending::>()); + let guarded = DownloadStreamPolicy::for_passthrough(1).guard_capped(stalled, 1024); + assert_eq!(io::ErrorKind::TimedOut, collect_error(guarded).await.kind()); + } +} diff --git a/fluxer_media_proxy/src/server/external/byte_range.rs b/fluxer_media_proxy/src/server/external/byte_range.rs new file mode 100644 index 000000000..b2bc564d6 --- /dev/null +++ b/fluxer_media_proxy/src/server/external/byte_range.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::range; +use axum::http::HeaderValue; + +#[derive(Clone, Copy)] +pub(super) enum ExternalRangeSelection<'a> { + Bounded { start: usize, end: usize }, + From { start: usize }, + Suffix { length: usize }, + Verbatim { spec: &'a str }, +} + +impl ExternalRangeSelection<'_> { + pub(super) fn header_value(self) -> HeaderValue { + let value = match self { + Self::Bounded { start, end } => format!("bytes={start}-{end}"), + Self::From { start } => format!("bytes={start}-"), + Self::Suffix { length } => format!("bytes=-{length}"), + Self::Verbatim { spec } => format!("bytes={spec}"), + }; + HeaderValue::from_str(&value).expect("a parsed external byte range is a valid header") + } + + fn matches(self, actual: range::ContentRange, actual_length: usize) -> bool { + match (self, actual.size) { + (Self::Verbatim { .. }, _) => true, + (Self::Bounded { start, end }, Some(size)) => { + start < size && actual.start == start && actual.end <= end.min(size - 1) + } + (Self::Bounded { start, end }, None) => actual.start == start && actual.end <= end, + (Self::From { start }, Some(size)) => { + start < size && actual.start == start && actual.end < size + } + (Self::From { start }, None) => actual.start == start, + (Self::Suffix { length }, Some(size)) => { + actual.start == size.saturating_sub(length) && actual.end == size - 1 + } + (Self::Suffix { length }, None) => { + actual_length == length || (actual.start == 0 && actual_length < length) + } + } + } +} + +#[derive(Clone)] +pub(super) enum ExternalPartial { + Validated { + content_range: range::ContentRange, + content_length: u64, + }, + Forwarded { + content_range: Option, + }, +} + +impl ExternalPartial { + pub(super) fn content_length(&self) -> Option { + match self { + Self::Validated { content_length, .. } => Some(*content_length), + Self::Forwarded { .. } => None, + } + } + + pub(super) fn header_value(&self) -> Option { + match self { + Self::Validated { content_range, .. } => { + let value = match content_range.size { + Some(size) => { + format!("bytes {}-{}/{size}", content_range.start, content_range.end) + } + None => format!("bytes {}-{}/*", content_range.start, content_range.end), + }; + Some( + HeaderValue::from_str(&value) + .expect("a validated external content range is a valid header"), + ) + } + Self::Forwarded { content_range } => content_range.clone(), + } + } +} + +pub(super) fn parse_external_requested_range(header: &str) -> Option> { + let header = header.trim_matches([' ', '\t']); + let (unit, spec) = header.split_once('=')?; + if !unit.eq_ignore_ascii_case("bytes") { + return None; + } + parse_external_range_spec(spec).or_else(|| forwardable_external_range_spec(spec)) +} + +fn parse_external_range_spec(spec: &str) -> Option> { + if spec.is_empty() || spec.contains(',') { + return None; + } + let (start, end) = spec.split_once('-')?; + match ( + range::parse_decimal_usize(start), + range::parse_decimal_usize(end), + ) { + (Some(start), Some(end)) if end >= start => { + Some(ExternalRangeSelection::Bounded { start, end }) + } + (Some(start), None) if end.is_empty() => Some(ExternalRangeSelection::From { start }), + (None, Some(length)) if start.is_empty() && length > 0 => { + Some(ExternalRangeSelection::Suffix { length }) + } + _ => None, + } +} + +fn forwardable_external_range_spec(spec: &str) -> Option> { + if spec.is_empty() || !spec.bytes().all(|byte| byte.is_ascii_graphic()) { + return None; + } + Some(ExternalRangeSelection::Verbatim { spec }) +} + +pub(super) fn validate_external_partial( + requested_range: Option>, + content_range: Option<&str>, + content_length: Option, + max_media_proxy_bytes: usize, +) -> Option { + let requested = requested_range?; + if let ExternalRangeSelection::Verbatim { .. } = requested { + return Some(ExternalPartial::Forwarded { + content_range: content_range.and_then(|raw| HeaderValue::from_str(raw).ok()), + }); + } + let actual = range::parse_content_range(content_range)?; + let actual_length = actual + .end + .checked_sub(actual.start) + .and_then(|length| length.checked_add(1))?; + if actual_length > max_media_proxy_bytes + || content_length.is_some_and(|length| length != actual_length as u64) + || !requested.matches(actual, actual_length) + { + return None; + } + Some(ExternalPartial::Validated { + content_range: actual, + content_length: u64::try_from(actual_length).ok()?, + }) +} diff --git a/fluxer_media_proxy/src/server/external/fetch.rs b/fluxer_media_proxy/src/server/external/fetch.rs new file mode 100644 index 000000000..98ce3e1f8 --- /dev/null +++ b/fluxer_media_proxy/src/server/external/fetch.rs @@ -0,0 +1,462 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ + ExternalBody, ExternalFetchError, ExternalSuccessStatus, FetchedExternal, + byte_range::{ExternalRangeSelection, validate_external_partial}, + is_redirect_status, + response::{ExternalStreamDecision, external_stream_decision}, +}; +use crate::{ + byte_budget::{BudgetedBytes, ByteBudget, ByteReservation}, + constants::{self, AssetExtension}, + disposition::PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES, + http_headers, metrics, mime, public_net_policy, + request_log::{self, Stage}, + response_body_limit, + server::{format_policy::image_extension_from_filename, params::url_filename, state::AppState}, +}; +use axum::http::{HeaderMap, HeaderName, Method, StatusCode, header}; +use bytes::Bytes; +use tracing::warn; + +const EXTERNAL_FETCH_REDIRECT_LIMIT: usize = 5; +const DEFAULT_EXTERNAL_CONTENT_TYPE: &str = "application/octet-stream"; +const EXTERNAL_SNIFF_PREFIX_BYTES: usize = 8192; + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(super) enum ExternalFetchMode { + Buffered, + Streaming, +} + +impl ExternalFetchMode { + fn allows_streaming(self) -> bool { + self == Self::Streaming + } +} + +pub(super) struct ExternalFetchRequest<'a> { + pub(super) app: &'a AppState, + pub(super) url: &'a str, + pub(super) range: Option>, + pub(super) mode: ExternalFetchMode, +} + +pub(super) async fn fetch_external_with_range( + request: ExternalFetchRequest<'_>, +) -> Result { + let start_ms = metrics::now_ms(); + let result = fetch_external_inner(request).await; + request_log::record_stage(Stage::Fetch, (metrics::now_ms() - start_ms).max(0) as u64); + result +} + +struct ExternalUpstream { + url: String, + response: reqwest::Response, +} + +async fn send_external_request( + app: &AppState, + url: &str, + method: Method, + range: Option>, +) -> Result { + let external_metrics = app.metrics.external(); + let mut current_url = url.to_owned(); + let mut visited: Vec = Vec::new(); + visited + .try_reserve_exact(EXTERNAL_FETCH_REDIRECT_LIMIT + 1) + .map_err(|_| ExternalFetchError::BufferAllocationFailed)?; + for _ in 0..=EXTERNAL_FETCH_REDIRECT_LIMIT { + if visited.iter().any(|seen| seen == ¤t_url) { + warn!(url = %current_url, "redirect loop detected"); + return Err(ExternalFetchError::TooManyRedirects); + } + visited.push(current_url.clone()); + if let Err(err) = public_net_policy::validate_url(¤t_url) { + warn!(?err, url = %current_url, "blocked external fetch"); + external_metrics.record_blocked_url(); + return Err(ExternalFetchError::BlockedUrl); + } + let mut outbound = app + .media + .external_client() + .request(method.clone(), ¤t_url); + if let Some(range) = range { + outbound = outbound.header(header::RANGE, range.header_value()); + } + let response = outbound.send().await.map_err(|err| { + if public_net_policy::is_pinned_dns_failure(&err) { + warn!(?err, url = %current_url, "blocked external fetch"); + external_metrics.record_blocked_url(); + return ExternalFetchError::BlockedUrl; + } + warn!(url = %current_url, %err, "external send failed"); + external_metrics.record_fetch_failure(); + ExternalFetchError::FetchFailed + })?; + let status = response.status(); + if is_redirect_status(status) { + let location = match bounded_single_visible_header( + response.headers(), + &header::LOCATION, + ) { + Some(location) if !location.is_empty() => location, + Some(_) | None => { + warn!(url = %current_url, status = status.as_u16(), "redirect has an invalid Location"); + external_metrics.record_fetch_failure(); + return Err(ExternalFetchError::FetchFailed); + } + }; + current_url = + public_net_policy::resolve_redirect(¤t_url, location).map_err(|err| { + warn!(url = %current_url, %location, ?err, "redirect target blocked"); + external_metrics.record_blocked_url(); + ExternalFetchError::BlockedUrl + })?; + continue; + } + if !status.is_success() { + return Err(ExternalFetchError::UpstreamFailure(status)); + } + return Ok(ExternalUpstream { + url: current_url, + response, + }); + } + Err(ExternalFetchError::TooManyRedirects) +} + +pub(super) struct ExternalHead { + pub(super) url: String, + pub(super) status: StatusCode, + pub(super) content_type: String, + pub(super) content_length: Option, +} + +pub(super) async fn fetch_external_head( + app: &AppState, + url: &str, +) -> Result { + let start_ms = metrics::now_ms(); + let result = send_external_request(app, url, Method::HEAD, None).await; + request_log::record_stage(Stage::Fetch, (metrics::now_ms() - start_ms).max(0) as u64); + let ExternalUpstream { url, response } = result?; + Ok(ExternalHead { + url, + status: response.status(), + content_type: external_content_type(response.headers()), + content_length: validated_content_length(response.headers()), + }) +} + +async fn fetch_external_inner( + request: ExternalFetchRequest<'_>, +) -> Result { + let ExternalFetchRequest { + app, + url, + range, + mode, + } = request; + let external_metrics = app.metrics.external(); + let ExternalUpstream { + url: current_url, + mut response, + } = send_external_request(app, url, Method::GET, range).await?; + let content_type = external_content_type(response.headers()); + let declared_length = validated_content_length(response.headers()); + if let Some(len) = declared_length + && len > constants::MAX_MEDIA_PROXY_BYTES as u64 + { + warn!(url = %current_url, len, "external payload too large"); + return Err(ExternalFetchError::PayloadTooLarge); + } + let status = if response.status() == StatusCode::PARTIAL_CONTENT { + let content_range = + bounded_single_visible_header(response.headers(), &header::CONTENT_RANGE); + let Some(partial) = validate_external_partial( + range, + content_range, + declared_length, + constants::MAX_MEDIA_PROXY_BYTES, + ) else { + warn!(url = %current_url, "invalid upstream partial response"); + external_metrics.record_fetch_failure(); + return Err(ExternalFetchError::FetchFailed); + }; + ExternalSuccessStatus::Partial(partial) + } else { + ExternalSuccessStatus::Complete + }; + let mut prefix = Bytes::new(); + if let ExternalStreamDecision::Stream(content_length) = external_stream_decision( + mode.allows_streaming(), + response.content_length(), + &content_type, + ) { + prefix = external_body_prefix(&mut response, ¤t_url, &external_metrics).await?; + if !external_streamed_source_is_svg(&prefix, &url_filename(¤t_url)) { + return Ok(FetchedExternal { + url: current_url, + status, + body: ExternalBody::Streaming { + response, + prefix, + content_length, + }, + content_type, + }); + } + } + let data = buffer_external_response(ExternalBufferRequest { + response, + prefix, + url: ¤t_url, + budget: app.media.external_buffer_bytes(), + metrics: &external_metrics, + content_length: declared_length, + limit: constants::MAX_MEDIA_PROXY_BYTES, + }) + .await?; + if status + .partial() + .and_then(|partial| partial.content_length()) + .is_some_and(|expected| data.len() as u64 != expected) + { + warn!(url = %current_url, "upstream partial response body length mismatch"); + external_metrics.record_fetch_failure(); + return Err(ExternalFetchError::FetchFailed); + } + Ok(FetchedExternal { + url: current_url, + status, + body: ExternalBody::Buffered(data), + content_type, + }) +} + +fn external_content_type(headers: &HeaderMap) -> String { + bounded_single_visible_header(headers, &header::CONTENT_TYPE) + .filter(|content_type| !content_type.is_empty()) + .unwrap_or(DEFAULT_EXTERNAL_CONTENT_TYPE) + .to_owned() +} + +async fn external_body_prefix( + response: &mut reqwest::Response, + url: &str, + metrics: &metrics::external::ExternalMetrics, +) -> Result { + let mut prefix: Vec = Vec::new(); + prefix + .try_reserve_exact(EXTERNAL_SNIFF_PREFIX_BYTES) + .map_err(|_| ExternalFetchError::BufferAllocationFailed)?; + let mut chunks_read = 0_u64; + let chunks_max = + response_body_limit::response_body_chunk_limit(constants::MAX_MEDIA_PROXY_BYTES as u64); + while prefix.len() < EXTERNAL_SNIFF_PREFIX_BYTES { + let Some(chunk) = response.chunk().await.map_err(|err| { + warn!(url = %url, %err, "external body read failed"); + metrics.record_fetch_failure(); + ExternalFetchError::FetchFailed + })? + else { + break; + }; + if chunk.len() > response_body_limit::RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX { + warn!(url = %url, "external response transport chunk exceeded its byte bound"); + return Err(ExternalFetchError::PayloadTooLarge); + } + chunks_read = chunks_read + .checked_add(1) + .filter(|chunks| *chunks <= chunks_max) + .ok_or_else(|| { + warn!(url = %url, chunks_max, "external sniff prefix exceeded its chunk limit"); + ExternalFetchError::PayloadTooLarge + })?; + prefix.extend_from_slice(&chunk); + } + Ok(Bytes::from(prefix)) +} + +pub(super) fn external_streamed_source_is_svg(prefix: &[u8], filename: &str) -> bool { + image_extension_from_filename(filename) == Some(AssetExtension::Svg) + || mime::sniff(&prefix[..prefix.len().min(EXTERNAL_SNIFF_PREFIX_BYTES)]).mime + == "image/svg+xml" +} + +pub(super) async fn buffer_external_body( + app: &AppState, + url: &str, + response: reqwest::Response, + prefix: Bytes, + content_length: Option, +) -> Result { + let external_metrics = app.metrics.external(); + buffer_external_response(ExternalBufferRequest { + response, + prefix, + url, + budget: app.media.external_buffer_bytes(), + metrics: &external_metrics, + content_length, + limit: constants::MAX_MEDIA_PROXY_BYTES, + }) + .await +} + +fn bounded_single_visible_header<'a>(headers: &'a HeaderMap, name: &HeaderName) -> Option<&'a str> { + let mut values = headers.get_all(name).iter(); + let value = values.next()?; + if values.next().is_some() || value.as_bytes().len() > PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES { + return None; + } + value.to_str().ok() +} + +fn validated_content_length(headers: &HeaderMap) -> Option { + let mut values = headers.get_all(header::CONTENT_LENGTH).iter(); + let value = values.next()?; + if values.next().is_some() || value.as_bytes().len() > PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES { + return None; + } + http_headers::parse_content_length(headers) +} + +pub(super) struct ExternalBufferRequest<'a> { + pub(super) response: reqwest::Response, + pub(super) prefix: Bytes, + pub(super) url: &'a str, + pub(super) budget: &'a ByteBudget, + pub(super) metrics: &'a metrics::external::ExternalMetrics, + pub(super) content_length: Option, + pub(super) limit: usize, +} + +pub(super) async fn buffer_external_response( + request: ExternalBufferRequest<'_>, +) -> Result { + let ExternalBufferRequest { + mut response, + prefix, + url, + budget, + metrics, + content_length, + limit, + } = request; + let declared_length = match content_length { + Some(length) if length > limit as u64 => { + warn!(url = %url, length, "external payload too large"); + return Err(ExternalFetchError::PayloadTooLarge); + } + Some(length) => { + Some(usize::try_from(length).expect("a bounded external content length fits usize")) + } + None => None, + }; + let initial_capacity = declared_length.unwrap_or(0).max(prefix.len()); + let _transport_chunk_reservation = reserve( + budget, + metrics, + response_body_limit::RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX, + )?; + let mut reservation = reserve(budget, metrics, initial_capacity)?; + let mut buf: Vec = Vec::new(); + buf.try_reserve_exact(initial_capacity) + .map_err(|_| ExternalFetchError::BufferAllocationFailed)?; + let mut reserved_bytes = + grow_to_capacity(&mut reservation, metrics, buf.capacity(), initial_capacity)?; + buf.extend_from_slice(&prefix); + let mut chunks_read = 0_u64; + let chunks_max = response_body_limit::response_body_chunk_limit(limit as u64); + while let Some(chunk) = response.chunk().await.map_err(|err| { + warn!(url = %url, %err, "external body read failed"); + metrics.record_fetch_failure(); + ExternalFetchError::FetchFailed + })? { + if chunk.len() > response_body_limit::RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX { + warn!(url = %url, "external response transport chunk exceeded its byte bound"); + return Err(ExternalFetchError::PayloadTooLarge); + } + chunks_read = chunks_read + .checked_add(1) + .filter(|chunks| *chunks <= chunks_max) + .ok_or_else(|| { + warn!(url = %url, chunks_max, "external payload exceeded its chunk limit"); + ExternalFetchError::PayloadTooLarge + })?; + let Some(next_len) = buf + .len() + .checked_add(chunk.len()) + .filter(|len| *len <= limit) + else { + warn!(url = %url, "external payload too large"); + return Err(ExternalFetchError::PayloadTooLarge); + }; + if next_len > buf.capacity() { + let target_capacity = buf + .capacity() + .checked_mul(2) + .expect("a bounded external buffer capacity doubling fits usize") + .max(next_len) + .min(limit); + let additional = target_capacity + .checked_sub(reserved_bytes) + .expect("an external buffer target capacity covers its reservation"); + if !reservation.try_grow(additional) { + metrics.record_buffer_rejected(); + return Err(ExternalFetchError::BufferBudgetExhausted); + } + reserved_bytes = target_capacity; + buf.try_reserve_exact(target_capacity - buf.len()) + .map_err(|_| ExternalFetchError::BufferAllocationFailed)?; + reserved_bytes = + grow_to_capacity(&mut reservation, metrics, buf.capacity(), reserved_bytes)?; + } + buf.extend_from_slice(&chunk); + } + if let Some(expected_length) = declared_length + && buf.len() != expected_length + { + warn!( + url = %url, + expected_length, + actual_length = buf.len(), + "external buffered body length did not match Content-Length" + ); + metrics.record_fetch_failure(); + return Err(ExternalFetchError::FetchFailed); + } + reservation.shrink_to(buf.capacity()); + Ok(BudgetedBytes::budgeted(Bytes::from(buf), reservation)) +} + +fn reserve( + budget: &ByteBudget, + metrics: &metrics::external::ExternalMetrics, + amount: usize, +) -> Result { + budget.try_reserve(amount).ok_or_else(|| { + metrics.record_buffer_rejected(); + ExternalFetchError::BufferBudgetExhausted + }) +} + +fn grow_to_capacity( + reservation: &mut ByteReservation, + metrics: &metrics::external::ExternalMetrics, + capacity: usize, + reserved_bytes: usize, +) -> Result { + if capacity <= reserved_bytes { + return Ok(reserved_bytes); + } + if !reservation.try_grow(capacity - reserved_bytes) { + metrics.record_buffer_rejected(); + return Err(ExternalFetchError::BufferBudgetExhausted); + } + Ok(capacity) +} diff --git a/fluxer_media_proxy/src/server/external/mod.rs b/fluxer_media_proxy/src/server/external/mod.rs new file mode 100644 index 000000000..530e49518 --- /dev/null +++ b/fluxer_media_proxy/src/server/external/mod.rs @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod byte_range; +mod fetch; +mod response; + +use crate::{ + byte_budget::BudgetedBytes, + config::Config, + constants::{self, AssetExtension}, + external_path, mime, + server::{ + download_stream::DownloadStreamPolicy, + external::{ + byte_range::{ExternalPartial, ExternalRangeSelection, parse_external_requested_range}, + fetch::{ + ExternalFetchMode, ExternalFetchRequest, ExternalHead, buffer_external_body, + fetch_external_head, fetch_external_with_range, + }, + response::{ + ExternalPartialResponse, ExternalStreamingResponse, external_partial_response, + external_streaming_response, + }, + }, + format_policy::{ + content_type_is_trustworthy, image_extension_from_filename, is_svg_content_type, + source_image_format, + }, + params::{animated_param, bool_param, url_filename}, + response::{ + content_disposition_header, + error::{text, text_with_source}, + }, + state::AppState, + stored::response::passthrough_head_response, + transform::{ + CachedTransformProbe, ServeBytesRequest, cached_transform_response, + parameters::TransformRoute, serve_bytes_or_transform, + }, + }, + signing, +}; +use axum::{ + http::{HeaderMap, Method, StatusCode, header}, + response::Response, +}; +use bytes::Bytes; +use std::{collections::HashMap, sync::Arc, time::Duration}; + +const EXTERNAL_SNIFF_PREFIX_BYTES: usize = 8192; +const EXTERNAL_HINT_CACHE_ENTRIES: u64 = 4096; + +pub(in crate::server) type ExternalHintCache = moka::sync::Cache; + +// What the origin last served for a url, so a repeat transform of a hot embed can be +// answered from the transform cache instead of downloading the origin asset again. +#[derive(Clone)] +pub(in crate::server) struct ExternalHint { + url: String, + content_type: String, + source_format: Option, +} + +pub(in crate::server) fn new_external_hint_cache(cfg: &Config) -> ExternalHintCache { + moka::sync::Cache::builder() + .max_capacity(EXTERNAL_HINT_CACHE_ENTRIES) + .time_to_live(Duration::from_millis( + cfg.media.transform_cache_ttl_ms.max(1), + )) + .build() +} + +fn external_sniffed_content_type(data: &[u8], filename: &str, content_type: String) -> String { + let prefix = &data[..data.len().min(EXTERNAL_SNIFF_PREFIX_BYTES)]; + if mime::sniff(prefix).mime == "image/svg+xml" { + return "image/svg+xml".to_owned(); + } + if content_type_is_trustworthy(&content_type) { + return content_type; + } + mime::detect(prefix, filename, Some(&content_type)) +} + +fn external_hint(url: &str, filename: &str, content_type: &str, data: &[u8]) -> ExternalHint { + let content_type = external_sniffed_content_type(data, filename, content_type.to_owned()); + let source_format = source_image_format(mime::sniff(data).mime, &content_type, filename); + ExternalHint { + url: url.to_owned(), + content_type, + source_format, + } +} + +fn external_cached_transform( + app: &Arc, + url: &str, + method: &Method, + params: &HashMap, + headers: &HeaderMap, +) -> Option { + let hint = app.media.external_hints().get(url)?; + let filename = url_filename(&hint.url); + cached_transform_response( + app.media.transforms(), + CachedTransformProbe { + method: method.clone(), + content_type: &hint.content_type, + source_format: hint.source_format, + cache_identity: &hint.url, + filename: &filename, + route: TransformRoute::External, + params, + headers, + }, + ) +} + +pub(in crate::server) struct FetchedExternal { + url: String, + status: ExternalSuccessStatus, + body: ExternalBody, + content_type: String, +} + +#[derive(Clone)] +enum ExternalSuccessStatus { + Complete, + Partial(ExternalPartial), +} + +impl ExternalSuccessStatus { + fn partial(&self) -> Option { + match self { + Self::Partial(partial) => Some(partial.clone()), + Self::Complete => None, + } + } + + fn is_partial(&self) -> bool { + matches!(self, Self::Partial(_)) + } +} + +enum ExternalBody { + Buffered(BudgetedBytes), + Streaming { + response: reqwest::Response, + prefix: Bytes, + content_length: Option, + }, +} + +impl ExternalBody { + async fn into_buffered( + self, + app: &AppState, + url: &str, + ) -> Result { + match self { + Self::Buffered(data) => Ok(data), + Self::Streaming { + response, + prefix, + content_length, + } => buffer_external_body(app, url, response, prefix, content_length).await, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub(in crate::server) enum ExternalFetchError { + BlockedUrl, + PayloadTooLarge, + BufferBudgetExhausted, + BufferAllocationFailed, + UpstreamFailure(StatusCode), + TooManyRedirects, + FetchFailed, +} + +struct PreparedExternalRequest<'a> { + app: &'a Arc, + method: Method, + params: &'a HashMap, + headers: &'a HeaderMap, + url: String, + wants_transform: bool, + forward_range: Option>, +} + +impl<'a> PreparedExternalRequest<'a> { + fn new( + app: &'a Arc, + method: Method, + rest: &str, + params: &'a HashMap, + headers: &'a HeaderMap, + ) -> Result> { + let Some((sig, proxy_path)) = rest.split_once('/') else { + return Err(Box::new(text(StatusCode::BAD_REQUEST, "Bad Request"))); + }; + if !signing::verify_signature(proxy_path, sig, app.cfg.secret_key.as_bytes()) { + return Err(Box::new(text(StatusCode::UNAUTHORIZED, "Unauthorized"))); + } + let Ok(url) = external_path::reconstruct_original_url(proxy_path) else { + return Err(Box::new(text(StatusCode::BAD_REQUEST, "Bad Request"))); + }; + let url_ext_is_svg = + image_extension_from_filename(&url_filename(&url)) == Some(AssetExtension::Svg); + let wants_transform = url_ext_is_svg + || params.contains_key("width") + || params.contains_key("height") + || params.contains_key("format") + || params.contains_key("quality") + || animated_param(params, false); + let client_range = headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + .and_then(parse_external_requested_range); + let forward_range = if wants_transform { None } else { client_range }; + Ok(Self { + app, + method, + params, + headers, + url, + wants_transform, + forward_range, + }) + } + + fn initial_fetch_mode(&self) -> ExternalFetchMode { + if self.wants_transform { + ExternalFetchMode::Buffered + } else { + ExternalFetchMode::Streaming + } + } + + async fn fetch(&self) -> Result> { + let fetched = self + .fetch_with(self.forward_range, self.initial_fetch_mode()) + .await?; + if self.forward_range.is_some() + && fetched.status.is_partial() + && is_svg_content_type(&fetched.content_type) + { + drop(fetched); + return self.fetch_with(None, ExternalFetchMode::Buffered).await; + } + Ok(fetched) + } + + async fn fetch_with( + &self, + range: Option>, + mode: ExternalFetchMode, + ) -> Result> { + fetch_external_with_range(ExternalFetchRequest { + app: self.app, + url: &self.url, + range, + mode, + }) + .await + .map_err(|err| Box::new(external_fetch_error_response(&self.url, err))) + } + + fn stream_policy(&self) -> DownloadStreamPolicy { + DownloadStreamPolicy::for_external( + self.app.cfg.socket_io_timeout_ms, + self.app.metrics.external(), + ) + } + + fn wants_head_passthrough(&self) -> bool { + self.method == Method::HEAD && !self.wants_transform && self.forward_range.is_none() + } +} + +pub(in crate::server) async fn serve_external( + app: &Arc, + method: Method, + rest: &str, + params: &HashMap, + headers: &HeaderMap, +) -> Response { + let prepared = match PreparedExternalRequest::new(app, method, rest, params, headers) { + Ok(prepared) => prepared, + Err(response) => return *response, + }; + if prepared.wants_transform + && let Some(cached) = + external_cached_transform(app, &prepared.url, &prepared.method, params, headers) + { + return cached; + } + if prepared.wants_head_passthrough() + && let Some(response) = external_head_passthrough(app, &prepared.url, params).await + { + return response; + } + let fetched = match prepared.fetch().await { + Ok(fetched) => fetched, + Err(response) => return *response, + }; + serve_fetched_external(prepared, fetched).await +} + +// Unlike the object store, a third-party origin answers a GET by sending a body the proxy +// then discards, and an untrusted content type makes the proxy buffer that body in full. +// A metadata-only probe is worth its own upstream HEAD here. +async fn external_head_passthrough( + app: &AppState, + url: &str, + params: &HashMap, +) -> Option { + let head = fetch_external_head(app, url).await.ok()?; + external_head_response(head, bool_param(params, "download", false)) +} + +fn external_head_response(head: ExternalHead, requested_download: bool) -> Option { + if head.status != StatusCode::OK || is_svg_content_type(&head.content_type) { + return None; + } + let total_len = usize::try_from(head.content_length?).ok()?; + if total_len > constants::MAX_MEDIA_PROXY_BYTES { + return None; + } + let filename = url_filename(&head.url); + let content_type = if content_type_is_trustworthy(&head.content_type) { + head.content_type + } else { + mime::detect(&[], &filename, Some(&head.content_type)) + }; + let disposition = + content_disposition_header(&content_type, requested_download, Some(&filename)); + Some(passthrough_head_response( + &content_type, + total_len, + None, + Some(disposition), + )) +} + +async fn serve_fetched_external( + prepared: PreparedExternalRequest<'_>, + fetched: FetchedExternal, +) -> Response { + let stream_policy = prepared.stream_policy(); + let PreparedExternalRequest { + app, + method, + params, + headers, + url: requested_url, + forward_range, + .. + } = prepared; + let filename = url_filename(&fetched.url); + let requested_download = bool_param(params, "download", false); + if forward_range.is_some() + && let Some(partial) = fetched.status.partial() + { + let disposition = Some(content_disposition_header( + &fetched.content_type, + requested_download, + Some(&filename), + )); + return external_partial_response(ExternalPartialResponse { + method, + fetched, + partial, + disposition, + stream_policy, + }); + } + let FetchedExternal { + url: fetched_url, + body, + content_type, + .. + } = fetched; + let data = match body { + ExternalBody::Streaming { + response, + prefix, + content_length, + } => { + let disposition = Some(content_disposition_header( + &content_type, + requested_download, + Some(&filename), + )); + return external_streaming_response(ExternalStreamingResponse { + method, + response, + prefix, + content_length, + content_type: &content_type, + disposition, + stream_policy, + }); + } + body => match body.into_buffered(app, &fetched_url).await { + Ok(data) => data, + Err(err) => return external_fetch_error_response(&fetched_url, err), + }, + }; + app.media.external_hints().insert( + requested_url, + external_hint(&fetched_url, &filename, &content_type, data.as_bytes()), + ); + serve_bytes_or_transform( + app.media.transforms(), + ServeBytesRequest { + method, + data: data.as_bytes().clone(), + content_type, + cache_identity: &fetched_url, + filename: &filename, + route: TransformRoute::External, + params, + headers, + }, + ) + .await +} + +pub(in crate::server) async fn fetch_external( + app: &AppState, + url: &str, +) -> Result<(String, BudgetedBytes), ExternalFetchError> { + let fetched = fetch_external_with_range(ExternalFetchRequest { + app, + url, + range: None, + mode: ExternalFetchMode::Buffered, + }) + .await?; + let FetchedExternal { url, body, .. } = fetched; + let data = body.into_buffered(app, &url).await?; + Ok((url, data)) +} + +fn external_fetch_error_response(url: &str, err: ExternalFetchError) -> Response { + match err { + ExternalFetchError::BlockedUrl => text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "external_blocked_url", + url, + ), + ExternalFetchError::PayloadTooLarge => text_with_source( + StatusCode::PAYLOAD_TOO_LARGE, + "Payload Too Large", + "external_payload_too_large", + url, + ), + ExternalFetchError::BufferBudgetExhausted => text_with_source( + StatusCode::SERVICE_UNAVAILABLE, + "Service Unavailable", + "external_buffer_budget_exhausted", + url, + ), + ExternalFetchError::BufferAllocationFailed => text_with_source( + StatusCode::SERVICE_UNAVAILABLE, + "Service Unavailable", + "external_buffer_allocation_failed", + url, + ), + ExternalFetchError::UpstreamFailure(status) => text_with_source( + map_upstream_status(status), + "Upstream fetch failed", + "external_upstream_status", + format!("url={url} upstream_status={}", status.as_u16()), + ), + ExternalFetchError::TooManyRedirects | ExternalFetchError::FetchFailed => text_with_source( + StatusCode::BAD_GATEWAY, + "Bad Gateway", + "external_fetch_failed", + format!("url={url} err={err:?}"), + ), + } +} + +pub(in crate::server) fn map_upstream_status(status: StatusCode) -> StatusCode { + match status.as_u16() { + 400 => StatusCode::BAD_REQUEST, + 401 => StatusCode::UNAUTHORIZED, + 403 => StatusCode::FORBIDDEN, + 404 => StatusCode::NOT_FOUND, + 405 => StatusCode::METHOD_NOT_ALLOWED, + 406 => StatusCode::NOT_ACCEPTABLE, + 408 => StatusCode::REQUEST_TIMEOUT, + 409 => StatusCode::CONFLICT, + 410 => StatusCode::GONE, + 411 => StatusCode::LENGTH_REQUIRED, + 412 => StatusCode::PRECONDITION_FAILED, + 413 => StatusCode::PAYLOAD_TOO_LARGE, + 414 => StatusCode::URI_TOO_LONG, + 415 => StatusCode::UNSUPPORTED_MEDIA_TYPE, + 416 => StatusCode::RANGE_NOT_SATISFIABLE, + 428 => StatusCode::from_u16(428).expect("428 is a valid status code"), + 429 => StatusCode::TOO_MANY_REQUESTS, + _ => StatusCode::BAD_GATEWAY, + } +} + +pub(in crate::server) fn map_internal_metadata_upstream_status(status: StatusCode) -> StatusCode { + match status.as_u16() { + 429 => StatusCode::SERVICE_UNAVAILABLE, + _ => map_upstream_status(status), + } +} + +fn is_redirect_status(status: StatusCode) -> bool { + matches!( + status, + StatusCode::MOVED_PERMANENTLY + | StatusCode::FOUND + | StatusCode::SEE_OTHER + | StatusCode::TEMPORARY_REDIRECT + | StatusCode::PERMANENT_REDIRECT + ) +} + +#[cfg(test)] +mod tests; diff --git a/fluxer_media_proxy/src/server/external/response.rs b/fluxer_media_proxy/src/server/external/response.rs new file mode 100644 index 000000000..dd1383c81 --- /dev/null +++ b/fluxer_media_proxy/src/server/external/response.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ExternalBody, FetchedExternal, byte_range::ExternalPartial}; +use crate::{ + constants, http_headers, + server::{ + download_stream::DownloadStreamPolicy, + format_policy::{content_type_is_trustworthy, is_svg_content_type}, + response::retained_response_bytes, + }, +}; +use axum::{ + body::Body, + http::{HeaderValue, Method, StatusCode, header}, + response::Response, +}; +use bytes::Bytes; +use futures_util::StreamExt as _; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ExternalStreamDecision { + Stream(Option), + Buffer, +} + +// A passthrough streams on the strength of its content type alone. An upstream that declares no +// length still streams; the declared length only decides whether the response can name one. +pub(super) fn external_stream_decision( + allow_stream: bool, + content_length: Option, + content_type: &str, +) -> ExternalStreamDecision { + if !allow_stream + || !content_type_is_trustworthy(content_type) + || is_svg_content_type(content_type) + || content_length.is_some_and(|length| length > constants::MAX_MEDIA_PROXY_BYTES as u64) + { + return ExternalStreamDecision::Buffer; + } + ExternalStreamDecision::Stream(content_length) +} + +pub(super) struct ExternalPartialResponse { + pub(super) method: Method, + pub(super) fetched: FetchedExternal, + pub(super) partial: ExternalPartial, + pub(super) disposition: Option, + pub(super) stream_policy: DownloadStreamPolicy, +} + +pub(super) fn external_partial_response(request: ExternalPartialResponse) -> Response { + let ExternalPartialResponse { + method, + fetched, + partial, + disposition, + stream_policy, + } = request; + let FetchedExternal { + body, content_type, .. + } = fetched; + let body_len = partial + .content_length() + .or_else(|| external_body_length(&body)); + let mut response = Response::new(external_body(method, body, body_len, stream_policy)); + *response.status_mut() = StatusCode::PARTIAL_CONTENT; + http_headers::add_media_headers( + response.headers_mut(), + body_length_hint(body_len), + &content_type, + None, + ); + if let Some(body_len) = body_len { + response + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); + } + if let Some(value) = partial.header_value() { + response.headers_mut().insert(header::CONTENT_RANGE, value); + } + if let Some(value) = disposition { + response + .headers_mut() + .insert(header::CONTENT_DISPOSITION, value); + } + response +} + +pub(super) struct ExternalStreamingResponse<'a> { + pub(super) method: Method, + pub(super) response: reqwest::Response, + pub(super) prefix: Bytes, + pub(super) content_length: Option, + pub(super) content_type: &'a str, + pub(super) disposition: Option, + pub(super) stream_policy: DownloadStreamPolicy, +} + +pub(super) fn external_streaming_response(request: ExternalStreamingResponse<'_>) -> Response { + let ExternalStreamingResponse { + method, + response, + prefix, + content_length, + content_type, + disposition, + stream_policy, + } = request; + let body = if method == Method::HEAD { + Body::empty() + } else { + guarded_stream(response, prefix, content_length, stream_policy) + }; + let mut http_response = Response::new(body); + *http_response.status_mut() = StatusCode::OK; + http_headers::add_media_headers( + http_response.headers_mut(), + body_length_hint(content_length), + content_type, + None, + ); + if let Some(content_length) = content_length { + http_response + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from(content_length)); + } + if let Some(value) = disposition { + http_response + .headers_mut() + .insert(header::CONTENT_DISPOSITION, value); + } + http_response +} + +fn external_body_length(body: &ExternalBody) -> Option { + match body { + ExternalBody::Buffered(data) => Some(data.len() as u64), + ExternalBody::Streaming { content_length, .. } => *content_length, + } +} + +fn external_body( + method: Method, + body: ExternalBody, + expected_length: Option, + stream_policy: DownloadStreamPolicy, +) -> Body { + if method == Method::HEAD { + return Body::empty(); + } + match body { + ExternalBody::Buffered(data) => Body::from(retained_response_bytes(data)), + ExternalBody::Streaming { + response, prefix, .. + } => guarded_stream(response, prefix, expected_length, stream_policy), + } +} + +fn guarded_stream( + response: reqwest::Response, + prefix: Bytes, + expected_length: Option, + stream_policy: DownloadStreamPolicy, +) -> Body { + let has_prefix = !prefix.is_empty(); + let prefixed = + futures_util::stream::iter(has_prefix.then_some(Ok::(prefix))) + .chain(response.bytes_stream()); + let body = Body::from_stream(prefixed); + match expected_length { + Some(length) => stream_policy.guard(body, body_length_hint(Some(length))), + None => stream_policy.guard_capped(body, constants::MAX_MEDIA_PROXY_BYTES), + } +} + +fn body_length_hint(length: Option) -> usize { + length + .and_then(|length| usize::try_from(length).ok()) + .unwrap_or(constants::MAX_MEDIA_PROXY_BYTES) +} diff --git a/fluxer_media_proxy/src/server/external/tests/cache_policy.rs b/fluxer_media_proxy/src/server/external/tests/cache_policy.rs new file mode 100644 index 000000000..1ccafdf5a --- /dev/null +++ b/fluxer_media_proxy/src/server/external/tests/cache_policy.rs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{test_stream_policy, validated_partial}; +use crate::{ + byte_budget::BudgetedBytes, + server::{ + external::{ + ExternalBody, ExternalSuccessStatus, FetchedExternal, + response::{ + ExternalPartialResponse, ExternalStreamingResponse, external_partial_response, + external_streaming_response, + }, + }, + response::{MediaResponse, error::text, media_response}, + stored::response::passthrough_head_response, + }, +}; +use axum::{ + http::{Method, StatusCode}, + response::Response, +}; +use bytes::Bytes; +use http::header; + +fn cache_control_of(response: &Response) -> &str { + response + .headers() + .get(header::CACHE_CONTROL) + .expect("cache-control is always set") + .to_str() + .expect("cache-control is ASCII") +} + +#[tokio::test] +async fn external_streaming_response_caches_forever() { + for (content_type, expected) in [ + ("video/mp4", "public, max-age=31536000, no-transform"), + ("image/webp", "public, max-age=31536000"), + ] { + let upstream = reqwest::Response::from( + http::Response::builder() + .status(StatusCode::OK) + .body("streamed bytes") + .unwrap(), + ); + let response = external_streaming_response(ExternalStreamingResponse { + method: Method::GET, + response: upstream, + prefix: Bytes::new(), + content_length: Some(14), + content_type, + disposition: None, + stream_policy: test_stream_policy(), + }); + + assert_eq!( + expected, + cache_control_of(&response), + "content_type={content_type}" + ); + } +} + +#[test] +fn external_partial_response_caches_forever() { + let fetched = FetchedExternal { + url: "https://media.example.test/clip.webm".to_owned(), + status: ExternalSuccessStatus::Partial(validated_partial("bytes=0-3", "bytes 0-3/10", 4)), + body: ExternalBody::Buffered(BudgetedBytes::unbudgeted(Bytes::from_static(b"abcd"))), + content_type: "video/webm".to_owned(), + }; + let response = external_partial_response(ExternalPartialResponse { + method: Method::GET, + partial: fetched + .status + .partial() + .expect("the fetched response is partial"), + fetched, + disposition: None, + stream_policy: test_stream_policy(), + }); + + assert_eq!( + "public, max-age=31536000, no-transform", + cache_control_of(&response) + ); +} + +#[test] +fn stored_media_responses_cache_forever() { + let response = media_response(MediaResponse { + method: Method::GET, + data: BudgetedBytes::from(Bytes::from_static(b"stored bytes")), + content_type: "image/webp", + range_header: None, + disposition: None, + }); + assert_eq!("public, max-age=31536000", cache_control_of(&response)); + + let streamable = media_response(MediaResponse { + method: Method::GET, + data: BudgetedBytes::from(Bytes::from_static(b"stored bytes")), + content_type: "video/mp4", + range_header: None, + disposition: None, + }); + assert_eq!( + "public, max-age=31536000, no-transform", + cache_control_of(&streamable) + ); + + let head = passthrough_head_response("image/webp", 12, None, None); + assert_eq!("public, max-age=31536000", cache_control_of(&head)); +} + +#[test] +fn error_responses_declare_an_explicit_no_store_policy() { + for status in [ + StatusCode::NOT_FOUND, + StatusCode::BAD_GATEWAY, + StatusCode::INTERNAL_SERVER_ERROR, + ] { + let response = text(status, "nope"); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "no-store", + "status {status} must not be cacheable" + ); + } +} + +#[test] +fn successful_text_responses_are_left_to_the_media_cache_policy() { + let response = text(StatusCode::OK, "fine"); + assert!(response.headers().get(header::CACHE_CONTROL).is_none()); +} diff --git a/fluxer_media_proxy/src/server/external/tests/mod.rs b/fluxer_media_proxy/src/server/external/tests/mod.rs new file mode 100644 index 000000000..b5b43a715 --- /dev/null +++ b/fluxer_media_proxy/src/server/external/tests/mod.rs @@ -0,0 +1,931 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod cache_policy; + +use super::{ + ExternalBody, ExternalFetchError, ExternalSuccessStatus, FetchedExternal, + PreparedExternalRequest, + byte_range::{ + ExternalPartial, ExternalRangeSelection, parse_external_requested_range, + validate_external_partial, + }, + external_fetch_error_response, external_head_response, + fetch::{ + ExternalBufferRequest, ExternalHead, buffer_external_response, + external_streamed_source_is_svg, + }, + is_redirect_status, map_internal_metadata_upstream_status, map_upstream_status, + response::{ + ExternalPartialResponse, ExternalStreamDecision, ExternalStreamingResponse, + external_partial_response, external_stream_decision, external_streaming_response, + }, + serve_fetched_external, +}; +use crate::{ + byte_budget::{BudgetedBytes, ByteBudget}, + config::Config, + constants, + external_path::build_external_media_proxy_path, + metrics::external::ExternalMetrics, + server::{download_stream::DownloadStreamPolicy, state::AppState}, + signing, +}; +use axum::{ + body::to_bytes, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, +}; +use bytes::Bytes; +use std::{collections::HashMap, sync::Arc}; + +fn test_stream_policy() -> DownloadStreamPolicy { + DownloadStreamPolicy::for_passthrough(30_000) +} + +fn validated_partial(requested: &str, content_range: &str, content_length: u64) -> ExternalPartial { + validate_external_partial( + parse_external_requested_range(requested), + Some(content_range), + Some(content_length), + constants::MAX_MEDIA_PROXY_BYTES, + ) + .expect("the upstream partial response is valid") +} + +#[test] +fn external_stream_decision_streams_an_unknown_length_media_body() { + assert_eq!( + ExternalStreamDecision::Stream(Some(1024)), + external_stream_decision(true, Some(1024), "video/mp4") + ); + assert_eq!( + ExternalStreamDecision::Stream(None), + external_stream_decision(true, None, "video/mp4") + ); + assert_eq!( + ExternalStreamDecision::Buffer, + external_stream_decision(false, Some(1024), "video/mp4") + ); + assert_eq!( + ExternalStreamDecision::Buffer, + external_stream_decision(true, Some(1024), "application/octet-stream") + ); + assert_eq!( + ExternalStreamDecision::Buffer, + external_stream_decision(true, None, "") + ); + assert_eq!( + ExternalStreamDecision::Buffer, + external_stream_decision(true, Some(1024), "image/svg+xml") + ); + assert_eq!( + ExternalStreamDecision::Buffer, + external_stream_decision(true, Some(1024), "image/svg+xml; charset=utf-8") + ); + assert_eq!( + ExternalStreamDecision::Buffer, + external_stream_decision( + true, + Some(constants::MAX_MEDIA_PROXY_BYTES as u64 + 1), + "video/mp4" + ) + ); + assert_eq!( + ExternalStreamDecision::Stream(Some(constants::MAX_MEDIA_PROXY_BYTES as u64)), + external_stream_decision( + true, + Some(constants::MAX_MEDIA_PROXY_BYTES as u64), + "video/mp4" + ) + ); +} + +#[tokio::test] +async fn external_streaming_response_passes_body_through() { + let upstream = reqwest::Response::from( + http::Response::builder() + .status(StatusCode::OK) + .body("streamed bytes") + .unwrap(), + ); + let response = external_streaming_response(ExternalStreamingResponse { + method: Method::GET, + response: upstream, + prefix: Bytes::new(), + content_length: Some(14), + content_type: "video/mp4", + disposition: Some(HeaderValue::from_static("inline; filename=\"clip.mp4\"")), + stream_policy: test_stream_policy(), + }); + + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + "14", + response + .headers() + .get(header::CONTENT_LENGTH) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!( + "video/mp4", + response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + ); + let body = to_bytes(response.into_body(), 64).await.unwrap(); + assert_eq!(b"streamed bytes", body.as_ref()); +} + +#[tokio::test] +async fn external_partial_response_streams_partial_body() { + let upstream = reqwest::Response::from( + http::Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .body("abcd") + .unwrap(), + ); + let fetched = FetchedExternal { + url: "https://media.example.test/clip.webm".to_owned(), + status: ExternalSuccessStatus::Partial(validated_partial("bytes=0-3", "bytes 0-3/10", 4)), + body: ExternalBody::Streaming { + response: upstream, + prefix: Bytes::new(), + content_length: Some(4), + }, + content_type: "video/webm".to_owned(), + }; + let response = external_partial_response(ExternalPartialResponse { + method: Method::GET, + partial: fetched + .status + .partial() + .expect("the fetched response is partial"), + fetched, + disposition: None, + stream_policy: test_stream_policy(), + }); + + assert_eq!(StatusCode::PARTIAL_CONTENT, response.status()); + assert_eq!( + "bytes 0-3/10", + response + .headers() + .get(header::CONTENT_RANGE) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!( + "4", + response + .headers() + .get(header::CONTENT_LENGTH) + .unwrap() + .to_str() + .unwrap() + ); + let body = to_bytes(response.into_body(), 64).await.unwrap(); + assert_eq!(b"abcd", body.as_ref()); +} + +#[test] +fn external_partial_response_uses_media_headers() { + let fetched = FetchedExternal { + url: "https://media.example.test/clip.webm".to_owned(), + status: ExternalSuccessStatus::Partial(validated_partial("bytes=0-3", "bytes 0-3/10", 4)), + body: ExternalBody::Buffered(BudgetedBytes::unbudgeted(Bytes::from_static(b"abcd"))), + content_type: "video/webm".to_owned(), + }; + let response = external_partial_response(ExternalPartialResponse { + method: Method::GET, + partial: fetched + .status + .partial() + .expect("the fetched response is partial"), + fetched, + disposition: Some(HeaderValue::from_static("inline; filename=\"clip.webm\"")), + stream_policy: test_stream_policy(), + }); + + assert_eq!(StatusCode::PARTIAL_CONTENT, response.status()); + assert_eq!( + "*", + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!( + "video/webm", + response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!( + "bytes 0-3/10", + response + .headers() + .get(header::CONTENT_RANGE) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!( + "inline; filename=\"clip.webm\"", + response + .headers() + .get(header::CONTENT_DISPOSITION) + .unwrap() + .to_str() + .unwrap() + ); + assert!( + response + .headers() + .contains_key(header::CONTENT_SECURITY_POLICY) + ); + assert!(response.headers().contains_key("strict-transport-security")); + assert!(response.headers().contains_key("permissions-policy")); + assert!( + response + .headers() + .contains_key(header::X_CONTENT_TYPE_OPTIONS) + ); + assert!(response.headers().contains_key("CDN-Cache-Control")); +} + +#[test] +fn external_range_parser_accepts_one_canonical_range() { + assert!(matches!( + parse_external_requested_range("bytes=10-20"), + Some(ExternalRangeSelection::Bounded { start: 10, end: 20 }) + )); + assert!(matches!( + parse_external_requested_range(" bytes=10- \t"), + Some(ExternalRangeSelection::From { start: 10 }) + )); + assert!(matches!( + parse_external_requested_range("BYTES=-20"), + Some(ExternalRangeSelection::Suffix { length: 20 }) + )); + for invalid in ["items=1-2", "bytes=", "bytes=1-2, 3-4"] { + assert!( + parse_external_requested_range(invalid).is_none(), + "accepted {invalid}" + ); + } +} + +#[test] +fn external_partial_validation_matches_requested_range_and_lengths() { + let bounded = parse_external_requested_range("bytes=10-99"); + let partial = validate_external_partial(bounded, Some("bytes 10-49/50"), Some(40), 100) + .expect("bounded partial"); + assert_eq!(Some(40), partial.content_length()); + assert_eq!( + Some(HeaderValue::from_static("bytes 10-49/50")), + partial.header_value() + ); + + let suffix = parse_external_requested_range("bytes=-10"); + assert!(validate_external_partial(suffix, Some("bytes 40-49/50"), Some(10), 10).is_some()); + let oversized_suffix = parse_external_requested_range("bytes=-100"); + assert!( + validate_external_partial(oversized_suffix, Some("bytes 0-49/50"), Some(50), 50).is_some() + ); + + let from = parse_external_requested_range("bytes=10-"); + assert!(validate_external_partial(from, Some("bytes 10-49/50"), Some(40), 40).is_some()); + assert!(validate_external_partial(from, Some("bytes 11-49/50"), Some(39), 40).is_none()); + assert!(validate_external_partial(from, Some("bytes 10-49/*"), Some(40), 40).is_some()); + assert!(validate_external_partial(from, Some("bytes 10-49/50"), Some(39), 40).is_none()); + assert!(validate_external_partial(from, Some("bytes 10-49/50"), Some(40), 39).is_none()); +} + +#[test] +fn upstream_status_mapping_and_redirect_policy_are_closed() { + assert_eq!( + map_upstream_status(StatusCode::NOT_FOUND), + StatusCode::NOT_FOUND + ); + assert_eq!( + map_upstream_status(StatusCode::INTERNAL_SERVER_ERROR), + StatusCode::BAD_GATEWAY + ); + assert!(is_redirect_status(StatusCode::MOVED_PERMANENTLY)); + assert!(is_redirect_status(StatusCode::PERMANENT_REDIRECT)); + assert!(!is_redirect_status(StatusCode::NOT_MODIFIED)); + assert!(!is_redirect_status(StatusCode::OK)); +} + +#[test] +fn upstream_status_is_clamped_to_known_whitelist() { + assert_eq!( + StatusCode::NOT_FOUND, + map_upstream_status(StatusCode::NOT_FOUND) + ); + assert_eq!( + StatusCode::TOO_MANY_REQUESTS, + map_upstream_status(StatusCode::TOO_MANY_REQUESTS) + ); + assert_eq!( + StatusCode::from_u16(428).unwrap(), + map_upstream_status(StatusCode::from_u16(428).unwrap()) + ); + assert_eq!( + StatusCode::BAD_GATEWAY, + map_upstream_status(StatusCode::from_u16(451).unwrap()) + ); + assert_eq!( + StatusCode::BAD_GATEWAY, + map_upstream_status(StatusCode::INTERNAL_SERVER_ERROR) + ); + assert_eq!( + StatusCode::BAD_GATEWAY, + map_upstream_status(StatusCode::SERVICE_UNAVAILABLE) + ); +} + +#[test] +fn internal_metadata_does_not_surface_origin_429() { + assert_eq!( + StatusCode::SERVICE_UNAVAILABLE, + map_internal_metadata_upstream_status(StatusCode::TOO_MANY_REQUESTS) + ); + assert_eq!( + StatusCode::NOT_FOUND, + map_internal_metadata_upstream_status(StatusCode::NOT_FOUND) + ); +} + +#[test] +fn external_partial_accepts_an_unknown_complete_length() { + let bounded = parse_external_requested_range("bytes=0-9"); + let partial = validate_external_partial(bounded, Some("bytes 0-9/*"), Some(10), 100) + .expect("an unknown complete length is a legal Content-Range"); + assert_eq!(Some(10), partial.content_length()); + assert_eq!( + Some(HeaderValue::from_static("bytes 0-9/*")), + partial.header_value() + ); + assert!(validate_external_partial(bounded, Some("bytes 1-9/*"), Some(9), 100).is_none()); + assert!(validate_external_partial(bounded, Some("bytes 0-8/*"), Some(9), 100).is_some()); + + let from = parse_external_requested_range("bytes=10-"); + assert!(validate_external_partial(from, Some("bytes 10-49/*"), Some(40), 100).is_some()); + assert!(validate_external_partial(from, Some("bytes 11-49/*"), Some(39), 100).is_none()); + + let suffix = parse_external_requested_range("bytes=-10"); + assert!(validate_external_partial(suffix, Some("bytes 40-49/*"), Some(10), 100).is_some()); + assert!(validate_external_partial(suffix, Some("bytes 0-4/*"), Some(5), 100).is_some()); + assert!(validate_external_partial(suffix, Some("bytes 40-48/*"), Some(9), 100).is_none()); +} + +#[test] +fn external_partial_response_serves_an_unknown_complete_length() { + let fetched = FetchedExternal { + url: "https://media.example.test/clip.webm".to_owned(), + status: ExternalSuccessStatus::Partial(validated_partial("bytes=0-3", "bytes 0-3/*", 4)), + body: ExternalBody::Buffered(BudgetedBytes::unbudgeted(Bytes::from_static(b"abcd"))), + content_type: "video/webm".to_owned(), + }; + let response = external_partial_response(ExternalPartialResponse { + method: Method::GET, + partial: fetched + .status + .partial() + .expect("the fetched response is partial"), + fetched, + disposition: None, + stream_policy: test_stream_policy(), + }); + + assert_eq!(StatusCode::PARTIAL_CONTENT, response.status()); + assert_eq!( + "bytes 0-3/*", + response + .headers() + .get(header::CONTENT_RANGE) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!( + "4", + response + .headers() + .get(header::CONTENT_LENGTH) + .unwrap() + .to_str() + .unwrap() + ); +} + +#[test] +fn external_multi_range_reaches_the_origin_verbatim() { + for spec in [ + "bytes=0-9,20-29", + "bytes=1-2,3-4", + "bytes=-0", + "bytes=2-1", + "bytes=1--2", + "bytes=+5-", + "bytes=cheese", + ] { + let forwarded = + parse_external_requested_range(spec).expect("an unparsed spec reaches the origin"); + assert!(matches!(forwarded, ExternalRangeSelection::Verbatim { .. })); + assert_eq!(forwarded.header_value(), spec); + } +} + +#[test] +fn external_verbatim_range_forwards_the_upstream_partial_unchanged() { + let multi = parse_external_requested_range("bytes=0-9,20-29"); + let partial = validate_external_partial(multi, Some("bytes 0-9/50"), Some(10), 100) + .expect("a verbatim range trusts the upstream partial"); + assert_eq!(None, partial.content_length()); + assert_eq!( + Some(HeaderValue::from_static("bytes 0-9/50")), + partial.header_value() + ); + + let multipart = validate_external_partial(multi, None, Some(4096), 100) + .expect("a multipart partial carries no Content-Range"); + assert_eq!(None, multipart.content_length()); + assert_eq!(None, multipart.header_value()); +} + +#[tokio::test] +async fn external_multipart_partial_keeps_the_upstream_body_length() { + let fetched = FetchedExternal { + url: "https://media.example.test/clip.webm".to_owned(), + status: ExternalSuccessStatus::Partial( + validate_external_partial( + parse_external_requested_range("bytes=0-1,3-4"), + None, + None, + constants::MAX_MEDIA_PROXY_BYTES, + ) + .expect("a verbatim range trusts the upstream partial"), + ), + body: ExternalBody::Buffered(BudgetedBytes::unbudgeted(Bytes::from_static(b"abcde"))), + content_type: "multipart/byteranges; boundary=xyz".to_owned(), + }; + let response = external_partial_response(ExternalPartialResponse { + method: Method::GET, + partial: fetched + .status + .partial() + .expect("the fetched response is partial"), + fetched, + disposition: None, + stream_policy: test_stream_policy(), + }); + + assert_eq!(StatusCode::PARTIAL_CONTENT, response.status()); + assert!(!response.headers().contains_key(header::CONTENT_RANGE)); + assert_eq!( + "5", + response + .headers() + .get(header::CONTENT_LENGTH) + .unwrap() + .to_str() + .unwrap() + ); + let body = to_bytes(response.into_body(), 64).await.unwrap(); + assert_eq!(b"abcde", body.as_ref()); +} + +#[test] +fn external_partial_validation_rejects_a_partial_the_client_did_not_request() { + let bounded = parse_external_requested_range("bytes=10-19"); + assert!(validate_external_partial(bounded, Some("bytes 10-19/50"), Some(10), 100).is_some()); + assert!(validate_external_partial(bounded, Some("bytes 0-19/50"), Some(20), 100).is_none()); + assert!(validate_external_partial(bounded, Some("bytes 10-29/50"), Some(20), 100).is_none()); + assert!(validate_external_partial(bounded, Some("bytes 11-19/50"), Some(9), 100).is_none()); + assert!(validate_external_partial(bounded, None, Some(10), 100).is_none()); + assert!( + validate_external_partial(None, Some("bytes 10-19/50"), Some(10), 100).is_none(), + "an unrequested partial is never validated" + ); +} + +#[tokio::test] +async fn external_buffering_rejects_a_body_shorter_than_its_content_length() { + let budget = ByteBudget::new(constants::MAX_MEDIA_PROXY_BYTES * 4); + let metrics = ExternalMetrics::new(); + let truncated = buffer_external_response(ExternalBufferRequest { + response: reqwest::Response::from( + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_LENGTH, "10") + .body("abcd") + .unwrap(), + ), + prefix: Bytes::new(), + url: "https://media.example.test/clip.webm", + budget: &budget, + metrics: &metrics, + content_length: Some(10), + limit: constants::MAX_MEDIA_PROXY_BYTES, + }) + .await; + assert!(matches!(truncated, Err(ExternalFetchError::FetchFailed))); + + let complete = buffer_external_response(ExternalBufferRequest { + response: reqwest::Response::from( + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_LENGTH, "4") + .body("abcd") + .unwrap(), + ), + prefix: Bytes::new(), + url: "https://media.example.test/clip.webm", + budget: &budget, + metrics: &metrics, + content_length: Some(4), + limit: constants::MAX_MEDIA_PROXY_BYTES, + }) + .await + .expect("a complete body is buffered"); + assert_eq!(b"abcd", complete.as_bytes().as_ref()); +} + +#[test] +fn a_narrowed_upstream_partial_is_forwarded_instead_of_failing_the_fetch() { + assert_eq!( + StatusCode::BAD_GATEWAY, + external_fetch_error_response( + "https://media.example.test/clip.webm", + ExternalFetchError::FetchFailed + ) + .status() + ); + + let bounded = parse_external_requested_range("bytes=0-999"); + let narrowed = validate_external_partial(bounded, Some("bytes 0-255/4096"), Some(256), 4096) + .expect("an origin may answer a range request with a narrower range"); + assert_eq!(Some(256), narrowed.content_length()); + assert_eq!( + Some(HeaderValue::from_static("bytes 0-255/4096")), + narrowed.header_value() + ); + assert!(validate_external_partial(bounded, Some("bytes 0-255/*"), Some(256), 4096).is_some()); + + let from = parse_external_requested_range("bytes=100-"); + assert!(validate_external_partial(from, Some("bytes 100-355/4096"), Some(256), 4096).is_some()); + assert!(validate_external_partial(from, Some("bytes 100-355/*"), Some(256), 4096).is_some()); + + assert!( + validate_external_partial(bounded, Some("bytes 1-255/4096"), Some(255), 4096).is_none() + ); + assert!( + validate_external_partial(bounded, Some("bytes 0-1000/4096"), Some(1001), 4096).is_none() + ); + assert!(validate_external_partial(from, Some("bytes 99-355/4096"), Some(257), 4096).is_none()); + assert!(validate_external_partial(None, Some("bytes 0-255/4096"), Some(256), 4096).is_none()); +} + +#[tokio::test] +async fn an_unknown_length_upstream_streams_without_a_content_length() { + let upstream = reqwest::Response::from( + http::Response::builder() + .status(StatusCode::OK) + .body("streamed bytes") + .unwrap(), + ); + let response = external_streaming_response(ExternalStreamingResponse { + method: Method::GET, + response: upstream, + prefix: Bytes::new(), + content_length: None, + content_type: "video/mp4", + disposition: None, + stream_policy: test_stream_policy(), + }); + + assert_eq!(StatusCode::OK, response.status()); + assert!(!response.headers().contains_key(header::CONTENT_LENGTH)); + assert_eq!( + "video/mp4", + response + .headers() + .get(header::CONTENT_TYPE) + .unwrap() + .to_str() + .unwrap() + ); + let body = to_bytes(response.into_body(), 64).await.unwrap(); + assert_eq!(b"streamed bytes", body.as_ref()); +} + +#[test] +fn a_blocked_url_answers_bad_request_rather_than_bad_gateway() { + assert_eq!( + StatusCode::BAD_REQUEST, + external_fetch_error_response( + "https://metadata.internal.example/latest/", + ExternalFetchError::BlockedUrl + ) + .status() + ); +} + +#[test] +fn a_mislabeled_svg_body_never_takes_the_external_streaming_path() { + let svg = b""; + assert_eq!( + ExternalStreamDecision::Stream(Some(svg.len() as u64)), + external_stream_decision(true, Some(svg.len() as u64), "image/png"), + "the declared headers alone still look streamable" + ); + assert!( + external_streamed_source_is_svg(svg, "logo.png"), + "an SVG body behind an image/png label must be rasterized, not proxied through" + ); + assert!( + external_streamed_source_is_svg(b"\x89PNG\r\n\x1a\n", "logo.svg"), + "a .svg final url is refused even when the bytes are not SVG" + ); + assert!(!external_streamed_source_is_svg( + b"\x89PNG\r\n\x1a\n", + "logo.png" + )); + assert!(!external_streamed_source_is_svg(b"", "clip.mp4")); +} + +#[tokio::test] +async fn external_streaming_response_emits_the_sniff_prefix_then_the_rest() { + let upstream = reqwest::Response::from( + http::Response::builder() + .status(StatusCode::OK) + .body("rest of the body") + .unwrap(), + ); + let response = external_streaming_response(ExternalStreamingResponse { + method: Method::GET, + response: upstream, + prefix: Bytes::from_static(b"prefix "), + content_length: Some(23), + content_type: "video/mp4", + disposition: None, + stream_policy: test_stream_policy(), + }); + + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + "23", + response + .headers() + .get(header::CONTENT_LENGTH) + .unwrap() + .to_str() + .unwrap() + ); + let body = to_bytes(response.into_body(), 64).await.unwrap(); + assert_eq!(b"prefix rest of the body", body.as_ref()); +} + +#[tokio::test] +async fn external_buffering_prepends_the_sniff_prefix() { + let budget = ByteBudget::new(constants::MAX_MEDIA_PROXY_BYTES * 4); + let metrics = ExternalMetrics::new(); + let buffered = buffer_external_response(ExternalBufferRequest { + response: reqwest::Response::from( + http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_LENGTH, "9") + .body("ffix rest") + .unwrap(), + ), + prefix: Bytes::from_static(b" = pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + assert_eq!( + StatusCode::BAD_REQUEST, + serve_external(&app, Method::GET, &rest, ¶ms, &headers) + .await + .status(), + "{pairs:?} must reach the fetch path while nothing is cached" + ); + + let served = serve_bytes_or_transform( + app.media.transforms(), + ServeBytesRequest { + method: Method::GET, + data: data.clone(), + content_type: "image/jpeg".to_owned(), + cache_identity: url, + filename: &filename, + route: TransformRoute::External, + params: ¶ms, + headers: &headers, + }, + ) + .await; + assert_eq!(StatusCode::OK, served.status(), "{pairs:?}"); + let served_content_type = served + .headers() + .get(header::CONTENT_TYPE) + .expect("a transform response declares its content type") + .clone(); + let transformed = to_bytes(served.into_body(), usize::MAX).await.unwrap(); + app.media.external_hints().insert( + url.to_owned(), + external_hint(url, &filename, "image/jpeg", &data), + ); + + let cached = serve_external(&app, Method::GET, &rest, ¶ms, &headers).await; + assert_eq!( + StatusCode::OK, + cached.status(), + "{pairs:?} must be answered from the transform cache" + ); + assert_eq!( + Some(&served_content_type), + cached.headers().get(header::CONTENT_TYPE), + "{pairs:?}" + ); + assert_eq!( + transformed, + to_bytes(cached.into_body(), usize::MAX).await.unwrap(), + "{pairs:?}" + ); + } +} + +fn probed_head(url: &str, content_type: &str, content_length: Option) -> ExternalHead { + ExternalHead { + url: url.to_owned(), + status: StatusCode::OK, + content_type: content_type.to_owned(), + content_length, + } +} + +#[test] +fn a_plain_head_is_answered_from_the_upstream_head_metadata() { + let response = external_head_response( + probed_head("https://cdn.example.test/clip.mp4", "video/mp4", Some(4096)), + false, + ) + .expect("an upstream HEAD with a usable length answers the probe"); + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + Some(&HeaderValue::from_static("4096")), + response.headers().get(header::CONTENT_LENGTH) + ); + assert_eq!( + Some(&HeaderValue::from_static("video/mp4")), + response.headers().get(header::CONTENT_TYPE) + ); + + assert_eq!( + Some(&HeaderValue::from_static("video/mp4")), + external_head_response( + probed_head( + "https://cdn.example.test/clip.mp4", + "application/octet-stream", + Some(4096) + ), + false, + ) + .expect("an untrusted upstream type falls back to the filename") + .headers() + .get(header::CONTENT_TYPE) + ); + + let mut partial = probed_head("https://cdn.example.test/clip.mp4", "video/mp4", Some(4096)); + partial.status = StatusCode::PARTIAL_CONTENT; + for unusable in [ + probed_head( + "https://cdn.example.test/logo.svg", + "image/svg+xml", + Some(16), + ), + probed_head("https://cdn.example.test/clip.mp4", "video/mp4", None), + probed_head( + "https://cdn.example.test/clip.mp4", + "video/mp4", + Some(constants::MAX_MEDIA_PROXY_BYTES as u64 + 1), + ), + partial, + ] { + assert!( + external_head_response(unusable, false).is_none(), + "unusable upstream metadata must fall back to the body fetch" + ); + } +} + +#[tokio::test] +async fn an_upstream_that_ignores_the_client_range_still_streams_a_complete_body() { + let cfg = + Config::load_from_iter([("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret")]).expect("test config"); + let app = Arc::new(AppState::for_tests(cfg)); + let url = "https://media.example.test/clip.mp4"; + let proxy_path = build_external_media_proxy_path(url).expect("the external path builds"); + let signature = signing::create_signature(&proxy_path, app.cfg.secret_key.as_bytes()); + let rest = format!("{signature}/{proxy_path}"); + let params = HashMap::new(); + let mut headers = HeaderMap::new(); + headers.insert(header::RANGE, HeaderValue::from_static("bytes=0-3")); + let prepared = PreparedExternalRequest::new(&app, Method::GET, &rest, ¶ms, &headers) + .expect("the signed external request is accepted"); + let fetched = FetchedExternal { + url: url.to_owned(), + status: ExternalSuccessStatus::Complete, + body: ExternalBody::Streaming { + response: reqwest::Response::from( + http::Response::builder() + .status(StatusCode::OK) + .body("streamed bytes") + .unwrap(), + ), + prefix: bytes::Bytes::new(), + content_length: Some(14), + }, + content_type: "video/mp4".to_owned(), + }; + + let response = serve_fetched_external(prepared, fetched).await; + + assert_eq!(StatusCode::OK, response.status()); + assert!(!response.headers().contains_key(header::CONTENT_RANGE)); + assert_eq!( + "14", + response + .headers() + .get(header::CONTENT_LENGTH) + .unwrap() + .to_str() + .unwrap() + ); + let body = to_bytes(response.into_body(), 64).await.unwrap(); + assert_eq!(b"streamed bytes", body.as_ref()); +} diff --git a/fluxer_media_proxy/src/server/format_policy.rs b/fluxer_media_proxy/src/server/format_policy.rs new file mode 100644 index 000000000..36a0f204e --- /dev/null +++ b/fluxer_media_proxy/src/server/format_policy.rs @@ -0,0 +1,599 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + constants::AssetExtension, image_quality::ImageQuality, image_transform::EncodeEffort, mime, + output_format, output_format::OutputFormat, server::params::extension_of, +}; + +pub(in crate::server) fn effective_animated_image_output_format( + source_ext: Option, + requested_out_ext: OutputFormat, + animated: bool, +) -> OutputFormat { + if animated + && source_ext == Some(AssetExtension::Apng) + && requested_out_ext == OutputFormat::PNG + { + return OutputFormat::APNG; + } + if animated + && source_ext == Some(AssetExtension::Gif) + && requested_out_ext == OutputFormat::WebP + { + return OutputFormat::GIF; + } + requested_out_ext +} + +pub(in crate::server) fn default_transform_quality( + format: OutputFormat, + animated: bool, + static_default: ImageQuality, +) -> ImageQuality { + if animated && format == OutputFormat::WebP { + ImageQuality::Auto + } else { + static_default + } +} + +pub(in crate::server) fn transform_static_quality_default( + source_ext: Option, +) -> ImageQuality { + if matches!( + source_ext, + Some(AssetExtension::Jpeg | AssetExtension::Heic | AssetExtension::Heif) + ) { + ImageQuality::High + } else { + ImageQuality::Lossless + } +} + +pub(in crate::server) fn is_v1_asset_manual_format(ext: AssetExtension) -> bool { + matches!( + ext, + AssetExtension::Png + | AssetExtension::Jpeg + | AssetExtension::Webp + | AssetExtension::Gif + | AssetExtension::Apng + | AssetExtension::Avif + ) +} + +fn animated_image_request_can_use_original( + source_ext: AssetExtension, + explicit_out_ext: Option, + out_ext: OutputFormat, + width: Option, + height: Option, + animated: bool, +) -> bool { + if !animated || width.is_some() || height.is_some() { + return false; + } + if !matches!( + source_ext, + AssetExtension::Gif | AssetExtension::Webp | AssetExtension::Apng + ) { + return false; + } + let requested = explicit_out_ext.unwrap_or(out_ext.as_asset_extension()); + requested == source_ext +} + +fn same_format_image_request_base_allows_original( + source_ext: AssetExtension, + explicit_out_ext: Option, + out_ext: OutputFormat, + has_quality: bool, + effort: Option, +) -> bool { + if effort.is_some() { + return false; + } + if !output_format::is_output_format_supported(source_ext) { + return false; + } + if out_ext.as_asset_extension() != source_ext { + return false; + } + let _ = explicit_out_ext; + if has_quality && source_ext != AssetExtension::Gif { + return false; + } + true +} + +#[derive(Clone, Copy)] +pub(in crate::server) struct OriginalImageRequest { + pub(in crate::server) source_ext: Option, + pub(in crate::server) explicit_out_ext: Option, + pub(in crate::server) out_ext: OutputFormat, + pub(in crate::server) width: Option, + pub(in crate::server) height: Option, + pub(in crate::server) has_quality: bool, + pub(in crate::server) effort: Option, + pub(in crate::server) animated: bool, +} + +pub(in crate::server) fn same_format_loaded_image_request_can_use_original_with_sniff( + sniffed: mime::SniffInfo, + request: OriginalImageRequest, +) -> bool { + let Some(source_ext) = request.source_ext else { + return false; + }; + if animated_image_request_can_use_original( + source_ext, + request.explicit_out_ext, + request.out_ext, + request.width, + request.height, + request.animated, + ) { + return true; + } + if !same_format_image_request_base_allows_original( + source_ext, + request.explicit_out_ext, + request.out_ext, + request.has_quality, + request.effort, + ) { + return false; + } + if !(request.animated + || request.explicit_out_ext.is_some() + || request.has_quality + || request.width.is_some() + || request.height.is_some()) + { + return false; + } + if sniffed.width == 0 || sniffed.height == 0 { + return false; + } + if let Some(target_w) = request.width + && target_w < sniffed.width + { + return false; + } + if let Some(target_h) = request.height + && target_h < sniffed.height + { + return false; + } + true +} + +pub(in crate::server) fn content_type_is_trustworthy(content_type: &str) -> bool { + if content_type.is_empty() { + return false; + } + if content_type.eq_ignore_ascii_case("application/octet-stream") { + return false; + } + matches!( + mime::category(content_type), + Some(mime::Category::Image | mime::Category::Video | mime::Category::Audio) + ) +} + +pub(in crate::server) fn is_svg_content_type(content_type: &str) -> bool { + mime::normalize(Some(content_type)) + .is_some_and(|value| value.eq_ignore_ascii_case("image/svg+xml")) +} + +pub(in crate::server) fn extension_from_mime(content_type: &str) -> Option { + match mime::normalize(Some(content_type))? { + "image/jpeg" => Some(AssetExtension::Jpeg), + "image/png" => Some(AssetExtension::Png), + "image/webp" => Some(AssetExtension::Webp), + "image/gif" => Some(AssetExtension::Gif), + "image/apng" => Some(AssetExtension::Apng), + "image/avif" => Some(AssetExtension::Avif), + "image/heic" => Some(AssetExtension::Heic), + "image/heif" => Some(AssetExtension::Heif), + "image/jxl" => Some(AssetExtension::Jxl), + "image/svg+xml" => Some(AssetExtension::Svg), + _ => None, + } +} + +pub(in crate::server) fn image_extension_from_filename(filename: &str) -> Option { + AssetExtension::parse(extension_of(filename)?) +} + +pub(in crate::server) fn source_image_format( + sniffed_mime: &str, + content_type: &str, + filename: &str, +) -> Option { + if extension_from_mime(sniffed_mime) == Some(AssetExtension::Apng) { + return Some(AssetExtension::Apng); + } + extension_from_mime(content_type).or_else(|| image_extension_from_filename(filename)) +} + +pub(in crate::server) fn external_default_output_extension( + filename: &str, + content_type: &str, +) -> AssetExtension { + extension_from_mime(content_type) + .or_else(|| image_extension_from_filename(filename)) + .unwrap_or(AssetExtension::Webp) +} + +pub(in crate::server) fn transform_response_content_type( + explicit_out_ext: Option, + requested_out_ext: AssetExtension, + out_ext: OutputFormat, + fallback_content_type: &str, +) -> &str { + if explicit_out_ext.is_some() + || out_ext.as_asset_extension() != requested_out_ext + || is_svg_content_type(fallback_content_type) + { + out_ext.mime() + } else { + fallback_content_type + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::params::{ + animated_param, explicit_output_format, filename_from_storage_key, url_filename, + }; + use std::collections::HashMap; + + #[test] + fn animated_gif_requests_downgrade_webp_output_to_gif() { + assert_eq!( + OutputFormat::APNG, + effective_animated_image_output_format( + Some(AssetExtension::Apng), + OutputFormat::PNG, + true + ) + ); + assert_eq!( + OutputFormat::PNG, + effective_animated_image_output_format( + Some(AssetExtension::Apng), + OutputFormat::PNG, + false + ) + ); + assert_eq!( + OutputFormat::GIF, + effective_animated_image_output_format( + Some(AssetExtension::Gif), + OutputFormat::WebP, + true + ) + ); + assert_eq!( + OutputFormat::WebP, + effective_animated_image_output_format( + Some(AssetExtension::Gif), + OutputFormat::WebP, + false + ) + ); + assert_eq!( + OutputFormat::WebP, + effective_animated_image_output_format( + Some(AssetExtension::Webp), + OutputFormat::WebP, + true + ) + ); + assert_eq!( + OutputFormat::PNG, + effective_animated_image_output_format( + Some(AssetExtension::Png), + OutputFormat::PNG, + true + ) + ); + assert_eq!( + OutputFormat::PNG, + effective_animated_image_output_format( + Some(AssetExtension::Png), + OutputFormat::PNG, + false + ) + ); + assert!(animated_param( + &HashMap::from([("animated".to_owned(), "true".to_owned())]), + false + )); + assert!(!animated_param( + &HashMap::from([("animatd".to_owned(), "true".to_owned())]), + false + )); + } + + #[test] + fn attachment_and_external_query_helpers_match_v1_edges() { + for name in [ + "clip.mp4", + "clip.m4v", + "clip.webm", + "clip.mov", + "clip.ogv", + "clip.mkv", + "clip.3gp", + "clip.avi", + "clip.flv", + "clip.ts", + "clip.mpg", + "clip.mpeg", + "clip.wmv", + ] { + assert_eq!(None, image_extension_from_filename(name)); + } + assert_eq!( + AssetExtension::Gif, + external_default_output_extension("welcome.png", "image/gif") + ); + assert_eq!( + AssetExtension::Png, + external_default_output_extension("welcome.png", "application/octet-stream") + ); + assert_eq!( + "image/gif", + transform_response_content_type( + None, + AssetExtension::Gif, + OutputFormat::GIF, + "image/gif" + ) + ); + assert_eq!( + "image/webp", + transform_response_content_type( + None, + AssetExtension::Heic, + OutputFormat::WebP, + "image/heic" + ) + ); + assert_eq!( + "image/webp", + transform_response_content_type( + None, + AssetExtension::Webp, + OutputFormat::WebP, + "image/svg+xml; charset=utf-8" + ) + ); + assert_eq!( + "image/gif", + transform_response_content_type( + Some(AssetExtension::Webp), + AssetExtension::Webp, + OutputFormat::GIF, + "image/gif" + ) + ); + assert_eq!( + "file.png", + url_filename("https://example.test/a/file.png?x=1#frag") + ); + assert_eq!( + "photo.png", + filename_from_storage_key("attachments/123/456/photo.png") + ); + assert_eq!( + "file.bin", + filename_from_storage_key("attachments/123/456/") + ); + assert!( + explicit_output_format(&HashMap::from([("format".to_owned(), "auto".to_owned())])) + .is_err() + ); + } + + #[test] + fn unmeasurable_bytes_never_reuse_the_original() { + assert!( + !same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(b"GIF89a"), + OriginalImageRequest { + source_ext: Some(AssetExtension::Gif), + explicit_out_ext: None, + out_ext: OutputFormat::GIF, + width: Some(300), + height: None, + has_quality: false, + effort: None, + animated: false, + } + ) + ); + assert!( + !same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(b"GIF89a"), + OriginalImageRequest { + source_ext: Some(AssetExtension::Png), + explicit_out_ext: None, + out_ext: OutputFormat::PNG, + width: Some(300), + height: None, + has_quality: false, + effort: None, + animated: true, + } + ) + ); + } + + #[test] + fn same_format_gif_noop_requests_use_original_bytes() { + let gif_header = b"GIF89a\x2c\x01\xe1\x00"; + assert!( + same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(gif_header), + OriginalImageRequest { + source_ext: Some(AssetExtension::Gif), + explicit_out_ext: None, + out_ext: OutputFormat::GIF, + width: Some(300), + height: Some(225), + has_quality: false, + effort: None, + animated: true, + } + ) + ); + assert!( + same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(gif_header), + OriginalImageRequest { + source_ext: Some(AssetExtension::Gif), + explicit_out_ext: None, + out_ext: OutputFormat::GIF, + width: Some(301), + height: None, + has_quality: false, + effort: None, + animated: true, + } + ) + ); + assert!( + !same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(gif_header), + OriginalImageRequest { + source_ext: Some(AssetExtension::Gif), + explicit_out_ext: None, + out_ext: OutputFormat::GIF, + width: Some(299), + height: None, + has_quality: false, + effort: None, + animated: true, + } + ) + ); + assert!( + !same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(gif_header), + OriginalImageRequest { + source_ext: Some(AssetExtension::Gif), + explicit_out_ext: Some(AssetExtension::Webp), + out_ext: OutputFormat::WebP, + width: None, + height: None, + has_quality: false, + effort: None, + animated: true, + } + ) + ); + assert!( + !same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(gif_header), + OriginalImageRequest { + source_ext: Some(AssetExtension::Gif), + explicit_out_ext: Some(AssetExtension::Webp), + out_ext: OutputFormat::WebP, + width: Some(300), + height: None, + has_quality: false, + effort: None, + animated: false, + } + ) + ); + assert!( + !same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(gif_header), + OriginalImageRequest { + source_ext: Some(AssetExtension::Gif), + explicit_out_ext: None, + out_ext: OutputFormat::GIF, + width: Some(300), + height: None, + has_quality: false, + effort: EncodeEffort::parse_lenient("1"), + animated: false, + } + ) + ); + } + + fn animated_vp8x_webp(width: u32, height: u32) -> Vec { + let mut webp = vec![0_u8; 30]; + webp[0..4].copy_from_slice(b"RIFF"); + webp[8..12].copy_from_slice(b"WEBP"); + webp[12..16].copy_from_slice(b"VP8X"); + webp[16..20].copy_from_slice(&10_u32.to_le_bytes()); + webp[20] = 0x02; + webp[24..27].copy_from_slice(&(width - 1).to_le_bytes()[..3]); + webp[27..30].copy_from_slice(&(height - 1).to_le_bytes()[..3]); + webp + } + + #[test] + fn still_requests_for_animation_capable_sources_use_original_bytes() { + let gif_header = b"GIF89a\x2c\x01\xe1\x00"; + assert!( + same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(gif_header), + OriginalImageRequest { + source_ext: Some(AssetExtension::Gif), + explicit_out_ext: None, + out_ext: OutputFormat::GIF, + width: Some(300), + height: None, + has_quality: false, + effort: None, + animated: false, + } + ) + ); + let animated_webp = animated_vp8x_webp(300, 225); + assert!(mime::sniff(&animated_webp).animated); + assert!( + same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(&animated_webp), + OriginalImageRequest { + source_ext: Some(AssetExtension::Webp), + explicit_out_ext: None, + out_ext: OutputFormat::WebP, + width: Some(300), + height: Some(225), + has_quality: false, + effort: None, + animated: false, + } + ) + ); + } + + #[test] + fn a_sniffed_mime_disagreeing_with_the_source_extension_still_uses_original_bytes() { + let gif_header = b"GIF89a\x2c\x01\xe1\x00"; + assert!( + same_format_loaded_image_request_can_use_original_with_sniff( + mime::sniff(gif_header), + OriginalImageRequest { + source_ext: Some(AssetExtension::Png), + explicit_out_ext: None, + out_ext: OutputFormat::PNG, + width: Some(300), + height: None, + has_quality: false, + effort: None, + animated: true, + } + ) + ); + } +} diff --git a/fluxer_media_proxy/src/server/media_operations/failure.rs b/fluxer_media_proxy/src/server/media_operations/failure.rs new file mode 100644 index 000000000..8cf69379d --- /dev/null +++ b/fluxer_media_proxy/src/server/media_operations/failure.rs @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::server::{ + external::{ExternalFetchError, map_internal_metadata_upstream_status}, + response::error::{canonical_reason_str, text_with_reason, text_with_source}, +}; +use axum::{http::StatusCode, response::Response}; + +#[derive(Debug)] +pub(in crate::server) enum MediaFailure { + MediaInputUnsupportedType, + MediaInputMissingField, + MetadataBase64TooLarge, + MetadataBase64Decode, + MetadataDecodedTooLarge, + MetadataUploadRead, + MetadataS3Read, + ExternalSourceBlocked, + ExternalSourcePayloadTooLarge, + ExternalSourceBufferBudgetExhausted, + ExternalSourceBufferAllocationFailed, + ExternalSourceUpstreamStatus(StatusCode), + ExternalSourceFetchFailed, + MetadataSvgRasterizeTimeout { detail: String }, + MetadataSvgRasterizeFailed { detail: String }, + MetadataExtractionFailed { detail: String }, +} + +impl MediaFailure { + pub(in crate::server) fn code(&self) -> &'static str { + match self { + Self::MediaInputUnsupportedType => "media_input_unsupported_type", + Self::MediaInputMissingField => "media_input_missing_field", + Self::MetadataBase64TooLarge => "metadata_base64_too_large", + Self::MetadataBase64Decode => "metadata_base64_decode", + Self::MetadataDecodedTooLarge => "metadata_decoded_too_large", + Self::MetadataUploadRead => "metadata_upload_read", + Self::MetadataS3Read => "metadata_s3_read", + Self::ExternalSourceBlocked => "metadata_external_blocked", + Self::ExternalSourcePayloadTooLarge => "metadata_external_payload_too_large", + Self::ExternalSourceBufferBudgetExhausted => { + "metadata_external_buffer_budget_exhausted" + } + Self::ExternalSourceBufferAllocationFailed => { + "metadata_external_buffer_allocation_failed" + } + Self::ExternalSourceUpstreamStatus(_) => "metadata_external_status", + Self::ExternalSourceFetchFailed => "metadata_external_fetch", + Self::MetadataSvgRasterizeTimeout { .. } => "metadata_svg_rasterize_timeout", + Self::MetadataSvgRasterizeFailed { .. } => "metadata_svg_rasterize_failed", + Self::MetadataExtractionFailed { .. } => "metadata_extraction_failed", + } + } + + pub(in crate::server) fn http_status(&self) -> StatusCode { + match self { + Self::MediaInputUnsupportedType + | Self::MediaInputMissingField + | Self::MetadataBase64TooLarge + | Self::MetadataBase64Decode + | Self::MetadataDecodedTooLarge + | Self::MetadataUploadRead + | Self::MetadataS3Read + | Self::ExternalSourceBlocked + | Self::MetadataSvgRasterizeFailed { .. } + | Self::MetadataExtractionFailed { .. } => StatusCode::BAD_REQUEST, + Self::ExternalSourcePayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::ExternalSourceBufferBudgetExhausted + | Self::ExternalSourceBufferAllocationFailed => StatusCode::SERVICE_UNAVAILABLE, + Self::ExternalSourceUpstreamStatus(status) => { + map_internal_metadata_upstream_status(*status) + } + Self::ExternalSourceFetchFailed => StatusCode::BAD_GATEWAY, + Self::MetadataSvgRasterizeTimeout { .. } => StatusCode::GATEWAY_TIMEOUT, + } + } + + pub(in crate::server) fn into_response(self) -> Response { + let status = self.http_status(); + let code = self.code(); + let body = canonical_reason_str(status); + match self.into_detail() { + Some(detail) => text_with_source(status, body, code, detail), + None => text_with_reason(status, body, code), + } + } + + fn into_detail(self) -> Option { + match self { + Self::MetadataSvgRasterizeTimeout { detail } + | Self::MetadataSvgRasterizeFailed { detail } + | Self::MetadataExtractionFailed { detail } => Some(detail), + _ => None, + } + } +} + +impl From for MediaFailure { + fn from(err: ExternalFetchError) -> Self { + match err { + ExternalFetchError::BlockedUrl => Self::ExternalSourceBlocked, + ExternalFetchError::PayloadTooLarge => Self::ExternalSourcePayloadTooLarge, + ExternalFetchError::BufferBudgetExhausted => Self::ExternalSourceBufferBudgetExhausted, + ExternalFetchError::BufferAllocationFailed => { + Self::ExternalSourceBufferAllocationFailed + } + ExternalFetchError::UpstreamFailure(status) => { + Self::ExternalSourceUpstreamStatus(status) + } + ExternalFetchError::TooManyRedirects | ExternalFetchError::FetchFailed => { + Self::ExternalSourceFetchFailed + } + } + } +} diff --git a/fluxer_media_proxy/src/server/media_operations/input.rs b/fluxer_media_proxy/src/server/media_operations/input.rs new file mode 100644 index 000000000..e74483b23 --- /dev/null +++ b/fluxer_media_proxy/src/server/media_operations/input.rs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::failure::MediaFailure; +use crate::{ + byte_budget::BudgetedBytes, + constants, + server::{external::fetch_external, params::url_filename, state::AppState}, +}; +use base64::{Engine as _, engine::general_purpose}; +use bytes::Bytes; +use tracing::warn; + +pub(in crate::server) enum MediaInput { + Base64 { + data: String, + filename: Option, + }, + Upload { + upload_filename: String, + filename: Option, + }, + Storage { + bucket: String, + key: String, + filename: Option, + }, + External { + url: String, + filename: Option, + }, +} + +pub(in crate::server) struct LoadedMediaInput { + pub(in crate::server) data: Bytes, + pub(in crate::server) filename: String, +} + +#[derive(Clone, Copy)] +pub(in crate::server) struct MediaInputLimit { + encoded_bytes: usize, + decoded_bytes: usize, +} + +impl MediaInputLimit { + pub(in crate::server) const INTERNAL_REQUEST: Self = Self { + encoded_bytes: constants::MAX_INTERNAL_REQUEST_BODY_BYTES, + decoded_bytes: constants::MAX_MEDIA_PROXY_BYTES, + }; +} + +pub(in crate::server) async fn load_media_input( + app: &AppState, + input: MediaInput, + limit: MediaInputLimit, +) -> Result { + match input { + MediaInput::Base64 { data, filename } => { + let encoded = data + .rsplit_once(',') + .map(|(_, value)| value) + .unwrap_or(data.as_str()); + if encoded.len() > limit.encoded_bytes { + let failure = MediaFailure::MetadataBase64TooLarge; + warn!(reason = failure.code(), len = encoded.len()); + return Err(failure); + } + let decoded = general_purpose::STANDARD.decode(encoded).map_err(|err| { + let failure = MediaFailure::MetadataBase64Decode; + warn!(reason = failure.code(), ?err); + failure + })?; + if decoded.len() > limit.decoded_bytes { + let failure = MediaFailure::MetadataDecodedTooLarge; + warn!(reason = failure.code(), len = decoded.len()); + return Err(failure); + } + Ok(LoadedMediaInput { + data: Bytes::from(decoded), + filename: filename.unwrap_or_else(|| "inline.bin".to_owned()), + }) + } + MediaInput::Upload { + upload_filename, + filename, + } => { + let object = app + .store + .read_object(&app.cfg.storage.bucket_uploads, &upload_filename) + .await + .map_err(|err| { + let failure = MediaFailure::MetadataUploadRead; + warn!(reason = failure.code(), key = upload_filename.as_str(), %err); + failure + })?; + Ok(LoadedMediaInput { + data: object.data, + filename: filename.unwrap_or(upload_filename), + }) + } + MediaInput::Storage { + bucket, + key, + filename, + } => { + let object = app.store.read_object(&bucket, &key).await.map_err(|err| { + let failure = MediaFailure::MetadataS3Read; + warn!(reason = failure.code(), bucket = bucket.as_str(), key = key.as_str(), %err); + failure + })?; + Ok(LoadedMediaInput { + data: object.data, + filename: filename.unwrap_or(key), + }) + } + MediaInput::External { url, filename } => { + let (fetched_url, data) = fetch_external(app, &url).await.map_err(|err| { + let failure = MediaFailure::from(err); + warn!(reason = failure.code(), url = url.as_str(), ?err); + failure + })?; + Ok(LoadedMediaInput { + data: retained_input_bytes(data), + filename: filename.unwrap_or_else(|| url_filename(&fetched_url)), + }) + } + } +} + +// The shared external buffer budget accounts for resident bytes, not for the fetch alone. The +// internal metadata and frame routes keep the whole input alive across extraction, so the +// reservation has to travel with the bytes instead of being dropped when the fetch returns. +pub(super) fn retained_input_bytes(data: BudgetedBytes) -> Bytes { + Bytes::from_owner(data) +} diff --git a/fluxer_media_proxy/src/server/media_operations/mod.rs b/fluxer_media_proxy/src/server/media_operations/mod.rs new file mode 100644 index 000000000..d697fce67 --- /dev/null +++ b/fluxer_media_proxy/src/server/media_operations/mod.rs @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod failure; +mod input; + +pub(in crate::server) use failure::MediaFailure; +pub(in crate::server) use input::{MediaInput, MediaInputLimit, load_media_input}; + +use crate::{ + constants::AssetExtension, + image_quality::ImageQuality, + image_transform::AnimationMode, + media_process, mime, + nsfw::NSFWPolicy, + output_format::OutputFormat, + server::{ + format_policy::image_extension_from_filename, + state::AppState, + transform::execution::{run_transform, transform_error_is_timeout}, + }, +}; +use bytes::Bytes; +use input::LoadedMediaInput; + +pub(in crate::server) struct MetadataOutput { + pub(in crate::server) metadata: serde_json::Value, + pub(in crate::server) data: Option, +} + +pub(in crate::server) async fn resolve_metadata( + app: &AppState, + input: MediaInput, + scan_nsfw: bool, + include_data: bool, +) -> Result { + let mut input = load_media_input(app, input, MediaInputLimit::INTERNAL_REQUEST).await?; + if include_data && metadata_input_is_svg(&input) { + input = rasterize_metadata_svg(app, input).await?; + } + let nsfw = if scan_nsfw { + app.media.nsfw().policy() + } else { + app.media.nsfw().record_declined_scan(); + NSFWPolicy::Disabled + }; + let json = media_process::metadata_json_with_options( + &input.data, + &input.filename, + media_process::MetadataOptions { + placeholder: true, + nsfw, + }, + &app.media.limits(), + app.media.nsfw(), + &app.metrics.transform(), + ) + .await + .map_err(|err| MediaFailure::MetadataExtractionFailed { + detail: format!("filename={} err={err:?}", input.filename), + })?; + Ok(MetadataOutput { + metadata: serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!({})), + data: include_data.then_some(input.data), + }) +} + +fn metadata_input_is_svg(input: &LoadedMediaInput) -> bool { + mime::sniff(&input.data).mime == "image/svg+xml" + || image_extension_from_filename(&input.filename) == Some(AssetExtension::Svg) +} + +async fn rasterize_metadata_svg( + app: &AppState, + input: LoadedMediaInput, +) -> Result { + let options = media_process::ImageOptions { + format: OutputFormat::WebP, + quality: ImageQuality::Lossless, + animation: AnimationMode::Static, + deadline_ms: app.media.transforms().transform_deadline_ms(), + ..Default::default() + }; + let filename = input.filename; + match run_transform(app.media.transforms(), input.data, options).await { + Ok(media) => Ok(LoadedMediaInput { + data: media.bytes.into(), + filename: replace_image_extension(&filename, AssetExtension::Webp), + }), + Err(err) if transform_error_is_timeout(&err) => { + Err(MediaFailure::MetadataSvgRasterizeTimeout { detail: filename }) + } + Err(err) => Err(MediaFailure::MetadataSvgRasterizeFailed { + detail: format!("filename={filename} err={err:?}"), + }), + } +} + +fn replace_image_extension(filename: &str, ext: AssetExtension) -> String { + let last_slash = filename.rfind('/').map(|idx| idx + 1).unwrap_or(0); + let last_dot = filename[last_slash..] + .rfind('.') + .map(|idx| last_slash + idx); + match last_dot { + Some(idx) => format!("{}.{}", &filename[..idx], ext.name()), + None => format!("{}.{}", filename, ext.name()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::byte_budget::{BudgetedBytes, ByteBudget}; + use crate::config::Config; + use std::sync::Arc; + + fn test_app_state() -> Arc { + let cfg = Config::load_from_iter([("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret")]) + .expect("test config"); + Arc::new(AppState::for_tests(cfg)) + } + + #[test] + fn metadata_base64_svg_detection_uses_bytes_or_filename() { + let svg_bytes = LoadedMediaInput { + data: Bytes::from_static(br#""#), + filename: "upload.bin".to_owned(), + }; + assert!(metadata_input_is_svg(&svg_bytes)); + + let svg_filename = LoadedMediaInput { + data: Bytes::from_static(b"not svg"), + filename: "icons/logo.svg".to_owned(), + }; + assert!(metadata_input_is_svg(&svg_filename)); + + let png_filename = LoadedMediaInput { + data: Bytes::from_static(b"not svg"), + filename: "icons/logo.png".to_owned(), + }; + assert!(!metadata_input_is_svg(&png_filename)); + } + + #[test] + fn replace_image_extension_only_changes_last_path_segment() { + assert_eq!( + "avatars/user.icon.webp", + replace_image_extension("avatars/user.icon.svg", AssetExtension::Webp) + ); + assert_eq!( + "avatars.v1/user.webp", + replace_image_extension("avatars.v1/user", AssetExtension::Webp) + ); + } + + #[tokio::test] + async fn metadata_base64_svg_rasterizes_to_webp_bytes() { + let app = test_app_state(); + let input = LoadedMediaInput { + data: Bytes::from_static( + br#""#, + ), + filename: "icons/logo.svg".to_owned(), + }; + let raster = match rasterize_metadata_svg(&app, input).await { + Ok(raster) => raster, + Err(failure) => panic!("unexpected failure {}", failure.code()), + }; + + assert_eq!("icons/logo.webp", raster.filename); + assert_eq!("image/webp", mime::sniff(&raster.data).mime); + } + + #[test] + fn an_external_media_input_keeps_owing_the_shared_buffer_budget() { + let budget = ByteBudget::new(16); + let reservation = budget.try_reserve(16).expect("reserve at limit"); + let data = input::retained_input_bytes(BudgetedBytes::budgeted( + Bytes::from_static(b"payload"), + reservation, + )); + + assert_eq!(b"payload", data.as_ref()); + assert!( + budget.try_reserve(1).is_none(), + "a resident external input still owes the external buffer budget" + ); + + drop(data); + assert_eq!( + 16, + budget + .try_reserve(16) + .expect("dropping the input releases its reservation") + .amount() + ); + } +} diff --git a/fluxer_media_proxy/src/server/middleware.rs b/fluxer_media_proxy/src/server/middleware.rs new file mode 100644 index 000000000..a2cc96353 --- /dev/null +++ b/fluxer_media_proxy/src/server/middleware.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{config::DeploymentMode, http_headers}; +use axum::{ + body::Body, + extract::State, + http::{HeaderValue, Request}, + response::Response, +}; +use bytes::Bytes; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use std::{ + pin::Pin, + sync::{ + Arc, OnceLock, + atomic::{AtomicU64, Ordering}, + }, + task::{Context, Poll}, +}; +use tokio::sync::Notify; + +#[derive(Clone, Default)] +pub(in crate::server) struct HttpRequestDrain { + inner: Arc, +} + +#[derive(Default)] +struct HttpRequestDrainInner { + active_requests: AtomicU64, + drained: Notify, +} + +struct ActiveHttpRequest { + drain: HttpRequestDrain, +} + +struct DrainedBody { + body: Pin>, + _active: ActiveHttpRequest, +} + +impl HttpRequestDrain { + pub(in crate::server) fn new() -> Self { + Self::default() + } + + pub(in crate::server) fn active_requests(&self) -> u64 { + self.inner.active_requests.load(Ordering::Relaxed) + } + + pub(in crate::server) async fn wait_for_requests_drained(&self) { + loop { + let drained = self.inner.drained.notified(); + tokio::pin!(drained); + drained.as_mut().enable(); + if self.inner.active_requests.load(Ordering::Acquire) == 0 { + return; + } + drained.await; + } + } + + fn begin_request(&self) -> ActiveHttpRequest { + self.inner + .active_requests + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + active.checked_add(1) + }) + .expect("http active request count must not overflow"); + ActiveHttpRequest { + drain: self.clone(), + } + } +} + +impl Drop for ActiveHttpRequest { + fn drop(&mut self) { + let previous = self + .drain + .inner + .active_requests + .fetch_sub(1, Ordering::AcqRel); + assert!(previous > 0, "http active request count must stay positive"); + if previous == 1 { + self.drain.inner.drained.notify_waiters(); + } + } +} + +impl HttpBody for DrainedBody { + type Data = Bytes; + type Error = axum::Error; + + fn poll_frame( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.body.as_mut().poll_frame(context) + } + + fn is_end_stream(&self) -> bool { + self.body.is_end_stream() + } + + fn size_hint(&self) -> SizeHint { + self.body.size_hint() + } +} + +pub(in crate::server) async fn track_active_request( + State(drain): State, + request: Request, + next: axum::middleware::Next, +) -> Response { + let active = drain.begin_request(); + next.run(request).await.map(|body| { + Body::new(DrainedBody { + body: Box::pin(body), + _active: active, + }) + }) +} + +pub(in crate::server) fn build_version() -> &'static str { + static BUILD_VERSION: OnceLock = OnceLock::new(); + BUILD_VERSION + .get_or_init(|| { + std::env::var("BUILD_VERSION") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "dev".to_owned()) + }) + .as_str() +} + +pub(in crate::server) async fn add_version_header( + request: Request, + next: axum::middleware::Next, +) -> Response { + let mut response = next.run(request).await; + if let Ok(value) = HeaderValue::from_str(build_version()) { + response.headers_mut().insert("x-fluxer-version", value); + } + response +} + +pub(in crate::server) async fn add_security_header_middleware( + State(mode): State, + request: Request, + next: axum::middleware::Next, +) -> Response { + let mut response = next.run(request).await; + let headers = response.headers_mut(); + http_headers::add_security_headers(headers); + if mode == DeploymentMode::Static { + headers.remove("X-Robots-Tag"); + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{Router, routing::get}; + + async fn robots_header_for(mode: DeploymentMode) -> Option { + let router = Router::new() + .route( + "/probe", + get(|| async { + let mut response = Response::new(Body::empty()); + http_headers::add_media_headers(response.headers_mut(), 0, "text/plain", None); + response + }), + ) + .layer(axum::middleware::from_fn_with_state( + mode, + add_security_header_middleware, + )); + let response = tower::ServiceExt::oneshot( + router, + Request::builder() + .uri("/probe") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + response + .headers() + .get("X-Robots-Tag") + .map(|v| v.to_str().unwrap().to_owned()) + } + + #[tokio::test] + async fn static_mode_does_not_set_robots_tag() { + assert_eq!(robots_header_for(DeploymentMode::Static).await, None); + } + + #[tokio::test] + async fn media_and_upload_modes_still_set_robots_tag() { + assert_eq!( + robots_header_for(DeploymentMode::Mp).await.as_deref(), + Some(http_headers::ROBOTS) + ); + assert_eq!( + robots_header_for(DeploymentMode::Upload).await.as_deref(), + Some(http_headers::ROBOTS) + ); + } + + #[tokio::test] + async fn request_drain_waits_until_every_response_body_is_dropped() { + let drain = HttpRequestDrain::new(); + let router = Router::new().route("/probe", get(|| async { "OK" })).layer( + axum::middleware::from_fn_with_state(drain.clone(), track_active_request), + ); + let response = tower::ServiceExt::oneshot( + router, + Request::builder() + .uri("/probe") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(drain.active_requests(), 1); + drop(response); + drain.wait_for_requests_drained().await; + assert_eq!(drain.active_requests(), 0); + } +} diff --git a/fluxer_media_proxy/src/server/mod.rs b/fluxer_media_proxy/src/server/mod.rs new file mode 100644 index 000000000..4dc855ffa --- /dev/null +++ b/fluxer_media_proxy/src/server/mod.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod asset_path; +mod download_stream; +mod external; +mod format_policy; +mod media_operations; +mod middleware; +mod native_task_executor; +mod params; +mod relay; +mod response; +mod routes; +mod runtime; +mod state; +mod stored; +pub mod transform; + +pub use runtime::run; diff --git a/fluxer_media_proxy/src/server/native_task_executor.rs b/fluxer_media_proxy/src/server/native_task_executor.rs new file mode 100644 index 000000000..ca11be2d4 --- /dev/null +++ b/fluxer_media_proxy/src/server/native_task_executor.rs @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::ByteBudget, + media_process::MediaError, + metrics::{ + self, + transform::{NativeTransformMetrics, TransformMetrics}, + }, + timed_semaphore::{TimedSemaphore, TimedSemaphoreError}, +}; +use parking_lot::Mutex; +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + time::Instant, +}; +use tokio::sync::{Notify, oneshot}; +use tracing::error; + +pub(in crate::server) struct NativeTaskExecutorSettings { + pub(in crate::server) max_native_transforms: usize, + pub(in crate::server) worker_queue_capacity: usize, + pub(in crate::server) decoded_bytes_per_transform: usize, + pub(in crate::server) native_metrics: Arc, + pub(in crate::server) transform_metrics: Arc, +} + +pub(in crate::server) struct NativeTaskExecutor { + native_transforms: TimedSemaphore, + decoded_bytes: ByteBudget, + decoded_bytes_per_transform: usize, + tasks: NativeTaskTracker, + native_metrics: Arc, + transform_metrics: Arc, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum NativeTaskState { + Running, + Detached, + Complete, +} + +struct NativeTaskWait { + state: Arc>, + metrics: Arc, +} + +#[derive(Clone, Default)] +struct NativeTaskTracker { + inner: Arc, +} + +#[derive(Default)] +struct NativeTaskTrackerInner { + active: AtomicU64, + closed: AtomicBool, + idle: Notify, +} + +struct NativeTaskToken { + tracker: NativeTaskTracker, +} + +impl NativeTaskExecutor { + pub(in crate::server) fn new(settings: NativeTaskExecutorSettings) -> Self { + let NativeTaskExecutorSettings { + max_native_transforms, + worker_queue_capacity, + decoded_bytes_per_transform, + native_metrics, + transform_metrics, + } = settings; + assert!(decoded_bytes_per_transform > 0); + let decoded_bytes_capacity = decoded_bytes_per_transform + .checked_mul(max_native_transforms) + .expect("native decoded image budget must not overflow"); + Self { + native_transforms: TimedSemaphore::with_queue_capacity( + max_native_transforms, + worker_queue_capacity, + ), + decoded_bytes: ByteBudget::new(decoded_bytes_capacity), + decoded_bytes_per_transform, + tasks: NativeTaskTracker::default(), + native_metrics, + transform_metrics, + } + } + + pub(in crate::server) async fn run_native( + &self, + deadline: Option, + work: F, + ) -> anyhow::Result + where + T: Send + 'static, + F: FnOnce() -> anyhow::Result + Send + 'static, + { + let admission = match self.native_transforms.try_admit() { + Ok(admission) => admission, + Err(TimedSemaphoreError::QueueFull) => { + self.native_metrics.record_rejected(); + return Err(TimedSemaphoreError::QueueFull.into()); + } + Err(error) => return Err(error.into()), + }; + let wait_started = Instant::now(); + let permit = self + .native_transforms + .wait_until_admitted(&admission, deadline) + .await; + self.native_metrics + .observe_wait(metrics::duration_millis(wait_started.elapsed())); + let permit = permit?; + let decoded_bytes = self + .decoded_bytes + .try_reserve(self.decoded_bytes_per_transform) + .ok_or(MediaError::AllocationFailed)?; + self.run_task(deadline, move || { + let _permit = permit; + let _decoded_bytes = decoded_bytes; + work() + }) + .await + } + + pub(in crate::server) fn begin_shutdown(&self) { + self.tasks.close(); + } + + pub(in crate::server) async fn wait_for_shutdown(&self) { + assert!(self.tasks.is_closed()); + self.tasks.wait().await; + } + + async fn run_task(&self, deadline: Option, work: F) -> anyhow::Result + where + T: Send + 'static, + F: FnOnce() -> anyhow::Result + Send + 'static, + { + let Some(token) = self.tasks.token() else { + return Err(TimedSemaphoreError::Closed.into()); + }; + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(TimedSemaphoreError::RequestTimeout.into()); + } + let (sender, receiver) = oneshot::channel(); + let started = Instant::now(); + let state = Arc::new(Mutex::new(NativeTaskState::Running)); + let wait = NativeTaskWait { + state: Arc::clone(&state), + metrics: Arc::clone(&self.native_metrics), + }; + let blocking = tokio::task::spawn_blocking(work); + let native_metrics = Arc::clone(&self.native_metrics); + let transform_metrics = Arc::clone(&self.transform_metrics); + drop(tokio::spawn(async move { + let _token = token; + let result = match blocking.await { + Ok(result) => result, + Err(error) => Err(anyhow::anyhow!("native transform failed to join: {error}")), + }; + let previous = { + let mut state = state.lock(); + let previous = *state; + *state = NativeTaskState::Complete; + previous + }; + if previous == NativeTaskState::Detached { + native_metrics + .record_detached_finished(metrics::duration_millis(started.elapsed())); + } + if result + .as_ref() + .is_err_and(|error| error.downcast_ref() == Some(&MediaError::MediaDecodeFailed)) + { + transform_metrics.record_decode_failure(); + } + if let Err(Err(error)) = sender.send(result) { + error!(error = ?error, "detached native transform failed"); + } + })); + let completion = match deadline { + Some(deadline) => match tokio::time::timeout_at(deadline.into(), receiver).await { + Ok(completion) => completion, + Err(_) => return Err(TimedSemaphoreError::RequestTimeout.into()), + }, + None => receiver.await, + }; + let completion = + completion.map_err(|error| anyhow::anyhow!("native transform ended: {error}"))?; + drop(wait); + completion + } +} + +impl Drop for NativeTaskWait { + fn drop(&mut self) { + let mut state = self.state.lock(); + if *state == NativeTaskState::Running { + self.metrics.record_detached_started(); + *state = NativeTaskState::Detached; + } + } +} + +impl NativeTaskTracker { + fn token(&self) -> Option { + if self.inner.closed.load(Ordering::Acquire) { + return None; + } + self.inner + .active + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + active.checked_add(1) + }) + .expect("native task count must not overflow"); + Some(NativeTaskToken { + tracker: self.clone(), + }) + } + + fn close(&self) { + self.inner.closed.store(true, Ordering::Release); + self.inner.idle.notify_waiters(); + } + + fn is_closed(&self) -> bool { + self.inner.closed.load(Ordering::Acquire) + } + + async fn wait(&self) { + loop { + let idle = self.inner.idle.notified(); + tokio::pin!(idle); + idle.as_mut().enable(); + if self.inner.active.load(Ordering::Acquire) == 0 { + return; + } + idle.await; + } + } +} + +impl Drop for NativeTaskToken { + fn drop(&mut self) { + let previous = self.tracker.inner.active.fetch_sub(1, Ordering::AcqRel); + assert!(previous > 0, "native task count must stay positive"); + if previous == 1 { + self.tracker.inner.idle.notify_waiters(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use tokio::sync::mpsc; + + fn executor(metrics: &metrics::Metrics, permits: usize, queue: usize) -> NativeTaskExecutor { + NativeTaskExecutor::new(NativeTaskExecutorSettings { + max_native_transforms: permits, + worker_queue_capacity: queue, + decoded_bytes_per_transform: 1024, + native_metrics: metrics.native_transform(), + transform_metrics: metrics.transform(), + }) + } + + fn counter(rendered: &str, name: &str) -> u64 { + rendered + .lines() + .find_map(|line| line.strip_prefix(name)?.trim().parse().ok()) + .expect("counter series is rendered") + } + + #[tokio::test] + async fn a_task_abandoned_at_its_deadline_is_counted_as_detached_until_it_finishes() { + let metrics = metrics::Metrics::new(); + let executor = executor(&metrics, 1, 0); + let (release, mut released) = mpsc::channel::<()>(1); + let (finished, mut task_finished) = mpsc::channel::<()>(1); + let error = executor + .run_native( + Some(Instant::now() + Duration::from_millis(30)), + move || { + released.blocking_recv(); + let _ = finished.blocking_send(()); + Ok(()) + }, + ) + .await + .expect_err("the task outlives its deadline"); + assert_eq!( + Some(&TimedSemaphoreError::RequestTimeout), + error.downcast_ref::() + ); + let rendered = metrics.render(); + assert_eq!( + 1, + counter(&rendered, "fluxer_media_proxy_native_tasks_detached_total") + ); + assert_eq!( + 1, + counter(&rendered, "fluxer_media_proxy_native_tasks_detached_active") + ); + drop(release); + task_finished.recv().await; + for _ in 0..100 { + if counter( + &metrics.render(), + "fluxer_media_proxy_native_tasks_detached_active", + ) == 0 + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let rendered = metrics.render(); + assert_eq!( + 1, + counter(&rendered, "fluxer_media_proxy_native_tasks_detached_total") + ); + assert_eq!( + 0, + counter(&rendered, "fluxer_media_proxy_native_tasks_detached_active"), + "a detached task decrements the active gauge when it finishes" + ); + } + + #[tokio::test] + async fn a_task_that_completes_before_its_deadline_is_never_detached() { + let metrics = metrics::Metrics::new(); + let executor = executor(&metrics, 1, 0); + let value = executor + .run_native(Some(Instant::now() + Duration::from_secs(30)), || Ok(7u32)) + .await + .expect("the task completes"); + assert_eq!(7, value); + let rendered = metrics.render(); + assert_eq!( + 0, + counter(&rendered, "fluxer_media_proxy_native_tasks_detached_total") + ); + assert_eq!( + 0, + counter(&rendered, "fluxer_media_proxy_native_tasks_detached_active") + ); + } + + #[tokio::test] + async fn an_over_queued_transform_is_rejected_without_waiting() { + let metrics = metrics::Metrics::new(); + let executor = Arc::new(executor(&metrics, 1, 0)); + let (release, mut released) = mpsc::channel::<()>(1); + let holder = Arc::clone(&executor); + let held = tokio::spawn(async move { + holder + .run_native(None, move || { + released.blocking_recv(); + Ok(()) + }) + .await + }); + tokio::time::sleep(Duration::from_millis(50)).await; + let error = executor + .run_native(None, || Ok(())) + .await + .expect_err("the admission queue is full"); + assert_eq!( + Some(&TimedSemaphoreError::QueueFull), + error.downcast_ref::() + ); + assert_eq!( + 1, + counter( + &metrics.render(), + "fluxer_media_proxy_native_transform_rejected_total" + ) + ); + drop(release); + held.await.expect("held task").expect("held work"); + } + + #[tokio::test] + async fn a_closed_executor_refuses_new_work_and_drains() { + let metrics = metrics::Metrics::new(); + let executor = executor(&metrics, 2, 4); + executor.begin_shutdown(); + let error = executor + .run_native(None, || Ok(())) + .await + .expect_err("a closed executor admits nothing"); + assert_eq!( + Some(&TimedSemaphoreError::Closed), + error.downcast_ref::() + ); + executor.wait_for_shutdown().await; + } +} diff --git a/fluxer_media_proxy/src/server/params.rs b/fluxer_media_proxy/src/server/params.rs new file mode 100644 index 000000000..4197e5f74 --- /dev/null +++ b/fluxer_media_proxy/src/server/params.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + asset_hash::AssetHash, + constants::AssetExtension, + disposition::{CONTENT_DISPOSITION_FILENAME_BYTES_MAX, truncate_on_char_boundary}, + image_transform::EncodeEffort, + media_limits::MediaLimits, + server::format_policy::is_v1_asset_manual_format, +}; +use std::collections::HashMap; + +pub(in crate::server) fn bool_param( + params: &HashMap, + key: &str, + default_value: bool, +) -> bool { + params + .get(key) + .map(|raw| raw.eq_ignore_ascii_case("true") || raw == "1") + .unwrap_or(default_value) +} + +pub(in crate::server) fn animated_param( + params: &HashMap, + default_value: bool, +) -> bool { + params + .get("animated") + .map(|raw| raw.eq_ignore_ascii_case("true") || raw == "1") + .unwrap_or(default_value) +} + +pub(in crate::server) fn explicit_output_format( + params: &HashMap, +) -> Result, ()> { + let Some(raw) = params.get("format") else { + return Ok(None); + }; + AssetExtension::parse(raw).map(Some).ok_or(()) +} + +#[cfg(test)] +fn parse_dimension(raw: Option<&str>, limits: &MediaLimits) -> Option { + raw.and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0 && *v <= limits.image_dimension()) +} + +pub(in crate::server) fn parse_optional_dimension_param( + params: &HashMap, + key: &str, + limits: &MediaLimits, +) -> Result, ()> { + let Some(raw) = params.get(key) else { + return Ok(None); + }; + if raw.is_empty() { + return Err(()); + } + let value = raw.parse::().map_err(|_| ())?; + if value == 0 || value > limits.image_dimension() { + return Err(()); + } + Ok(Some(value)) +} + +pub(in crate::server) fn parse_effort(params: &HashMap) -> Option { + EncodeEffort::parse_lenient(params.get("effort")?) +} + +pub(in crate::server) fn asset_manual_format_override( + params: &HashMap, + url_ext: AssetExtension, +) -> Option { + let raw = params.get("format").or_else(|| params.get("fmt")); + if let Some(raw) = raw { + if raw.eq_ignore_ascii_case("auto") { + return None; + } + if let Some(parsed) = AssetExtension::parse(raw) + && is_v1_asset_manual_format(parsed) + { + return Some(parsed); + } + } + is_v1_asset_manual_format(url_ext).then_some(url_ext) +} + +pub(in crate::server) fn asset_wants_animated( + params: &HashMap, + hash: &str, +) -> bool { + animated_param(params, AssetHash::parse(hash).is_animated()) +} + +pub(in crate::server) fn last_segment(value: &str) -> &str { + value + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("file.bin") +} + +pub(in crate::server) fn filename_from_storage_key(key: &str) -> &str { + last_segment(key) +} + +fn strip_query_fragment(value: &str) -> &str { + let query = value.find('?').unwrap_or(value.len()); + let fragment = value.find('#').unwrap_or(value.len()); + &value[..query.min(fragment)] +} + +pub(in crate::server) fn url_filename(url: &str) -> String { + let filename = last_segment(strip_query_fragment(url)); + truncate_on_char_boundary(filename, CONTENT_DISPOSITION_FILENAME_BYTES_MAX).to_owned() +} + +pub(in crate::server) fn extension_of(filename: &str) -> Option<&str> { + filename.rsplit_once('.').map(|(_, ext)| ext) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dimensions_are_bounded() { + let limits = MediaLimits::default_from_config(); + assert_eq!(Some(128), parse_dimension(Some("128"), &limits)); + assert_eq!(None, parse_dimension(Some("0"), &limits)); + assert_eq!(None, parse_dimension(Some("999999999"), &limits)); + } + + #[test] + fn asset_manual_format_override_is_v1_compatible() { + assert_eq!( + Some(AssetExtension::Webp), + asset_manual_format_override(&HashMap::new(), AssetExtension::Webp) + ); + assert_eq!( + None, + asset_manual_format_override( + &HashMap::from([("format".to_owned(), "auto".to_owned())]), + AssetExtension::Webp + ) + ); + assert_eq!( + Some(AssetExtension::Png), + asset_manual_format_override( + &HashMap::from([("fmt".to_owned(), "png".to_owned())]), + AssetExtension::Webp + ) + ); + assert_eq!( + Some(AssetExtension::Webp), + asset_manual_format_override( + &HashMap::from([("format".to_owned(), "svg".to_owned())]), + AssetExtension::Webp + ) + ); + } + + #[test] + fn an_empty_last_url_segment_keeps_the_old_era_file_bin_fallback() { + assert_eq!("file.bin", url_filename("https://example.test/")); + assert_eq!("file.bin", url_filename("https://example.test/a/b/")); + assert_eq!("file.bin", url_filename("https://example.test/a/?x=1")); + assert_eq!("file.bin", url_filename("https://example.test/a/#frag")); + } + + #[test] + fn an_external_url_filename_is_bounded_to_the_disposition_budget() { + assert_eq!( + "file.png", + url_filename("https://example.test/a/file.png?x=1#frag") + ); + assert_eq!("file.bin", url_filename("https://example.test/")); + let long = format!( + "https://example.test/{}", + "\u{e9}".repeat(CONTENT_DISPOSITION_FILENAME_BYTES_MAX) + ); + let bounded = url_filename(&long); + assert!(bounded.len() <= CONTENT_DISPOSITION_FILENAME_BYTES_MAX); + assert!(bounded.starts_with('\u{e9}')); + assert!(bounded.ends_with('\u{e9}')); + } +} diff --git a/fluxer_media_proxy/src/server/relay/body.rs b/fluxer_media_proxy/src/server/relay/body.rs new file mode 100644 index 000000000..bcd8abf00 --- /dev/null +++ b/fluxer_media_proxy/src/server/relay/body.rs @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{storage::RelayBodyChunks, upload_relay::RelayError}; +use axum::{body::Body, http::HeaderValue}; +use bytes::Bytes; +use http_body_util::BodyExt as _; +use parking_lot::Mutex; +use std::{ + io, + sync::{Arc, OnceLock}, + time::Duration, +}; + +const RELAY_STREAM_FRAME_BYTES: usize = 64 * 1024; +const RELAY_SOURCE_FRAME_LIMIT: u32 = 131_072; + +#[derive(Clone)] +pub(in crate::server) struct RelayBodyProgress { + relayed_bytes: Arc>, +} + +impl RelayBodyProgress { + fn new() -> Self { + Self { + relayed_bytes: Arc::new(Mutex::new(0)), + } + } + + fn update(&self, frame: &Bytes) { + if frame.is_empty() { + return; + } + let mut relayed_bytes = self.relayed_bytes.lock(); + let total = relayed_bytes.saturating_add(frame.len() as u64); + *relayed_bytes = total; + } +} + +pub(in crate::server) fn validate_completed_relay_body( + client_failure: &Arc>, + progress: &RelayBodyProgress, + expected_bytes: u64, +) -> Result<(), RelayError> { + if let Some(failure) = client_failure.get().copied() { + return Err(failure); + } + if *progress.relayed_bytes.lock() != expected_bytes { + return Err(RelayError::ClientUploadFailed); + } + Ok(()) +} + +pub(in crate::server) fn relay_etag(value: &str) -> HeaderValue { + HeaderValue::from_str(value).unwrap_or_else(|_| HeaderValue::from_static("")) +} + +pub(in crate::server) struct RelayBodyStreamRequest { + pub(in crate::server) body: Body, + pub(in crate::server) declared_length: u64, + pub(in crate::server) deadline: tokio::time::Instant, + pub(in crate::server) failure: Arc>, +} + +pub(in crate::server) struct RelayBodyStream { + state: RelayBodyStreamState, +} + +enum RelayBodyStreamState { + Active(Box), + Terminal(RelayBodyProgress), +} + +struct RelayBodyActiveStream { + body: Body, + declared_length: u64, + written: u64, + source_frames_read: u32, + deadline: tokio::time::Instant, + pending: Bytes, + failure: Arc>, + progress: RelayBodyProgress, +} + +impl RelayBodyStream { + pub(in crate::server) fn new(request: RelayBodyStreamRequest) -> Self { + let RelayBodyStreamRequest { + body, + declared_length, + deadline, + failure, + } = request; + Self { + state: RelayBodyStreamState::Active(Box::new(RelayBodyActiveStream { + body, + declared_length, + written: 0, + source_frames_read: 0, + deadline, + pending: Bytes::new(), + failure, + progress: RelayBodyProgress::new(), + })), + } + } + + pub(in crate::server) fn progress(&self) -> RelayBodyProgress { + match &self.state { + RelayBodyStreamState::Active(stream) => stream.progress.clone(), + RelayBodyStreamState::Terminal(progress) => progress.clone(), + } + } + + pub(in crate::server) async fn next(self) -> Option<(Result, Self)> { + match self.state { + RelayBodyStreamState::Active(stream) => stream.next().await, + RelayBodyStreamState::Terminal(_) => None, + } + } + + fn active(stream: Box) -> Self { + Self { + state: RelayBodyStreamState::Active(stream), + } + } + + fn terminal(progress: RelayBodyProgress) -> Self { + Self { + state: RelayBodyStreamState::Terminal(progress), + } + } +} + +impl RelayBodyActiveStream { + async fn next(mut self: Box) -> Option<(Result, RelayBodyStream)> { + let now = tokio::time::Instant::now(); + if now >= self.deadline { + return self.fail( + RelayError::ClientUploadFailed, + io::ErrorKind::TimedOut, + "client upload total deadline elapsed", + ); + } + if !self.pending.is_empty() { + return Some(self.emit_pending_frame()); + } + let read_timeout = self + .deadline + .duration_since(now) + .max(Duration::from_millis(1)); + let frame = match tokio::time::timeout_at(now + read_timeout, self.body.frame()).await { + Ok(Some(Ok(frame))) => frame, + Ok(Some(Err(_))) => { + return self.fail( + RelayError::ClientUploadFailed, + io::ErrorKind::ConnectionAborted, + "client body read failed", + ); + } + Ok(None) if self.written == self.declared_length => return None, + Ok(None) => { + return self.fail( + RelayError::ClientUploadFailed, + io::ErrorKind::UnexpectedEof, + "payload shorter than declared length", + ); + } + Err(_) => { + return self.fail( + RelayError::ClientUploadFailed, + io::ErrorKind::TimedOut, + "client upload body timed out", + ); + } + }; + let Some(source_frames_read) = self + .source_frames_read + .checked_add(1) + .filter(|count| *count <= RELAY_SOURCE_FRAME_LIMIT) + else { + return self.fail( + RelayError::PayloadTooLarge, + io::ErrorKind::InvalidData, + "client upload exceeded the source frame limit", + ); + }; + self.source_frames_read = source_frames_read; + let Ok(chunk) = frame.into_data() else { + return Some((Ok(Bytes::new()), RelayBodyStream::active(self))); + }; + let next = self.written.saturating_add(chunk.len() as u64); + if next > self.declared_length { + return self.fail( + RelayError::PayloadTooLarge, + io::ErrorKind::InvalidData, + "payload exceeded declared length", + ); + } + self.written = next; + self.pending = chunk; + Some(self.emit_pending_frame()) + } + + fn emit_pending_frame(mut self: Box) -> (Result, RelayBodyStream) { + let frame_length = self.pending.len().min(RELAY_STREAM_FRAME_BYTES); + let frame = self.pending.split_to(frame_length); + self.progress.update(&frame); + (Ok(frame), RelayBodyStream::active(self)) + } + + fn fail( + self, + failure: RelayError, + kind: io::ErrorKind, + message: &'static str, + ) -> Option<(Result, RelayBodyStream)> { + let _ = self.failure.set(failure); + Some(( + Err(io::Error::new(kind, message)), + RelayBodyStream::terminal(self.progress), + )) + } +} + +pub(in crate::server) fn relay_body_chunks(stream: RelayBodyStream) -> RelayBodyChunks { + Box::pin(futures_util::stream::unfold( + Some(stream), + |state: Option| async move { + let mut stream = state?; + loop { + match stream.next().await { + Some((Ok(chunk), next)) if chunk.is_empty() => { + stream = next; + tokio::task::yield_now().await; + } + Some((Ok(chunk), next)) => return Some((Ok(chunk), Some(next))), + Some((Err(err), _)) => return Some((Err(err), None)), + None => return None, + } + } + }, + )) +} diff --git a/fluxer_media_proxy/src/server/relay/mod.rs b/fluxer_media_proxy/src/server/relay/mod.rs new file mode 100644 index 000000000..5e787af71 --- /dev/null +++ b/fluxer_media_proxy/src/server/relay/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub(in crate::server) mod body; +#[cfg(test)] +mod tests; diff --git a/fluxer_media_proxy/src/server/relay/tests.rs b/fluxer_media_proxy/src/server/relay/tests.rs new file mode 100644 index 000000000..de4185176 --- /dev/null +++ b/fluxer_media_proxy/src/server/relay/tests.rs @@ -0,0 +1,529 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::body::{ + RelayBodyStream, RelayBodyStreamRequest, relay_etag, validate_completed_relay_body, +}; +use crate::{ + config::Config, + secret::SecretBytes, + server::{ + routes::relay::{relay_cors, relay_put}, + state::AppState, + }, + upload_relay::{ + RelayError, + token::{TokenMethod, TokenPayload, encode_token, now_unix}, + }, +}; +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::{HeaderMap, HeaderValue, Method, Request, StatusCode, header}, +}; +use base64::engine::general_purpose::STANDARD; +use bytes::Bytes; +use std::{ + collections::HashMap, + sync::{Arc, OnceLock}, + time::Duration, +}; + +fn body_stream(body: Body, declared_length: u64) -> (RelayBodyStream, Arc>) { + let failure = Arc::new(OnceLock::new()); + let stream = RelayBodyStream::new(RelayBodyStreamRequest { + body, + declared_length, + deadline: tokio::time::Instant::now() + Duration::from_secs(5), + failure: Arc::clone(&failure), + }); + (stream, failure) +} + +#[tokio::test] +async fn relay_body_stream_splits_frames_and_counts_every_relayed_byte() { + let input = Bytes::from(vec![7; 70 * 1024]); + let declared = input.len() as u64; + let (mut stream, failure) = body_stream(Body::from(input.clone()), declared); + let progress = stream.progress(); + let mut output = Vec::new(); + while let Some((frame, next)) = stream.next().await { + let frame = frame.expect("relay frame"); + assert!(frame.len() <= 64 * 1024); + output.extend_from_slice(&frame); + stream = next; + } + + assert_eq!(output, input); + validate_completed_relay_body(&failure, &progress, declared).expect("completed body"); + assert_eq!( + validate_completed_relay_body(&failure, &progress, declared + 1), + Err(RelayError::ClientUploadFailed) + ); +} + +#[tokio::test] +async fn relay_body_stream_rejects_short_and_long_bodies() { + let (stream, failure) = body_stream(Body::from("abc"), 4); + let progress = stream.progress(); + let Some((first, stream)) = stream.next().await else { + panic!("short body omitted its data frame") + }; + assert_eq!(first.expect("first frame"), Bytes::from_static(b"abc")); + let Some((error, terminal)) = stream.next().await else { + panic!("short body did not report an error") + }; + assert_eq!( + error.expect_err("short body error").kind(), + std::io::ErrorKind::UnexpectedEof + ); + assert_eq!(failure.get(), Some(&RelayError::ClientUploadFailed)); + assert_eq!( + validate_completed_relay_body(&failure, &progress, 4), + Err(RelayError::ClientUploadFailed) + ); + assert!(terminal.next().await.is_none()); + + let (stream, failure) = body_stream(Body::from("abc"), 2); + let progress = stream.progress(); + let Some((error, terminal)) = stream.next().await else { + panic!("long body did not report an error") + }; + assert_eq!( + error.expect_err("long body error").kind(), + std::io::ErrorKind::InvalidData + ); + assert_eq!(failure.get(), Some(&RelayError::PayloadTooLarge)); + assert_eq!( + validate_completed_relay_body(&failure, &progress, 2), + Err(RelayError::PayloadTooLarge) + ); + assert!(terminal.next().await.is_none()); +} + +#[tokio::test] +async fn relay_body_stream_handles_empty_body_and_elapsed_total_deadline() { + let (stream, failure) = body_stream(Body::empty(), 0); + let progress = stream.progress(); + assert!(stream.next().await.is_none()); + validate_completed_relay_body(&failure, &progress, 0).expect("empty body"); + + let failure = Arc::new(OnceLock::new()); + let stream = RelayBodyStream::new(RelayBodyStreamRequest { + body: Body::from("a"), + declared_length: 1, + deadline: tokio::time::Instant::now(), + failure: Arc::clone(&failure), + }); + let Some((error, terminal)) = stream.next().await else { + panic!("elapsed deadline did not report an error") + }; + assert_eq!( + error.expect_err("deadline error").kind(), + std::io::ErrorKind::TimedOut + ); + assert_eq!(failure.get(), Some(&RelayError::ClientUploadFailed)); + assert!(terminal.next().await.is_none()); +} + +#[tokio::test] +async fn relay_body_stream_waits_out_a_client_stall_inside_the_total_budget() { + let payload = Bytes::from_static(b"resumed after a stall"); + let declared = payload.len() as u64; + let stalled = payload.clone(); + let (mut stream, failure) = body_stream( + Body::from_stream(futures_util::stream::once(async move { + tokio::time::sleep(Duration::from_millis(1_500)).await; + Ok::(stalled) + })), + declared, + ); + let progress = stream.progress(); + let mut output = Vec::new(); + while let Some((frame, next)) = stream.next().await { + output.extend_from_slice(&frame.expect("stalled client frame")); + stream = next; + } + + assert_eq!(output, payload); + assert!(failure.get().is_none()); + validate_completed_relay_body(&failure, &progress, declared).expect("stalled body"); +} + +fn upload_relay_test_config( + storage_root: &std::path::Path, + spool_dir: &std::path::Path, + relay_secret: &[u8], +) -> Config { + Config::load_from_iter([ + ( + "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), + "secret".to_owned(), + ), + ("FLUXER_MEDIA_PROXY_MODE".to_owned(), "upload".to_owned()), + ( + "FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(), + "local".to_owned(), + ), + ( + "FLUXER_MEDIA_PROXY_STORAGE_ROOT".to_owned(), + storage_root.display().to_string(), + ), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64".to_owned(), + base64::Engine::encode(&STANDARD, relay_secret), + ), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_MAX_BODY_BYTES".to_owned(), + "4096".to_owned(), + ), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_DIR".to_owned(), + spool_dir.display().to_string(), + ), + ( + "FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SPOOL_MAX_TOTAL_BYTES".to_owned(), + (1u64 << 30).to_string(), + ), + ]) + .unwrap() +} + +fn test_app_state(cfg: Config) -> Arc { + Arc::new(AppState::for_tests(cfg)) +} + +#[test] +fn relay_cors_allows_client_context_headers() { + let mut headers = HeaderMap::new(); + relay_cors(&mut headers); + let allow_headers = headers + .get(header::ACCESS_CONTROL_ALLOW_HEADERS) + .unwrap() + .to_str() + .unwrap(); + + assert!( + allow_headers + .split(',') + .any(|name| name.trim().eq_ignore_ascii_case("x-fluxer-features")) + ); + assert!( + allow_headers + .split(',') + .any(|name| name.trim().eq_ignore_ascii_case("x-client-context")) + ); +} + +#[tokio::test] +async fn relay_put_accepts_unknown_content_length_body() { + let tmp = tempfile::tempdir().unwrap(); + let tmp_root = tmp.path().canonicalize().unwrap(); + let storage_root = tmp_root.join("storage"); + let spool_dir = tmp_root.join("spool"); + tokio::fs::create_dir_all(&spool_dir).await.unwrap(); + let relay_secret = [7u8; 32]; + let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); + let key = "guild/diagnostics.txt"; + let token = encode_token( + &TokenPayload { + b: "uploads".to_owned(), + k: key.to_owned(), + m: TokenMethod::Put, + u: None, + p: None, + ct: Some("text/plain".to_owned()), + mb: 4096, + e: now_unix() + 60, + }, + &relay_secret, + ) + .unwrap(); + let body = Bytes::from_static(b"diagnostics bundle"); + let request = Request::builder() + .method(Method::PUT) + .body(Body::from(body.clone())) + .unwrap(); + assert!(request.headers().get(header::CONTENT_LENGTH).is_none()); + + let response = relay_put( + State(test_app_state(cfg)), + Path(key.to_owned()), + Query(HashMap::from([("t".to_owned(), token)])), + HeaderMap::new(), + request, + ) + .await; + + assert_eq!(StatusCode::OK, response.status()); + let stored = tokio::fs::read(storage_root.join("uploads").join(key)) + .await + .unwrap(); + assert_eq!(body.as_ref(), stored.as_slice()); +} + +fn relay_test_token(key: &str, relay_secret: &[u8]) -> String { + encode_token( + &TokenPayload { + b: "uploads".to_owned(), + k: key.to_owned(), + m: TokenMethod::Put, + u: None, + p: None, + ct: Some("application/octet-stream".to_owned()), + mb: 4096, + e: now_unix() + 60, + }, + relay_secret, + ) + .unwrap() +} + +fn content_length_headers(declared: u64) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&declared.to_string()).unwrap(), + ); + headers +} + +#[tokio::test] +async fn relay_put_streams_known_length_body_without_spooling() { + let tmp = tempfile::tempdir().unwrap(); + let tmp_root = tmp.path().canonicalize().unwrap(); + let storage_root = tmp_root.join("storage"); + let spool_dir = tmp_root.join("spool"); + tokio::fs::create_dir_all(&spool_dir).await.unwrap(); + let relay_secret = [7u8; 32]; + let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); + let key = "guild/streamed.bin"; + let token = relay_test_token(key, &relay_secret); + let body = Bytes::from_static(b"streamed straight through"); + + let response = relay_put( + State(test_app_state(cfg)), + Path(key.to_owned()), + Query(HashMap::from([("t".to_owned(), token)])), + content_length_headers(body.len() as u64), + Request::builder() + .method(Method::PUT) + .body(Body::from(body.clone())) + .unwrap(), + ) + .await; + + assert_eq!(StatusCode::OK, response.status()); + let stored = tokio::fs::read(storage_root.join("uploads").join(key)) + .await + .unwrap(); + assert_eq!(body.as_ref(), stored.as_slice()); + let mut spool_entries = tokio::fs::read_dir(&spool_dir).await.unwrap(); + assert!(spool_entries.next_entry().await.unwrap().is_none()); +} + +#[tokio::test] +async fn relay_put_rejects_streaming_body_longer_than_declared() { + let tmp = tempfile::tempdir().unwrap(); + let tmp_root = tmp.path().canonicalize().unwrap(); + let storage_root = tmp_root.join("storage"); + let spool_dir = tmp_root.join("spool"); + tokio::fs::create_dir_all(&spool_dir).await.unwrap(); + let relay_secret = [7u8; 32]; + let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); + let key = "guild/overrun.bin"; + let token = relay_test_token(key, &relay_secret); + + let response = relay_put( + State(test_app_state(cfg)), + Path(key.to_owned()), + Query(HashMap::from([("t".to_owned(), token)])), + content_length_headers(4), + Request::builder() + .method(Method::PUT) + .body(Body::from(Bytes::from_static(b"way past four bytes"))) + .unwrap(), + ) + .await; + + assert_eq!(StatusCode::PAYLOAD_TOO_LARGE, response.status()); + assert!( + tokio::fs::try_exists(storage_root.join("uploads").join(key)) + .await + .ok() + != Some(true) + ); +} + +#[tokio::test] +async fn relay_put_rejects_streaming_body_shorter_than_declared() { + let tmp = tempfile::tempdir().unwrap(); + let tmp_root = tmp.path().canonicalize().unwrap(); + let storage_root = tmp_root.join("storage"); + let spool_dir = tmp_root.join("spool"); + tokio::fs::create_dir_all(&spool_dir).await.unwrap(); + let relay_secret = [7u8; 32]; + let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); + let key = "guild/short.bin"; + let token = relay_test_token(key, &relay_secret); + + let response = relay_put( + State(test_app_state(cfg)), + Path(key.to_owned()), + Query(HashMap::from([("t".to_owned(), token)])), + content_length_headers(32), + Request::builder() + .method(Method::PUT) + .body(Body::from(Bytes::from_static(b"tiny"))) + .unwrap(), + ) + .await; + + assert_eq!(StatusCode::BAD_REQUEST, response.status()); + assert!( + tokio::fs::try_exists(storage_root.join("uploads").join(key)) + .await + .ok() + != Some(true) + ); +} + +#[tokio::test] +async fn relay_put_never_forwards_an_injected_content_type_upstream() { + let fake = crate::storage::tests::fake_s3().await; + let tmp = tempfile::tempdir().unwrap(); + let relay_secret = [7u8; 32]; + let mut cfg = fake.config(tmp.path()); + cfg.mode = crate::config::DeploymentMode::Upload; + cfg.socket_io_timeout_ms = 30_000; + cfg.upload_relay.secret = SecretBytes::new(relay_secret.to_vec()); + cfg.upload_relay.max_body_bytes = 4096; + let key = "guild/injected.bin"; + let token = encode_token( + &TokenPayload { + b: "uploads".to_owned(), + k: key.to_owned(), + m: TokenMethod::Put, + u: None, + p: None, + ct: Some("image/png\r\nInjected: yes".to_owned()), + mb: 4096, + e: now_unix() + 60, + }, + &relay_secret, + ) + .unwrap(); + let body = Bytes::from_static(b"payload"); + + let response = relay_put( + State(test_app_state(cfg)), + Path(key.to_owned()), + Query(HashMap::from([("t".to_owned(), token)])), + content_length_headers(body.len() as u64), + Request::builder() + .method(Method::PUT) + .body(Body::from(body.clone())) + .unwrap(), + ) + .await; + + assert_eq!(StatusCode::OK, response.status()); + let (method, uri, headers, sent) = fake.last_request(); + assert_eq!(Method::PUT, method); + assert_eq!("/uploads/guild/injected.bin", uri.path()); + assert_eq!(body.as_ref(), sent.as_ref()); + assert_eq!( + "application/octet-stream", + headers.get(header::CONTENT_TYPE).unwrap() + ); + assert!(headers.get("injected").is_none()); + for value in headers.values() { + assert!(!value.as_bytes().windows(8).any(|w| w == b"Injected")); + } + assert!(response.headers().get(header::ETAG).is_none()); +} + +#[tokio::test] +async fn relay_put_returns_ok_for_a_malformed_upstream_etag() { + for upstream_etag in [ + "unquoted-etag-123".to_owned(), + format!("\"{}\"", "a".repeat(300)), + ] { + let fake = crate::storage::tests::fake_s3().await; + fake.set_put_etag(&upstream_etag); + let tmp = tempfile::tempdir().unwrap(); + let relay_secret = [7u8; 32]; + let mut cfg = fake.config(tmp.path()); + cfg.mode = crate::config::DeploymentMode::Upload; + cfg.socket_io_timeout_ms = 30_000; + cfg.upload_relay.secret = SecretBytes::new(relay_secret.to_vec()); + cfg.upload_relay.max_body_bytes = 4096; + let key = "guild/malformed-etag.bin"; + let token = relay_test_token(key, &relay_secret); + let body = Bytes::from_static(b"committed upload bytes"); + + let response = relay_put( + State(test_app_state(cfg)), + Path(key.to_owned()), + Query(HashMap::from([("t".to_owned(), token)])), + content_length_headers(body.len() as u64), + Request::builder() + .method(Method::PUT) + .body(Body::from(body.clone())) + .unwrap(), + ) + .await; + + assert_eq!(StatusCode::OK, response.status()); + assert_eq!(body.as_ref(), fake.last_request().3.as_ref()); + assert_eq!( + upstream_etag.as_str(), + response + .headers() + .get(header::ETAG) + .unwrap() + .to_str() + .unwrap() + ); + } +} + +#[test] +fn relay_etag_degrades_an_unrepresentable_upstream_value_to_an_empty_header() { + assert_eq!("", relay_etag("\"broken\u{7f}tag\"").to_str().unwrap()); + assert_eq!("", relay_etag("\"broken\ntag\"").to_str().unwrap()); + assert_eq!("W/\"weak\"", relay_etag("W/\"weak\"").to_str().unwrap()); + assert_eq!("\"etag-123\"", relay_etag("\"etag-123\"").to_str().unwrap()); +} + +#[tokio::test] +async fn relay_put_omits_the_etag_when_the_store_returns_none() { + let tmp = tempfile::tempdir().unwrap(); + let tmp_root = tmp.path().canonicalize().unwrap(); + let storage_root = tmp_root.join("storage"); + let spool_dir = tmp_root.join("spool"); + tokio::fs::create_dir_all(&spool_dir).await.unwrap(); + let relay_secret = [7u8; 32]; + let cfg = upload_relay_test_config(&storage_root, &spool_dir, &relay_secret); + let key = "guild/no-upstream-etag.bin"; + let token = relay_test_token(key, &relay_secret); + let body = Bytes::from_static(b"local backend bytes"); + + let response = relay_put( + State(test_app_state(cfg)), + Path(key.to_owned()), + Query(HashMap::from([("t".to_owned(), token)])), + content_length_headers(body.len() as u64), + Request::builder() + .method(Method::PUT) + .body(Body::from(body.clone())) + .unwrap(), + ) + .await; + + assert_eq!(StatusCode::OK, response.status()); + assert!(response.headers().get(header::ETAG).is_none()); + let stored = tokio::fs::read(storage_root.join("uploads").join(key)) + .await + .unwrap(); + assert_eq!(body.as_ref(), stored.as_slice()); +} diff --git a/fluxer_media_proxy/src/server/response/error.rs b/fluxer_media_proxy/src/server/response/error.rs new file mode 100644 index 000000000..7e17f36ee --- /dev/null +++ b/fluxer_media_proxy/src/server/response/error.rs @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{http_headers, request_log::ErrorReason, storage::StorageError}; +use axum::{ + body::Body, + http::{HeaderValue, StatusCode, header}, + response::Response, +}; + +pub(in crate::server) fn storage_status(err: &StorageError) -> StatusCode { + match err { + StorageError::NotFound => StatusCode::NOT_FOUND, + StorageError::ReadOnlyStorage => StatusCode::FORBIDDEN, + StorageError::InvalidBucket | StorageError::InvalidKey => StatusCode::BAD_REQUEST, + StorageError::StreamTooLong => StatusCode::PAYLOAD_TOO_LARGE, + _ => StatusCode::BAD_GATEWAY, + } +} + +pub(in crate::server) fn storage_error_response(key: &str, err: StorageError) -> Response { + let status = storage_status(&err); + let body = if status == StatusCode::NOT_FOUND { + "Not Found" + } else { + canonical_reason_str(status) + }; + text_with_source( + status, + body, + "storage_error", + format!("key={key} err={err}"), + ) +} + +pub(in crate::server) fn text(status: StatusCode, body: &str) -> Response { + text_inner(status, body, None) +} + +pub(in crate::server) fn text_with_source( + status: StatusCode, + body: &str, + code: &'static str, + source: impl std::fmt::Debug, +) -> Response { + text_inner(status, body, Some(ErrorReason::with_source(code, source))) +} + +pub(in crate::server) fn text_with_reason( + status: StatusCode, + body: &str, + code: &'static str, +) -> Response { + text_inner(status, body, Some(ErrorReason::new(code))) +} + +const ERROR_CACHE_CONTROL: &str = "no-store"; + +fn text_inner(status: StatusCode, body: &str, reason: Option) -> Response { + let mut response = Response::new(Body::from(body.to_owned())); + *response.status_mut() = status; + http_headers::add_security_headers(response.headers_mut()); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + if status.is_client_error() || status.is_server_error() { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(ERROR_CACHE_CONTROL), + ); + response + .extensions_mut() + .insert(reason.unwrap_or_else(|| ErrorReason::new(canonical_reason_str(status)))); + } + response +} + +pub(in crate::server) fn canonical_reason_str(status: StatusCode) -> &'static str { + status.canonical_reason().unwrap_or("error") +} + +pub(in crate::server) fn json_response(status: StatusCode, body: String) -> Response { + let mut response = Response::new(Body::from(body)); + *response.status_mut() = status; + http_headers::add_security_headers(response.headers_mut()); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + if status.is_client_error() || status.is_server_error() { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(ERROR_CACHE_CONTROL), + ); + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + metrics::Metrics, + request_log::{RequestId, trace_public_request}, + }; + use axum::http::{HeaderMap, Method}; + use std::sync::{Arc, Mutex}; + use tracing_subscriber::fmt::MakeWriter; + + #[derive(Clone, Default)] + struct CapturedLog(Arc>>); + + impl CapturedLog { + fn text(&self) -> String { + String::from_utf8(self.0.lock().expect("captured log is not poisoned").clone()) + .expect("captured log is utf-8") + } + } + + impl std::io::Write for CapturedLog { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0 + .lock() + .expect("captured log is not poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> MakeWriter<'a> for CapturedLog { + type Writer = Self; + + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + #[test] + fn text_responses_set_nosniff_header() { + let response = text(StatusCode::NOT_FOUND, "Not Found"); + assert_eq!( + "nosniff", + response + .headers() + .get(header::X_CONTENT_TYPE_OPTIONS) + .unwrap() + .to_str() + .unwrap() + ); + assert_eq!( + http_headers::STRICT_TRANSPORT_SECURITY, + response + .headers() + .get("strict-transport-security") + .unwrap() + .to_str() + .unwrap() + ); + assert!( + response + .headers() + .contains_key(header::CONTENT_SECURITY_POLICY) + ); + assert!(response.headers().contains_key("permissions-policy")); + } + + #[test] + fn error_responses_declare_an_explicit_no_store_policy() { + for status in [ + StatusCode::NOT_FOUND, + StatusCode::BAD_GATEWAY, + StatusCode::INTERNAL_SERVER_ERROR, + ] { + for response in [text(status, "nope"), json_response(status, "{}".to_owned())] { + assert_eq!( + ERROR_CACHE_CONTROL, + response + .headers() + .get(header::CACHE_CONTROL) + .expect("an error response declares a cache policy"), + "status {status} must not be cacheable" + ); + } + } + } + + #[test] + fn successful_text_responses_are_left_to_the_media_cache_policy() { + assert!( + text(StatusCode::OK, "fine") + .headers() + .get(header::CACHE_CONTROL) + .is_none() + ); + assert!( + json_response(StatusCode::OK, "{}".to_owned()) + .headers() + .get(header::CACHE_CONTROL) + .is_none() + ); + } + + #[tokio::test] + async fn a_very_long_storage_key_cannot_bloat_the_logged_source() { + let key = format!("attachments/1/2/{}.png", "k".repeat(4096)); + let captured = CapturedLog::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(captured.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .finish(); + let response = { + let _guard = tracing::subscriber::set_default(subscriber); + let metrics = Metrics::new(); + trace_public_request( + metrics.request().as_ref(), + RequestId::generate(), + Method::GET, + "/attachments/1/2/missing.png", + &HeaderMap::new(), + async { storage_error_response(&key, StorageError::NotFound) }, + ) + .await + }; + assert_eq!(StatusCode::NOT_FOUND, response.status()); + let line = captured.text(); + assert!(line.contains("reason=\"storage_error\""), "{line}"); + assert!(!line.contains(&key), "{line}"); + let source = line + .split("source=") + .nth(1) + .expect("a failure log line carries a source field") + .trim_end(); + assert_eq!(513, source.len(), "{source}"); + assert!(source.ends_with('~'), "{source}"); + assert!(source.contains("key=attachments/1/2/kkk"), "{source}"); + } +} diff --git a/fluxer_media_proxy/src/server/response/mod.rs b/fluxer_media_proxy/src/server/response/mod.rs new file mode 100644 index 000000000..46e279bf1 --- /dev/null +++ b/fluxer_media_proxy/src/server/response/mod.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub(in crate::server) mod error; + +use crate::{ + byte_budget::BudgetedBytes, + disposition, http_headers, range, + server::format_policy::{extension_from_mime, image_extension_from_filename}, +}; +use axum::{ + body::Body, + http::{HeaderValue, Method, StatusCode, header}, + response::Response, +}; +use bytes::Bytes; +use std::borrow::Cow; + +pub(in crate::server) fn retained_response_bytes(data: BudgetedBytes) -> Bytes { + Bytes::from_owner(data) +} + +pub(in crate::server) struct MediaResponse<'a> { + pub(in crate::server) method: Method, + pub(in crate::server) data: BudgetedBytes, + pub(in crate::server) content_type: &'a str, + pub(in crate::server) range_header: Option<&'a str>, + pub(in crate::server) disposition: Option, +} + +pub(in crate::server) fn media_response(response: MediaResponse<'_>) -> Response { + let MediaResponse { + method, + data, + content_type, + range_header, + disposition, + } = response; + let total_len = data.len(); + let byte_range = match range::parse_range(range_header, total_len) { + range::RangeSelection::Full => None, + range::RangeSelection::Partial(byte_range) => Some(byte_range), + range::RangeSelection::Unsatisfiable => { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; + http_headers::add_unsatisfiable_headers(response.headers_mut(), total_len); + return response; + } + }; + let retained = retained_response_bytes(data); + let (status, body_bytes) = if let Some(r) = byte_range { + (StatusCode::PARTIAL_CONTENT, retained.slice(r.start..=r.end)) + } else { + (StatusCode::OK, retained) + }; + let body_len = body_bytes.len(); + let mut response = if method == Method::HEAD { + Response::new(Body::empty()) + } else { + Response::new(Body::from(body_bytes)) + }; + *response.status_mut() = status; + http_headers::add_media_headers(response.headers_mut(), total_len, content_type, byte_range); + response + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); + if let Some(value) = disposition { + response + .headers_mut() + .insert(header::CONTENT_DISPOSITION, value); + } + response +} + +pub(in crate::server) fn content_disposition_header( + content_type: &str, + requested_download: bool, + filename: Option<&str>, +) -> HeaderValue { + let decision = disposition::decide(content_type, requested_download); + let filename = filename + .map(|name| download_filename_for_content_type(name, content_type, requested_download)); + disposition::header(decision, filename.as_deref()) + .map(disposition::ContentDisposition::into_header_value) + .unwrap_or_else(|_| decision.header_value()) +} + +fn download_filename_for_content_type<'a>( + filename: &'a str, + content_type: &str, + requested_download: bool, +) -> Cow<'a, str> { + if !requested_download || filename.is_empty() { + return Cow::Borrowed(filename); + } + let Some(expected_ext) = extension_from_mime(content_type) else { + return Cow::Borrowed(filename); + }; + if image_extension_from_filename(filename) == Some(expected_ext) { + return Cow::Borrowed(filename); + } + let ext = expected_ext.name(); + let Some((stem, _)) = filename.rsplit_once('.') else { + return Cow::Owned(format!("{filename}.{ext}")); + }; + if stem.is_empty() { + Cow::Owned(format!("{filename}.{ext}")) + } else { + Cow::Owned(format!("{stem}.{ext}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::params::{filename_from_storage_key, url_filename}; + + fn disposition_string( + content_type: &str, + requested_download: bool, + filename: Option<&str>, + ) -> String { + content_disposition_header(content_type, requested_download, filename) + .to_str() + .expect("content disposition is ascii") + .to_owned() + } + + #[test] + fn explicit_download_disposition_uses_response_image_extension() { + assert_eq!( + "attachment; filename=\"welcome.gif\"", + disposition_string("image/gif", true, Some("welcome.png")) + ); + assert_eq!( + "attachment; filename=\"welcome.gif\"", + disposition_string("image/gif", true, Some("welcome")) + ); + assert_eq!( + "attachment; filename=\"photo.jpg\"", + disposition_string("image/jpeg", true, Some("photo.jpg")) + ); + assert_eq!( + "attachment; filename=\"photo.jpg\"", + disposition_string( + "image/jpeg", + true, + Some(filename_from_storage_key("attachments/123/456/photo.jpg")) + ) + ); + assert_eq!( + "inline; filename=\"welcome.png\"", + disposition_string("image/gif", false, Some("welcome.png")) + ); + } + + #[test] + fn a_long_external_filename_keeps_its_leading_bytes_in_the_header() { + let url = format!( + "https://example.test/lead{}tail.png", + "a".repeat(disposition::CONTENT_DISPOSITION_FILENAME_BYTES_MAX) + ); + let filename = url_filename(&url); + let value = disposition_string("image/png", false, Some(&filename)); + assert!( + value.starts_with("inline; filename=\"lead"), + "{}", + &value[..32] + ); + assert!(!value.contains("tail.png")); + assert!(value.len() <= disposition::PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); + } + + #[test] + fn a_long_multi_byte_external_filename_still_carries_a_disposition() { + let filename = "\u{e9}".repeat(1100); + let inline = disposition_string("image/png", false, Some(&filename)); + assert!(inline.starts_with("inline; filename=\"")); + assert!(inline.len() <= disposition::PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); + let attachment = disposition_string("application/octet-stream", false, Some(&filename)); + assert!(attachment.starts_with("attachment; filename=\"")); + assert!(attachment.len() <= disposition::PUBLIC_MEDIA_MAX_HEADER_VALUE_BYTES); + } +} diff --git a/fluxer_media_proxy/src/server/routes/dispatch.rs b/fluxer_media_proxy/src/server/routes/dispatch.rs new file mode 100644 index 000000000..a5f7c2d1a --- /dev/null +++ b/fluxer_media_proxy/src/server/routes/dispatch.rs @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + config::DeploymentMode, + constants::AssetKind, + server::{ + asset_path::{ + StorageKeyDecodeError, decode_storage_key, parse_entrance_sound_path, + parse_guild_member_asset_path, parse_simple_asset_path, parse_standard_asset_path, + }, + external::serve_external, + response::error::{text, text_with_source}, + state::AppState, + stored, + }, +}; +use axum::{ + body::Body, + extract::{Query, State}, + http::{Method, Request, StatusCode}, + response::Response, +}; +use std::{collections::HashMap, sync::Arc}; + +pub(in crate::server) async fn catch_all( + State(app): State>, + Query(params): Query>, + request: Request, +) -> Response { + let method = request.method().clone(); + if method != Method::GET && method != Method::HEAD { + return text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed"); + } + let path = request.uri().path().to_owned(); + if app.cfg.mode == DeploymentMode::Static { + let key = match decode_storage_key(&path) { + Ok(key) => key, + Err(err) => return storage_key_decode_response(err), + }; + return stored::serve_stored_raw( + &app, + method, + &app.cfg.storage.bucket_static, + &key, + request.headers(), + ) + .await; + } + if let Some(rest) = path.strip_prefix("/external/") { + return serve_external(&app, method, rest, ¶ms, request.headers()).await; + } + if path.starts_with("/attachments/") { + let key = match decode_storage_key(&path) { + Ok(key) => key, + Err(err) => return storage_key_decode_response(err), + }; + return stored::serve_attachment(&app, method, &key, ¶ms, request.headers()).await; + } + if path.starts_with("/themes/") && path.ends_with(".css") { + let key = match decode_storage_key(&path) { + Ok(key) => key, + Err(err) => return storage_key_decode_response(err), + }; + return stored::serve_stored_with_override( + &app, + method, + &app.cfg.storage.bucket_cdn, + &key, + "text/css; charset=utf-8", + request.headers(), + ) + .await; + } + if let Some(key) = parse_entrance_sound_path(&path) { + return stored::serve_stored_raw( + &app, + method, + &app.cfg.storage.bucket_cdn, + &key, + request.headers(), + ) + .await; + } + if let Some(asset) = parse_guild_member_asset_path(&path) { + return stored::serve_asset_image(&app, method, asset, ¶ms, request.headers()).await; + } + if let Some(asset) = parse_simple_asset_path(&path, AssetKind::Emoji) { + return stored::serve_asset_image(&app, method, asset, ¶ms, request.headers()).await; + } + if let Some(asset) = parse_simple_asset_path(&path, AssetKind::Sticker) { + return stored::serve_asset_image(&app, method, asset, ¶ms, request.headers()).await; + } + if let Some(asset) = parse_standard_asset_path(&path) { + return stored::serve_asset_image(&app, method, asset, ¶ms, request.headers()).await; + } + text(StatusCode::NOT_FOUND, "Not Found") +} + +fn storage_key_decode_response(err: StorageKeyDecodeError) -> Response { + text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "invalid_storage_key", + err, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use axum::{body::to_bytes, http::header}; + use bytes::Bytes; + use std::path::Path; + + const STORED_BYTES: &[u8] = b"raw-stored-bytes"; + + fn dispatch_config(mode: &str, storage_root: &Path) -> Config { + Config::load_from_iter([ + ( + "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), + "secret".to_owned(), + ), + ("FLUXER_MEDIA_PROXY_MODE".to_owned(), mode.to_owned()), + ( + "FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(), + "local".to_owned(), + ), + ( + "FLUXER_MEDIA_PROXY_STORAGE_ROOT".to_owned(), + storage_root.display().to_string(), + ), + ]) + .expect("dispatch test config") + } + + fn write_object(storage_root: &Path, bucket: &str, key: &str, body: &[u8]) { + let path = storage_root.join(bucket).join(key); + std::fs::create_dir_all(path.parent().expect("object parent")).expect("create bucket dir"); + std::fs::write(path, body).expect("write object"); + } + + async fn dispatch(app: &Arc, method: Method, path: &str) -> Response { + let request = Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("dispatch request"); + catch_all(State(Arc::clone(app)), Query(HashMap::new()), request).await + } + + async fn dispatch_body(app: &Arc, path: &str) -> Bytes { + let response = dispatch(app, Method::GET, path).await; + assert_eq!(StatusCode::OK, response.status(), "{path} must be served"); + to_bytes(response.into_body(), usize::MAX) + .await + .expect("dispatch body") + } + + #[tokio::test] + async fn static_mode_short_circuits_every_path_to_the_static_bucket() { + let tmp = tempfile::tempdir().expect("storage root"); + let root = tmp.path().canonicalize().expect("canonical storage root"); + let root = root.as_path(); + write_object(root, "static", "external/v2/signed.png", STORED_BYTES); + write_object( + root, + "static", + "avatars/1216100949629702144/a1b2c3d4e5f6.png", + STORED_BYTES, + ); + write_object(root, "cdn", "themes/only-in-cdn.css", STORED_BYTES); + let app = Arc::new(AppState::for_tests(dispatch_config("static", root))); + + assert_eq!( + STORED_BYTES, + dispatch_body(&app, "/external/v2/signed.png").await + ); + assert_eq!( + STORED_BYTES, + dispatch_body(&app, "/avatars/1216100949629702144/a1b2c3d4e5f6.png").await + ); + assert_eq!( + StatusCode::NOT_FOUND, + dispatch(&app, Method::GET, "/themes/only-in-cdn.css") + .await + .status(), + "static mode never reaches the cdn bucket" + ); + assert_eq!( + StatusCode::NOT_FOUND, + dispatch(&app, Method::GET, "/attachments/1/2/file.bin") + .await + .status() + ); + } + + const STATIC_CACHE_CONTROL: &str = "public, max-age=31536000"; + + const STATIC_KEYS: [&str; 4] = [ + "avatars/0.png", + "web/favicon.ico", + "emoji/1f600.svg", + "web/NOTICE.md", + ]; + + fn static_mode_app(root: &Path) -> Arc { + for key in STATIC_KEYS { + write_object(root, "static", key, STORED_BYTES); + } + Arc::new(AppState::for_tests(dispatch_config("static", root))) + } + + fn header_value(response: &Response, name: impl header::AsHeaderName) -> Option { + response + .headers() + .get(name) + .map(|value| value.to_str().expect("header is ascii").to_owned()) + } + + #[tokio::test] + async fn static_mode_caches_every_asset_forever() { + let tmp = tempfile::tempdir().expect("storage root"); + let root = tmp.path().canonicalize().expect("canonical storage root"); + let app = static_mode_app(root.as_path()); + for key in STATIC_KEYS { + let response = dispatch(&app, Method::GET, &format!("/{key}")).await; + assert_eq!(StatusCode::OK, response.status(), "key={key}"); + assert_eq!( + Some(STATIC_CACHE_CONTROL.to_owned()), + header_value(&response, header::CACHE_CONTROL), + "key={key}" + ); + } + } + + #[tokio::test] + async fn static_mode_omits_expires_and_relies_on_cache_control() { + let tmp = tempfile::tempdir().expect("storage root"); + let root = tmp.path().canonicalize().expect("canonical storage root"); + let app = static_mode_app(root.as_path()); + let response = dispatch(&app, Method::GET, "/avatars/0.png").await; + assert_eq!(StatusCode::OK, response.status()); + assert_eq!(None, header_value(&response, header::EXPIRES)); + assert_eq!( + Some(STATIC_CACHE_CONTROL.to_owned()), + header_value(&response, "CDN-Cache-Control") + ); + } + + #[tokio::test] + async fn media_mode_matches_the_v1_route_order() { + let tmp = tempfile::tempdir().expect("storage root"); + let root = tmp.path().canonicalize().expect("canonical storage root"); + let root = root.as_path(); + write_object(root, "cdn", "themes/dark.css", b"body{color:#fff}"); + write_object( + root, + "cdn", + "entrance-sounds/42/abc123def456.wav", + STORED_BYTES, + ); + write_object(root, "static", "themes/dark.css", STORED_BYTES); + let app = Arc::new(AppState::for_tests(dispatch_config("mp", root))); + + let response = dispatch(&app, Method::GET, "/themes/dark.css").await; + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + "text/css; charset=utf-8", + response + .headers() + .get(header::CONTENT_TYPE) + .expect("content type") + .to_str() + .expect("ascii content type") + ); + assert_eq!( + b"body{color:#fff}".as_slice(), + to_bytes(response.into_body(), usize::MAX) + .await + .expect("theme body") + ); + + assert_eq!( + STORED_BYTES, + dispatch_body(&app, "/entrance-sounds/42/abc123def456.wav").await + ); + assert_eq!( + StatusCode::NOT_FOUND, + dispatch(&app, Method::GET, "/themes/dark.png") + .await + .status(), + "the themes branch only matches .css" + ); + assert_eq!( + StatusCode::NOT_FOUND, + dispatch(&app, Method::GET, "/nothing/here").await.status() + ); + assert_eq!( + StatusCode::METHOD_NOT_ALLOWED, + dispatch(&app, Method::POST, "/themes/dark.css") + .await + .status() + ); + } +} diff --git a/fluxer_media_proxy/src/server/routes/internal.rs b/fluxer_media_proxy/src/server/routes/internal.rs new file mode 100644 index 000000000..8bc4484d2 --- /dev/null +++ b/fluxer_media_proxy/src/server/routes/internal.rs @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::BudgetedBytes, + constants, + image_transform::ResizeMode, + media_process, mime, + output_format::OutputFormat, + server::{ + media_operations::{ + MediaFailure, MediaInput, MediaInputLimit, MetadataOutput, load_media_input, + resolve_metadata, + }, + response::{ + MediaResponse, + error::{ + canonical_reason_str, json_response, storage_error_response, text, + text_with_reason, text_with_source, + }, + media_response, + }, + state::AppState, + transform::execution::run_transform, + }, +}; +use axum::{ + body::{Body, to_bytes}, + extract::State, + http::{HeaderMap, Method, Request, StatusCode, header}, + response::Response, +}; +use base64::{Engine as _, engine::general_purpose}; +use bytes::Bytes; +use serde::Deserialize; +use std::sync::Arc; + +#[derive(Debug, Deserialize)] +struct MetadataRequest { + version: Option, + #[serde(rename = "type")] + typ: String, + nsfw: String, + base64: Option, + upload_filename: Option, + filename: Option, + bucket: Option, + key: Option, + url: Option, + with_base64: Option, +} + +impl MetadataRequest { + fn into_media_input(self) -> Result { + match self.typ.as_str() { + "base64" => Ok(MediaInput::Base64 { + data: self.base64.ok_or(MediaFailure::MediaInputMissingField)?, + filename: self.filename, + }), + "upload" => Ok(MediaInput::Upload { + upload_filename: self + .upload_filename + .ok_or(MediaFailure::MediaInputMissingField)?, + filename: self.filename, + }), + "s3" => Ok(MediaInput::Storage { + bucket: self.bucket.ok_or(MediaFailure::MediaInputMissingField)?, + key: self.key.ok_or(MediaFailure::MediaInputMissingField)?, + filename: self.filename, + }), + "external" => Ok(MediaInput::External { + url: self.url.ok_or(MediaFailure::MediaInputMissingField)?, + filename: self.filename, + }), + _ => Err(MediaFailure::MediaInputUnsupportedType), + } + } +} + +#[derive(Debug, Deserialize)] +struct FramesRequest { + version: Option, + #[serde(rename = "type")] + typ: String, + base64: Option, + upload_filename: Option, + filename: Option, + bucket: Option, + key: Option, + url: Option, +} + +impl FramesRequest { + fn into_metadata_request(self) -> MetadataRequest { + MetadataRequest { + version: self.version, + typ: self.typ, + nsfw: "allow".to_owned(), + base64: self.base64, + upload_filename: self.upload_filename, + filename: self.filename, + bucket: self.bucket, + key: self.key, + url: self.url, + with_base64: None, + } + } +} + +#[derive(Debug, Deserialize)] +struct UploadFileRequest { + upload_filename: String, +} + +pub(in crate::server) async fn metadata_handler( + State(app): State>, + headers: HeaderMap, + request: Request, +) -> Response { + if !check_internal_auth(&headers, app.cfg.secret_key.expose()) { + return text(StatusCode::UNAUTHORIZED, "Unauthorized"); + } + let body = match read_limited_body(request).await { + Ok(body) => body, + Err(status) => return text(status, canonical_reason_str(status)), + }; + let req: MetadataRequest = match serde_json::from_slice::(&body) { + Ok(req) if req.version == Some(2) => req, + _ => return text(StatusCode::BAD_REQUEST, "Bad Request"), + }; + let scan_nsfw = match req.nsfw.as_str() { + "block" | "flag" => true, + "allow" => false, + _ => return text(StatusCode::BAD_REQUEST, "Bad Request"), + }; + let include_data = req.with_base64.unwrap_or(false); + let input = match req.into_media_input() { + Ok(input) => input, + Err(failure) => return failure.into_response(), + }; + let MetadataOutput { mut metadata, data } = + match resolve_metadata(&app, input, scan_nsfw, include_data).await { + Ok(output) => output, + Err(failure) => return failure.into_response(), + }; + if let Some(data) = data { + metadata["base64"] = serde_json::Value::String(general_purpose::STANDARD.encode(&data)); + } + json_response(StatusCode::OK, metadata.to_string()) +} + +pub(in crate::server) async fn thumbnail_handler( + State(app): State>, + headers: HeaderMap, + request: Request, +) -> Response { + if !check_internal_auth(&headers, app.cfg.secret_key.expose()) { + return text(StatusCode::UNAUTHORIZED, "Unauthorized"); + } + let body = match read_limited_body(request).await { + Ok(body) => body, + Err(status) => return text(status, "Bad Request"), + }; + let req: UploadFileRequest = match serde_json::from_slice(&body) { + Ok(req) => req, + Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"), + }; + let object = match app + .store + .read_object(&app.cfg.storage.bucket_uploads, &req.upload_filename) + .await + { + Ok(object) => object, + Err(err) => return storage_error_response(&req.upload_filename, err), + }; + let media = if mime::category(&object.content_type) == Some(mime::Category::Video) { + match media_process::extract_video_thumbnail( + &object.data, + OutputFormat::WebP, + &app.media.limits(), + ) { + Ok(media) => media, + Err(err) => { + return text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "video_thumbnail_failed", + err, + ); + } + } + } else { + let options = media_process::ImageOptions { + width: Some(512), + height: Some(512), + format: OutputFormat::WebP, + resize_mode: ResizeMode::Fit, + ..Default::default() + }; + match run_transform(app.media.transforms(), object.data.clone(), options).await { + Ok(media) => media, + Err(err) => { + return text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "image_thumbnail_failed", + err, + ); + } + } + }; + media_response(MediaResponse { + method: Method::GET, + data: BudgetedBytes::from(Bytes::from(media.bytes)), + content_type: media.content_type, + range_header: None, + disposition: None, + }) +} + +pub(in crate::server) async fn frames_handler( + State(app): State>, + headers: HeaderMap, + request: Request, +) -> Response { + if !check_internal_auth(&headers, app.cfg.secret_key.expose()) { + return text(StatusCode::UNAUTHORIZED, "Unauthorized"); + } + let body = match read_limited_body(request).await { + Ok(body) => body, + Err(_) => return text(StatusCode::BAD_REQUEST, "Bad Request"), + }; + let req: FramesRequest = match serde_json::from_slice::(&body) { + Ok(req) if req.version.is_none_or(|version| version == 2) => req, + _ => return text(StatusCode::BAD_REQUEST, "Bad Request"), + }; + let input = match req.into_metadata_request().into_media_input() { + Ok(input) => input, + Err(failure) => return frames_input_failure(&failure), + }; + let input = match load_media_input(&app, input, MediaInputLimit::INTERNAL_REQUEST).await { + Ok(input) => input, + Err(failure) => return frames_input_failure(&failure), + }; + match media_process::extract_video_thumbnail( + &input.data, + OutputFormat::JPEG, + &app.media.limits(), + ) { + Ok(frame) => { + let encoded = general_purpose::STANDARD.encode(frame.bytes); + json_response( + StatusCode::OK, + format!( + "{{\"frames\":[{{\"timestamp\":0,\"mime_type\":\"image/jpeg\",\"base64\":\"{encoded}\"}}]}}" + ), + ) + } + Err(_) => json_response(StatusCode::OK, "{\"frames\":[]}".to_owned()), + } +} + +fn frames_input_failure(failure: &MediaFailure) -> Response { + text_with_reason(StatusCode::BAD_REQUEST, "Bad Request", failure.code()) +} + +fn check_internal_auth(headers: &HeaderMap, secret: &str) -> bool { + let Some(auth) = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + else { + return false; + }; + let expected = format!("Bearer {secret}"); + if auth.len() != expected.len() { + return false; + } + auth.bytes() + .zip(expected.bytes()) + .fold(0u8, |diff, (a, b)| diff | (a ^ b)) + == 0 +} + +async fn read_limited_body(request: Request) -> Result { + to_bytes( + request.into_body(), + constants::MAX_INTERNAL_REQUEST_BODY_BYTES + 1, + ) + .await + .map_err(|_| StatusCode::BAD_REQUEST) + .and_then(|body| { + if body.len() > constants::MAX_INTERNAL_REQUEST_BODY_BYTES { + Err(StatusCode::PAYLOAD_TOO_LARGE) + } else { + Ok(body) + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::Config, test_fixtures::synthetic_png}; + use axum::http::HeaderValue; + use http_body_util::BodyExt as _; + + fn test_app_state() -> Arc { + let cfg = Config::load_from_iter([("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret")]) + .expect("test config"); + Arc::new(AppState::for_tests(cfg)) + } + + fn authorized_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + HeaderValue::from_static("Bearer secret"), + ); + headers + } + + fn json_request(body: String) -> Request { + Request::builder() + .method(Method::POST) + .body(Body::from(body)) + .expect("json request") + } + + async fn response_body(response: Response) -> String { + let bytes = response + .into_body() + .collect() + .await + .expect("collected body") + .to_bytes(); + String::from_utf8(bytes.to_vec()).expect("utf8 body") + } + + #[test] + fn internal_auth_uses_bearer_secret() { + let headers = authorized_headers(); + assert!(check_internal_auth(&headers, "secret")); + assert!(!check_internal_auth(&headers, "other")); + } + + #[tokio::test] + async fn frames_returns_empty_frames_when_extraction_fails() { + let body = format!( + r#"{{"version":2,"type":"base64","base64":"{}"}}"#, + general_purpose::STANDARD.encode(b"definitely not a video") + ); + let response = frames_handler( + State(test_app_state()), + authorized_headers(), + json_request(body), + ) + .await; + + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + "application/json", + response + .headers() + .get(header::CONTENT_TYPE) + .expect("content type") + .to_str() + .expect("ascii content type") + ); + assert_eq!("{\"frames\":[]}", response_body(response).await); + } + + #[tokio::test] + async fn metadata_requires_version_two_exactly() { + let encoded = general_purpose::STANDARD.encode(synthetic_png(4, 4)); + let app = test_app_state(); + + for version in ["1", "3", "null"] { + let body = format!( + r#"{{"version":{version},"type":"base64","nsfw":"allow","base64":"{encoded}"}}"# + ); + let response = metadata_handler( + State(Arc::clone(&app)), + authorized_headers(), + json_request(body), + ) + .await; + assert_eq!( + StatusCode::BAD_REQUEST, + response.status(), + "version {version} must be rejected" + ); + } + + let missing_version = format!(r#"{{"type":"base64","nsfw":"allow","base64":"{encoded}"}}"#); + let response = metadata_handler( + State(Arc::clone(&app)), + authorized_headers(), + json_request(missing_version), + ) + .await; + assert_eq!(StatusCode::BAD_REQUEST, response.status()); + + let body = + format!(r#"{{"version":2,"type":"base64","nsfw":"allow","base64":"{encoded}"}}"#); + let response = metadata_handler(State(app), authorized_headers(), json_request(body)).await; + assert_eq!(StatusCode::OK, response.status()); + assert!(response_body(response).await.contains("\"width\":4")); + } +} diff --git a/fluxer_media_proxy/src/server/routes/mod.rs b/fluxer_media_proxy/src/server/routes/mod.rs new file mode 100644 index 000000000..fa05214f5 --- /dev/null +++ b/fluxer_media_proxy/src/server/routes/mod.rs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub(in crate::server) mod dispatch; +pub(in crate::server) mod internal; +pub(in crate::server) mod ops; +pub(in crate::server) mod relay; diff --git a/fluxer_media_proxy/src/server/routes/ops.rs b/fluxer_media_proxy/src/server/routes/ops.rs new file mode 100644 index 000000000..46b44516a --- /dev/null +++ b/fluxer_media_proxy/src/server/routes/ops.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{http_headers, server::state::AppState}; +use axum::{ + body::Body, + extract::{ConnectInfo, State}, + http::{HeaderValue, StatusCode, header}, + response::Response, +}; +use std::{net::SocketAddr, sync::Arc}; + +pub(in crate::server) async fn health() -> &'static str { + "OK" +} + +fn is_loopback_peer(peer: &SocketAddr) -> bool { + peer.ip().to_canonical().is_loopback() +} + +pub(in crate::server) async fn metrics_handler( + ConnectInfo(peer): ConnectInfo, + State(app): State>, +) -> Response { + if !is_loopback_peer(&peer) { + let mut denied = Response::new(Body::from("FORBIDDEN")); + *denied.status_mut() = StatusCode::FORBIDDEN; + http_headers::add_security_headers(denied.headers_mut()); + denied + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static("text/plain")); + return denied; + } + let mut response = Response::new(Body::from(app.metrics.render())); + http_headers::add_security_headers(response.headers_mut()); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"), + ); + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_peer(value: &str) -> SocketAddr { + value.parse().expect("valid socket address") + } + + #[test] + fn metrics_guard_accepts_loopback_peers() { + assert!(is_loopback_peer(&test_peer("127.0.0.1:5000"))); + assert!(is_loopback_peer(&test_peer("127.0.0.2:5000"))); + assert!(is_loopback_peer(&test_peer("[::1]:5000"))); + assert!(is_loopback_peer(&test_peer("[::ffff:127.0.0.1]:5000"))); + } + + #[test] + fn metrics_guard_rejects_remote_peers() { + assert!(!is_loopback_peer(&test_peer("8.8.8.8:5000"))); + assert!(!is_loopback_peer(&test_peer("10.0.0.5:5000"))); + assert!(!is_loopback_peer(&test_peer("172.18.0.4:5000"))); + assert!(!is_loopback_peer(&test_peer("[fe80::1]:5000"))); + assert!(!is_loopback_peer(&test_peer("[::ffff:8.8.8.8]:5000"))); + } +} diff --git a/fluxer_media_proxy/src/server/routes/relay.rs b/fluxer_media_proxy/src/server/routes/relay.rs new file mode 100644 index 000000000..2da254c56 --- /dev/null +++ b/fluxer_media_proxy/src/server/routes/relay.rs @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + config::DeploymentMode, + external_path, http_headers, + server::{ + relay::body::{ + RelayBodyProgress, RelayBodyStream, RelayBodyStreamRequest, relay_body_chunks, + relay_etag, validate_completed_relay_body, + }, + response::error::text, + state::AppState, + }, + spool::{SpoolError, spool_to_temp}, + storage::{RelayBody, RelayPutOptions}, + upload_relay::{RelayError, target, token}, +}; +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::{HeaderMap, HeaderValue, Request, StatusCode, header}, + response::Response, +}; +use std::{ + collections::HashMap, + sync::{Arc, OnceLock}, + time::Duration, +}; +use tracing::warn; + +const RELAY_STREAM_MIN_CLIENT_BYTES_PER_SEC: u64 = 16 * 1024; + +struct StreamedRelayBody { + failure: Arc>, + progress: RelayBodyProgress, +} + +pub(in crate::server) async fn relay_options() -> Response { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::NO_CONTENT; + http_headers::add_security_headers(response.headers_mut()); + relay_cors(response.headers_mut()); + response.headers_mut().insert( + header::ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static("600"), + ); + response +} + +pub(in crate::server) async fn relay_put( + State(app): State>, + Path(key): Path, + Query(params): Query>, + headers: HeaderMap, + request: Request, +) -> Response { + if app.cfg.mode != DeploymentMode::Upload { + return text(StatusCode::NOT_FOUND, "Not Found"); + } + let key = external_path::percent_decode_string(&key, false); + let token_raw = match token::token_from_query(params.get("t").map(String::as_str)) { + Ok(token_raw) => token_raw, + Err(err) => return relay_error(err), + }; + let token = match token::decode_token( + token_raw, + app.cfg.upload_relay.secret.expose(), + token::now_unix(), + ) { + Ok(token) => token, + Err(err) => return relay_error(token::map_token_error(err)), + }; + let part_number = match target::query_part_number(params.get("partNumber").map(String::as_str)) + { + Ok(part_number) => part_number, + Err(err) => return relay_error(err), + }; + let content_length = http_headers::parse_content_length(&headers); + if let Err(err) = target::validate_relay_request( + &token, + target::RelayRequest { + uploads_bucket: &app.cfg.storage.bucket_uploads, + request_key: &key, + request_method: request.method(), + query_upload_id: params.get("uploadId").map(String::as_str), + query_part_number: part_number, + content_length, + max_body_bytes: app.cfg.upload_relay.max_body_bytes, + }, + ) { + return relay_error(err); + } + let timeout_ms = relay_upstream_timeout_ms(&app, content_length); + let (body, body_length, streamed) = match content_length { + Some(declared) => { + let Some(deadline) = + tokio::time::Instant::now().checked_add(Duration::from_millis(timeout_ms)) + else { + return relay_error(RelayError::InternalError); + }; + let failure = Arc::new(OnceLock::new()); + let stream = RelayBodyStream::new(RelayBodyStreamRequest { + body: request.into_body(), + declared_length: declared, + deadline, + failure: Arc::clone(&failure), + }); + let progress = stream.progress(); + ( + RelayBody::Streamed(relay_body_chunks(stream)), + declared, + Some(StreamedRelayBody { failure, progress }), + ) + } + None => { + let body_length_limit = token.mb.min(app.cfg.upload_relay.max_body_bytes); + let spooled = match spool_to_temp( + request.into_body(), + content_length, + body_length_limit, + &app.cfg.upload_relay.spool_dir, + app.cfg.upload_relay.spool_chunk_bytes, + app.cfg.upload_relay.spool_max_total_bytes, + ) + .await + { + Ok(spooled) => spooled, + Err(SpoolError::PayloadTooLarge) => { + return relay_error(RelayError::PayloadTooLarge); + } + Err(SpoolError::PayloadShortRead) | Err(SpoolError::Body(_)) => { + return relay_error(RelayError::ClientUploadFailed); + } + Err(SpoolError::BudgetExhausted) => { + app.metrics.relay().record_retryable_failure(); + return relay_error(RelayError::UpstreamRetryable); + } + Err(SpoolError::Io(_)) => { + return relay_error(RelayError::InternalError); + } + }; + let (file, spooled_length) = spooled.into_parts(); + (RelayBody::Spooled(file), spooled_length, None) + } + }; + let content_type = resolve_relay_content_type( + token.ct.as_deref(), + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + ); + let options = RelayPutOptions { + body, + content_length: body_length, + content_type, + upload_id: params.get("uploadId").cloned(), + part_number, + timeout_ms, + }; + match app + .store + .relay_put_object(&app.cfg.storage.bucket_uploads, &key, options) + .await + { + Ok(etag) => match relay_success_etag(etag, streamed.as_ref(), body_length) { + Ok(etag) => { + app.metrics.relay().record_success(); + relay_success_response(etag) + } + Err(err) => relay_error(err), + }, + Err(err) => { + if let Some(client_err) = streamed + .as_ref() + .and_then(|streamed| streamed.failure.get()) + { + return relay_error(*client_err); + } + warn!(error = %err, "upload relay upstream S3 PUT failed"); + app.metrics.relay().record_hard_failure(); + relay_error(RelayError::UpstreamS3Error) + } + } +} + +fn relay_upstream_timeout_ms(app: &AppState, content_length: Option) -> u64 { + let Some(declared) = content_length else { + return app.cfg.upload_relay.s3_timeout_ms; + }; + app.cfg.upload_relay.s3_timeout_ms.saturating_add( + declared + .div_ceil(RELAY_STREAM_MIN_CLIENT_BYTES_PER_SEC) + .saturating_mul(1000), + ) +} + +fn relay_success_etag( + upstream_etag: Option, + streamed: Option<&StreamedRelayBody>, + body_length: u64, +) -> Result, RelayError> { + if let Some(streamed) = streamed { + validate_completed_relay_body(&streamed.failure, &streamed.progress, body_length)?; + } + // Only the store's own entity tag goes back to the client, because that is the value S3 wants + // when the client completes a multipart upload. A tag invented here would be rejected there, + // so a store that returns none leaves the response without an ETag. + Ok(upstream_etag.as_deref().map(relay_etag)) +} + +fn relay_success_response(etag: Option) -> Response { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::OK; + http_headers::add_security_headers(response.headers_mut()); + relay_cors(response.headers_mut()); + if let Some(etag) = etag { + response.headers_mut().insert(header::ETAG, etag); + } + response +} + +fn resolve_relay_content_type( + token_content_type: Option<&str>, + header_content_type: Option<&str>, +) -> Option { + token_content_type + .or(header_content_type) + .filter(|value| target::valid_content_type(value)) + .map(ToOwned::to_owned) +} + +pub(in crate::server) fn relay_error(err: RelayError) -> Response { + let status = match err { + RelayError::MissingToken | RelayError::InvalidToken | RelayError::RelayTokenExpired => { + StatusCode::UNAUTHORIZED + } + RelayError::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + RelayError::UpstreamRetryable => StatusCode::SERVICE_UNAVAILABLE, + RelayError::UpstreamS3Error => StatusCode::BAD_GATEWAY, + _ => StatusCode::BAD_REQUEST, + }; + let mut response = text(status, status.canonical_reason().unwrap_or("Bad Request")); + relay_cors(response.headers_mut()); + response +} + +pub(in crate::server) fn relay_cors(headers: &mut HeaderMap) { + headers.insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("PUT, OPTIONS"), + ); + headers.insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static( + "Content-Type, Content-Length, Authorization, X-Fluxer-Features, X-Client-Context", + ), + ); + headers.insert( + header::ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_static("ETag, X-Fluxer-Version"), + ); +} diff --git a/fluxer_media_proxy/src/server/runtime.rs b/fluxer_media_proxy/src/server/runtime.rs new file mode 100644 index 000000000..24147bd74 --- /dev/null +++ b/fluxer_media_proxy/src/server/runtime.rs @@ -0,0 +1,230 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ + middleware::{ + HttpRequestDrain, add_security_header_middleware, add_version_header, track_active_request, + }, + routes, + state::AppState, +}; +use crate::{ + aggregate_error::aggregate_results, + bunny_ip_gate::{self, BunnyIpGate}, + config::Config, + media_process, request_log, +}; +use anyhow::Context as _; +use axum::{ + Router, middleware, + routing::{any, get, post, put}, +}; +use std::{net::SocketAddr, sync::Arc, time::Duration}; +use tokio::{net::TcpListener, time::timeout_at}; +use tracing::info; + +pub async fn run(cfg: Config) -> anyhow::Result<()> { + media_process::warmup_vips()?; + let addr: SocketAddr = format!("{}:{}", cfg.bind_host, cfg.port).parse()?; + let state = Arc::new(AppState::try_new(cfg)?); + let bunny_gate = start_bunny_ip_gate(&state).await?; + if let Some(read_endpoint) = state.cfg.storage.s3_read_endpoint.as_deref() { + info!( + endpoint = read_endpoint, + bucket = state.cfg.storage.s3_read_bucket, + style = ?state.cfg.storage.s3_read_bucket_style, + signed = state.cfg.storage.s3_read_signed, + "object body reads served from the S3 read endpoint" + ); + } + let drain = HttpRequestDrain::new(); + let app = build_router(Arc::clone(&state), bunny_gate, drain.clone()); + let listener = TcpListener::bind(addr).await?; + info!(%addr, "media proxy listening"); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; + drain_and_shutdown(&state, &drain).await +} + +fn build_router( + state: Arc, + bunny_gate: Option>, + drain: HttpRequestDrain, +) -> Router { + let mut router = Router::new() + .route("/_health", get(routes::ops::health)) + .route("/_metrics", get(routes::ops::metrics_handler)) + .route("/_metadata", post(routes::internal::metadata_handler)) + .route("/_thumbnail", post(routes::internal::thumbnail_handler)) + .route("/_frames", post(routes::internal::frames_handler)) + .route( + "/v1/relay/{*key}", + put(routes::relay::relay_put).options(routes::relay::relay_options), + ) + .fallback(any(routes::dispatch::catch_all)) + .layer(middleware::from_fn(add_version_header)) + .layer(middleware::from_fn_with_state( + state.metrics.request(), + request_log::trace, + )); + if let Some(gate) = bunny_gate { + router = router.layer(middleware::from_fn_with_state( + gate, + bunny_ip_gate::gate_middleware, + )); + } + router + .layer(middleware::from_fn_with_state( + state.cfg.mode, + add_security_header_middleware, + )) + .layer(middleware::from_fn_with_state(drain, track_active_request)) + .with_state(state) +} + +async fn start_bunny_ip_gate(state: &Arc) -> anyhow::Result>> { + if !state.cfg.bunny_ip_gate_enabled { + return Ok(None); + } + let gate = Arc::new(BunnyIpGate::new( + bunny_ip_gate::build_refresh_client()?, + state.cfg.bunny_ip_gate_trusted_proxies.clone(), + )); + let count = gate + .refresh_once() + .await + .context("initial bunny ip allowlist fetch failed")?; + info!( + count, + trusted_proxies = state.cfg.bunny_ip_gate_trusted_proxies.len(), + refresh_secs = state.cfg.bunny_ip_gate_refresh_secs, + "bunny ip gate enabled" + ); + Arc::clone(&gate) + .spawn_background_refresher(Duration::from_secs(state.cfg.bunny_ip_gate_refresh_secs)); + Ok(Some(gate)) +} + +async fn drain_and_shutdown(state: &Arc, drain: &HttpRequestDrain) -> anyhow::Result<()> { + let grace_ms = state.cfg.shutdown_grace_ms; + let deadline = tokio::time::Instant::now() + Duration::from_millis(grace_ms); + info!( + active_requests = drain.active_requests(), + grace_ms, "media proxy draining" + ); + let requests = timeout_at(deadline, drain.wait_for_requests_drained()) + .await + .map_err(|_| anyhow::anyhow!("media proxy http request shutdown exceeded {grace_ms} ms")); + let transforms = state.media.transforms(); + transforms.cache().begin_shutdown(); + transforms.tasks().begin_shutdown(); + let coalescer = timeout_at(deadline, transforms.cache().wait_for_shutdown()) + .await + .map_err(|_| { + anyhow::anyhow!("media proxy transform coalescer shutdown exceeded {grace_ms} ms") + }); + let native_tasks = timeout_at(deadline, transforms.tasks().wait_for_shutdown()) + .await + .map_err(|_| anyhow::anyhow!("media proxy native task shutdown exceeded {grace_ms} ms")); + aggregate_results("media proxy shutdown", [requests, coalescer, native_tasks]) +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + let Ok(mut sigterm) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + else { + return; + }; + sigterm.recv().await; + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Method, Request, StatusCode}, + }; + + const DECLARED_ROUTES: &[(&str, &str, &str)] = &[ + ("/_health", "/_health", "GET,HEAD"), + ("/_metrics", "/_metrics", "GET,HEAD"), + ("/_metadata", "/_metadata", "POST"), + ("/_thumbnail", "/_thumbnail", "POST"), + ("/_frames", "/_frames", "POST"), + ( + "/v1/relay/{*key}", + "/v1/relay/uploads/file.png", + "PUT,OPTIONS", + ), + ]; + + fn test_router() -> Router { + let cfg = Config::load_from_iter([("FLUXER_MEDIA_PROXY_SECRET_KEY", "secret")]) + .expect("test config"); + build_router( + Arc::new(AppState::for_tests(cfg)), + None, + HttpRequestDrain::new(), + ) + } + + async fn probe(path: &str, method: Method) -> axum::response::Response { + tower::ServiceExt::oneshot( + test_router(), + Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("probe request"), + ) + .await + .expect("router response") + } + + #[tokio::test] + async fn router_exposes_exactly_the_declared_route_and_method_table() { + for (declared, path, allowed) in DECLARED_ROUTES { + let response = probe(path, Method::DELETE).await; + assert_eq!( + StatusCode::METHOD_NOT_ALLOWED, + response.status(), + "{declared} must reject an undeclared method" + ); + assert_eq!( + *allowed, + response + .headers() + .get("allow") + .expect("method router sets allow") + .to_str() + .expect("ascii allow header"), + "{declared} method table" + ); + } + } + + #[tokio::test] + async fn unknown_paths_reach_the_catch_all_fallback() { + let response = probe("/avatars/1/hash.png", Method::DELETE).await; + assert_eq!(StatusCode::METHOD_NOT_ALLOWED, response.status()); + assert!(response.headers().get("allow").is_none()); + let response = probe("/nope", Method::GET).await; + assert_eq!(StatusCode::NOT_FOUND, response.status()); + } +} diff --git a/fluxer_media_proxy/src/server/state.rs b/fluxer_media_proxy/src/server/state.rs new file mode 100644 index 000000000..eff5338cc --- /dev/null +++ b/fluxer_media_proxy/src/server/state.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::ByteBudget, + config::Config, + constants, + http_client::{self, HTTPClientOptions}, + image_transform::AnimationLimits, + media_limits::MediaLimits, + metrics, + nsfw::NSFWClient, + response_body_limit::RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX, + server::{ + external::{ExternalHintCache, new_external_hint_cache}, + transform::TransformRuntime, + }, + storage::Store, +}; +use std::{num::NonZeroU64, sync::Arc}; + +pub(in crate::server) struct AppState { + pub(in crate::server) cfg: Config, + pub(in crate::server) metrics: Arc, + pub(in crate::server) store: Store, + pub(in crate::server) media: MediaRuntime, +} + +pub(in crate::server) struct MediaRuntime { + nsfw: NSFWClient, + external_client: http_client::HttpClient, + external_buffer_bytes: ByteBudget, + external_hints: ExternalHintCache, + transforms: TransformRuntime, +} + +impl AppState { + pub(in crate::server) fn try_new(cfg: Config) -> anyhow::Result { + let metrics = Arc::new(metrics::Metrics::new()); + let socket_io_timeout = + NonZeroU64::new(cfg.socket_io_timeout_ms).unwrap_or(NonZeroU64::MIN); + let store = Store::try_new(cfg.clone(), metrics.storage(), metrics.http_client())?; + let external_client = http_client::build( + HTTPClientOptions::new(socket_io_timeout, socket_io_timeout).restrict_to_public(), + metrics.http_client(), + )?; + let nsfw = NSFWClient::new( + &cfg.media.nsfw_service_endpoint, + cfg.media.nsfw_threshold, + metrics.nsfw(), + )?; + let media = MediaRuntime::new(&cfg, &metrics, nsfw, external_client)?; + Ok(Self { + cfg, + metrics, + store, + media, + }) + } + + #[cfg(test)] + pub(in crate::server) fn for_tests(cfg: Config) -> Self { + let metrics = Arc::new(metrics::Metrics::new()); + let store = Store::new(cfg.clone(), metrics.storage(), metrics.http_client()); + let external_client = http_client::build_default(metrics.http_client()); + let media = MediaRuntime::new(&cfg, &metrics, NSFWClient::disabled(), external_client) + .expect("test media runtime limits are valid"); + Self { + cfg, + metrics, + store, + media, + } + } +} + +impl MediaRuntime { + fn new( + cfg: &Config, + metrics: &Arc, + nsfw: NSFWClient, + external_client: http_client::HttpClient, + ) -> anyhow::Result { + Ok(Self { + nsfw, + external_client, + external_buffer_bytes: ByteBudget::new(external_buffer_budget_bytes(cfg)), + external_hints: new_external_hint_cache(cfg), + transforms: TransformRuntime::new(cfg, metrics)?, + }) + } + + pub(in crate::server) fn limits(&self) -> MediaLimits { + self.transforms.limits() + } + + pub(in crate::server) fn animation(&self) -> AnimationLimits { + self.transforms.animation() + } + + pub(in crate::server) fn nsfw(&self) -> &NSFWClient { + &self.nsfw + } + + pub(in crate::server) fn external_client(&self) -> &http_client::HttpClient { + &self.external_client + } + + pub(in crate::server) fn external_buffer_bytes(&self) -> &ByteBudget { + &self.external_buffer_bytes + } + + pub(in crate::server) fn external_hints(&self) -> &ExternalHintCache { + &self.external_hints + } + + pub(in crate::server) fn transforms(&self) -> &TransformRuntime { + &self.transforms + } +} + +fn external_buffer_budget_bytes(cfg: &Config) -> usize { + let concurrent_inputs = cfg + .media + .max_native_transforms + .saturating_add(cfg.media.worker_queue_capacity) + .max(1); + constants::MAX_MEDIA_PROXY_BYTES + .saturating_mul(concurrent_inputs) + .saturating_add(RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX) +} diff --git a/fluxer_media_proxy/src/server/stored/mod.rs b/fluxer_media_proxy/src/server/stored/mod.rs new file mode 100644 index 000000000..f7a689a7f --- /dev/null +++ b/fluxer_media_proxy/src/server/stored/mod.rs @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub(in crate::server) mod passthrough; +pub(in crate::server) mod response; +#[cfg(test)] +mod tests; + +use crate::{ + asset_size, + coalescer::CoalescerError, + constants::{self, AssetExtension, AssetKind}, + image_quality::ImageQuality, + image_transform::{AnimationMode, ResizeMode}, + media_process, mime, output_format, + server::{ + asset_path::{ParsedAssetPath, asset_filename_hint}, + format_policy::{ + OriginalImageRequest, default_transform_quality, + effective_animated_image_output_format, extension_from_mime, is_svg_content_type, + same_format_loaded_image_request_can_use_original_with_sniff, + }, + params::{ + animated_param, asset_manual_format_override, asset_wants_animated, bool_param, + filename_from_storage_key, + }, + response::{ + MediaResponse, content_disposition_header, + error::{storage_error_response, text_with_source}, + media_response, + }, + state::AppState, + transform::{ + ServeBytesRequest, + cache::{ + CachedTransformRequest, cached_transform, cached_transform_hit, + coalescer_failure_response, + }, + cache_key::{TransformCacheKeyInput, transform_cache_key}, + execution::{coalesced_work_result, deadline_instant, run_transform}, + original_image_content_type, + parameters::TransformRoute, + serve_bytes_or_transform, + }, + }, + storage::{self, StorageError}, +}; +use axum::{ + http::{HeaderMap, Method, StatusCode, header}, + response::Response, +}; +pub(in crate::server) use passthrough::serve_stored_raw; +use passthrough::{PassthroughDisposition, serve_stored_passthrough_stream}; +use std::{collections::HashMap, sync::Arc}; + +pub(in crate::server) async fn serve_asset_image( + app: &Arc, + method: Method, + asset: ParsedAssetPath, + params: &HashMap, + headers: &HeaderMap, +) -> Response { + let runtime = app.media.transforms(); + let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); + let requested_download = bool_param(params, "download", false); + let asset_filename = asset_filename_hint(&asset); + let size = asset_size::parse_image_size(params.get("size").map(String::as_str)); + let requested_manual_format = asset_manual_format_override(params, asset.original_ext); + let manual_format_override = asset.forced_output_format.or(requested_manual_format); + let selected = output_format::select_url_variant(output_format::Input { + kind: asset.kind, + original: asset.original_ext, + requested_size: Some(size), + manual_format_override, + }); + let animated = asset_wants_animated(params, &asset.hash); + let requested_quality = params + .get("quality") + .map(|raw| ImageQuality::parse_lenient(raw)); + let width = selected.size; + let height = selected.size; + let resize_mode = if matches!(asset.kind, AssetKind::Emoji | AssetKind::Sticker) { + ResizeMode::Cover + } else { + ResizeMode::Fit + }; + let CdnSourceObject { + object, + content_identity, + } = match read_cdn_object_with_fallback(app, &asset.storage_key, asset.original_ext).await { + Ok(source) => source, + Err(err) => return storage_error_response(&asset.storage_key, err), + }; + let sniffed = mime::sniff(&object.data); + let sniffed_source_ext = extension_from_mime(sniffed.mime); + let source_format = if sniffed_source_ext == Some(AssetExtension::Apng) { + AssetExtension::Apng + } else { + extension_from_mime(&object.content_type) + .or(sniffed_source_ext) + .unwrap_or(asset.original_ext) + }; + let serve_content_type = original_image_content_type(&object.content_type, Some(source_format)); + let out_ext = + effective_animated_image_output_format(Some(source_format), selected.format, animated); + let quality = requested_quality + .unwrap_or_else(|| default_transform_quality(out_ext, animated, ImageQuality::High)); + if same_format_loaded_image_request_can_use_original_with_sniff( + sniffed, + OriginalImageRequest { + source_ext: Some(source_format), + explicit_out_ext: requested_manual_format, + out_ext, + width, + height, + has_quality: requested_quality.is_some(), + effort: None, + animated, + }, + ) { + return media_response(MediaResponse { + method, + data: object.data.into(), + content_type: &serve_content_type, + range_header, + disposition: Some(content_disposition_header( + &serve_content_type, + requested_download, + Some(&asset_filename), + )), + }); + } + let cache_key = transform_cache_key(TransformCacheKeyInput { + route: TransformRoute::Asset, + asset_kind: Some(asset.kind), + cache_identity: &content_identity, + width, + height, + format: out_ext, + quality: Some(quality), + animated, + effort: None, + resize_mode: Some(resize_mode), + }); + if let Some(hit) = cached_transform_hit(runtime, &cache_key) { + return media_response(MediaResponse { + method, + data: hit.data.as_bytes().clone().into(), + content_type: hit.format.mime(), + range_header, + disposition: Some(content_disposition_header( + hit.format.mime(), + requested_download, + Some(&asset_filename), + )), + }); + } + let options = media_process::ImageOptions { + width, + height, + format: out_ext, + quality, + animation: AnimationMode::new(animated, app.media.animation()), + effort_override: None, + resize_mode, + deadline_ms: runtime.transform_deadline_ms(), + }; + let source_data = object.data.clone(); + let transformed = match cached_transform(CachedTransformRequest { + runtime, + cache_key, + format: out_ext, + deadline: deadline_instant(options.deadline_ms), + work: move || async move { + coalesced_work_result(run_transform(runtime, source_data, options).await) + .map(|media| media_process::MediaBytes::from(media.bytes)) + }, + }) + .await + { + Ok(bytes) => bytes.as_bytes().clone(), + Err(error) => { + let detail = format!( + "asset key={} src_ct={} out={} size={:?} animated={}", + asset.storage_key, + object.content_type, + out_ext.extension(), + selected.size, + animated, + ); + return asset_transform_failure_response(AssetTransformFailure { + method, + error, + object, + range_header, + source_format, + detail, + }); + } + }; + media_response(MediaResponse { + method, + data: transformed.into(), + content_type: out_ext.mime(), + range_header, + disposition: Some(content_disposition_header( + out_ext.mime(), + requested_download, + Some(&asset_filename), + )), + }) +} + +struct AssetTransformFailure<'a> { + method: Method, + error: CoalescerError, + object: storage::Object, + range_header: Option<&'a str>, + source_format: AssetExtension, + detail: String, +} + +fn asset_transform_failure_response(failure: AssetTransformFailure<'_>) -> Response { + let src_ct = failure.object.content_type.as_str(); + let src_is_displayable = src_ct.starts_with("image/") + && src_ct != "image/avif" + && src_ct != "image/heic" + && src_ct != "image/heif" + && failure.source_format != AssetExtension::Svg + && !is_svg_content_type(src_ct); + if src_is_displayable && failure.error != CoalescerError::RequestTimeout { + return media_response(MediaResponse { + method: failure.method, + data: failure.object.data.into(), + content_type: &failure.object.content_type, + range_header: failure.range_header, + disposition: None, + }); + } + if let Some(response) = + coalescer_failure_response(failure.error, "coalescer_timeout_asset_image") + { + return response; + } + text_with_source( + StatusCode::INTERNAL_SERVER_ERROR, + "Transcode Failed", + "transcode_failed", + failure.detail, + ) +} + +struct CdnSourceObject { + object: storage::Object, + content_identity: String, +} + +async fn read_cdn_object_with_fallback( + app: &Arc, + key: &str, + original_ext: AssetExtension, +) -> Result { + let bucket = &app.cfg.storage.bucket_cdn; + let limit = constants::MAX_MEDIA_PROXY_BYTES; + let budget = storage::unversioned_read_budget(limit); + let read = match app + .store + .read_object_limited_with_digest(bucket, key, limit, &budget) + .await + { + Err(StorageError::NotFound) => { + let fallback_key = format!("{key}.{}", original_ext.name()); + app.store + .read_object_limited_with_digest(bucket, &fallback_key, limit, &budget) + .await? + } + other => other?, + }; + Ok(CdnSourceObject { + content_identity: hex::encode( + read.content_digest + .expect("a digested read always reports its content digest"), + ), + object: storage::Object { + data: read.data.as_bytes().clone(), + content_type: read.content_type, + }, + }) +} + +pub(in crate::server) async fn serve_attachment( + app: &Arc, + method: Method, + key: &str, + params: &HashMap, + headers: &HeaderMap, +) -> Response { + let filename = filename_from_storage_key(key); + let animated = animated_param(params, false); + let wants_transform = params.contains_key("width") + || params.contains_key("height") + || params.contains_key("format") + || params.contains_key("quality") + || animated; + if !wants_transform { + return serve_stored_passthrough_stream( + app, + method, + &app.cfg.storage.bucket_cdn, + key, + headers, + PassthroughDisposition::Attachment { + requested_download: bool_param(params, "download", false), + filename, + }, + ) + .await; + } + let object = match app + .store + .read_object(&app.cfg.storage.bucket_cdn, key) + .await + { + Ok(object) => object, + Err(err) => return storage_error_response(key, err), + }; + serve_bytes_or_transform( + app.media.transforms(), + ServeBytesRequest { + method, + data: object.data, + content_type: object.content_type, + cache_identity: key, + filename, + route: TransformRoute::Attachment, + params, + headers, + }, + ) + .await +} + +pub(in crate::server) async fn serve_stored_with_override( + app: &Arc, + method: Method, + bucket: &str, + key: &str, + content_type: &str, + headers: &HeaderMap, +) -> Response { + let object = match app.store.read_object(bucket, key).await { + Ok(object) => object, + Err(err) => return storage_error_response(key, err), + }; + media_response(MediaResponse { + method, + data: object.data.into(), + content_type, + range_header: headers.get(header::RANGE).and_then(|v| v.to_str().ok()), + disposition: None, + }) +} diff --git a/fluxer_media_proxy/src/server/stored/passthrough.rs b/fluxer_media_proxy/src/server/stored/passthrough.rs new file mode 100644 index 000000000..9e44aa80e --- /dev/null +++ b/fluxer_media_proxy/src/server/stored/passthrough.rs @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::response::{ + StreamingMediaResponse, passthrough_head_response, streaming_media_response, +}; +use crate::{ + config::DeploymentMode, + constants::{self, AssetExtension}, + http_headers, + image_quality::ImageQuality, + image_transform::AnimationMode, + media_process, mime, + output_format::OutputFormat, + range, + server::{ + download_stream::DownloadStreamPolicy, + format_policy::{ + content_type_is_trustworthy, image_extension_from_filename, is_svg_content_type, + }, + response::{ + MediaResponse, content_disposition_header, error::storage_error_response, + error::text_with_source, media_response, + }, + state::AppState, + transform::{ + cache::{ + CachedTransformRequest, cached_transform, cached_transform_hit, + coalescer_failure_response, + }, + cache_key::{TransformCacheKeyInput, transform_cache_key}, + execution::{coalesced_work_result, deadline_instant, run_transform}, + parameters::TransformRoute, + }, + }, + storage::StorageError, +}; +use axum::{ + body::Body, + http::{HeaderMap, HeaderValue, Method, StatusCode, header}, + response::Response, +}; +use bytes::Bytes; +use std::sync::Arc; + +pub(in crate::server) enum PassthroughDisposition<'a> { + None, + Attachment { + requested_download: bool, + filename: &'a str, + }, +} + +pub(in crate::server) async fn serve_stored_raw( + app: &Arc, + method: Method, + bucket: &str, + key: &str, + headers: &HeaderMap, +) -> Response { + serve_stored_passthrough_stream( + app, + method, + bucket, + key, + headers, + PassthroughDisposition::None, + ) + .await +} + +pub(in crate::server) async fn serve_stored_passthrough_stream( + app: &Arc, + method: Method, + bucket: &str, + key: &str, + headers: &HeaderMap, + disposition: PassthroughDisposition<'_>, +) -> Response { + // A GET is answered with a single upstream operation: the client's Range is forwarded + // verbatim and the object store's own reply supplies the status, the content type, the + // satisfied range and the total length. Probing with a HEAD first would double the object + // store requests on the hottest path in the proxy. + if method == Method::HEAD { + return serve_stored_passthrough_head(app, bucket, key, headers, &disposition).await; + } + if app.cfg.mode == DeploymentMode::Mp + && image_extension_from_filename(key) == Some(AssetExtension::Svg) + { + return serve_stored_passthrough_svg(app, method, bucket, key, headers, &disposition).await; + } + let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); + let forwarded_range = match range::classify_request_range(range_header) { + range::RequestRange::Absent => None, + range::RequestRange::Forwardable(value) => Some(value), + range::RequestRange::Unsatisfiable => { + return passthrough_unsatisfiable_response(app, bucket, key, None).await; + } + }; + let object = match app.store.stream_object(bucket, key, forwarded_range).await { + Ok(object) => object, + Err(err) => return storage_error_response(key, err), + }; + if object.status == StatusCode::RANGE_NOT_SATISFIABLE { + return passthrough_unsatisfiable_response(app, bucket, key, object.total_length).await; + } + let content_type = passthrough_content_type(&object.content_type, key); + if app.cfg.mode == DeploymentMode::Mp && is_svg_content_type(&content_type) { + return serve_stored_passthrough_svg(app, method, bucket, key, headers, &disposition).await; + } + let total_len = match passthrough_total_len(app, bucket, key, object.total_length).await { + Ok(value) => value, + Err(err) => return storage_error_response(key, err), + }; + if total_len > constants::MAX_MEDIA_PROXY_BYTES { + return storage_error_response(key, StorageError::StreamTooLong); + } + streaming_media_response(StreamingMediaResponse { + method, + object, + total_len, + content_type: &content_type, + disposition: passthrough_disposition_header(&disposition, &content_type), + stream_policy: DownloadStreamPolicy::for_passthrough(app.cfg.socket_io_timeout_ms), + }) +} + +async fn serve_stored_passthrough_head( + app: &Arc, + bucket: &str, + key: &str, + headers: &HeaderMap, + disposition: &PassthroughDisposition<'_>, +) -> Response { + let head = match app.store.head_object(bucket, key).await { + Ok(head) => head, + Err(err) => return storage_error_response(key, err), + }; + if head.content_length > constants::MAX_MEDIA_PROXY_BYTES as u64 { + return storage_error_response(key, StorageError::StreamTooLong); + } + let content_type = passthrough_content_type(&head.content_type, key); + if app.cfg.mode == DeploymentMode::Mp + && (is_svg_content_type(&content_type) + || image_extension_from_filename(key) == Some(AssetExtension::Svg)) + { + return serve_stored_passthrough_svg(app, Method::HEAD, bucket, key, headers, disposition) + .await; + } + let total_len = match usize::try_from(head.content_length) { + Ok(value) => value, + Err(_) => return storage_error_response(key, StorageError::StreamTooLong), + }; + let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); + let byte_range = match range::parse_range(range_header, total_len) { + range::RangeSelection::Full => None, + range::RangeSelection::Partial(byte_range) => Some(byte_range), + range::RangeSelection::Unsatisfiable => return unsatisfiable_response(total_len), + }; + passthrough_head_response( + &content_type, + total_len, + byte_range, + passthrough_disposition_header(disposition, &content_type), + ) +} + +async fn serve_stored_passthrough_svg( + app: &Arc, + method: Method, + bucket: &str, + key: &str, + headers: &HeaderMap, + disposition: &PassthroughDisposition<'_>, +) -> Response { + let object = match app.store.read_object(bucket, key).await { + Ok(object) => object, + Err(err) => return storage_error_response(key, err), + }; + let cache_identity = format!("{bucket}/{key}"); + serve_stored_svg_rasterized( + app, + method, + object.data, + &cache_identity, + headers, + disposition, + ) + .await +} + +/// Resolves the object's full length, preferring what the store already reported on the streamed +/// reply so the common path never needs a second upstream request. +async fn passthrough_total_len( + app: &Arc, + bucket: &str, + key: &str, + known: Option, +) -> Result { + let total = match known { + Some(value) => value, + None => app.store.head_object(bucket, key).await?.content_length, + }; + usize::try_from(total).map_err(|_| StorageError::StreamTooLong) +} + +async fn passthrough_unsatisfiable_response( + app: &Arc, + bucket: &str, + key: &str, + known: Option, +) -> Response { + match passthrough_total_len(app, bucket, key, known).await { + Ok(total_len) => unsatisfiable_response(total_len), + Err(err) => storage_error_response(key, err), + } +} + +fn unsatisfiable_response(total_len: usize) -> Response { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE; + http_headers::add_unsatisfiable_headers(response.headers_mut(), total_len); + response +} + +fn passthrough_content_type(source_content_type: &str, key: &str) -> String { + let extension_mime = mime::extension_mime(key); + if extension_mime == Some("audio/mp4") + && mime::normalize(Some(source_content_type)) == Some("video/mp4") + { + return "audio/mp4".to_owned(); + } + if content_type_is_trustworthy(source_content_type) { + source_content_type.to_owned() + } else { + extension_mime + .or_else(|| { + mime::normalize(Some(source_content_type)).filter(|value| { + !value.is_empty() && !value.eq_ignore_ascii_case("application/octet-stream") + }) + }) + .unwrap_or("application/octet-stream") + .to_owned() + } +} + +fn passthrough_disposition_header( + disposition: &PassthroughDisposition<'_>, + content_type: &str, +) -> Option { + match disposition { + PassthroughDisposition::None => None, + PassthroughDisposition::Attachment { + requested_download, + filename, + } => Some(content_disposition_header( + content_type, + *requested_download, + Some(filename), + )), + } +} + +async fn serve_stored_svg_rasterized( + app: &Arc, + method: Method, + data: Bytes, + cache_identity: &str, + headers: &HeaderMap, + disposition: &PassthroughDisposition<'_>, +) -> Response { + let runtime = app.media.transforms(); + let format = OutputFormat::WebP; + let quality = ImageQuality::Lossless; + let options = media_process::ImageOptions { + format, + quality, + animation: AnimationMode::Static, + deadline_ms: runtime.transform_deadline_ms(), + ..Default::default() + }; + let cache_key = transform_cache_key(TransformCacheKeyInput { + route: TransformRoute::Stored, + asset_kind: None, + cache_identity, + width: None, + height: None, + format, + quality: Some(quality), + animated: false, + effort: None, + resize_mode: Some(options.resize_mode), + }); + if let Some(hit) = cached_transform_hit(runtime, &cache_key) { + return media_response(MediaResponse { + method, + data: hit.data.as_bytes().clone().into(), + content_type: hit.format.mime(), + range_header: headers.get(header::RANGE).and_then(|v| v.to_str().ok()), + disposition: passthrough_disposition_header(disposition, hit.format.mime()), + }); + } + let transformed = match cached_transform(CachedTransformRequest { + runtime, + cache_key, + format, + deadline: deadline_instant(options.deadline_ms), + work: move || async move { + coalesced_work_result(run_transform(runtime, data, options).await) + .map(|media| media_process::MediaBytes::from(media.bytes)) + }, + }) + .await + { + Ok(bytes) => bytes.as_bytes().clone(), + Err(error) => { + if let Some(response) = + coalescer_failure_response(error, "coalescer_timeout_svg_rasterize") + { + return response; + } + return text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "svg_rasterize_failed", + cache_identity, + ); + } + }; + media_response(MediaResponse { + method, + data: transformed.into(), + content_type: format.mime(), + range_header: headers.get(header::RANGE).and_then(|v| v.to_str().ok()), + disposition: passthrough_disposition_header(disposition, format.mime()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn passthrough_content_type_preserves_non_media_metadata() { + assert_eq!( + "application/zip", + passthrough_content_type("application/zip", "downloads/app.zip") + ); + } + + #[test] + fn passthrough_content_type_prefers_known_extension_over_bad_metadata() { + assert_eq!( + "image/png", + passthrough_content_type("text/plain", "image.png") + ); + } + + #[test] + fn passthrough_content_type_prefers_m4a_extension_over_mp4_metadata() { + assert_eq!( + "audio/mp4", + passthrough_content_type("video/mp4", "track.m4a") + ); + } +} diff --git a/fluxer_media_proxy/src/server/stored/response.rs b/fluxer_media_proxy/src/server/stored/response.rs new file mode 100644 index 000000000..1bd8a29c4 --- /dev/null +++ b/fluxer_media_proxy/src/server/stored/response.rs @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + http_headers, range, server::download_stream::DownloadStreamPolicy, storage::StreamObject, +}; +use axum::{ + body::Body, + http::{HeaderValue, Method, StatusCode, header}, + response::Response, +}; + +pub(in crate::server) fn passthrough_head_response( + content_type: &str, + total_len: usize, + byte_range: Option, + disposition: Option, +) -> Response { + let body_len = byte_range.map(|r| r.end - r.start + 1).unwrap_or(total_len); + let mut response = Response::new(Body::empty()); + *response.status_mut() = if byte_range.is_some() { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }; + http_headers::add_media_headers(response.headers_mut(), total_len, content_type, byte_range); + response + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); + if let Some(value) = disposition { + response + .headers_mut() + .insert(header::CONTENT_DISPOSITION, value); + } + response +} + +pub(super) struct StreamingMediaResponse<'a> { + pub(super) method: Method, + pub(super) object: StreamObject, + pub(super) total_len: usize, + pub(super) content_type: &'a str, + pub(super) disposition: Option, + pub(super) stream_policy: DownloadStreamPolicy, +} + +pub(super) fn streaming_media_response(response: StreamingMediaResponse<'_>) -> Response { + let StreamingMediaResponse { + method, + object, + total_len, + content_type, + disposition, + stream_policy, + } = response; + let status = if object.status == StatusCode::PARTIAL_CONTENT { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }; + // Advertise the range the store actually satisfied, not one recomputed locally: an origin is + // free to answer a different span than the client asked for, and Content-Range must describe + // the bytes in this body. + let effective_byte_range = if status == StatusCode::PARTIAL_CONTENT { + object.byte_range + } else { + None + }; + let expected_body_len = effective_byte_range + .map(|r| r.end - r.start + 1) + .unwrap_or(total_len); + let body_len = object + .content_length + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(expected_body_len); + let response_content_type = if content_type.is_empty() { + object.content_type.as_str() + } else { + content_type + }; + let mut response = if method == Method::HEAD { + Response::new(Body::empty()) + } else { + Response::new(stream_policy.guard(object.body, body_len)) + }; + *response.status_mut() = status; + http_headers::add_media_headers( + response.headers_mut(), + total_len, + response_content_type, + effective_byte_range, + ); + response + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from(body_len)); + if let Some(value) = disposition { + response + .headers_mut() + .insert(header::CONTENT_DISPOSITION, value); + } + response +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use http_body_util::BodyExt as _; + use std::io; + + fn stored_stream(chunks: Vec<&'static [u8]>, content_length: u64) -> StreamObject { + StreamObject { + body: Body::from_stream(futures_util::stream::iter( + chunks + .into_iter() + .map(|chunk| Ok::<_, io::Error>(Bytes::from_static(chunk))), + )), + status: StatusCode::OK, + content_length: Some(content_length), + content_type: "text/plain".to_owned(), + byte_range: None, + total_length: Some(content_length), + } + } + + fn stored_response(object: StreamObject) -> Response { + streaming_media_response(StreamingMediaResponse { + method: Method::GET, + object, + total_len: 5, + content_type: "text/plain", + disposition: None, + stream_policy: DownloadStreamPolicy::for_passthrough(30_000), + }) + } + + async fn body_error_kind(response: Response) -> io::ErrorKind { + let mut error = response + .into_body() + .collect() + .await + .map(|collected| collected.to_bytes()) + .expect_err("the stored stream fails") + .into_inner(); + loop { + error = match error.downcast::() { + Ok(error) => return error.kind(), + Err(error) => error + .downcast::() + .expect("the stored stream fails with an io error") + .into_inner(), + }; + } + } + + #[tokio::test] + async fn a_stored_stream_that_ends_short_of_its_content_length_fails_with_unexpected_eof() { + let response = stored_response(stored_stream(vec![b"hel"], 5)); + assert_eq!( + Some("5"), + response + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + ); + assert_eq!( + io::ErrorKind::UnexpectedEof, + body_error_kind(response).await + ); + } + + #[tokio::test] + async fn a_stored_stream_that_overruns_its_content_length_fails_with_invalid_data() { + let response = stored_response(stored_stream(vec![b"hello", b"world"], 5)); + assert_eq!(io::ErrorKind::InvalidData, body_error_kind(response).await); + } +} diff --git a/fluxer_media_proxy/src/server/stored/tests.rs b/fluxer_media_proxy/src/server/stored/tests.rs new file mode 100644 index 000000000..cbc978df5 --- /dev/null +++ b/fluxer_media_proxy/src/server/stored/tests.rs @@ -0,0 +1,575 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::*; +use crate::{ + config::Config, + output_format::OutputFormat, + server::asset_path::parse_standard_asset_path, + storage::{Object, StorageError}, +}; +use axum::{body::to_bytes, http::header}; +use bytes::Bytes; +use std::time::{Duration, Instant}; + +const ASSET_TEST_PATH: &str = "/avatars/123456789012345678/a1b2c3d4e5f6.png"; + +fn test_app_state(cfg: Config) -> Arc { + Arc::new(AppState::for_tests(cfg)) +} + +fn asset_test_config(storage_root: &std::path::Path) -> Config { + Config::load_from_iter([ + ( + "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), + "secret".to_owned(), + ), + ("FLUXER_MEDIA_PROXY_MODE".to_owned(), "mp".to_owned()), + ( + "FLUXER_MEDIA_PROXY_STORAGE_BACKEND".to_owned(), + "local".to_owned(), + ), + ( + "FLUXER_MEDIA_PROXY_STORAGE_ROOT".to_owned(), + storage_root.display().to_string(), + ), + ]) + .unwrap() +} + +fn asset_source_object() -> Object { + Object { + data: Bytes::from_static(b"\x89PNG\r\n\x1a\noriginal-asset-bytes"), + content_type: "image/png".to_owned(), + } +} + +fn asset_transform_failure(error: CoalescerError) -> Response { + asset_transform_failure_response(AssetTransformFailure { + method: Method::GET, + error, + object: asset_source_object(), + range_header: None, + source_format: AssetExtension::Png, + detail: "asset key=avatars/1/abc".to_owned(), + }) +} + +async fn response_body(response: Response) -> Bytes { + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body") +} + +fn test_transform_cache() -> crate::transform_cache::TransformCache { + let cfg = Config::load_from_iter([( + "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), + "secret".to_owned(), + )]) + .expect("config loads"); + AppState::for_tests(cfg).media.transforms().cache().clone() +} + +#[test] +fn asset_size_query_is_clamped_by_kind() { + let params = HashMap::from([("size".to_owned(), "4096".to_owned())]); + let size = asset_size::parse_image_size(params.get("size").map(String::as_str)); + let selected = output_format::select_url_variant(output_format::Input { + kind: AssetKind::Avatar, + original: AssetExtension::Webp, + requested_size: Some(size), + manual_format_override: asset_manual_format_override(¶ms, AssetExtension::Webp), + }); + assert_eq!(Some(1024), selected.size); +} + +#[tokio::test] +async fn a_native_allocation_failure_still_serves_the_original_image() { + let error = test_transform_cache() + .get_or_run( + "asset:alloc".to_owned(), + OutputFormat::WebP, + None, + || async { + Err(anyhow::Error::new( + media_process::MediaError::AllocationFailed, + )) + }, + ) + .await + .expect_err("the transform fails"); + assert_eq!(CoalescerError::AllocationFailed, error); + let response = asset_transform_failure(error); + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + Some("image/png"), + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + ); + assert_eq!(asset_source_object().data, response_body(response).await); +} + +#[tokio::test] +async fn a_cancelled_transform_leader_still_serves_the_original_image() { + let cache = test_transform_cache(); + let leader_cache = cache.clone(); + let leader = tokio::spawn(async move { + let _ = leader_cache + .get_or_run( + "asset:cancelled".to_owned(), + OutputFormat::WebP, + None, + || async { + tokio::time::sleep(Duration::from_secs(60)).await; + Ok(media_process::MediaBytes::from(vec![0u8; 4])) + }, + ) + .await; + }); + tokio::time::sleep(Duration::from_millis(20)).await; + let waiter_cache = cache.clone(); + let waiter = tokio::spawn(async move { + waiter_cache + .get_or_run( + "asset:cancelled".to_owned(), + OutputFormat::WebP, + Some(Instant::now() + Duration::from_secs(60)), + || async { Ok(media_process::MediaBytes::from(vec![0u8; 4])) }, + ) + .await + }); + tokio::time::sleep(Duration::from_millis(20)).await; + leader.abort(); + let _ = leader.await; + let error = waiter + .await + .expect("waiter task") + .expect_err("the cancelled leader publishes a failure"); + assert_eq!(CoalescerError::WorkCancelled, error); + let response = asset_transform_failure(error); + assert_eq!(StatusCode::OK, response.status()); + assert_eq!(asset_source_object().data, response_body(response).await); +} + +#[test] +fn a_coalescer_timeout_is_never_degraded_to_the_original_image() { + let response = asset_transform_failure(CoalescerError::RequestTimeout); + assert_eq!(StatusCode::GATEWAY_TIMEOUT, response.status()); +} + +#[tokio::test] +async fn an_asset_image_transform_is_served_and_then_reused_from_its_content_identity_key() { + let tmp = tempfile::tempdir().unwrap(); + let storage_root = tmp.path().canonicalize().unwrap(); + let app = test_app_state(asset_test_config(&storage_root)); + let asset = parse_standard_asset_path(ASSET_TEST_PATH).unwrap(); + app.store + .write_object( + &app.cfg.storage.bucket_cdn, + &asset.storage_key, + &crate::test_fixtures::synthetic_png(512, 512), + "image/png", + ) + .await + .unwrap(); + let params = HashMap::new(); + for _ in 0..2 { + let response = serve_asset_image( + &app, + Method::GET, + parse_standard_asset_path(ASSET_TEST_PATH).unwrap(), + ¶ms, + &HeaderMap::new(), + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + Some("image/png"), + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + ); + assert!(!response_body(response).await.is_empty()); + } + let rendered = app.metrics.render(); + assert!( + rendered.contains("fluxer_media_proxy_transform_cache_hits_total 1\n"), + "the second request reuses the cached transform" + ); + assert!(rendered.contains("fluxer_media_proxy_transform_cache_misses_total 1\n")); + assert!( + rendered.contains("fluxer_media_proxy_storage_hits_total 2\n"), + "each asset request reads its source exactly once and never heads it first" + ); + app.store + .write_object( + &app.cfg.storage.bucket_cdn, + &asset.storage_key, + &crate::test_fixtures::synthetic_png(256, 256), + "image/png", + ) + .await + .unwrap(); + let response = serve_asset_image( + &app, + Method::GET, + parse_standard_asset_path(ASSET_TEST_PATH).unwrap(), + ¶ms, + &HeaderMap::new(), + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + assert!( + app.metrics + .render() + .contains("fluxer_media_proxy_transform_cache_misses_total 2\n"), + "rewritten source bytes take a new cache key" + ); +} + +#[tokio::test] +async fn an_asset_read_that_races_a_rewrite_is_refused_rather_than_cached() { + let tmp = tempfile::tempdir().unwrap(); + let storage_root = tmp.path().canonicalize().unwrap(); + let app = test_app_state(asset_test_config(&storage_root)); + let asset = parse_standard_asset_path(ASSET_TEST_PATH).unwrap(); + app.store + .write_object( + &app.cfg.storage.bucket_cdn, + &asset.storage_key, + &crate::test_fixtures::synthetic_png(512, 512), + "image/png", + ) + .await + .unwrap(); + let head = app + .store + .head_object(&app.cfg.storage.bucket_cdn, &asset.storage_key) + .await + .unwrap(); + app.store + .write_object( + &app.cfg.storage.bucket_cdn, + &asset.storage_key, + &crate::test_fixtures::synthetic_png(256, 256), + "image/png", + ) + .await + .unwrap(); + let read_budget = storage::unversioned_read_budget(constants::MAX_MEDIA_PROXY_BYTES); + let error = app + .store + .read_object_versioned(storage::ObjectReadRequest { + bucket: &app.cfg.storage.bucket_cdn, + key: &asset.storage_key, + max_bytes: constants::MAX_MEDIA_PROXY_BYTES, + budget: &read_budget, + expected_identity: &head.identity, + }) + .await + .expect_err("the rewritten object no longer matches its head"); + assert!(matches!(error, StorageError::ObjectChanged)); +} + +#[test] +fn a_transform_failure_on_a_source_the_browser_cannot_show_stays_an_error() { + let svg_failure = |error| { + asset_transform_failure_response(AssetTransformFailure { + method: Method::GET, + error, + object: Object { + data: Bytes::from_static(b""), + content_type: "image/svg+xml".to_owned(), + }, + range_header: None, + source_format: AssetExtension::Svg, + detail: "asset key=avatars/1/abc".to_owned(), + }) + }; + assert_eq!( + StatusCode::INTERNAL_SERVER_ERROR, + svg_failure(CoalescerError::WorkFailed).status() + ); + assert_eq!( + StatusCode::SERVICE_UNAVAILABLE, + svg_failure(CoalescerError::AllocationFailed).status() + ); +} + +#[tokio::test] +async fn a_stored_passthrough_stream_declares_the_length_it_serves() { + let tmp = tempfile::tempdir().unwrap(); + let storage_root = tmp.path().canonicalize().unwrap(); + let app = test_app_state(asset_test_config(&storage_root)); + app.store + .write_object( + &app.cfg.storage.bucket_cdn, + "a/b.txt", + b"hello", + "text/plain", + ) + .await + .unwrap(); + let response = passthrough::serve_stored_raw( + &app, + Method::GET, + &app.cfg.storage.bucket_cdn, + "a/b.txt", + &HeaderMap::new(), + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + assert_eq!( + Some("5"), + response + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + ); + assert_eq!(&b"hello"[..], &response_body(response).await[..]); +} + +fn fake_asset_object(read_status: Option) -> crate::storage::tests::FakeObject { + crate::storage::tests::FakeObject { + body: crate::test_fixtures::synthetic_png(512, 512), + etag: Some("\"asset-v1\"".to_owned()), + content_type: Some("image/png".to_owned()), + read_status, + ..crate::storage::tests::FakeObject::default() + } +} + +#[tokio::test] +async fn an_asset_image_reads_its_source_once_and_never_pins_it() { + let fake = crate::storage::tests::fake_s3().await; + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fake.config(tmp.path()); + cfg.media.transform_timeout_ms = 60_000; + let app = test_app_state(cfg); + let asset = parse_standard_asset_path(ASSET_TEST_PATH).unwrap(); + fake.put_object( + &format!("{}/{}", app.cfg.storage.bucket_cdn, asset.storage_key), + fake_asset_object(None), + ); + let response = + serve_asset_image(&app, Method::GET, asset, &HashMap::new(), &HeaderMap::new()).await; + assert_eq!(StatusCode::OK, response.status()); + let requests = fake.requests(); + assert_eq!( + 1, + requests.len(), + "the asset image path costs exactly one storage operation" + ); + assert_eq!(Method::GET, requests[0].0); + assert!( + requests[0].2.get(header::IF_MATCH).is_none(), + "an asset read is never pinned to an identity resolved by a separate request" + ); +} + +#[tokio::test] +async fn an_asset_image_source_that_vanishes_mid_request_is_not_found() { + let fake = crate::storage::tests::fake_s3().await; + let tmp = tempfile::tempdir().unwrap(); + let app = test_app_state(fake.config(tmp.path())); + let asset = parse_standard_asset_path(ASSET_TEST_PATH).unwrap(); + fake.put_object( + &format!("{}/{}", app.cfg.storage.bucket_cdn, asset.storage_key), + fake_asset_object(Some(404)), + ); + let response = + serve_asset_image(&app, Method::GET, asset, &HashMap::new(), &HeaderMap::new()).await; + assert_eq!(StatusCode::NOT_FOUND, response.status()); +} + +fn avatar_cache_key_for_requested_size(raw: &str) -> String { + let size = asset_size::parse_image_size(Some(raw)); + let selected = output_format::select_url_variant(output_format::Input { + kind: AssetKind::Avatar, + original: AssetExtension::Webp, + requested_size: Some(size), + manual_format_override: None, + }); + transform_cache_key(TransformCacheKeyInput { + route: TransformRoute::Asset, + asset_kind: Some(AssetKind::Avatar), + cache_identity: "avatars/852813040100737024/hash", + width: selected.size, + height: selected.size, + format: selected.format, + quality: Some(ImageQuality::High), + animated: false, + effort: None, + resize_mode: Some(ResizeMode::Fit), + }) +} + +#[test] +fn requested_sizes_off_the_ladder_share_the_cache_key_of_the_rung_they_snap_to() { + let canonical = avatar_cache_key_for_requested_size("1024"); + assert_eq!( + "asset:avatars/852813040100737024/hash|asset_kind=avatar|w=1024|h=1024|fmt=webp|q=high|anim=false|effort=none|resize=fit", + canonical + ); + for raw in ["641", "700", "1000", "1023", "1024"] { + assert_eq!( + canonical, + avatar_cache_key_for_requested_size(raw), + "size={raw} minted a second cache key" + ); + } + let floor = avatar_cache_key_for_requested_size("128"); + assert_eq!( + "asset:avatars/852813040100737024/hash|asset_kind=avatar|w=128|h=128|fmt=webp|q=high|anim=false|effort=none|resize=fit", + floor + ); + for raw in ["1", "17", "20", "100", "128"] { + assert_eq!( + floor, + avatar_cache_key_for_requested_size(raw), + "size={raw} minted a second cache key" + ); + } +} + +#[test] +fn requested_sizes_on_different_rungs_keep_distinct_cache_keys() { + assert_ne!( + avatar_cache_key_for_requested_size("300"), + avatar_cache_key_for_requested_size("512") + ); + assert_ne!( + avatar_cache_key_for_requested_size("300"), + avatar_cache_key_for_requested_size("1000") + ); +} + +#[tokio::test] +async fn asset_sources_that_share_bytes_but_not_their_stored_format_keep_distinct_entries() { + let fake = crate::storage::tests::fake_s3().await; + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fake.config(tmp.path()); + cfg.media.transform_timeout_ms = 60_000; + cfg.media.transform_cache_capacity_bytes = 1 << 20; + cfg.media.transform_cache_max_entry_bytes = 1 << 20; + cfg.media.transform_cache_ttl_ms = 60_000; + let app = test_app_state(cfg); + let shared_bytes = crate::test_fixtures::synthetic_png(512, 512); + let gif_sourced = + parse_standard_asset_path("/avatars/123456789012345678/a1b2c3d4e5f6.webp").unwrap(); + let png_sourced = + parse_standard_asset_path("/avatars/123456789012345679/a1b2c3d4e5f6.webp").unwrap(); + for (asset, content_type) in [(&gif_sourced, "image/gif"), (&png_sourced, "image/png")] { + fake.put_object( + &format!("{}/{}", app.cfg.storage.bucket_cdn, asset.storage_key), + crate::storage::tests::FakeObject { + body: shared_bytes.clone(), + etag: Some("\"asset-v1\"".to_owned()), + content_type: Some(content_type.to_owned()), + ..crate::storage::tests::FakeObject::default() + }, + ); + } + let params = HashMap::from([("animated".to_owned(), "true".to_owned())]); + let gif_response = + serve_asset_image(&app, Method::GET, gif_sourced, ¶ms, &HeaderMap::new()).await; + assert_eq!(StatusCode::OK, gif_response.status()); + assert_eq!( + Some("image/gif"), + gif_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + ); + assert!( + response_body(gif_response).await.starts_with(b"GIF8"), + "an image/gif source encodes to gif once animation is asked for" + ); + let webp_response = + serve_asset_image(&app, Method::GET, png_sourced, ¶ms, &HeaderMap::new()).await; + assert_eq!(StatusCode::OK, webp_response.status()); + assert_eq!( + Some("image/webp"), + webp_response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + "identical source bytes stored under a different format never share one entry" + ); + assert!(response_body(webp_response).await.starts_with(b"RIFF")); +} + +#[tokio::test] +async fn an_explicit_quality_reuses_the_entry_the_resolved_default_minted() { + let tmp = tempfile::tempdir().unwrap(); + let storage_root = tmp.path().canonicalize().unwrap(); + let app = test_app_state(asset_test_config(&storage_root)); + let asset = parse_standard_asset_path(ASSET_TEST_PATH).unwrap(); + app.store + .write_object( + &app.cfg.storage.bucket_cdn, + &asset.storage_key, + &crate::test_fixtures::synthetic_png(512, 512), + "image/png", + ) + .await + .unwrap(); + for params in [ + HashMap::new(), + HashMap::from([("quality".to_owned(), "high".to_owned())]), + ] { + let response = serve_asset_image( + &app, + Method::GET, + parse_standard_asset_path(ASSET_TEST_PATH).unwrap(), + ¶ms, + &HeaderMap::new(), + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + } + let rendered = app.metrics.render(); + assert!( + rendered.contains("fluxer_media_proxy_transform_cache_hits_total 1\n"), + "quality=high resolves to the entry the default request already minted" + ); + assert!(rendered.contains("fluxer_media_proxy_transform_cache_misses_total 1\n")); +} + +fn cache_control_of(response: &Response) -> &str { + response + .headers() + .get(header::CACHE_CONTROL) + .expect("cache-control is always set") + .to_str() + .expect("cache-control is ASCII") +} + +#[test] +fn stored_media_responses_cache_forever() { + let stored = media_response(MediaResponse { + method: Method::GET, + data: Bytes::from_static(b"stored bytes").into(), + content_type: "image/webp", + range_header: None, + disposition: None, + }); + assert_eq!("public, max-age=31536000", cache_control_of(&stored)); + + let streamable = media_response(MediaResponse { + method: Method::GET, + data: Bytes::from_static(b"stored bytes").into(), + content_type: "video/mp4", + range_header: None, + disposition: None, + }); + assert_eq!( + "public, max-age=31536000, no-transform", + cache_control_of(&streamable) + ); + + let head = super::response::passthrough_head_response("image/webp", 12, None, None); + assert_eq!("public, max-age=31536000", cache_control_of(&head)); +} diff --git a/fluxer_media_proxy/src/server/transform/cache.rs b/fluxer_media_proxy/src/server/transform/cache.rs new file mode 100644 index 000000000..69824468c --- /dev/null +++ b/fluxer_media_proxy/src/server/transform/cache.rs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::BudgetedBytes, + coalescer::CoalescerError, + media_process::MediaBytes, + output_format::OutputFormat, + server::{response::error::text_with_reason, transform::TransformRuntime}, +}; +use axum::{http::StatusCode, response::Response}; +use std::{future::Future, time::Instant}; + +pub(in crate::server) struct CachedTransformHit { + pub(in crate::server) data: BudgetedBytes, + pub(in crate::server) format: OutputFormat, +} + +pub(in crate::server) struct CachedTransformRequest<'a, F> { + pub(in crate::server) runtime: &'a TransformRuntime, + pub(in crate::server) cache_key: String, + pub(in crate::server) format: OutputFormat, + pub(in crate::server) deadline: Option, + pub(in crate::server) work: F, +} + +pub(in crate::server) fn cached_transform_hit( + runtime: &TransformRuntime, + cache_key: &str, +) -> Option { + let cached = runtime.cache().get(cache_key)?; + Some(CachedTransformHit { + data: cached.data, + format: cached.format, + }) +} + +pub(in crate::server) async fn cached_transform( + request: CachedTransformRequest<'_, F>, +) -> Result +where + F: FnOnce() -> Fut, + Fut: Future>, +{ + let CachedTransformRequest { + runtime, + cache_key, + format, + deadline, + work, + } = request; + let result = runtime + .cache() + .get_or_run(cache_key, format, deadline, work) + .await; + if matches!(result, Err(CoalescerError::WorkFailed)) { + runtime.metrics().record_transform_failure(); + } + result +} + +pub(in crate::server) fn coalescer_failure_response( + error: CoalescerError, + timeout_code: &'static str, +) -> Option { + let reason = match error { + CoalescerError::WorkFailed => return None, + CoalescerError::RequestTimeout => { + return Some(text_with_reason( + StatusCode::GATEWAY_TIMEOUT, + "Gateway Timeout", + timeout_code, + )); + } + CoalescerError::BufferBudgetExhausted => "transform_output_budget_exhausted", + CoalescerError::AllocationFailed => "native_transform_allocation_failed", + CoalescerError::Overloaded => "native_transform_queue_full", + CoalescerError::Unavailable => "native_transform_unavailable", + CoalescerError::WorkCancelled => "transform_work_cancelled", + }; + Some(text_with_reason( + StatusCode::SERVICE_UNAVAILABLE, + "Service Unavailable", + reason, + )) +} diff --git a/fluxer_media_proxy/src/server/transform/cache_key.rs b/fluxer_media_proxy/src/server/transform/cache_key.rs new file mode 100644 index 000000000..34083c588 --- /dev/null +++ b/fluxer_media_proxy/src/server/transform/cache_key.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + constants::AssetKind, + image_quality::ImageQuality, + image_transform::{EncodeEffort, ResizeMode}, + output_format::OutputFormat, + server::transform::parameters::TransformRoute, +}; +use sha2::{Digest, Sha256}; + +pub struct TransformCacheKeyInput<'a> { + pub route: TransformRoute, + pub asset_kind: Option, + pub cache_identity: &'a str, + pub width: Option, + pub height: Option, + pub format: OutputFormat, + pub quality: Option, + pub animated: bool, + pub effort: Option, + pub resize_mode: Option, +} + +pub fn transform_cache_key(input: TransformCacheKeyInput<'_>) -> String { + let prefix = match input.route { + TransformRoute::Attachment => "attachment", + TransformRoute::External => "external", + TransformRoute::Stored => "stored", + TransformRoute::Asset => "asset", + }; + let identity = match input.route { + TransformRoute::Attachment | TransformRoute::Stored | TransformRoute::Asset => { + input.cache_identity.to_owned() + } + TransformRoute::External => sha256_hex(input.cache_identity.as_bytes()), + }; + format!( + "{prefix}:{identity}|asset_kind={}|w={}|h={}|fmt={}|q={}|anim={}|effort={}|resize={}", + serialize_asset_kind(input.route, input.asset_kind), + serialize_optional_number(input.width), + serialize_optional_number(input.height), + input.format.cache_serialization(), + input + .quality + .map(ImageQuality::cache_serialization) + .unwrap_or("default"), + input.animated, + serialize_optional_number(input.effort.map(EncodeEffort::get)), + serialize_resize_mode(input.resize_mode), + ) +} + +fn serialize_asset_kind(route: TransformRoute, kind: Option) -> &'static str { + match (route, kind) { + (TransformRoute::Asset, Some(kind)) => asset_kind_name(kind), + (TransformRoute::Attachment | TransformRoute::External | TransformRoute::Stored, None) => { + "not-applicable" + } + (TransformRoute::Asset, None) => panic!("an asset transform cache key requires its kind"), + ( + TransformRoute::Attachment | TransformRoute::External | TransformRoute::Stored, + Some(_), + ) => panic!("a non-asset transform cache key cannot carry an asset kind"), + } +} + +fn asset_kind_name(kind: AssetKind) -> &'static str { + match kind { + AssetKind::Avatar => "avatar", + AssetKind::GuildIcon => "guild_icon", + AssetKind::Banner => "banner", + AssetKind::Splash => "splash", + AssetKind::EmbedSplash => "embed_splash", + AssetKind::Emoji => "emoji", + AssetKind::Sticker => "sticker", + AssetKind::Attachment => "attachment", + } +} + +fn serialize_resize_mode(mode: Option) -> &'static str { + match mode { + Some(ResizeMode::Fit) => "fit", + Some(ResizeMode::Cover) => "cover", + None => "not-applicable", + } +} + +fn serialize_optional_number(value: Option) -> String { + match value { + Some(value) => value.to_string(), + None => "none".to_owned(), + } +} + +pub(in crate::server) fn sha256_hex(data: &[u8]) -> String { + hex::encode(Sha256::digest(data)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn input<'a>(route: TransformRoute, identity: &'a str) -> TransformCacheKeyInput<'a> { + TransformCacheKeyInput { + route, + asset_kind: None, + cache_identity: identity, + width: None, + height: None, + format: OutputFormat::WebP, + quality: None, + animated: false, + effort: None, + resize_mode: None, + } + } + + #[test] + fn cache_key_serializes_every_transform_decision() { + let key = transform_cache_key(TransformCacheKeyInput { + route: TransformRoute::Attachment, + asset_kind: None, + cache_identity: "attachment/1", + width: Some(128), + height: Some(256), + format: OutputFormat::PNG, + quality: Some(ImageQuality::Lossless), + animated: true, + effort: EncodeEffort::parse_lenient("3"), + resize_mode: Some(ResizeMode::Cover), + }); + assert_eq!( + "attachment:attachment/1|asset_kind=not-applicable|w=128|h=256|fmt=png|q=lossless|anim=true|effort=3|resize=cover", + key + ); + assert_ne!( + key, + transform_cache_key(input(TransformRoute::Attachment, "attachment/1")) + ); + } + + #[test] + fn external_cache_identity_is_hashed_and_route_namespaces_are_distinct() { + let raw = "https://user:secret@example.invalid/media.png?token=private"; + let external = transform_cache_key(input(TransformRoute::External, raw)); + assert!(external.starts_with("external:")); + assert!(!external.contains(raw)); + assert!(!external.contains("secret")); + assert_ne!( + external, + transform_cache_key(input(TransformRoute::Stored, raw)) + ); + assert_eq!(64, sha256_hex(b"abc").len()); + } + + #[test] + fn asset_keys_name_their_kind_and_stay_distinct_per_kind() { + let emoji = transform_cache_key(TransformCacheKeyInput { + asset_kind: Some(AssetKind::Emoji), + resize_mode: Some(ResizeMode::Cover), + ..input(TransformRoute::Asset, "identity") + }); + assert_eq!( + "asset:identity|asset_kind=emoji|w=none|h=none|fmt=webp|q=default|anim=false|effort=none|resize=cover", + emoji + ); + assert_ne!( + emoji, + transform_cache_key(TransformCacheKeyInput { + asset_kind: Some(AssetKind::Sticker), + resize_mode: Some(ResizeMode::Cover), + ..input(TransformRoute::Asset, "identity") + }) + ); + } + + #[test] + #[should_panic(expected = "an asset transform cache key requires its kind")] + fn an_asset_key_without_a_kind_is_a_programming_error() { + let _ = transform_cache_key(input(TransformRoute::Asset, "identity")); + } +} diff --git a/fluxer_media_proxy/src/server/transform/execution.rs b/fluxer_media_proxy/src/server/transform/execution.rs new file mode 100644 index 000000000..6a01fcd30 --- /dev/null +++ b/fluxer_media_proxy/src/server/transform/execution.rs @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + coalescer::CoalescerError, + image_quality::ImageQuality, + image_transform::{AnimationMode, ImageOptions}, + media_process::{self, MediaError, ProcessedMedia}, + metrics, + output_format::OutputFormat, + request_log::{self, Stage}, + server::transform::TransformRuntime, + timed_semaphore::TimedSemaphoreError, +}; +use bytes::Bytes; +use std::time::{Duration, Instant}; + +struct TimedMedia { + media: ProcessedMedia, + elapsed_ms: u64, +} + +pub(in crate::server) struct VideoTransformOptions { + pub(in crate::server) format: OutputFormat, + pub(in crate::server) width: Option, + pub(in crate::server) height: Option, + pub(in crate::server) quality: ImageQuality, + pub(in crate::server) deadline_ms: Option, +} + +pub(in crate::server) async fn run_transform( + runtime: &TransformRuntime, + data: Bytes, + options: ImageOptions, +) -> anyhow::Result { + let deadline = deadline_instant(options.deadline_ms); + let media_limits = runtime.limits(); + let transform_metrics = runtime.metrics(); + let timed = runtime + .tasks() + .run_native(deadline, move || { + let started = Instant::now(); + let media = + media_process::transform_image(&data, &options, &media_limits, &transform_metrics)?; + Ok(TimedMedia { + media, + elapsed_ms: metrics::duration_millis(started.elapsed()), + }) + }) + .await?; + runtime.metrics().observe_image_duration(timed.elapsed_ms); + request_log::record_stage(Stage::Transform, timed.elapsed_ms); + Ok(timed.media) +} + +pub(in crate::server) async fn run_video_transform( + runtime: &TransformRuntime, + data: Bytes, + options: VideoTransformOptions, +) -> anyhow::Result { + let VideoTransformOptions { + format, + width, + height, + quality, + deadline_ms, + } = options; + let deadline = deadline_instant(deadline_ms); + let media_limits = runtime.limits(); + let transform_metrics = runtime.metrics(); + let timed = runtime + .tasks() + .run_native(deadline, move || { + let started = Instant::now(); + let thumbnail = media_process::extract_video_thumbnail(&data, format, &media_limits)?; + let media = if width.is_none() && height.is_none() { + thumbnail + } else { + media_process::transform_image( + &thumbnail.bytes, + &ImageOptions { + width, + height, + format, + quality, + animation: AnimationMode::Static, + deadline_ms, + ..Default::default() + }, + &media_limits, + &transform_metrics, + )? + }; + Ok(TimedMedia { + media, + elapsed_ms: metrics::duration_millis(started.elapsed()), + }) + }) + .await?; + runtime.metrics().observe_video_duration(timed.elapsed_ms); + request_log::record_stage(Stage::Transform, timed.elapsed_ms); + Ok(timed.media) +} + +pub(in crate::server) fn coalesced_work_result(result: anyhow::Result) -> anyhow::Result { + match result { + Ok(value) => Ok(value), + Err(error) if transform_error_is_timeout(&error) => { + Err(anyhow::Error::new(CoalescerError::RequestTimeout)) + } + Err(error) + if error.downcast_ref::() + == Some(&TimedSemaphoreError::Closed) => + { + Err(anyhow::Error::new(CoalescerError::Unavailable)) + } + Err(error) => Err(error), + } +} + +pub(in crate::server) fn transform_error_is_timeout(error: &anyhow::Error) -> bool { + // A shed admission is reported as a timeout: the old era had one admission semaphore whose + // exhaustion surfaced as TimedSemaphoreError::RequestTimeout, so every caller answered 504 + // with its own timeout reason code. Giving a full queue its own class moves the transform + // routes to 503, metadata SVG rasterize to 400, and on a stored asset it stops counting as a + // timeout at all, which degrades the reply to the untransformed original. + let admission = error.downcast_ref::(); + error.downcast_ref::() == Some(&MediaError::RequestTimeout) + || admission == Some(&TimedSemaphoreError::RequestTimeout) + || admission == Some(&TimedSemaphoreError::QueueFull) +} + +pub(in crate::server) fn deadline_instant(deadline_ms: Option) -> Option { + let deadline_ms = deadline_ms?; + let remaining_ms = deadline_ms.saturating_sub(metrics::now_ms()).max(0) as u64; + Some(Instant::now() + Duration::from_millis(remaining_ms)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_admission_failures_are_retyped_for_the_coalescer() { + let shed = coalesced_work_result::<()>(Err(TimedSemaphoreError::QueueFull.into())) + .expect_err("a full queue sheds the request"); + assert_eq!( + Some(&CoalescerError::RequestTimeout), + shed.downcast_ref::() + ); + let unavailable = coalesced_work_result::<()>(Err(TimedSemaphoreError::Closed.into())) + .expect_err("a closed pool is unavailable"); + assert_eq!( + Some(&CoalescerError::Unavailable), + unavailable.downcast_ref::() + ); + let timed_out = + coalesced_work_result::<()>(Err(TimedSemaphoreError::RequestTimeout.into())) + .expect_err("a deadline is a timeout"); + assert_eq!( + Some(&CoalescerError::RequestTimeout), + timed_out.downcast_ref::() + ); + let failed = coalesced_work_result::<()>(Err(MediaError::MediaDecodeFailed.into())) + .expect_err("a decode failure passes through"); + assert_eq!( + Some(&MediaError::MediaDecodeFailed), + failed.downcast_ref::() + ); + } + + #[test] + fn a_full_native_queue_answers_the_gateway_timeout_the_old_era_answered() { + let queue_full: anyhow::Error = TimedSemaphoreError::QueueFull.into(); + assert!( + transform_error_is_timeout(&queue_full), + "a shed admission is a timeout for every caller, not just the coalescer" + ); + let shed = coalesced_work_result::<()>(Err(TimedSemaphoreError::QueueFull.into())) + .expect_err("a full queue sheds the request"); + let error = shed + .downcast_ref::() + .copied() + .expect("the coalescer sees a typed failure"); + let response = crate::server::transform::cache::coalescer_failure_response( + error, + "coalescer_timeout_image", + ) + .expect("a shed transform has a response"); + assert_eq!( + axum::http::StatusCode::GATEWAY_TIMEOUT, + response.status(), + "the old era answered 504 when the admission queue was full" + ); + assert_eq!( + Some("coalescer_timeout_image"), + response + .extensions() + .get::() + .map(|reason| reason.code) + ); + } +} diff --git a/fluxer_media_proxy/src/server/transform/mod.rs b/fluxer_media_proxy/src/server/transform/mod.rs new file mode 100644 index 000000000..e61566952 --- /dev/null +++ b/fluxer_media_proxy/src/server/transform/mod.rs @@ -0,0 +1,710 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub(in crate::server) mod cache; +pub mod cache_key; +pub(in crate::server) mod execution; +pub mod parameters; +#[cfg(test)] +mod tests; + +use crate::{ + byte_budget::ByteBudget, + byte_cache::ByteCacheSettings, + coalescer::ByteCoalescer, + config::Config, + constants::AssetExtension, + image_quality::ImageQuality, + image_transform::{AnimationLimits, AnimationMode, ImageOptions, ResizeMode}, + media_limits::MediaLimits, + media_process::MediaBytes, + metrics::{self, transform::TransformMetrics}, + mime, + output_format::OutputFormat, + server::{ + format_policy::{ + OriginalImageRequest, content_type_is_trustworthy, default_transform_quality, + effective_animated_image_output_format, extension_from_mime, + external_default_output_extension, image_extension_from_filename, is_svg_content_type, + same_format_loaded_image_request_can_use_original_with_sniff, source_image_format, + transform_response_content_type, transform_static_quality_default, + }, + native_task_executor::{NativeTaskExecutor, NativeTaskExecutorSettings}, + response::{ + MediaResponse, content_disposition_header, + error::{text_with_reason, text_with_source}, + media_response, + }, + transform::{ + cache::{ + CachedTransformRequest, cached_transform, cached_transform_hit, + coalescer_failure_response, + }, + cache_key::{TransformCacheKeyInput, transform_cache_key}, + execution::{ + VideoTransformOptions, coalesced_work_result, deadline_instant, run_transform, + run_video_transform, + }, + parameters::{ + TransformRoute, ValidatedTransformParameters, transform_parameter_error_response, + validate_transform_parameters, + }, + }, + }, + transform_cache::{TransformCache, TransformCacheSettings}, +}; +use axum::{ + http::{HeaderMap, Method, StatusCode, header}, + response::Response, +}; +use bytes::Bytes; +use std::{collections::HashMap, sync::Arc}; + +const DECODED_BYTES_PER_PIXEL: usize = 4; +const DECODED_PIXEL_BUFFERS_PER_TRANSFORM: usize = 2; +const CONTENT_TYPE_SNIFF_PREFIX_BYTES: usize = 8192; + +pub(in crate::server) struct TransformRuntime { + limits: MediaLimits, + animation: AnimationLimits, + transform_timeout_ms: u64, + tasks: NativeTaskExecutor, + cache: TransformCache, + metrics: Arc, +} + +pub(in crate::server) struct ServeBytesRequest<'a> { + pub(in crate::server) method: Method, + pub(in crate::server) data: Bytes, + pub(in crate::server) content_type: String, + pub(in crate::server) cache_identity: &'a str, + pub(in crate::server) filename: &'a str, + pub(in crate::server) route: TransformRoute, + pub(in crate::server) params: &'a HashMap, + pub(in crate::server) headers: &'a HeaderMap, +} + +impl TransformRuntime { + pub(in crate::server) fn new( + cfg: &Config, + metrics: &Arc, + ) -> anyhow::Result { + let limits = MediaLimits::default_from_config(); + Ok(Self { + limits, + animation: AnimationLimits::new( + cfg.media.max_encode_frames, + cfg.media.max_encode_duration_ms, + )?, + transform_timeout_ms: cfg.media.transform_timeout_ms, + tasks: NativeTaskExecutor::new(NativeTaskExecutorSettings { + max_native_transforms: cfg.media.max_native_transforms, + worker_queue_capacity: cfg.media.worker_queue_capacity, + decoded_bytes_per_transform: decoded_bytes_per_transform(&limits), + native_metrics: metrics.native_transform(), + transform_metrics: metrics.transform(), + }), + cache: TransformCache::new(TransformCacheSettings { + cache: ByteCacheSettings::clamped( + cfg.media.transform_cache_capacity_bytes, + cfg.media.transform_cache_max_entry_bytes, + cfg.media.transform_cache_ttl_ms, + ), + budget: ByteBudget::new(usize::MAX), + max_in_flight: ByteCoalescer::UNBOUNDED_CAPACITY, + max_waiters: ByteCoalescer::UNBOUNDED_CAPACITY, + cache_metrics: metrics.transform_cache(), + coalescer_metrics: metrics.coalescer(), + }), + metrics: metrics.transform(), + }) + } + + pub(in crate::server) fn limits(&self) -> MediaLimits { + self.limits + } + + pub(in crate::server) fn animation(&self) -> AnimationLimits { + self.animation + } + + pub(in crate::server) fn cache(&self) -> &TransformCache { + &self.cache + } + + pub(in crate::server) fn tasks(&self) -> &NativeTaskExecutor { + &self.tasks + } + + pub(in crate::server) fn metrics(&self) -> Arc { + Arc::clone(&self.metrics) + } + + pub(in crate::server) fn transform_deadline_ms(&self) -> Option { + Some(metrics::now_ms() + self.transform_timeout_ms as i64) + } +} + +fn decoded_bytes_per_transform(limits: &MediaLimits) -> usize { + limits + .image_pixels() + .max(limits.animated_total_pixels()) + .checked_mul(DECODED_BYTES_PER_PIXEL * DECODED_PIXEL_BUFFERS_PER_TRANSFORM) + .expect("the native decoded image budget must not overflow") +} + +pub(in crate::server) async fn serve_bytes_or_transform( + runtime: &TransformRuntime, + request: ServeBytesRequest<'_>, +) -> Response { + let ServeBytesRequest { + method, + data, + content_type, + cache_identity, + filename, + route, + params, + headers, + } = request; + let transform = match validate_transform_parameters(params, &runtime.limits(), route) { + Ok(transform) => transform, + Err(error) => return transform_parameter_error_response(error), + }; + let prefix = &data[..data.len().min(CONTENT_TYPE_SNIFF_PREFIX_BYTES)]; + let content_type = if mime::sniff(prefix).mime == "image/svg+xml" { + "image/svg+xml".to_owned() + } else if content_type_is_trustworthy(&content_type) { + content_type + } else { + mime::detect(prefix, filename, Some(&content_type)) + }; + let source_is_svg = is_svg_content_type(&content_type) + || image_extension_from_filename(filename) == Some(AssetExtension::Svg); + let range_header = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); + + if !source_is_svg && !transform.has_transform_request { + return original_bytes_response(OriginalBytes { + method, + data, + content_type: &content_type, + filename, + range_header, + requested_download: transform.requested_download, + }); + } + + match mime::category(&content_type) { + Some(mime::Category::Video) => { + serve_video_transform( + runtime, + VideoTransformRequest { + method, + data, + content_type: &content_type, + cache_identity, + filename, + route, + transform, + range_header, + }, + ) + .await + } + Some(mime::Category::Image) => { + serve_image_transform( + runtime, + ImageTransformRequest { + method, + data, + content_type: &content_type, + cache_identity, + filename, + route, + transform, + range_header, + }, + ) + .await + } + _ => { + if route == TransformRoute::Attachment && transform.explicit_format.is_some() { + return text_with_reason( + StatusCode::BAD_REQUEST, + "Bad Request", + "media_transform_unsupported", + ); + } + original_bytes_response(OriginalBytes { + method, + data, + content_type: &content_type, + filename, + range_header, + requested_download: transform.requested_download, + }) + } + } +} + +struct OriginalBytes<'a> { + method: Method, + data: Bytes, + content_type: &'a str, + filename: &'a str, + range_header: Option<&'a str>, + requested_download: bool, +} + +fn original_bytes_response(request: OriginalBytes<'_>) -> Response { + let disposition = Some(content_disposition_header( + request.content_type, + request.requested_download, + Some(request.filename), + )); + media_response(MediaResponse { + method: request.method, + data: request.data.into(), + content_type: request.content_type, + range_header: request.range_header, + disposition, + }) +} + +struct VideoTransformRequest<'a> { + method: Method, + data: Bytes, + content_type: &'a str, + cache_identity: &'a str, + filename: &'a str, + route: TransformRoute, + transform: ValidatedTransformParameters, + range_header: Option<&'a str>, +} + +async fn serve_video_transform( + runtime: &TransformRuntime, + request: VideoTransformRequest<'_>, +) -> Response { + let VideoTransformRequest { + method, + data, + content_type, + cache_identity, + filename, + route, + transform, + range_header, + } = request; + let Some(requested_format) = transform.explicit_format else { + if route == TransformRoute::Attachment { + return text_with_reason( + StatusCode::BAD_REQUEST, + "Bad Request", + "video_transform_format_required", + ); + } + return original_bytes_response(OriginalBytes { + method, + data, + content_type, + filename, + range_header, + requested_download: transform.requested_download, + }); + }; + let format = OutputFormat::coerce_from_extension(requested_format); + let quality = transform.quality.unwrap_or(ImageQuality::High); + let cache_key = transform_cache_key(TransformCacheKeyInput { + route, + asset_kind: None, + cache_identity, + width: transform.width, + height: transform.height, + format, + quality: Some(quality), + animated: transform.animated, + effort: None, + resize_mode: None, + }); + if let Some(hit) = cached_transform_hit(runtime, &cache_key) { + return transformed_response(TransformedBytes { + method, + data: hit.data.as_bytes().clone(), + content_type: hit.format.mime(), + filename, + range_header, + requested_download: transform.requested_download, + }); + } + let deadline_ms = runtime.transform_deadline_ms(); + let transformed = match cached_transform(CachedTransformRequest { + runtime, + cache_key, + format, + deadline: deadline_instant(deadline_ms), + work: move || async move { + coalesced_work_result( + run_video_transform( + runtime, + data, + VideoTransformOptions { + format, + width: transform.width, + height: transform.height, + quality, + deadline_ms, + }, + ) + .await, + ) + .map(|media| MediaBytes::from(media.bytes)) + }, + }) + .await + { + Ok(bytes) => bytes.as_bytes().clone(), + Err(error) => { + if let Some(response) = coalescer_failure_response(error, "coalescer_timeout_video") { + return response; + } + return text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "video_transform_failed", + format!( + "fmt={} w={:?} h={:?} q={}", + format.extension(), + transform.width, + transform.height, + quality + ), + ); + } + }; + transformed_response(TransformedBytes { + method, + data: transformed, + content_type: format.mime(), + filename, + range_header, + requested_download: transform.requested_download, + }) +} + +struct ImageTransformRequest<'a> { + method: Method, + data: Bytes, + content_type: &'a str, + cache_identity: &'a str, + filename: &'a str, + route: TransformRoute, + transform: ValidatedTransformParameters, + range_header: Option<&'a str>, +} + +async fn serve_image_transform( + runtime: &TransformRuntime, + request: ImageTransformRequest<'_>, +) -> Response { + let ImageTransformRequest { + method, + data, + content_type, + cache_identity, + filename, + route, + transform, + range_header, + } = request; + let sniffed = mime::sniff(&data); + let source_format = source_image_format(sniffed.mime, content_type, filename); + let ImageTransformPlan { + format, + quality, + resize_mode, + response_content_type, + cache_key, + } = plan_image_transform(ImageTransformPlanRequest { + content_type, + cache_identity, + filename, + route, + source_format, + transform, + }); + if same_format_loaded_image_request_can_use_original_with_sniff( + sniffed, + OriginalImageRequest { + source_ext: source_format, + explicit_out_ext: transform.explicit_format, + out_ext: format, + width: transform.width, + height: transform.height, + has_quality: transform.quality.is_some(), + effort: transform.effort, + animated: transform.animated, + }, + ) { + let serve_content_type = original_image_content_type(content_type, source_format); + return original_bytes_response(OriginalBytes { + method, + data, + content_type: &serve_content_type, + filename, + range_header, + requested_download: transform.requested_download, + }); + } + let options = ImageOptions { + width: transform.width, + height: transform.height, + format, + quality, + animation: AnimationMode::new(transform.animated, runtime.animation()), + effort_override: transform.effort, + resize_mode, + deadline_ms: runtime.transform_deadline_ms(), + }; + if let Some(hit) = cached_transform_hit(runtime, &cache_key) { + return transformed_response(TransformedBytes { + method, + data: hit.data.as_bytes().clone(), + content_type: response_content_type, + filename, + range_header, + requested_download: transform.requested_download, + }); + } + let transformed = match cached_transform(CachedTransformRequest { + runtime, + cache_key, + format: options.format, + deadline: deadline_instant(options.deadline_ms), + work: move || async move { + coalesced_work_result(run_transform(runtime, data, options).await) + .map(|media| MediaBytes::from(media.bytes)) + }, + }) + .await + { + Ok(bytes) => bytes.as_bytes().clone(), + Err(error) => { + if let Some(response) = coalescer_failure_response(error, "coalescer_timeout_image") { + return response; + } + return text_with_source( + StatusCode::BAD_REQUEST, + "Bad Request", + "image_transform_failed", + format!( + "route={:?} cache_identity={} fmt={} w={:?} h={:?} q={} animated={}", + route, + cache_identity, + options.format.extension(), + options.width, + options.height, + options.quality, + transform.animated, + ), + ); + } + }; + transformed_response(TransformedBytes { + method, + data: transformed, + content_type: response_content_type, + filename, + range_header, + requested_download: transform.requested_download, + }) +} + +struct ImageTransformPlanRequest<'a> { + content_type: &'a str, + cache_identity: &'a str, + filename: &'a str, + route: TransformRoute, + source_format: Option, + transform: ValidatedTransformParameters, +} + +struct ImageTransformPlan<'a> { + format: OutputFormat, + quality: ImageQuality, + resize_mode: ResizeMode, + response_content_type: &'a str, + cache_key: String, +} + +fn plan_image_transform<'a>(request: ImageTransformPlanRequest<'a>) -> ImageTransformPlan<'a> { + let ImageTransformPlanRequest { + content_type, + cache_identity, + filename, + route, + source_format, + transform, + } = request; + let default_out_ext = match route { + TransformRoute::External => external_default_output_extension(filename, content_type), + TransformRoute::Attachment | TransformRoute::Stored | TransformRoute::Asset => { + image_extension_from_filename(filename).unwrap_or(AssetExtension::Webp) + } + }; + let requested_format = transform.explicit_format.unwrap_or(default_out_ext); + let requested_supported_format = OutputFormat::coerce_from_extension(requested_format); + let format = effective_animated_image_output_format( + source_format, + requested_supported_format, + transform.animated, + ); + let quality = transform.quality.unwrap_or_else(|| { + default_transform_quality( + format, + transform.animated, + transform_static_quality_default(source_format), + ) + }); + let resize_mode = if transform.wants_cover_crop() { + ResizeMode::Cover + } else { + ResizeMode::Fit + }; + ImageTransformPlan { + format, + quality, + resize_mode, + response_content_type: transform_response_content_type( + transform.explicit_format, + requested_format, + format, + content_type, + ), + cache_key: transform_cache_key(TransformCacheKeyInput { + route, + asset_kind: None, + cache_identity, + width: transform.width, + height: transform.height, + format, + quality: Some(quality), + animated: transform.animated, + effort: transform.effort, + resize_mode: Some(resize_mode), + }), + } +} + +pub(in crate::server) struct CachedTransformProbe<'a> { + pub(in crate::server) method: Method, + pub(in crate::server) content_type: &'a str, + pub(in crate::server) source_format: Option, + pub(in crate::server) cache_identity: &'a str, + pub(in crate::server) filename: &'a str, + pub(in crate::server) route: TransformRoute, + pub(in crate::server) params: &'a HashMap, + pub(in crate::server) headers: &'a HeaderMap, +} + +// Answers a repeat transform from the cache without the caller fetching the source +// bytes again, so a hot remote asset costs one upstream fetch per cache lifetime. +pub(in crate::server) fn cached_transform_response( + runtime: &TransformRuntime, + probe: CachedTransformProbe<'_>, +) -> Option { + let CachedTransformProbe { + method, + content_type, + source_format, + cache_identity, + filename, + route, + params, + headers, + } = probe; + let transform = validate_transform_parameters(params, &runtime.limits(), route).ok()?; + let source_is_svg = is_svg_content_type(content_type) + || image_extension_from_filename(filename) == Some(AssetExtension::Svg); + if !source_is_svg && !transform.has_transform_request { + return None; + } + let (cache_key, response_content_type) = match mime::category(content_type)? { + mime::Category::Image => { + let plan = plan_image_transform(ImageTransformPlanRequest { + content_type, + cache_identity, + filename, + route, + source_format, + transform, + }); + (plan.cache_key, plan.response_content_type.to_owned()) + } + mime::Category::Video => { + let format = OutputFormat::coerce_from_extension(transform.explicit_format?); + let quality = transform.quality.unwrap_or(ImageQuality::High); + let cache_key = transform_cache_key(TransformCacheKeyInput { + route, + asset_kind: None, + cache_identity, + width: transform.width, + height: transform.height, + format, + quality: Some(quality), + animated: transform.animated, + effort: None, + resize_mode: None, + }); + (cache_key, format.mime().to_owned()) + } + mime::Category::Audio => return None, + }; + let hit = cached_transform_hit(runtime, &cache_key)?; + Some(transformed_response(TransformedBytes { + method, + data: hit.data.as_bytes().clone(), + content_type: &response_content_type, + filename, + range_header: headers.get(header::RANGE).and_then(|v| v.to_str().ok()), + requested_download: transform.requested_download, + })) +} + +struct TransformedBytes<'a> { + method: Method, + data: Bytes, + content_type: &'a str, + filename: &'a str, + range_header: Option<&'a str>, + requested_download: bool, +} + +fn transformed_response(request: TransformedBytes<'_>) -> Response { + let disposition = Some(content_disposition_header( + request.content_type, + request.requested_download, + Some(request.filename), + )); + media_response(MediaResponse { + method: request.method, + data: request.data.into(), + content_type: request.content_type, + range_header: request.range_header, + disposition, + }) +} + +pub(in crate::server) fn original_image_content_type( + content_type: &str, + source_format: Option, +) -> String { + if content_type.is_empty() + || content_type.eq_ignore_ascii_case("application/octet-stream") + || extension_from_mime(content_type).is_none() + { + return source_format + .map(|ext| ext.mime().to_owned()) + .unwrap_or_else(|| content_type.to_owned()); + } + content_type.to_owned() +} diff --git a/fluxer_media_proxy/src/server/transform/parameters.rs b/fluxer_media_proxy/src/server/transform/parameters.rs new file mode 100644 index 000000000..e4b962d1a --- /dev/null +++ b/fluxer_media_proxy/src/server/transform/parameters.rs @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + constants::AssetExtension, + image_quality::ImageQuality, + image_transform::EncodeEffort, + media_limits::MediaLimits, + server::{ + params::{ + animated_param, bool_param, explicit_output_format, parse_effort, + parse_optional_dimension_param, + }, + response::error::text_with_reason, + }, +}; +use axum::{http::StatusCode, response::Response}; +use std::collections::HashMap; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransformRoute { + Attachment, + External, + Stored, + Asset, +} + +#[derive(Clone, Copy)] +pub(in crate::server) struct ValidatedTransformParameters { + pub(in crate::server) explicit_format: Option, + pub(in crate::server) width: Option, + pub(in crate::server) height: Option, + pub(in crate::server) quality: Option, + pub(in crate::server) animated: bool, + pub(in crate::server) effort: Option, + pub(in crate::server) requested_download: bool, + pub(in crate::server) has_transform_request: bool, +} + +#[derive(Clone, Copy, Debug)] +pub(in crate::server) enum TransformParameterError { + Format, + Width, + Height, +} + +pub(in crate::server) fn validate_transform_parameters( + params: &HashMap, + limits: &MediaLimits, + route: TransformRoute, +) -> Result { + let explicit_format = + explicit_output_format(params).map_err(|()| TransformParameterError::Format)?; + let width = parse_optional_dimension_param(params, "width", limits) + .map_err(|()| TransformParameterError::Width)?; + let height = parse_optional_dimension_param(params, "height", limits) + .map_err(|()| TransformParameterError::Height)?; + let animated = animated_param(params, false); + let effort = (route == TransformRoute::Attachment) + .then(|| parse_effort(params)) + .flatten(); + Ok(ValidatedTransformParameters { + explicit_format, + width, + height, + quality: params + .get("quality") + .map(|raw| ImageQuality::parse_lenient(raw)), + animated, + effort, + requested_download: bool_param(params, "download", false), + has_transform_request: params.contains_key("width") + || params.contains_key("height") + || params.contains_key("format") + || params.contains_key("quality") + || animated, + }) +} + +pub(in crate::server) fn transform_parameter_error_response( + error: TransformParameterError, +) -> Response { + let reason = match error { + TransformParameterError::Format => "transform_format_invalid", + TransformParameterError::Width => "transform_width_invalid", + TransformParameterError::Height => "transform_height_invalid", + }; + text_with_reason(StatusCode::BAD_REQUEST, "Bad Request", reason) +} + +impl ValidatedTransformParameters { + pub(in crate::server) fn wants_cover_crop(&self) -> bool { + self.width.is_some() && self.height.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() + } + + fn validate( + pairs: &[(&str, &str)], + route: TransformRoute, + ) -> Result { + validate_transform_parameters(¶ms(pairs), &MediaLimits::default_from_config(), route) + } + + #[test] + fn invalid_formats_and_dimensions_are_the_only_rejections() { + assert!(matches!( + validate(&[("format", "bogus")], TransformRoute::Attachment), + Err(TransformParameterError::Format) + )); + assert!(matches!( + validate(&[("width", "0")], TransformRoute::Attachment), + Err(TransformParameterError::Width) + )); + assert!(matches!( + validate(&[("height", "")], TransformRoute::Attachment), + Err(TransformParameterError::Height) + )); + let lenient = validate( + &[ + ("quality", "bogus"), + ("animated", "yes"), + ("download", "maybe"), + ("effort", "bogus"), + ], + TransformRoute::Attachment, + ) + .expect("every remaining parameter stays lenient"); + assert_eq!(Some(ImageQuality::High), lenient.quality); + assert!(!lenient.animated); + assert!(!lenient.requested_download); + assert_eq!(None, lenient.effort); + assert!(lenient.has_transform_request); + } + + #[test] + fn effort_is_honoured_only_on_the_attachment_route() { + let attachment = validate(&[("effort", "3")], TransformRoute::Attachment) + .expect("attachment effort parses"); + assert_eq!(Some(3), attachment.effort.map(EncodeEffort::get)); + assert!(!attachment.has_transform_request); + for route in [ + TransformRoute::External, + TransformRoute::Stored, + TransformRoute::Asset, + ] { + let other = validate(&[("effort", "3")], route).expect("effort is ignored elsewhere"); + assert_eq!(None, other.effort); + } + } + + #[test] + fn the_animatd_typo_is_no_longer_an_alias_for_animated() { + let aliased = validate(&[("animatd", "true")], TransformRoute::External) + .expect("an unknown parameter is ignored"); + assert!(!aliased.animated); + assert!(!aliased.has_transform_request); + let requested = validate(&[("animated", "true")], TransformRoute::External) + .expect("the animated parameter parses"); + assert!(requested.animated); + assert!(requested.has_transform_request); + } +} diff --git a/fluxer_media_proxy/src/server/transform/tests.rs b/fluxer_media_proxy/src/server/transform/tests.rs new file mode 100644 index 000000000..41b384d6c --- /dev/null +++ b/fluxer_media_proxy/src/server/transform/tests.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::*; +use crate::{ + constants::AssetExtension, + server::format_policy::{default_transform_quality, transform_static_quality_default}, + test_fixtures::{fixture_h264_mp4, fixture_jpeg, synthetic_png}, +}; + +const TRANSFORM_WIDTH: u32 = 32; + +fn test_runtime() -> TransformRuntime { + let cfg = Config::load_from_iter([( + "FLUXER_MEDIA_PROXY_SECRET_KEY".to_owned(), + "secret".to_owned(), + )]) + .expect("config loads"); + TransformRuntime::new(&cfg, &Arc::new(metrics::Metrics::new())) + .expect("test transform runtime limits are valid") +} + +fn attachment_cache_key( + filename: &str, + quality: ImageQuality, + resize_mode: Option, +) -> String { + transform_cache_key(TransformCacheKeyInput { + route: TransformRoute::Attachment, + asset_kind: None, + cache_identity: filename, + width: Some(TRANSFORM_WIDTH), + height: None, + format: OutputFormat::WebP, + quality: Some(quality), + animated: false, + effort: None, + resize_mode, + }) +} + +async fn transform_qualities_reaching_the_encoder( + fixture: Vec, + content_type: &str, + filename: &str, +) -> Vec { + let runtime = test_runtime(); + let params = HashMap::from([ + ("format".to_owned(), "webp".to_owned()), + ("width".to_owned(), TRANSFORM_WIDTH.to_string()), + ]); + let headers = HeaderMap::new(); + let response = serve_bytes_or_transform( + &runtime, + ServeBytesRequest { + method: Method::GET, + data: Bytes::from(fixture), + content_type: content_type.to_owned(), + cache_identity: filename, + filename, + route: TransformRoute::Attachment, + params: ¶ms, + headers: &headers, + }, + ) + .await; + assert_eq!( + StatusCode::OK, + response.status(), + "{filename} did not reach the transform path" + ); + [ + ImageQuality::Lossless, + ImageQuality::High, + ImageQuality::Auto, + ImageQuality::Low, + ] + .into_iter() + .filter(|quality| { + [None, Some(ResizeMode::Fit)] + .into_iter() + .any(|resize_mode| { + runtime + .cache() + .get(&attachment_cache_key(filename, *quality, resize_mode)) + .is_some() + }) + }) + .collect() +} + +#[test] +fn lossy_source_transform_defaults_to_lossy_output() { + for lossy in [ + AssetExtension::Jpeg, + AssetExtension::Heic, + AssetExtension::Heif, + ] { + assert_eq!( + ImageQuality::High, + default_transform_quality( + OutputFormat::WebP, + false, + transform_static_quality_default(Some(lossy)) + ), + "{} source must not be re-encoded losslessly", + lossy.name() + ); + } + for lossless in [ + AssetExtension::Png, + AssetExtension::Apng, + AssetExtension::Gif, + AssetExtension::Avif, + AssetExtension::Webp, + ] { + assert_eq!( + ImageQuality::Lossless, + default_transform_quality( + OutputFormat::WebP, + false, + transform_static_quality_default(Some(lossless)) + ), + "{} source must keep the lossless default", + lossless.name() + ); + } + assert_eq!( + ImageQuality::Lossless, + default_transform_quality( + OutputFormat::WebP, + false, + transform_static_quality_default(None) + ) + ); + assert_eq!( + ImageQuality::Auto, + default_transform_quality( + OutputFormat::WebP, + true, + transform_static_quality_default(Some(AssetExtension::Jpeg)) + ) + ); +} + +#[tokio::test] +async fn image_transform_route_sends_lossy_sources_to_a_lossy_encode() { + assert_eq!( + vec![ImageQuality::High], + transform_qualities_reaching_the_encoder(fixture_jpeg(), "image/jpeg", "photo.jpg").await, + "a jpeg attachment must not be re-encoded losslessly" + ); + assert_eq!( + vec![ImageQuality::Lossless], + transform_qualities_reaching_the_encoder(synthetic_png(48, 48), "image/png", "art.png") + .await, + "a png attachment must keep the lossless encode" + ); +} + +#[tokio::test] +async fn video_poster_transform_route_sends_decoded_frames_to_a_lossy_encode() { + assert_eq!( + vec![ImageQuality::High], + transform_qualities_reaching_the_encoder(fixture_h264_mp4(), "video/mp4", "clip.mp4").await, + "a decoded video frame is already lossy and must not be re-encoded losslessly" + ); +} + +#[tokio::test] +async fn a_redundant_format_parameter_shares_the_implicit_transform_cache_entry() { + const KEY: &str = "attachment:art.png|asset_kind=not-applicable|w=32|h=none|fmt=png|q=lossless|anim=false|effort=none|resize=fit"; + for params in [ + HashMap::from([("width".to_owned(), TRANSFORM_WIDTH.to_string())]), + HashMap::from([ + ("width".to_owned(), TRANSFORM_WIDTH.to_string()), + ("format".to_owned(), "png".to_owned()), + ]), + ] { + let runtime = test_runtime(); + let headers = HeaderMap::new(); + let response = serve_bytes_or_transform( + &runtime, + ServeBytesRequest { + method: Method::GET, + data: Bytes::from(synthetic_png(48, 48)), + content_type: "image/png".to_owned(), + cache_identity: "art.png", + filename: "art.png", + route: TransformRoute::Attachment, + params: ¶ms, + headers: &headers, + }, + ) + .await; + assert_eq!(StatusCode::OK, response.status()); + assert!( + runtime.cache().get(KEY).is_some(), + "naming the format the url already implies must not mint a second cache key" + ); + } +} diff --git a/fluxer_media_proxy/src/signing.rs b/fluxer_media_proxy/src/signing.rs index 9422ce12c..a30422be1 100644 --- a/fluxer_media_proxy/src/signing.rs +++ b/fluxer_media_proxy/src/signing.rs @@ -27,6 +27,10 @@ pub fn verify_signature(input: &str, provided: &str, secret: &[u8]) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::test_fixtures::ADVERSARIAL_TEXT_INPUTS; + use proptest::prelude::*; + + const HMAC_SHA256_BASE64URL_LEN: usize = 43; #[test] fn signature_matches_node_hmac_base64url_behavior() { @@ -80,4 +84,64 @@ mod tests { create_signature("input", b"secret") ); } + + #[test] + fn adversarial_text_signs_and_verifies_without_panicking() { + for input in ADVERSARIAL_TEXT_INPUTS { + let signature = create_signature(input, b"secret"); + assert_eq!(HMAC_SHA256_BASE64URL_LEN, signature.len()); + assert!(verify_signature(input, &signature, b"secret")); + assert!(!verify_signature(input, &signature, b"another-secret")); + assert!(!verify_signature(input, &signature, b"")); + } + } + + #[test] + fn an_empty_provided_signature_is_rejected_by_the_length_check() { + for input in ADVERSARIAL_TEXT_INPUTS { + assert!(!verify_signature(input, "", b"secret")); + assert!(!verify_signature(input, " ", b"secret")); + let truncated = &create_signature(input, b"secret")[..HMAC_SHA256_BASE64URL_LEN - 1]; + assert!(!verify_signature(input, truncated, b"secret")); + } + } + + #[test] + fn a_signature_from_another_adversarial_input_never_verifies() { + for input in ADVERSARIAL_TEXT_INPUTS { + let signature = create_signature(input, b"secret"); + for other in ADVERSARIAL_TEXT_INPUTS { + assert_eq!( + input == other, + verify_signature(other, &signature, b"secret"), + "{input:?} against {other:?}" + ); + } + } + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 256, + failure_persistence: None, + ..ProptestConfig::default() + })] + + #[test] + fn only_the_signed_input_and_its_own_secret_verify( + input in ".{0,96}", + other in ".{0,96}", + secret in prop::collection::vec(1u8..=u8::MAX, 0..64), + other_secret in prop::collection::vec(1u8..=u8::MAX, 0..64), + ) { + let signature = create_signature(&input, &secret); + prop_assert_eq!(HMAC_SHA256_BASE64URL_LEN, signature.len()); + prop_assert!(verify_signature(&input, &signature, &secret)); + prop_assert_eq!(input == other, verify_signature(&other, &signature, &secret)); + prop_assert_eq!( + secret == other_secret, + verify_signature(&input, &signature, &other_secret) + ); + } + } } diff --git a/fluxer_media_proxy/src/spool.rs b/fluxer_media_proxy/src/spool.rs index 854646102..b57ab9236 100644 --- a/fluxer_media_proxy/src/spool.rs +++ b/fluxer_media_proxy/src/spool.rs @@ -136,8 +136,40 @@ async fn spool_body_inner( #[cfg(test)] mod tests { use super::*; + use bytes::Bytes; + use http_body::Frame; + use http_body_util::StreamBody; use tokio::io::AsyncReadExt; + const SPOOL_RESERVATION_BYTES: u64 = 600 * 1024 * 1024; + const SPOOL_CEILING_BYTES: u64 = 1000 * 1024 * 1024; + + async fn budgeted_spool( + dir: &Path, + declared_length: Option, + ) -> Result { + spool_to_temp( + Body::from(vec![0u8; 16]), + declared_length, + SPOOL_RESERVATION_BYTES, + dir, + 64 * 1024, + SPOOL_CEILING_BYTES, + ) + .await + } + + fn body_with_trailers(chunks: &[&[u8]]) -> Body { + let mut frames: Vec, std::io::Error>> = chunks + .iter() + .map(|chunk| Ok(Frame::data(Bytes::copy_from_slice(chunk)))) + .collect(); + let mut trailers = http::HeaderMap::new(); + trailers.insert("x-checksum", http::HeaderValue::from_static("deadbeef")); + frames.push(Ok(Frame::trailers(trailers))); + Body::new(StreamBody::new(futures_util::stream::iter(frames))) + } + #[tokio::test] async fn spools_full_body_to_disk() { let payload = vec![0xABu8; 4096]; @@ -214,4 +246,96 @@ mod tests { .unwrap_err(); assert!(matches!(err, SpoolError::PayloadTooLarge)); } + + #[tokio::test] + async fn concurrent_spools_share_and_release_one_total_budget() { + let dir = tempfile::tempdir().unwrap(); + let held = budgeted_spool(dir.path(), None).await.unwrap(); + assert_eq!(held.len(), 16); + let contended = budgeted_spool(dir.path(), None).await.unwrap_err(); + assert!(matches!(contended, SpoolError::BudgetExhausted)); + + drop(held); + let failed = budgeted_spool(dir.path(), Some(SPOOL_RESERVATION_BYTES)) + .await + .unwrap_err(); + assert!(matches!(failed, SpoolError::PayloadShortRead)); + + let after_release = budgeted_spool(dir.path(), None).await.unwrap(); + assert_eq!(after_release.len(), 16); + } + + #[tokio::test] + async fn a_trailer_frame_carries_no_payload_towards_the_declared_length() { + let dir = tempfile::tempdir().unwrap(); + let short = spool_to_temp( + body_with_trailers(&[b"hello".as_slice()]), + Some(8), + 1 << 20, + dir.path(), + 1024, + 1 << 30, + ) + .await + .unwrap_err(); + assert!(matches!(short, SpoolError::PayloadShortRead)); + + let exact = spool_to_temp( + body_with_trailers(&[b"hel".as_slice(), b"".as_slice(), b"lo".as_slice()]), + Some(5), + 1 << 20, + dir.path(), + 1024, + 1 << 30, + ) + .await + .unwrap(); + let (mut file, len) = exact.into_parts(); + assert_eq!(len, 5); + let mut read_back = Vec::new(); + file.read_to_end(&mut read_back).await.unwrap(); + assert_eq!(read_back, b"hello".as_slice()); + } + + #[tokio::test] + async fn no_spool_file_is_ever_left_behind_in_the_spool_directory() { + let dir = tempfile::tempdir().unwrap(); + for (declared, max_body_bytes, payload) in [ + (Some(1024u64), 1 << 20, 2048usize), + (Some(2048), 1 << 20, 1024), + (None, 1024, 2048), + ] { + assert!( + spool_to_temp( + Body::from(vec![0u8; payload]), + declared, + max_body_bytes, + dir.path(), + 1024, + 1 << 30, + ) + .await + .is_err() + ); + assert!(spool_dir_is_empty(dir.path()).await); + } + let spooled = spool_to_temp( + Body::from(vec![0u8; 1024]), + Some(1024), + 1 << 20, + dir.path(), + 1024, + 1 << 30, + ) + .await + .unwrap(); + assert!(spool_dir_is_empty(dir.path()).await); + drop(spooled); + assert!(spool_dir_is_empty(dir.path()).await); + } + + async fn spool_dir_is_empty(dir: &Path) -> bool { + let mut entries = tokio::fs::read_dir(dir).await.unwrap(); + entries.next_entry().await.unwrap().is_none() + } } diff --git a/fluxer_media_proxy/src/srgb_profile.h b/fluxer_media_proxy/src/srgb_profile.h deleted file mode 100644 index daf2e0da0..000000000 --- a/fluxer_media_proxy/src/srgb_profile.h +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#pragma once - -#include -#include - -static const uint8_t srgb_profile[] = { 0x00, 0x00, 0x01, 0xE0, 0x6C, 0x63, 0x6D, 0x73, 0x04, 0x20, 0x00, 0x00, 0x6D, 0x6E, 0x74, 0x72, 0x52, 0x47, 0x42, 0x20, 0x58, 0x59, 0x5A, 0x20, 0x07, 0xE2, 0x00, 0x03, 0x00, 0x14, 0x00, 0x09, 0x00, 0x0E, 0x00, 0x1D, 0x61, 0x63, 0x73, 0x70, 0x4D, 0x53, 0x46, 0x54, 0x00, 0x00, 0x00, 0x00, 0x73, 0x61, 0x77, 0x73, 0x63, 0x74, 0x72, 0x6C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF6, 0xD6, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xD3, 0x2D, 0x68, 0x61, 0x6E, 0x64, 0x79, 0xE9, 0xBF, 0x56, 0x5A, 0x3E, 0x01, 0xB6, 0x83, 0x23, 0x85, 0x55, 0x46, 0xF7, 0x4F, 0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0A, 0x64, 0x65, 0x73, 0x63, 0x00, 0x00, 0x00, 0xFC, 0x00, 0x00, 0x00, 0x24, 0x63, 0x70, 0x72, 0x74, 0x00, 0x00, 0x01, 0x20, 0x00, 0x00, 0x00, 0x22, 0x77, 0x74, 0x70, 0x74, 0x00, 0x00, 0x01, 0x44, 0x00, 0x00, 0x00, 0x14, 0x63, 0x68, 0x61, 0x64, 0x00, 0x00, 0x01, 0x58, 0x00, 0x00, 0x00, 0x2C, 0x72, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0x84, 0x00, 0x00, 0x00, 0x14, 0x67, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0x98, 0x00, 0x00, 0x00, 0x14, 0x62, 0x58, 0x59, 0x5A, 0x00, 0x00, 0x01, 0xAC, 0x00, 0x00, 0x00, 0x14, 0x72, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x00, 0x20, 0x67, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x00, 0x20, 0x62, 0x54, 0x52, 0x43, 0x00, 0x00, 0x01, 0xC0, 0x00, 0x00, 0x00, 0x20, 0x6D, 0x6C, 0x75, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0C, 0x65, 0x6E, 0x55, 0x53, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x73, 0x00, 0x52, 0x00, 0x47, 0x00, 0x42, 0x6D, 0x6C, 0x75, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x0C, 0x65, 0x6E, 0x55, 0x53, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x1C, 0x00, 0x43, 0x00, 0x43, 0x00, 0x30, 0x00, 0x00, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF6, 0xD6, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0xD3, 0x2D, 0x73, 0x66, 0x33, 0x32, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x0C, 0x3F, 0x00, 0x00, 0x05, 0xDD, 0xFF, 0xFF, 0xF3, 0x26, 0x00, 0x00, 0x07, 0x90, 0x00, 0x00, 0xFD, 0x92, 0xFF, 0xFF, 0xFB, 0xA1, 0xFF, 0xFF, 0xFD, 0xA2, 0x00, 0x00, 0x03, 0xDC, 0x00, 0x00, 0xC0, 0x71, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6F, 0xA0, 0x00, 0x00, 0x38, 0xF2, 0x00, 0x00, 0x03, 0x8F, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x62, 0x96, 0x00, 0x00, 0xB7, 0x89, 0x00, 0x00, 0x18, 0xDA, 0x58, 0x59, 0x5A, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x24, 0xA0, 0x00, 0x00, 0x0F, 0x85, 0x00, 0x00, 0xB6, 0xC4, 0x70, 0x61, 0x72, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, 0x66, 0x69, 0x00, 0x00, 0xF2, 0xA7, 0x00, 0x00, 0x0D, 0x59, 0x00, 0x00, 0x13, 0xD0, 0x00, 0x00, 0x0A, 0x5B }; -static const size_t srgb_profile_size = sizeof(srgb_profile); diff --git a/fluxer_media_proxy/src/storage.rs b/fluxer_media_proxy/src/storage.rs deleted file mode 100644 index 5b0261f1c..000000000 --- a/fluxer_media_proxy/src/storage.rs +++ /dev/null @@ -1,1765 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use crate::{ - aws_sigv4, - config::{BucketStyle, Config, StorageBackend}, - constants, http_client, mime, -}; -use axum::body::Body; -use bytes::Bytes; -use http::{StatusCode, header}; -use http_body::{Frame, SizeHint}; -use percent_encoding::{AsciiSet, CONTROLS, percent_encode}; -use reqwest::Method; -use std::{ - path::{Path, PathBuf}, - pin::Pin, - task::{Context, Poll}, - time::Duration, -}; -use thiserror::Error; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, ReadBuf}; -use tokio_util::io::ReaderStream; - -const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; - -const PATH_ENCODE_SET: &AsciiSet = &percent_encoding::NON_ALPHANUMERIC - .remove(b'-') - .remove(b'.') - .remove(b'_') - .remove(b'~') - .remove(b'/'); - -const QUERY_ENCODE_SET: &AsciiSet = &CONTROLS - .add(b' ') - .add(b'"') - .add(b'#') - .add(b'%') - .add(b'&') - .add(b'+') - .add(b'<') - .add(b'=') - .add(b'>') - .add(b'?') - .add(b'`') - .add(b'{') - .add(b'}'); - -#[derive(Clone, Debug)] -pub struct Object { - pub data: Bytes, - pub content_type: String, -} - -pub struct StreamObject { - pub body: Body, - pub status: StatusCode, - pub content_length: Option, - pub content_type: String, - pub byte_range: Option, - pub total_length: Option, -} - -#[derive(Clone, Debug)] -pub struct HeadResult { - pub content_length: u64, - pub content_type: String, -} - -pub enum RelayBody { - Spooled(tokio::fs::File), - Streamed(tokio::sync::mpsc::Receiver>), -} - -pub struct RelayPutOptions { - pub body: RelayBody, - pub content_length: u64, - pub content_type: Option, - pub upload_id: Option, - pub part_number: Option, - pub timeout_ms: u64, -} - -#[derive(Debug, Error)] -pub enum StorageError { - #[error("object not found")] - NotFound, - #[error("invalid key")] - InvalidKey, - #[error("invalid bucket")] - InvalidBucket, - #[error("read-only storage")] - ReadOnlyStorage, - #[error("stream too long")] - StreamTooLong, - #[error("invalid S3 endpoint")] - InvalidS3Endpoint, - #[error("S3 request failed: {0}")] - S3(String), - #[error(transparent)] - Io(#[from] std::io::Error), - #[error(transparent)] - Http(#[from] reqwest::Error), - #[error(transparent)] - HttpMiddleware(#[from] reqwest_middleware::Error), - #[error(transparent)] - Sign(#[from] aws_sigv4::Error), -} - -#[derive(Clone)] -pub struct Store { - pub cfg: Config, - client: http_client::HttpClient, - raw_client: reqwest::Client, -} - -impl Store { - pub fn new(cfg: Config) -> Self { - Self { - cfg, - client: http_client::build_default(), - raw_client: http_client::build_raw_default(), - } - } - - pub fn try_new(cfg: Config) -> Result { - let options = http_client::Options { - connect_timeout_ms: cfg.socket_io_timeout_ms.max(1), - timeout_ms: cfg.socket_io_timeout_ms.max(1), - ..http_client::Options::default() - }; - let client = http_client::build(options)?; - let raw_client = http_client::build_raw(options)?; - Ok(Self { - cfg, - client, - raw_client, - }) - } - - pub async fn read_object(&self, bucket: &str, key: &str) -> Result { - let result = match self.cfg.storage_backend { - StorageBackend::Local => self.read_local(bucket, key).await, - StorageBackend::S3 => self.read_s3(bucket, key).await, - }; - match &result { - Ok(_) => crate::metrics::GLOBAL - .storage_hits - .fetch_add(1, std::sync::atomic::Ordering::Relaxed), - Err(StorageError::NotFound) => crate::metrics::GLOBAL - .storage_misses - .fetch_add(1, std::sync::atomic::Ordering::Relaxed), - Err(_) => crate::metrics::GLOBAL - .storage_errors - .fetch_add(1, std::sync::atomic::Ordering::Relaxed), - }; - result - } - - pub async fn head_object(&self, bucket: &str, key: &str) -> Result { - match self.cfg.storage_backend { - StorageBackend::Local => self.head_local(bucket, key).await, - StorageBackend::S3 => self.head_s3(bucket, key).await, - } - } - - pub async fn stream_object( - &self, - bucket: &str, - key: &str, - range_header: Option<&str>, - ) -> Result { - let result = match self.cfg.storage_backend { - StorageBackend::Local => self.stream_local(bucket, key, range_header).await, - StorageBackend::S3 => self.stream_s3(bucket, key, range_header).await, - }; - match &result { - Ok(_) => crate::metrics::GLOBAL - .storage_hits - .fetch_add(1, std::sync::atomic::Ordering::Relaxed), - Err(StorageError::NotFound) => crate::metrics::GLOBAL - .storage_misses - .fetch_add(1, std::sync::atomic::Ordering::Relaxed), - Err(_) => crate::metrics::GLOBAL - .storage_errors - .fetch_add(1, std::sync::atomic::Ordering::Relaxed), - }; - result - } - - pub async fn write_object( - &self, - bucket: &str, - key: &str, - data: &[u8], - content_type: &str, - ) -> Result<(), StorageError> { - if self.cfg.read_only { - return Err(StorageError::ReadOnlyStorage); - } - match self.cfg.storage_backend { - StorageBackend::Local => self.write_local(bucket, key, data).await, - StorageBackend::S3 => self.write_s3(bucket, key, data, content_type).await, - } - } - - pub async fn ensure_bucket(&self, bucket: &str) -> Result<(), StorageError> { - safe_bucket(bucket)?; - match self.cfg.storage_backend { - StorageBackend::Local => { - tokio::fs::create_dir_all(Path::new(&self.cfg.storage_root).join(bucket)).await?; - Ok(()) - } - StorageBackend::S3 => { - let url = self.s3_bucket_url(bucket)?; - let signed = self.sign(Method::PUT, &url, &[], None, &[])?; - let response = self - .client - .put(&url) - .headers(signed_headers(&signed, &self.cfg)) - .send() - .await?; - if response.status().is_success() - || response.status() == reqwest::StatusCode::CONFLICT - { - Ok(()) - } else { - Err(StorageError::S3(response.status().to_string())) - } - } - } - } - - pub async fn relay_put_object( - &self, - bucket: &str, - key: &str, - options: RelayPutOptions, - ) -> Result, StorageError> { - if self.cfg.read_only { - return Err(StorageError::ReadOnlyStorage); - } - match self.cfg.storage_backend { - StorageBackend::Local => { - self.write_local_relay(bucket, key, options).await?; - Ok(None) - } - StorageBackend::S3 => self.relay_put_s3(bucket, key, options).await, - } - } - - async fn write_local_relay( - &self, - bucket: &str, - key: &str, - options: RelayPutOptions, - ) -> Result<(), StorageError> { - let path = self.local_path(bucket, key)?; - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let mut dest = tokio::fs::File::create(&path).await?; - let result = async { - match options.body { - RelayBody::Spooled(mut source) => { - source.seek(std::io::SeekFrom::Start(0)).await?; - tokio::io::copy(&mut source, &mut dest).await?; - } - RelayBody::Streamed(mut rx) => { - while let Some(chunk) = rx.recv().await { - dest.write_all(&chunk?).await?; - } - } - } - dest.flush().await?; - Ok(()) - } - .await; - if result.is_err() { - let _ = tokio::fs::remove_file(&path).await; - } - result - } - - fn local_path(&self, bucket: &str, key: &str) -> Result { - safe_bucket(bucket)?; - safe_key(key)?; - let root = Path::new(&self.cfg.storage_root); - let path = root.join(bucket).join(key); - reject_symlink_chain(&path)?; - Ok(path) - } - - async fn read_local(&self, bucket: &str, key: &str) -> Result { - let path = self.local_path(bucket, key)?; - let data = tokio::fs::read(&path).await.map_err(map_not_found)?; - if data.len() > constants::MAX_MEDIA_PROXY_BYTES { - return Err(StorageError::StreamTooLong); - } - let content_type = mime::detect(&data[..data.len().min(8192)], key, None); - Ok(Object { - data: Bytes::from(data), - content_type, - }) - } - - async fn head_local(&self, bucket: &str, key: &str) -> Result { - let path = self.local_path(bucket, key)?; - let meta = tokio::fs::metadata(&path).await.map_err(map_not_found)?; - if !meta.is_file() { - return Err(StorageError::NotFound); - } - if meta.len() > constants::MAX_MEDIA_PROXY_BYTES as u64 { - return Err(StorageError::StreamTooLong); - } - Ok(HeadResult { - content_length: meta.len(), - content_type: mime::extension_mime(key) - .unwrap_or("application/octet-stream") - .to_owned(), - }) - } - - async fn stream_local( - &self, - bucket: &str, - key: &str, - range_header: Option<&str>, - ) -> Result { - let path = self.local_path(bucket, key)?; - let meta = tokio::fs::metadata(&path).await.map_err(map_not_found)?; - if !meta.is_file() { - return Err(StorageError::NotFound); - } - if meta.len() > constants::MAX_MEDIA_PROXY_BYTES as u64 { - return Err(StorageError::StreamTooLong); - } - let total_len = meta.len() as usize; - let parsed_range = crate::range::parse_range(range_header, total_len); - let content_type = mime::extension_mime(key) - .unwrap_or("application/octet-stream") - .to_owned(); - if parsed_range.unsatisfiable { - return Ok(StreamObject { - content_length: Some(0), - body: Body::empty(), - status: StatusCode::RANGE_NOT_SATISFIABLE, - content_type, - byte_range: None, - total_length: Some(meta.len()), - }); - } - let (status, body_len, start) = if let Some(r) = parsed_range.range { - ( - StatusCode::PARTIAL_CONTENT, - (r.end - r.start + 1) as u64, - r.start as u64, - ) - } else { - (StatusCode::OK, total_len as u64, 0) - }; - let mut file = tokio::fs::File::open(&path).await.map_err(map_not_found)?; - if start > 0 { - file.seek(std::io::SeekFrom::Start(start)).await?; - } - let reader = file.take(body_len); - Ok(StreamObject { - content_length: Some(body_len), - body: Body::from_stream(ReaderStream::new(reader)), - status, - content_type, - byte_range: parsed_range.range, - total_length: Some(meta.len()), - }) - } - - async fn write_local(&self, bucket: &str, key: &str, data: &[u8]) -> Result<(), StorageError> { - let path = self.local_path(bucket, key)?; - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(path, data).await?; - Ok(()) - } - - fn s3_url(&self, bucket: &str, key: &str) -> Result { - safe_bucket(bucket)?; - safe_key(key)?; - object_url( - &self.cfg.s3_endpoint, - write_bucket_style(&self.cfg), - bucket, - key, - ) - } - - fn read_endpoint_for(&self, bucket: &str) -> Option<&str> { - self.cfg - .s3_read_endpoint - .as_deref() - .filter(|_| bucket == self.cfg.s3_read_bucket) - } - - fn s3_read_url(&self, bucket: &str, key: &str) -> Result { - let Some(endpoint) = self.read_endpoint_for(bucket) else { - return self.s3_url(bucket, key); - }; - safe_bucket(bucket)?; - safe_key(key)?; - object_url(endpoint, self.cfg.s3_read_bucket_style, bucket, key) - } - - fn read_status_is_miss(&self, bucket: &str, status: reqwest::StatusCode) -> bool { - if status == reqwest::StatusCode::NOT_FOUND { - return true; - } - status == reqwest::StatusCode::FORBIDDEN - && self.read_endpoint_for(bucket).is_some() - && !self.cfg.s3_read_signed - } - - fn read_headers( - &self, - bucket: &str, - method: Method, - url: &str, - extra_signed_headers: &[aws_sigv4::Header<'_>], - ) -> Result { - if self.read_endpoint_for(bucket).is_some() && !self.cfg.s3_read_signed { - return Ok(reqwest::header::HeaderMap::new()); - } - let signed = self.sign(method, url, &[], None, extra_signed_headers)?; - Ok(signed_headers(&signed, &self.cfg)) - } - - fn s3_bucket_url(&self, bucket: &str) -> Result { - safe_bucket(bucket)?; - if self.cfg.s3_endpoint.is_empty() { - return Err(StorageError::InvalidS3Endpoint); - } - let endpoint = self.cfg.s3_endpoint.trim_end_matches('/'); - if self.cfg.s3_force_path_style { - return Ok(format!("{endpoint}/{bucket}")); - } - validate_virtual_hosted_bucket(bucket)?; - let parsed = url::Url::parse(endpoint).map_err(|_| StorageError::InvalidS3Endpoint)?; - let scheme = parsed.scheme(); - let host = parsed.host_str().ok_or(StorageError::InvalidS3Endpoint)?; - let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); - let base_path = parsed.path().trim_end_matches('/'); - Ok(format!("{scheme}://{bucket}.{host}{port}{base_path}")) - } - - async fn read_s3(&self, bucket: &str, key: &str) -> Result { - let url = self.s3_read_url(bucket, key)?; - let headers = self.read_headers(bucket, Method::GET, &url, &[])?; - let response = self.client.get(&url).headers(headers).send().await?; - if self.read_status_is_miss(bucket, response.status()) { - return Err(StorageError::NotFound); - } - if !response.status().is_success() { - return Err(StorageError::S3(s3_error_summary(response).await)); - } - let content_length: Option = response - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()); - if let Some(content_length) = content_length - && content_length > constants::MAX_MEDIA_PROXY_BYTES as u64 - { - return Err(StorageError::StreamTooLong); - } - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_owned(); - let data = response.bytes().await?; - if data.len() > constants::MAX_MEDIA_PROXY_BYTES { - return Err(StorageError::StreamTooLong); - } - Ok(Object { data, content_type }) - } - - async fn head_s3(&self, bucket: &str, key: &str) -> Result { - let url = self.s3_read_url(bucket, key)?; - let headers = self.read_headers(bucket, Method::HEAD, &url, &[])?; - let response = self.client.head(&url).headers(headers).send().await?; - if self.read_status_is_miss(bucket, response.status()) { - return Err(StorageError::NotFound); - } - if !response.status().is_success() { - return Err(StorageError::S3(s3_error_summary(response).await)); - } - let content_length: u64 = response - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_owned(); - Ok(HeadResult { - content_length, - content_type, - }) - } - - async fn stream_s3( - &self, - bucket: &str, - key: &str, - range_header: Option<&str>, - ) -> Result { - let url = self.s3_read_url(bucket, key)?; - let range_extra = range_header.map(|value| aws_sigv4::Header { - name: "Range", - value, - }); - let extra = range_extra.as_slice(); - let mut headers = self.read_headers(bucket, Method::GET, &url, extra)?; - if let Some(range_value) = range_header { - headers.insert( - header::RANGE, - range_value - .parse() - .map_err(|_| StorageError::S3("invalid Range header".to_owned()))?, - ); - } - let response = self.client.get(&url).headers(headers).send().await?; - if self.read_status_is_miss(bucket, response.status()) { - return Err(StorageError::NotFound); - } - let status = response.status(); - let content_range = response - .headers() - .get(header::CONTENT_RANGE) - .and_then(|v| v.to_str().ok()) - .map(ToOwned::to_owned); - if status == StatusCode::RANGE_NOT_SATISFIABLE { - return Ok(StreamObject { - body: Body::empty(), - status, - content_length: Some(0), - content_type: String::new(), - byte_range: None, - total_length: crate::range::parse_unsatisfiable_content_range( - content_range.as_deref(), - ) - .map(|total| total as u64), - }); - } - if !status.is_success() { - return Err(StorageError::S3(s3_error_summary(response).await)); - } - let content_length: Option = response - .headers() - .get(header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse().ok()); - if let Some(content_length) = content_length - && range_header.is_none() - && content_length > constants::MAX_MEDIA_PROXY_BYTES as u64 - { - return Err(StorageError::StreamTooLong); - } - let (byte_range, total_length) = if status == StatusCode::PARTIAL_CONTENT { - let Some(parsed) = crate::range::parse_content_range(content_range.as_deref()) else { - return Err(StorageError::S3( - "partial response without a usable Content-Range".to_owned(), - )); - }; - ( - Some(crate::range::ByteRange { - start: parsed.start, - end: parsed.end, - }), - parsed.size.map(|size| size as u64), - ) - } else { - (None, content_length) - }; - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_owned(); - Ok(StreamObject { - body: Body::from_stream(response.bytes_stream()), - status, - content_length, - content_type, - byte_range, - total_length, - }) - } - - async fn write_s3( - &self, - bucket: &str, - key: &str, - data: &[u8], - content_type: &str, - ) -> Result<(), StorageError> { - let url = self.s3_url(bucket, key)?; - let extra = [aws_sigv4::Header { - name: "Content-Type", - value: content_type, - }]; - let signed = self.sign(Method::PUT, &url, data, None, &extra)?; - let mut headers = signed_headers(&signed, &self.cfg); - headers.insert( - header::CONTENT_TYPE, - content_type - .parse() - .unwrap_or_else(|_| "application/octet-stream".parse().unwrap()), - ); - let response = self - .client - .put(&url) - .headers(headers) - .body(data.to_vec()) - .send() - .await?; - if !response.status().is_success() { - return Err(StorageError::S3(response.status().to_string())); - } - Ok(()) - } - - async fn relay_put_s3( - &self, - bucket: &str, - key: &str, - options: RelayPutOptions, - ) -> Result, StorageError> { - let mut url = self.s3_url(bucket, key)?; - if let (Some(upload_id), Some(part_number)) = (&options.upload_id, options.part_number) { - url.push_str(if url.contains('?') { "&" } else { "?" }); - url.push_str("partNumber="); - url.push_str( - &percent_encode(part_number.to_string().as_bytes(), QUERY_ENCODE_SET).to_string(), - ); - url.push_str("&uploadId="); - url.push_str(&percent_encode(upload_id.as_bytes(), QUERY_ENCODE_SET).to_string()); - } - let content_type = options - .content_type - .as_deref() - .unwrap_or("application/octet-stream"); - let extra = [aws_sigv4::Header { - name: "Content-Type", - value: content_type, - }]; - let signed = self.sign(Method::PUT, &url, &[], Some(UNSIGNED_PAYLOAD), &extra)?; - let mut headers = signed_headers(&signed, &self.cfg); - headers.insert( - header::CONTENT_TYPE, - content_type - .parse() - .unwrap_or_else(|_| "application/octet-stream".parse().unwrap()), - ); - headers.insert( - header::CONTENT_LENGTH, - header::HeaderValue::from(options.content_length), - ); - let body = match options.body { - RelayBody::Spooled(mut file) => { - file.seek(std::io::SeekFrom::Start(0)).await?; - reqwest::Body::wrap(SizedFileBody::new(file, options.content_length)) - } - RelayBody::Streamed(rx) => { - reqwest::Body::wrap(ChannelBody::new(rx, options.content_length)) - } - }; - let response = self - .raw_client - .put(&url) - .headers(headers) - .timeout(Duration::from_millis(options.timeout_ms.max(1))) - .body(body) - .send() - .await?; - if !response.status().is_success() { - return Err(StorageError::S3(s3_error_summary(response).await)); - } - Ok(response - .headers() - .get(header::ETAG) - .and_then(|v| v.to_str().ok()) - .map(ToOwned::to_owned)) - } - - fn sign( - &self, - method: Method, - url: &str, - payload: &[u8], - payload_hash_override: Option<&str>, - extra_signed_headers: &[aws_sigv4::Header<'_>], - ) -> Result { - let mut options = aws_sigv4::Options::new( - method.as_str(), - url, - &self.cfg.s3_region, - &self.cfg.s3_access_key_id, - &self.cfg.s3_secret_access_key, - ); - options.payload = payload; - options.payload_hash_override = payload_hash_override; - options.extra_signed_headers = extra_signed_headers; - options.session_token = &self.cfg.s3_session_token; - Ok(aws_sigv4::sign(options)?) - } -} - -struct SizedFileBody { - file: tokio::fs::File, - remaining: u64, -} - -impl SizedFileBody { - fn new(file: tokio::fs::File, len: u64) -> Self { - Self { - file, - remaining: len, - } - } -} - -impl http_body::Body for SizedFileBody { - type Data = Bytes; - type Error = std::io::Error; - - fn poll_frame( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll, Self::Error>>> { - if self.remaining == 0 { - return Poll::Ready(None); - } - let chunk_len = self.remaining.min(256 * 1024) as usize; - let mut buffer = vec![0u8; chunk_len]; - let read = { - let mut read_buf = ReadBuf::new(&mut buffer); - match Pin::new(&mut self.file).poll_read(cx, &mut read_buf) { - Poll::Ready(Ok(())) => read_buf.filled().len(), - Poll::Ready(Err(err)) => return Poll::Ready(Some(Err(err))), - Poll::Pending => return Poll::Pending, - } - }; - if read == 0 { - return Poll::Ready(Some(Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "upload relay body ended before declared content length", - )))); - } - buffer.truncate(read); - self.remaining = self.remaining.saturating_sub(read as u64); - Poll::Ready(Some(Ok(Frame::data(Bytes::from(buffer))))) - } - - fn size_hint(&self) -> SizeHint { - SizeHint::with_exact(self.remaining) - } -} - -struct ChannelBody { - rx: tokio::sync::mpsc::Receiver>, - remaining: u64, -} - -impl ChannelBody { - fn new(rx: tokio::sync::mpsc::Receiver>, len: u64) -> Self { - Self { rx, remaining: len } - } -} - -impl http_body::Body for ChannelBody { - type Data = Bytes; - type Error = std::io::Error; - - fn poll_frame( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll, Self::Error>>> { - if self.remaining == 0 { - return Poll::Ready(None); - } - match self.rx.poll_recv(cx) { - Poll::Ready(Some(Ok(chunk))) => { - if chunk.len() as u64 > self.remaining { - return Poll::Ready(Some(Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "upload relay body exceeded declared content length", - )))); - } - self.remaining -= chunk.len() as u64; - Poll::Ready(Some(Ok(Frame::data(chunk)))) - } - Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))), - Poll::Ready(None) => Poll::Ready(Some(Err(std::io::Error::new( - std::io::ErrorKind::UnexpectedEof, - "upload relay body ended before declared content length", - )))), - Poll::Pending => Poll::Pending, - } - } - - fn size_hint(&self) -> SizeHint { - SizeHint::with_exact(self.remaining) - } -} - -fn write_bucket_style(cfg: &Config) -> BucketStyle { - if cfg.s3_force_path_style { - BucketStyle::Path - } else { - BucketStyle::VirtualHosted - } -} - -fn object_url( - endpoint: &str, - style: BucketStyle, - bucket: &str, - key: &str, -) -> Result { - if endpoint.is_empty() { - return Err(StorageError::InvalidS3Endpoint); - } - let endpoint = endpoint.trim_end_matches('/'); - let encoded_key = percent_encode(key.as_bytes(), PATH_ENCODE_SET).to_string(); - match style { - BucketStyle::Path => Ok(format!("{endpoint}/{bucket}/{encoded_key}")), - BucketStyle::Rooted => Ok(format!("{endpoint}/{encoded_key}")), - BucketStyle::VirtualHosted => { - validate_virtual_hosted_bucket(bucket)?; - let parsed = url::Url::parse(endpoint).map_err(|_| StorageError::InvalidS3Endpoint)?; - let scheme = parsed.scheme(); - let host = parsed.host_str().ok_or(StorageError::InvalidS3Endpoint)?; - let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); - let base_path = parsed.path().trim_end_matches('/'); - Ok(format!( - "{scheme}://{bucket}.{host}{port}{base_path}/{encoded_key}" - )) - } - } -} - -fn signed_headers(signed: &aws_sigv4::SignedRequest, cfg: &Config) -> reqwest::header::HeaderMap { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert( - header::HOST, - signed.host.parse().expect("signed host is a valid header"), - ); - headers.insert( - "x-amz-content-sha256", - signed.payload_hash.parse().expect("payload hash is ASCII"), - ); - headers.insert( - "x-amz-date", - signed.amz_date.parse().expect("date is ASCII"), - ); - headers.insert( - header::AUTHORIZATION, - signed - .authorization - .parse() - .expect("authorization is ASCII"), - ); - if !cfg.s3_session_token.is_empty() { - headers.insert( - "x-amz-security-token", - cfg.s3_session_token - .parse() - .expect("session token is ASCII"), - ); - } - headers -} - -async fn s3_error_summary(response: reqwest::Response) -> String { - let status = response.status(); - let body = response - .bytes() - .await - .map(|b| b.to_vec()) - .unwrap_or_default(); - let snippet: String = String::from_utf8_lossy(&body) - .chars() - .filter(|c| !c.is_control() || *c == ' ') - .take(512) - .collect(); - if snippet.is_empty() { - status.to_string() - } else { - format!("{status}: {snippet}") - } -} - -fn map_not_found(err: std::io::Error) -> StorageError { - if err.kind() == std::io::ErrorKind::NotFound { - StorageError::NotFound - } else { - StorageError::Io(err) - } -} - -fn safe_bucket(bucket: &str) -> Result<(), StorageError> { - if bucket.is_empty() || bucket.contains('/') || bucket == "." || bucket == ".." { - return Err(StorageError::InvalidBucket); - } - Ok(()) -} - -pub fn safe_key(key: &str) -> Result<(), StorageError> { - if key.is_empty() || key.starts_with('/') { - return Err(StorageError::InvalidKey); - } - for part in key.split('/') { - if part.is_empty() || part == "." || part == ".." { - return Err(StorageError::InvalidKey); - } - } - Ok(()) -} - -fn validate_virtual_hosted_bucket(bucket: &str) -> Result<(), StorageError> { - if bucket.len() < 3 || bucket.len() > 63 { - return Err(StorageError::InvalidBucket); - } - if !bucket - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'.') - { - return Err(StorageError::InvalidBucket); - } - Ok(()) -} - -fn reject_symlink_chain(path: &Path) -> Result<(), StorageError> { - let mut cur = PathBuf::new(); - for component in path.components() { - cur.push(component.as_os_str()); - match std::fs::symlink_metadata(&cur) { - Ok(meta) if meta.file_type().is_symlink() => return Err(StorageError::InvalidKey), - Ok(_) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(StorageError::Io(err)), - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::{BucketStyle, DeploymentMode, StorageBackend}; - - fn test_config(root: &Path) -> Config { - Config { - node_env: "test".to_owned(), - bind_host: "127.0.0.1".to_owned(), - port: 0, - secret_key: "secret".to_owned(), - mode: DeploymentMode::Mp, - read_only: false, - storage_backend: StorageBackend::Local, - storage_root: root.display().to_string(), - s3_endpoint: String::new(), - s3_region: "us-east-1".to_owned(), - s3_access_key_id: String::new(), - s3_secret_access_key: String::new(), - s3_session_token: String::new(), - s3_force_path_style: true, - s3_read_endpoint: None, - s3_read_bucket: "cdn".to_owned(), - s3_read_bucket_style: BucketStyle::Path, - s3_read_signed: false, - bucket_cdn: "cdn".to_owned(), - bucket_uploads: "uploads".to_owned(), - bucket_static: "static".to_owned(), - upload_relay_secret: Vec::new(), - upload_relay_max_body_bytes: 1024, - upload_relay_token_ttl_secs: 3600, - upload_relay_s3_timeout_ms: 1000, - upload_relay_buffered_retry_max_bytes: 0, - upload_relay_buffered_retry_total_bytes: 0, - upload_relay_spool_dir: std::env::temp_dir(), - upload_relay_spool_chunk_bytes: 64 * 1024, - upload_relay_spool_max_total_bytes: 1 << 30, - max_native_transforms: 2, - worker_queue_capacity: 16, - nsfw_service_endpoint: String::new(), - nsfw_threshold: 0.85, - transform_cache_capacity_bytes: 0, - transform_cache_max_entry_bytes: 0, - transform_cache_ttl_ms: 0, - shutdown_grace_ms: 0, - socket_io_timeout_ms: 0, - transform_timeout_ms: 1000, - max_encode_frames: constants::MAX_ANIMATED_FRAMES_DEFAULT, - max_encode_duration_ms: 30_000, - bunny_ip_gate_enabled: false, - bunny_ip_gate_trusted_proxies: Vec::new(), - bunny_ip_gate_refresh_secs: 3_600, - } - } - - #[tokio::test] - async fn local_write_read_head_roundtrip() { - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(test_config(&tmp.path().canonicalize().unwrap())); - store - .write_object("cdn", "a/b.txt", b"hello", "text/plain") - .await - .unwrap(); - let head = store.head_object("cdn", "a/b.txt").await.unwrap(); - assert_eq!(5, head.content_length); - let object = store.read_object("cdn", "a/b.txt").await.unwrap(); - assert_eq!(b"hello", &object.data[..]); - } - - #[tokio::test] - async fn local_stream_honors_range_without_buffered_read() { - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(test_config(&tmp.path().canonicalize().unwrap())); - store - .write_object("cdn", "a/b.txt", b"hello world", "text/plain") - .await - .unwrap(); - let object = store - .stream_object("cdn", "a/b.txt", Some("bytes=6-10")) - .await - .unwrap(); - assert_eq!(StatusCode::PARTIAL_CONTENT, object.status); - assert_eq!(Some(5), object.content_length); - let body = axum::body::to_bytes(object.body, 16).await.unwrap(); - assert_eq!(b"world", &body[..]); - } - - #[test] - fn safe_key_rejects_traversal() { - assert!(safe_key("a/b").is_ok()); - assert!(safe_key("../x").is_err()); - assert!(safe_key("a//b").is_err()); - assert!(safe_key("/a").is_err()); - } - - #[tokio::test] - async fn relay_put_s3_streams_body_with_unsigned_payload() { - type CapturedRequest = (http::Uri, http::HeaderMap, Bytes); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let captured: std::sync::Arc>> = - std::sync::Arc::new(tokio::sync::Mutex::new(None)); - let captured_handler = std::sync::Arc::clone(&captured); - let app = axum::Router::new().fallback(axum::routing::any( - move |request: axum::extract::Request| { - let captured = std::sync::Arc::clone(&captured_handler); - async move { - let (parts, body) = request.into_parts(); - let bytes = axum::body::to_bytes(body, 1 << 20).await.unwrap(); - *captured.lock().await = Some((parts.uri, parts.headers, bytes)); - ([(header::ETAG, "\"etag-123\"")], "") - } - }, - )); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = test_config(tmp.path()); - cfg.storage_backend = StorageBackend::S3; - cfg.s3_endpoint = format!("http://{addr}"); - cfg.s3_access_key_id = "AKIAIOSFODNN7EXAMPLE".to_owned(); - cfg.s3_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_owned(); - let store = Store::new(cfg); - - let (tx, rx) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - tx.send(Ok(Bytes::from_static(b"hello "))).await.unwrap(); - tx.send(Ok(Bytes::from_static(b"world"))).await.unwrap(); - }); - let etag = store - .relay_put_object( - "uploads", - "guild/streamed.bin", - RelayPutOptions { - body: RelayBody::Streamed(rx), - content_length: 11, - content_type: Some("application/octet-stream".to_owned()), - upload_id: Some("upload-1".to_owned()), - part_number: Some(2), - timeout_ms: 5_000, - }, - ) - .await - .unwrap(); - - assert_eq!(Some("\"etag-123\"".to_owned()), etag); - let (uri, headers, body) = captured.lock().await.take().unwrap(); - assert_eq!("/uploads/guild/streamed.bin", uri.path()); - assert_eq!(Some("partNumber=2&uploadId=upload-1"), uri.query()); - assert_eq!( - UNSIGNED_PAYLOAD, - headers.get("x-amz-content-sha256").unwrap() - ); - assert_eq!("11", headers.get(header::CONTENT_LENGTH).unwrap()); - assert!( - headers - .get(header::AUTHORIZATION) - .unwrap() - .to_str() - .unwrap() - .starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") - ); - assert_eq!(b"hello world", body.as_ref()); - } - - #[tokio::test] - async fn relay_put_s3_fails_when_stream_ends_short() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let app = axum::Router::new().fallback(axum::routing::any( - move |request: axum::extract::Request| async move { - let _ = axum::body::to_bytes(request.into_body(), 1 << 20).await; - "" - }, - )); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = test_config(tmp.path()); - cfg.storage_backend = StorageBackend::S3; - cfg.s3_endpoint = format!("http://{addr}"); - cfg.s3_access_key_id = "AKIAIOSFODNN7EXAMPLE".to_owned(); - cfg.s3_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_owned(); - let store = Store::new(cfg); - - let (tx, rx) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - tx.send(Ok(Bytes::from_static(b"only"))).await.unwrap(); - }); - let result = store - .relay_put_object( - "uploads", - "guild/short.bin", - RelayPutOptions { - body: RelayBody::Streamed(rx), - content_length: 32, - content_type: None, - upload_id: None, - part_number: None, - timeout_ms: 5_000, - }, - ) - .await; - assert!(result.is_err()); - } - - type CapturedRequests = - std::sync::Arc>>; - - async fn capture_server() -> (String, CapturedRequests) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let captured: CapturedRequests = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())); - let handler = std::sync::Arc::clone(&captured); - let app = axum::Router::new().fallback(axum::routing::any( - move |request: axum::extract::Request| { - let captured = std::sync::Arc::clone(&handler); - async move { - let (parts, _body) = request.into_parts(); - captured - .lock() - .await - .push((parts.method, parts.uri, parts.headers)); - ([(header::CONTENT_TYPE, "image/png")], "payload") - } - }, - )); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - (format!("http://{addr}"), captured) - } - - async fn status_server(status: u16) -> (String, CapturedRequests) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let captured: CapturedRequests = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())); - let handler = std::sync::Arc::clone(&captured); - let app = axum::Router::new().fallback(axum::routing::any( - move |request: axum::extract::Request| { - let captured = std::sync::Arc::clone(&handler); - async move { - let (parts, _body) = request.into_parts(); - captured - .lock() - .await - .push((parts.method, parts.uri, parts.headers)); - (StatusCode::from_u16(status).unwrap(), "denied") - } - }, - )); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - (format!("http://{addr}"), captured) - } - - fn s3_test_config(root: &Path, s3_endpoint: &str) -> Config { - let mut cfg = test_config(root); - cfg.storage_backend = StorageBackend::S3; - cfg.s3_endpoint = s3_endpoint.to_owned(); - cfg.s3_access_key_id = "AKIAIOSFODNN7EXAMPLE".to_owned(); - cfg.s3_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_owned(); - cfg - } - - async fn only_request( - captured: &CapturedRequests, - ) -> (http::Method, http::Uri, http::HeaderMap) { - let mut guard = captured.lock().await; - assert_eq!(1, guard.len(), "expected exactly one captured request"); - guard.remove(0) - } - - fn assert_unsigned(headers: &http::HeaderMap) { - assert!(headers.get(header::AUTHORIZATION).is_none()); - assert!(headers.get("x-amz-date").is_none()); - assert!(headers.get("x-amz-content-sha256").is_none()); - } - - fn assert_signed(headers: &http::HeaderMap) { - assert!( - headers - .get(header::AUTHORIZATION) - .unwrap() - .to_str() - .unwrap() - .starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") - ); - assert!(headers.get("x-amz-date").is_some()); - assert!(headers.get("x-amz-content-sha256").is_some()); - } - - #[tokio::test] - async fn read_without_read_endpoint_hits_s3_endpoint_signed() { - let (s3, s3_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(s3_test_config(tmp.path(), &s3)); - - let object = store - .read_object("cdn", "attachments/1/2/a.png") - .await - .unwrap(); - - assert_eq!("payload", object.data); - let (method, uri, headers) = only_request(&s3_seen).await; - assert_eq!(http::Method::GET, method); - assert_eq!("/cdn/attachments/1/2/a.png", uri.path()); - assert_signed(&headers); - } - - #[tokio::test] - async fn read_endpoint_routes_fronted_bucket_to_cdn_unsigned() { - let (s3, s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - let store = Store::new(cfg); - - let object = store - .read_object("cdn", "attachments/1/2/a.png") - .await - .unwrap(); - - assert_eq!("payload", object.data); - assert!( - s3_seen.lock().await.is_empty(), - "S3 endpoint must not be touched" - ); - let (method, uri, headers) = only_request(&cdn_seen).await; - assert_eq!(http::Method::GET, method); - assert_eq!("/cdn/attachments/1/2/a.png", uri.path()); - assert_unsigned(&headers); - } - - #[tokio::test] - async fn read_endpoint_rooted_style_omits_bucket_segment() { - let (s3, _s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_read_bucket_style = BucketStyle::Rooted; - let store = Store::new(cfg); - - store - .read_object("cdn", "attachments/1/2/a.png") - .await - .unwrap(); - - let (_, uri, _) = only_request(&cdn_seen).await; - assert_eq!("/attachments/1/2/a.png", uri.path()); - } - - #[test] - fn read_endpoint_virtual_style_uses_bucket_subdomain() { - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), "https://s3.example.test"); - cfg.s3_read_endpoint = Some("https://cdn.example.net".to_owned()); - cfg.s3_read_bucket_style = BucketStyle::VirtualHosted; - let store = Store::new(cfg); - - assert_eq!( - "https://cdn.cdn.example.net/attachments/1/2/a.png", - store.s3_read_url("cdn", "attachments/1/2/a.png").unwrap() - ); - } - - #[tokio::test] - async fn read_endpoint_never_redirects_other_buckets() { - let (s3, s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_read_bucket_style = BucketStyle::Rooted; - let store = Store::new(cfg); - - store - .read_object("uploads", "fresh-upload-key") - .await - .unwrap(); - - assert!( - cdn_seen.lock().await.is_empty(), - "uploads must not hit the CDN" - ); - let (_, uri, headers) = only_request(&s3_seen).await; - assert_eq!("/uploads/fresh-upload-key", uri.path()); - assert_signed(&headers); - } - - #[tokio::test] - async fn read_endpoint_never_affects_writes() { - let (s3, s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_read_bucket = "uploads".to_owned(); - let store = Store::new(cfg); - - let (tx, rx) = tokio::sync::mpsc::channel(2); - tokio::spawn(async move { - tx.send(Ok(Bytes::from_static(b"body"))).await.unwrap(); - }); - store - .relay_put_object( - "uploads", - "guild/x.bin", - RelayPutOptions { - body: RelayBody::Streamed(rx), - content_length: 4, - content_type: Some("application/octet-stream".to_owned()), - upload_id: None, - part_number: None, - timeout_ms: 5_000, - }, - ) - .await - .unwrap(); - - assert!( - cdn_seen.lock().await.is_empty(), - "writes must not hit the CDN" - ); - let (method, uri, headers) = only_request(&s3_seen).await; - assert_eq!(http::Method::PUT, method); - assert_eq!("/uploads/guild/x.bin", uri.path()); - assert_signed(&headers); - } - - #[tokio::test] - async fn read_endpoint_signs_when_read_signed_enabled() { - let (s3, _s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let cdn_host = cdn.trim_start_matches("http://").to_owned(); - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_read_signed = true; - let store = Store::new(cfg); - - store.read_object("cdn", "a.png").await.unwrap(); - - let (_, _, headers) = only_request(&cdn_seen).await; - assert_signed(&headers); - assert_eq!( - cdn_host, - headers.get(header::HOST).unwrap().to_str().unwrap() - ); - } - - #[tokio::test] - async fn unsigned_read_still_sends_range_header() { - let (s3, _s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - let store = Store::new(cfg); - - store - .stream_object("cdn", "video.mp4", Some("bytes=10-19")) - .await - .unwrap(); - - let (_, _, headers) = only_request(&cdn_seen).await; - assert_eq!("bytes=10-19", headers.get(header::RANGE).unwrap()); - assert_unsigned(&headers); - } - - async fn range_server( - status: u16, - content_range: Option<&'static str>, - ) -> (String, CapturedRequests) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let captured: CapturedRequests = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())); - let handler = std::sync::Arc::clone(&captured); - let app = axum::Router::new().fallback(axum::routing::any( - move |request: axum::extract::Request| { - let captured = std::sync::Arc::clone(&handler); - async move { - let (parts, _body) = request.into_parts(); - captured - .lock() - .await - .push((parts.method, parts.uri, parts.headers)); - let mut response = axum::response::Response::new(Body::from("partial")); - *response.status_mut() = StatusCode::from_u16(status).unwrap(); - if let Some(content_range) = content_range { - response.headers_mut().insert( - header::CONTENT_RANGE, - http::HeaderValue::from_static(content_range), - ); - } - response - } - }, - )); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - (format!("http://{addr}"), captured) - } - - #[tokio::test] - async fn stream_s3_reads_the_span_and_the_total_off_a_partial_response() { - let (s3, _s3_seen) = range_server(206, Some("bytes 10-16/100")).await; - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(s3_test_config(tmp.path(), &s3)); - - let object = store - .stream_object("cdn", "video.mp4", Some("bytes=10-16")) - .await - .unwrap(); - - assert_eq!(StatusCode::PARTIAL_CONTENT, object.status); - assert_eq!( - Some(crate::range::ByteRange { start: 10, end: 16 }), - object.byte_range - ); - assert_eq!(Some(100), object.total_length); - } - - #[tokio::test] - async fn stream_s3_rejects_a_partial_response_without_a_usable_content_range() { - let (s3, _s3_seen) = range_server(206, None).await; - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(s3_test_config(tmp.path(), &s3)); - - let result = store - .stream_object("cdn", "video.mp4", Some("bytes=10-16")) - .await; - - assert!(matches!(result, Err(StorageError::S3(_)))); - } - - #[tokio::test] - async fn stream_s3_surfaces_an_upstream_416_with_the_total_it_reports() { - let (s3, _s3_seen) = range_server(416, Some("bytes */100")).await; - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(s3_test_config(tmp.path(), &s3)); - - let object = store - .stream_object("cdn", "video.mp4", Some("bytes=900-999")) - .await - .unwrap(); - - assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, object.status); - assert_eq!(None, object.byte_range); - assert_eq!(Some(100), object.total_length); - } - - #[tokio::test] - async fn stream_s3_surfaces_an_upstream_416_that_omits_the_total() { - let (s3, _s3_seen) = range_server(416, None).await; - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(s3_test_config(tmp.path(), &s3)); - - let object = store - .stream_object("cdn", "video.mp4", Some("bytes=900-999")) - .await - .unwrap(); - - assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, object.status); - assert_eq!(None, object.total_length); - } - - #[tokio::test] - async fn local_stream_reports_an_unsatisfiable_range_instead_of_the_whole_file() { - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(test_config(&tmp.path().canonicalize().unwrap())); - store - .write_object("cdn", "a/b.txt", b"hello world", "text/plain") - .await - .unwrap(); - - let object = store - .stream_object("cdn", "a/b.txt", Some("bytes=99-200")) - .await - .unwrap(); - - assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, object.status); - assert_eq!(Some(11), object.total_length); - assert_eq!(None, object.byte_range); - } - - #[tokio::test] - async fn local_stream_reports_the_total_alongside_a_partial_span() { - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(test_config(&tmp.path().canonicalize().unwrap())); - store - .write_object("cdn", "a/b.txt", b"hello world", "text/plain") - .await - .unwrap(); - - let object = store - .stream_object("cdn", "a/b.txt", Some("bytes=6-10")) - .await - .unwrap(); - - assert_eq!(StatusCode::PARTIAL_CONTENT, object.status); - assert_eq!(Some(11), object.total_length); - assert_eq!( - Some(crate::range::ByteRange { start: 6, end: 10 }), - object.byte_range - ); - } - - #[tokio::test] - async fn head_object_uses_the_read_endpoint_like_every_other_read() { - let (s3, s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_read_bucket_style = BucketStyle::Rooted; - let store = Store::new(cfg); - - store.head_object("cdn", "a.png").await.unwrap(); - - assert!( - s3_seen.lock().await.is_empty(), - "HEAD must not hit the origin" - ); - let (method, uri, headers) = only_request(&cdn_seen).await; - assert_eq!(http::Method::HEAD, method); - assert_eq!("/a.png", uri.path()); - assert_unsigned(&headers); - } - - #[tokio::test] - async fn head_object_without_a_read_endpoint_still_signs_the_origin() { - let (s3, s3_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(s3_test_config(tmp.path(), &s3)); - - store.head_object("cdn", "a.png").await.unwrap(); - - let (method, uri, headers) = only_request(&s3_seen).await; - assert_eq!(http::Method::HEAD, method); - assert_eq!("/cdn/a.png", uri.path()); - assert_signed(&headers); - } - - #[tokio::test] - async fn body_reads_and_heads_both_use_the_cdn() { - let (s3, s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_read_bucket_style = BucketStyle::Rooted; - let store = Store::new(cfg); - - store.head_object("cdn", "video.mp4").await.unwrap(); - store - .stream_object("cdn", "video.mp4", Some("bytes=0-3")) - .await - .unwrap(); - - assert!(s3_seen.lock().await.is_empty()); - let cdn_reqs = cdn_seen.lock().await.clone(); - assert_eq!(2, cdn_reqs.len()); - assert_eq!(http::Method::HEAD, cdn_reqs[0].0); - assert_eq!(http::Method::GET, cdn_reqs[1].0); - assert_eq!("bytes=0-3", cdn_reqs[1].2.get(header::RANGE).unwrap()); - } - - #[tokio::test] - async fn unsigned_read_works_without_credentials() { - let (s3, _s3_seen) = capture_server().await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_access_key_id = String::new(); - cfg.s3_secret_access_key = String::new(); - let store = Store::new(cfg); - - store.read_object("cdn", "a.png").await.unwrap(); - - let (_, _, headers) = only_request(&cdn_seen).await; - assert_unsigned(&headers); - } - - #[test] - fn read_url_matches_write_url_shape_for_encoding_and_trailing_slash() { - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), "https://s3.example.test/"); - cfg.s3_read_endpoint = Some("https://cdn.example.net/".to_owned()); - let store = Store::new(cfg); - let key = "attachments/1/2/na me+ü.png"; - - assert_eq!( - "https://s3.example.test/uploads/attachments/1/2/na%20me%2B%C3%BC.png", - store.s3_url("uploads", key).unwrap() - ); - assert_eq!( - "https://cdn.example.net/cdn/attachments/1/2/na%20me%2B%C3%BC.png", - store.s3_read_url("cdn", key).unwrap() - ); - } - - #[test] - fn read_url_rejects_unsafe_keys_and_buckets() { - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), "https://s3.example.test"); - cfg.s3_read_endpoint = Some("https://cdn.example.net".to_owned()); - cfg.s3_read_bucket_style = BucketStyle::Rooted; - let store = Store::new(cfg); - - assert!(store.s3_read_url("cdn", "../escape").is_err()); - assert!(store.s3_read_url("cdn", "/leading").is_err()); - } - - #[test] - fn read_url_validates_bucket_inside_the_read_endpoint_branch() { - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), "https://s3.example.test"); - cfg.s3_read_endpoint = Some("https://cdn.example.net".to_owned()); - cfg.s3_read_bucket = "..".to_owned(); - cfg.s3_read_bucket_style = BucketStyle::Rooted; - let store = Store::new(cfg); - - assert!(store.read_endpoint_for("..").is_some()); - assert!(store.s3_read_url("..", "a.png").is_err()); - } - - #[tokio::test] - async fn unsigned_cdn_read_treats_403_as_not_found() { - let (s3, _s3_seen) = capture_server().await; - let (cdn, cdn_seen) = status_server(403).await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_read_bucket_style = BucketStyle::Rooted; - let store = Store::new(cfg); - - let err = store - .read_object("cdn", "avatars/1/hash") - .await - .unwrap_err(); - assert!(matches!(err, StorageError::NotFound), "got {err:?}"); - assert_eq!(1, cdn_seen.lock().await.len()); - - let stream = store.stream_object("cdn", "avatars/1/hash", None).await; - assert!( - matches!(stream, Err(StorageError::NotFound)), - "stream_object should map 403 to NotFound too" - ); - } - - #[tokio::test] - async fn signed_cdn_read_keeps_403_as_an_error() { - let (s3, _s3_seen) = capture_server().await; - let (cdn, _cdn_seen) = status_server(403).await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - cfg.s3_read_signed = true; - let store = Store::new(cfg); - - let err = store - .read_object("cdn", "avatars/1/hash") - .await - .unwrap_err(); - assert!(!matches!(err, StorageError::NotFound), "got {err:?}"); - } - - #[tokio::test] - async fn origin_read_keeps_403_as_an_error() { - let (s3, _s3_seen) = status_server(403).await; - let tmp = tempfile::tempdir().unwrap(); - let store = Store::new(s3_test_config(tmp.path(), &s3)); - - let err = store - .read_object("cdn", "avatars/1/hash") - .await - .unwrap_err(); - assert!(!matches!(err, StorageError::NotFound), "got {err:?}"); - } - - #[tokio::test] - async fn unfronted_bucket_keeps_403_as_an_error() { - let (s3, _s3_seen) = status_server(403).await; - let (cdn, cdn_seen) = capture_server().await; - let tmp = tempfile::tempdir().unwrap(); - let mut cfg = s3_test_config(tmp.path(), &s3); - cfg.s3_read_endpoint = Some(cdn.clone()); - let store = Store::new(cfg); - - let err = store.read_object("uploads", "fresh").await.unwrap_err(); - assert!(!matches!(err, StorageError::NotFound), "got {err:?}"); - assert!(cdn_seen.lock().await.is_empty()); - } -} diff --git a/fluxer_media_proxy/src/storage/identity.rs b/fluxer_media_proxy/src/storage/identity.rs new file mode 100644 index 000000000..e5a470f24 --- /dev/null +++ b/fluxer_media_proxy/src/storage/identity.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use sha2::{Digest, Sha256}; + +const REMOTE_IDENTITY_DOMAIN: &[u8] = b"fluxer.media-proxy.source-object.remote"; +const LOCAL_IDENTITY_DOMAIN: &[u8] = b"fluxer.media-proxy.source-object.local"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SourceObjectIdentity { + cache_identity: String, + content_length: u64, + content_type: String, + etag: Option, +} + +pub(super) struct RemoteSourceObject<'a> { + pub(super) bucket: &'a str, + pub(super) key: &'a str, + pub(super) content_length: u64, + pub(super) content_type: &'a str, + pub(super) etag: Option<&'a str>, + pub(super) last_modified: Option<&'a str>, +} + +pub(super) struct LocalSourceObject<'a> { + pub(super) bucket: &'a str, + pub(super) key: &'a str, + pub(super) content_length: u64, + pub(super) content_type: &'a str, + pub(super) modified_nanos: i128, + pub(super) inode: u64, +} + +impl SourceObjectIdentity { + pub fn cache_identity(&self) -> &str { + &self.cache_identity + } + + pub fn content_length(&self) -> u64 { + self.content_length + } + + pub fn content_type(&self) -> &str { + &self.content_type + } + + pub(super) fn etag(&self) -> Option<&str> { + self.etag.as_deref() + } +} + +pub(super) fn remote_source_object_identity( + object: RemoteSourceObject<'_>, +) -> SourceObjectIdentity { + let mut digest = Sha256::new(); + update_identity_field(&mut digest, REMOTE_IDENTITY_DOMAIN); + update_identity_field(&mut digest, object.bucket.as_bytes()); + update_identity_field(&mut digest, object.key.as_bytes()); + update_identity_field(&mut digest, &object.content_length.to_be_bytes()); + update_identity_field(&mut digest, object.content_type.as_bytes()); + update_optional_identity_field(&mut digest, object.etag); + update_optional_identity_field(&mut digest, object.last_modified); + SourceObjectIdentity { + cache_identity: hex::encode(digest.finalize()), + content_length: object.content_length, + content_type: object.content_type.to_owned(), + etag: object.etag.map(ToOwned::to_owned), + } +} + +pub(super) fn local_source_object_identity(object: LocalSourceObject<'_>) -> SourceObjectIdentity { + let mut digest = Sha256::new(); + update_identity_field(&mut digest, LOCAL_IDENTITY_DOMAIN); + update_identity_field(&mut digest, object.bucket.as_bytes()); + update_identity_field(&mut digest, object.key.as_bytes()); + update_identity_field(&mut digest, &object.content_length.to_be_bytes()); + update_identity_field(&mut digest, object.content_type.as_bytes()); + update_identity_field(&mut digest, &object.modified_nanos.to_be_bytes()); + update_identity_field(&mut digest, &object.inode.to_be_bytes()); + SourceObjectIdentity { + cache_identity: hex::encode(digest.finalize()), + content_length: object.content_length, + content_type: object.content_type.to_owned(), + etag: None, + } +} + +fn update_optional_identity_field(digest: &mut Sha256, value: Option<&str>) { + match value { + Some(value) => { + digest.update([1]); + update_identity_field(digest, value.as_bytes()); + } + None => digest.update([0]), + } +} + +fn update_identity_field(digest: &mut Sha256, value: &[u8]) { + let length = u64::try_from(value.len()).expect("source identity field length must fit u64"); + digest.update(length.to_be_bytes()); + digest.update(value); +} diff --git a/fluxer_media_proxy/src/storage/keys.rs b/fluxer_media_proxy/src/storage/keys.rs new file mode 100644 index 000000000..097439070 --- /dev/null +++ b/fluxer_media_proxy/src/storage/keys.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::storage::StorageError; + +pub(crate) fn safe_bucket(bucket: &str) -> Result<(), StorageError> { + if !safe_path_component(bucket) { + return Err(StorageError::InvalidBucket); + } + Ok(()) +} + +pub(crate) fn safe_key(key: &str) -> Result<(), StorageError> { + if key.is_empty() || key.starts_with('/') { + return Err(StorageError::InvalidKey); + } + for component in key.split('/') { + if !safe_path_component(component) { + return Err(StorageError::InvalidKey); + } + } + Ok(()) +} + +pub(crate) fn validate_virtual_hosted_bucket(bucket: &str) -> Result<(), StorageError> { + if bucket.len() < 3 || bucket.len() > 63 { + return Err(StorageError::InvalidBucket); + } + if !bucket + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'.') + { + return Err(StorageError::InvalidBucket); + } + Ok(()) +} + +fn safe_path_component(component: &str) -> bool { + !component.is_empty() + && component != "." + && component != ".." + && !component.contains(['/', '\0']) +} diff --git a/fluxer_media_proxy/src/storage/local.rs b/fluxer_media_proxy/src/storage/local.rs new file mode 100644 index 000000000..8f86b8a46 --- /dev/null +++ b/fluxer_media_proxy/src/storage/local.rs @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ + BufferedObjectReadRequest, BufferedStorageObject, ContentDigestRequest, HeadResult, + ObjectStreamPlan, STORAGE_STREAM_CHUNK_BYTES, StorageError, Store, StreamObject, StreamRange, + identity::{LocalSourceObject, SourceObjectIdentity, local_source_object_identity}, + keys::{safe_bucket, safe_key}, + map_not_found, + relay_body::{RelayBody, RelayPutOptions}, + response_body::{exact_byte_stream, local_reader_stream, read_exact_bytes}, +}; +use crate::{mime, range}; +use axum::body::Body; +use futures_util::StreamExt as _; +use http::StatusCode; +use sha2::{Digest as _, Sha256}; +use std::{ + fs::Metadata, + os::unix::fs::MetadataExt as _, + path::{Path, PathBuf}, +}; +use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _, AsyncWriteExt as _}; + +const LOCAL_OPEN_FLAGS: i32 = libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK; + +impl Store { + pub(super) async fn local_path( + &self, + bucket: &str, + key: &str, + ) -> Result { + safe_bucket(bucket)?; + safe_key(key)?; + let root = Path::new(&self.cfg.storage.root); + let path = root.join(bucket).join(key); + reject_symlink_chain(&path)?; + Ok(path) + } + + pub(super) async fn ensure_bucket_local(&self, bucket: &str) -> Result<(), StorageError> { + safe_bucket(bucket)?; + tokio::fs::create_dir_all(Path::new(&self.cfg.storage.root).join(bucket)).await?; + Ok(()) + } + + pub(super) async fn read_local( + &self, + request: BufferedObjectReadRequest<'_>, + ) -> Result { + let path = self.local_path(request.bucket, request.key).await?; + let (file, metadata) = open_local_read_file(&path).await?; + if metadata.len() > request.limit as u64 { + return Err(StorageError::StreamTooLong); + } + let content_length = + usize::try_from(metadata.len()).map_err(|_| StorageError::StreamTooLong)?; + if let Some(expected) = request.expected_identity { + let identity = local_identity(request.bucket, request.key, &metadata); + if identity != *expected { + return Err(StorageError::ObjectChanged); + } + } + let data = read_exact_bytes(file, content_length, request.budget).await?; + let content_type = mime::detect(&data[..data.len().min(8192)], request.key, None); + Ok(BufferedStorageObject { + content_digest: match request.content_digest { + ContentDigestRequest::Omit => None, + ContentDigestRequest::Include => Some(Sha256::digest(data.as_ref()).into()), + }, + data, + content_type, + }) + } + + pub(super) async fn head_local( + &self, + bucket: &str, + key: &str, + max_bytes: usize, + ) -> Result { + let path = self.local_path(bucket, key).await?; + let metadata = tokio::fs::symlink_metadata(&path) + .await + .map_err(map_not_found)?; + if !metadata.is_file() { + return Err(StorageError::NotFound); + } + if metadata.len() > max_bytes as u64 { + return Err(StorageError::StreamTooLong); + } + Ok(HeadResult { + content_length: metadata.len(), + content_type: local_content_type(key), + identity: local_identity(bucket, key, &metadata), + }) + } + + pub(super) async fn stream_local( + &self, + plan: ObjectStreamPlan<'_>, + ) -> Result { + let path = self.local_path(plan.bucket, plan.key).await?; + let (mut file, metadata) = open_local_read_file(&path).await?; + if metadata.len() > plan.max_bytes as u64 { + return Err(StorageError::StreamTooLong); + } + if let Some(expected) = plan.expected_identity { + let identity = local_identity(plan.bucket, plan.key, &metadata); + if identity != *expected { + return Err(StorageError::ObjectChanged); + } + } + let total_len = usize::try_from(metadata.len()).map_err(|_| StorageError::StreamTooLong)?; + let byte_range = match plan.range { + StreamRange::Full => None, + StreamRange::Header(header) => match range::parse_range(Some(header), total_len) { + range::RangeSelection::Partial(byte_range) => Some(byte_range), + range::RangeSelection::Full => None, + // The caller decides how to render 416; reporting it as a plain 200 here would + // serve the whole object for a range the client can never use. + range::RangeSelection::Unsatisfiable => { + return Ok(StreamObject { + body: Body::empty(), + status: StatusCode::RANGE_NOT_SATISFIABLE, + content_length: Some(0), + content_type: local_content_type(plan.key), + byte_range: None, + total_length: Some(metadata.len()), + }); + } + }, + StreamRange::Bytes(byte_range) => { + if byte_range.start > byte_range.end || byte_range.end >= total_len { + return Err(StorageError::ObjectChanged); + } + Some(byte_range) + } + }; + let (status, body_len, start) = match byte_range { + Some(byte_range) => ( + StatusCode::PARTIAL_CONTENT, + (byte_range.end - byte_range.start + 1) as u64, + byte_range.start as u64, + ), + None => (StatusCode::OK, total_len as u64, 0), + }; + if start > 0 { + file.seek(std::io::SeekFrom::Start(start)).await?; + } + let reader = file.take(body_len); + let body = if body_len == 0 { + Body::empty() + } else { + let capacity = body_len.min(STORAGE_STREAM_CHUNK_BYTES as u64) as usize; + let stream = local_reader_stream(reader, self.local_stream_buffers.clone(), capacity)?; + Body::from_stream(exact_byte_stream(stream, body_len)) + }; + Ok(StreamObject { + body, + status, + content_length: Some(body_len), + content_type: local_content_type(plan.key), + byte_range, + total_length: Some(metadata.len()), + }) + } + + pub(super) async fn write_local( + &self, + bucket: &str, + key: &str, + data: &[u8], + ) -> Result<(), StorageError> { + let path = self.local_path(bucket, key).await?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(path, data).await?; + Ok(()) + } + + pub(super) async fn write_local_relay( + &self, + bucket: &str, + key: &str, + options: RelayPutOptions, + ) -> Result<(), StorageError> { + let path = self.local_path(bucket, key).await?; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut dest = tokio::fs::File::create(&path).await?; + let content_length = options.content_length; + let result = async { + match options.body { + RelayBody::Spooled(mut source) => { + source.seek(std::io::SeekFrom::Start(0)).await?; + tokio::io::copy(&mut source, &mut dest).await?; + } + RelayBody::Streamed(mut chunks) => { + while let Some(chunk) = chunks.next().await { + dest.write_all(&chunk?).await?; + } + } + } + dest.flush().await?; + dest.sync_all().await?; + if dest.metadata().await?.len() != content_length { + return Err(StorageError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "upload relay body did not match its declared content length", + ))); + } + Ok(()) + } + .await; + if result.is_err() { + let _ = tokio::fs::remove_file(&path).await; + } + result + } +} + +fn local_content_type(key: &str) -> String { + mime::extension_mime(key) + .unwrap_or("application/octet-stream") + .to_owned() +} + +fn local_identity(bucket: &str, key: &str, metadata: &Metadata) -> SourceObjectIdentity { + local_source_object_identity(LocalSourceObject { + bucket, + key, + content_length: metadata.len(), + content_type: &local_content_type(key), + modified_nanos: i128::from(metadata.mtime()) * 1_000_000_000 + + i128::from(metadata.mtime_nsec()), + inode: metadata.ino(), + }) +} + +async fn open_local_read_file(path: &Path) -> Result<(tokio::fs::File, Metadata), StorageError> { + let mut options = tokio::fs::OpenOptions::new(); + options.read(true).custom_flags(LOCAL_OPEN_FLAGS); + let file = options.open(path).await.map_err(map_local_open_error)?; + let metadata = file.metadata().await?; + if !metadata.is_file() { + return Err(StorageError::NotFound); + } + Ok((file, metadata)) +} + +fn map_local_open_error(error: std::io::Error) -> StorageError { + if error.raw_os_error() == Some(libc::ELOOP) { + return StorageError::InvalidKey; + } + map_not_found(error) +} + +/// Walks the resolved path and refuses any component that is a symlink. +/// +/// This is deliberately synchronous. It runs once per storage operation and touches one dentry +/// per path component, which the kernel serves from cache in well under a microsecond. Issuing +/// each `symlink_metadata` through `tokio::fs` instead turns a handful of cheap syscalls into one +/// blocking-threadpool round-trip *per component*, which measured as a 2-3x throughput loss on +/// every storage-backed request. +fn reject_symlink_chain(path: &Path) -> Result<(), StorageError> { + let mut current = PathBuf::new(); + for component in path.components() { + current.push(component.as_os_str()); + match std::fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(StorageError::InvalidKey); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(StorageError::Io(error)), + } + } + Ok(()) +} diff --git a/fluxer_media_proxy/src/storage/mod.rs b/fluxer_media_proxy/src/storage/mod.rs new file mode 100644 index 000000000..eb23b6afb --- /dev/null +++ b/fluxer_media_proxy/src/storage/mod.rs @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod identity; +mod keys; +mod local; +mod object_read; +mod object_stream; +mod relay_body; +mod response_body; +mod s3; +mod s3_endpoint; +mod source_read; + +#[cfg(test)] +pub(crate) mod tests; + +pub use identity::SourceObjectIdentity; +pub(crate) use keys::safe_key; +pub use relay_body::{RelayBody, RelayBodyChunks, RelayPutOptions}; + +use crate::{ + aws_sigv4, + byte_budget::{BudgetedBytes, ByteBudget}, + config::{Config, StorageBackend}, + http_client::{self, HTTPClientOptions}, + metrics::{http_client::HTTPClientMetrics, storage::StorageMetrics}, + range::ByteRange, + response_body_limit, +}; +use axum::body::Body; +use bytes::Bytes; +use http::StatusCode; +use response_body::LocalStreamBufferPool; +use source_read::SourceReadCoordinator; +use std::{num::NonZeroU64, sync::Arc}; +use thiserror::Error; + +const STORAGE_STREAM_CHUNK_BYTES: usize = 256 * 1024; +const LOCAL_STREAM_BUFFER_COUNT: usize = 256; + +#[derive(Clone, Debug)] +pub struct Object { + pub data: Bytes, + pub content_type: String, +} + +#[derive(Clone, Debug)] +pub struct BufferedStorageObject { + pub data: BudgetedBytes, + pub content_type: String, + pub content_digest: Option<[u8; 32]>, +} + +pub struct StreamObject { + pub body: Body, + pub status: StatusCode, + pub content_length: Option, + pub content_type: String, + pub byte_range: Option, + pub total_length: Option, +} + +#[derive(Clone, Debug)] +pub struct HeadResult { + pub content_length: u64, + pub content_type: String, + pub identity: SourceObjectIdentity, +} + +pub struct ObjectReadRequest<'a> { + pub bucket: &'a str, + pub key: &'a str, + pub max_bytes: usize, + pub budget: &'a ByteBudget, + pub expected_identity: &'a SourceObjectIdentity, +} + +#[derive(Clone, Copy)] +pub struct ObjectStreamRequest<'a> { + pub bucket: &'a str, + pub key: &'a str, + pub max_bytes: usize, + pub byte_range: Option, + pub expected_identity: &'a SourceObjectIdentity, +} + +#[derive(Clone, Copy)] +struct BufferedObjectReadRequest<'a> { + bucket: &'a str, + key: &'a str, + limit: usize, + budget: &'a ByteBudget, + expected_identity: Option<&'a SourceObjectIdentity>, + content_digest: ContentDigestRequest, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ContentDigestRequest { + Omit, + Include, +} + +impl ContentDigestRequest { + const fn cache_key(self) -> &'static str { + match self { + Self::Omit => "omit-digest", + Self::Include => "include-digest", + } + } +} + +#[derive(Clone, Copy)] +struct ObjectStreamPlan<'a> { + bucket: &'a str, + key: &'a str, + max_bytes: usize, + range: StreamRange<'a>, + expected_identity: Option<&'a SourceObjectIdentity>, +} + +#[derive(Clone, Copy)] +enum StreamRange<'a> { + Full, + Header(&'a str), + Bytes(ByteRange), +} + +#[derive(Debug, Error)] +pub enum StorageError { + #[error("object not found")] + NotFound, + #[error("invalid key")] + InvalidKey, + #[error("invalid bucket")] + InvalidBucket, + #[error("read-only storage")] + ReadOnlyStorage, + #[error("stream too long")] + StreamTooLong, + #[error("invalid S3 endpoint")] + InvalidS3Endpoint, + #[error("object changed between identity resolution and content read")] + ObjectChanged, + #[error("buffered input byte budget exhausted")] + BufferBudgetExhausted, + #[error("buffered input allocation failed")] + BufferAllocationFailed, + #[error("source read capacity exhausted")] + SourceReadCapacityExhausted, + #[error("source read waiter capacity exhausted")] + SourceReadWaiterCapacityExhausted, + #[error("source read leader ended without publishing a result")] + SourceReadLeaderEnded, + #[error("coalesced source read failed: {0}")] + CoalescedSourceReadFailed(String), + #[error("object storage operation failed: {0}")] + ObjectStorage(#[source] anyhow::Error), + #[error("S3 request failed: {0}")] + S3(String), + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + Http(#[from] reqwest::Error), + #[error(transparent)] + HttpMiddleware(#[from] reqwest_middleware::Error), + #[error(transparent)] + Sign(#[from] aws_sigv4::Error), +} + +#[derive(Clone)] +pub struct Store { + pub cfg: Config, + client: http_client::HttpClient, + raw_client: reqwest::Client, + metrics: Arc, + source_reads: SourceReadCoordinator, + local_stream_buffers: LocalStreamBufferPool, +} + +impl Store { + pub fn new( + cfg: Config, + metrics: Arc, + http_client_metrics: Arc, + ) -> Self { + Self { + cfg, + client: http_client::build_default(http_client_metrics), + raw_client: http_client::build_raw_default(), + metrics, + source_reads: SourceReadCoordinator::new(), + local_stream_buffers: local_stream_buffer_pool(), + } + } + + pub fn try_new( + cfg: Config, + metrics: Arc, + http_client_metrics: Arc, + ) -> Result { + let socket_io_timeout = + NonZeroU64::new(cfg.socket_io_timeout_ms).unwrap_or(NonZeroU64::MIN); + let options = HTTPClientOptions::new(socket_io_timeout, socket_io_timeout); + let client = http_client::build(options, http_client_metrics)?; + let raw_client = http_client::build_raw(options)?; + Ok(Self { + cfg, + client, + raw_client, + metrics, + source_reads: SourceReadCoordinator::new(), + local_stream_buffers: local_stream_buffer_pool(), + }) + } + + pub async fn write_object( + &self, + bucket: &str, + key: &str, + data: &[u8], + content_type: &str, + ) -> Result<(), StorageError> { + if self.cfg.read_only { + return Err(StorageError::ReadOnlyStorage); + } + match self.cfg.storage.backend { + StorageBackend::Local => self.write_local(bucket, key, data).await, + StorageBackend::S3 => self.write_s3(bucket, key, data, content_type).await, + } + } + + pub async fn ensure_bucket(&self, bucket: &str) -> Result<(), StorageError> { + match self.cfg.storage.backend { + StorageBackend::Local => self.ensure_bucket_local(bucket).await, + StorageBackend::S3 => self.ensure_bucket_s3(bucket).await, + } + } + + pub async fn relay_put_object( + &self, + bucket: &str, + key: &str, + options: RelayPutOptions, + ) -> Result, StorageError> { + if self.cfg.read_only { + return Err(StorageError::ReadOnlyStorage); + } + match self.cfg.storage.backend { + StorageBackend::Local => { + self.write_local_relay(bucket, key, options).await?; + Ok(None) + } + StorageBackend::S3 => self.relay_put_s3(bucket, key, options).await, + } + } +} + +fn local_stream_buffer_pool() -> LocalStreamBufferPool { + LocalStreamBufferPool::new(STORAGE_STREAM_CHUNK_BYTES, LOCAL_STREAM_BUFFER_COUNT) + .expect("local stream buffer pool bounds are constant and nonzero") +} + +pub(crate) fn unversioned_read_budget(limit: usize) -> ByteBudget { + ByteBudget::new( + limit.saturating_add(response_body_limit::RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX), + ) +} + +fn record_storage_outcome(metrics: &StorageMetrics, result: &Result) { + match result { + Ok(_) => metrics.record_hit(), + Err(StorageError::NotFound) => metrics.record_miss(), + Err(_) => metrics.record_error(), + } +} + +fn map_not_found(err: std::io::Error) -> StorageError { + if err.kind() == std::io::ErrorKind::NotFound { + StorageError::NotFound + } else { + StorageError::Io(err) + } +} diff --git a/fluxer_media_proxy/src/storage/object_read.rs b/fluxer_media_proxy/src/storage/object_read.rs new file mode 100644 index 000000000..d99057e57 --- /dev/null +++ b/fluxer_media_proxy/src/storage/object_read.rs @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ + BufferedObjectReadRequest, BufferedStorageObject, ContentDigestRequest, Object, + ObjectReadRequest, StorageError, Store, record_storage_outcome, unversioned_read_budget, +}; +use crate::{ + byte_budget::ByteBudget, + config::StorageBackend, + constants, + storage::source_read::{SourceReadClaim, SourceReadWaitOutcome}, +}; + +const SOURCE_READ_LEADER_RETRY_LIMIT: usize = 1; + +impl Store { + pub async fn read_object(&self, bucket: &str, key: &str) -> Result { + let budget = unversioned_read_budget(constants::MAX_MEDIA_PROXY_BYTES); + let object = self + .read_object_limited(bucket, key, constants::MAX_MEDIA_PROXY_BYTES, &budget) + .await?; + Ok(Object { + data: object.data.as_bytes().clone(), + content_type: object.content_type, + }) + } + + pub async fn read_object_limited( + &self, + bucket: &str, + key: &str, + limit: usize, + budget: &ByteBudget, + ) -> Result { + self.read_object_inner(BufferedObjectReadRequest { + bucket, + key, + limit, + budget, + expected_identity: None, + content_digest: ContentDigestRequest::Omit, + }) + .await + } + + pub async fn read_object_limited_with_digest( + &self, + bucket: &str, + key: &str, + limit: usize, + budget: &ByteBudget, + ) -> Result { + self.read_object_inner(BufferedObjectReadRequest { + bucket, + key, + limit, + budget, + expected_identity: None, + content_digest: ContentDigestRequest::Include, + }) + .await + } + + pub async fn read_object_versioned( + &self, + request: ObjectReadRequest<'_>, + ) -> Result { + self.read_object_inner(BufferedObjectReadRequest { + bucket: request.bucket, + key: request.key, + limit: request.max_bytes, + budget: request.budget, + expected_identity: Some(request.expected_identity), + content_digest: ContentDigestRequest::Omit, + }) + .await + } + + async fn read_object_inner( + &self, + request: BufferedObjectReadRequest<'_>, + ) -> Result { + let Some(expected_identity) = request.expected_identity else { + let result = self.read_object_direct(request).await; + record_storage_outcome(&self.metrics, &result); + return result; + }; + let key = format!( + "{}\u{0}{}\u{0}{}\u{0}{}\u{0}{}", + request.bucket, + request.key, + expected_identity.cache_identity(), + request.limit, + request.content_digest.cache_key() + ); + let mut leader_retries = 0; + loop { + let claim = match self.source_reads.claim(key.clone()) { + Ok(claim) => claim, + Err(error) => { + let result = Err(error); + record_storage_outcome::(&self.metrics, &result); + return result; + } + }; + match claim { + SourceReadClaim::Leader(leader) => { + let result = self.read_object_direct(request).await; + leader.publish(&result); + record_storage_outcome(&self.metrics, &result); + return result; + } + SourceReadClaim::Waiter(waiter) => match waiter.wait().await { + SourceReadWaitOutcome::Retry + if leader_retries < SOURCE_READ_LEADER_RETRY_LIMIT => + { + leader_retries += 1; + } + SourceReadWaitOutcome::Retry => { + let result = Err(StorageError::SourceReadLeaderEnded); + record_storage_outcome::(&self.metrics, &result); + return result; + } + SourceReadWaitOutcome::Completed(result) => { + record_storage_outcome::(&self.metrics, &result); + return result; + } + }, + } + } + } + + async fn read_object_direct( + &self, + request: BufferedObjectReadRequest<'_>, + ) -> Result { + match self.cfg.storage.backend { + StorageBackend::Local => self.read_local(request).await, + StorageBackend::S3 => self.read_s3(request).await, + } + } +} diff --git a/fluxer_media_proxy/src/storage/object_stream.rs b/fluxer_media_proxy/src/storage/object_stream.rs new file mode 100644 index 000000000..6793e380b --- /dev/null +++ b/fluxer_media_proxy/src/storage/object_stream.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ + HeadResult, ObjectStreamPlan, ObjectStreamRequest, StorageError, Store, StreamObject, + StreamRange, record_storage_outcome, +}; +use crate::{config::StorageBackend, constants}; + +impl Store { + pub async fn head_object(&self, bucket: &str, key: &str) -> Result { + self.head_object_limited(bucket, key, constants::MAX_MEDIA_PROXY_BYTES) + .await + } + + pub async fn head_object_limited( + &self, + bucket: &str, + key: &str, + max_bytes: usize, + ) -> Result { + match self.cfg.storage.backend { + StorageBackend::Local => self.head_local(bucket, key, max_bytes).await, + StorageBackend::S3 => self.head_s3(bucket, key, max_bytes).await, + } + } + + pub async fn stream_object( + &self, + bucket: &str, + key: &str, + range_header: Option<&str>, + ) -> Result { + self.stream_object_inner(ObjectStreamPlan { + bucket, + key, + max_bytes: constants::MAX_MEDIA_PROXY_BYTES, + range: match range_header { + Some(header) => StreamRange::Header(header), + None => StreamRange::Full, + }, + expected_identity: None, + }) + .await + } + + pub async fn stream_object_limited( + &self, + request: ObjectStreamRequest<'_>, + ) -> Result { + self.stream_object_inner(ObjectStreamPlan { + bucket: request.bucket, + key: request.key, + max_bytes: request.max_bytes, + range: match request.byte_range { + Some(byte_range) => StreamRange::Bytes(byte_range), + None => StreamRange::Full, + }, + expected_identity: Some(request.expected_identity), + }) + .await + } + + async fn stream_object_inner( + &self, + plan: ObjectStreamPlan<'_>, + ) -> Result { + let result = match self.cfg.storage.backend { + StorageBackend::Local => self.stream_local(plan).await, + StorageBackend::S3 => self.stream_s3(plan).await, + }; + record_storage_outcome(&self.metrics, &result); + result + } +} diff --git a/fluxer_media_proxy/src/storage/relay_body.rs b/fluxer_media_proxy/src/storage/relay_body.rs new file mode 100644 index 000000000..fd34690d2 --- /dev/null +++ b/fluxer_media_proxy/src/storage/relay_body.rs @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use bytes::Bytes; +use futures_util::Stream; +use http_body::{Frame, SizeHint}; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, ReadBuf}; + +const RELAY_BODY_CHUNK_BYTES: usize = 256 * 1024; + +pub type RelayBodyChunks = + Pin> + Send + 'static>>; + +pub enum RelayBody { + Spooled(tokio::fs::File), + Streamed(RelayBodyChunks), +} + +pub struct RelayPutOptions { + pub body: RelayBody, + pub content_length: u64, + pub content_type: Option, + pub upload_id: Option, + pub part_number: Option, + pub timeout_ms: u64, +} + +pub(super) struct SizedFileBody { + file: tokio::fs::File, + remaining: u64, +} + +impl SizedFileBody { + pub(super) fn new(file: tokio::fs::File, len: u64) -> Self { + Self { + file, + remaining: len, + } + } +} + +impl http_body::Body for SizedFileBody { + type Data = Bytes; + type Error = std::io::Error; + + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + if self.remaining == 0 { + return Poll::Ready(None); + } + let chunk_len = self.remaining.min(RELAY_BODY_CHUNK_BYTES as u64) as usize; + let mut buffer = vec![0u8; chunk_len]; + let read = { + let mut read_buf = ReadBuf::new(&mut buffer); + match Pin::new(&mut self.file).poll_read(cx, &mut read_buf) { + Poll::Ready(Ok(())) => read_buf.filled().len(), + Poll::Ready(Err(err)) => return Poll::Ready(Some(Err(err))), + Poll::Pending => return Poll::Pending, + } + }; + if read == 0 { + return Poll::Ready(Some(Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "upload relay body ended before declared content length", + )))); + } + buffer.truncate(read); + self.remaining = self.remaining.saturating_sub(read as u64); + Poll::Ready(Some(Ok(Frame::data(Bytes::from(buffer))))) + } + + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(self.remaining) + } +} diff --git a/fluxer_media_proxy/src/storage/response_body.rs b/fluxer_media_proxy/src/storage/response_body.rs new file mode 100644 index 000000000..a774b7f05 --- /dev/null +++ b/fluxer_media_proxy/src/storage/response_body.rs @@ -0,0 +1,614 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::{BudgetedBytes, ByteBudget, ByteReservation}, + http_headers, range, response_body_limit, + storage::StorageError, +}; +use bytes::Bytes; +use futures_util::{Stream, StreamExt as _}; +use http::{HeaderMap, StatusCode, header}; +use parking_lot::Mutex; +use std::{ + io, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt as _, ReadBuf}, + sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError}, +}; + +struct ReadBufferBudget { + reservation: ByteReservation, + reserved_bytes: usize, +} + +impl ReadBufferBudget { + fn reserve(budget: &ByteBudget, bytes: usize) -> Result { + let Some(reservation) = budget.try_reserve(bytes) else { + return Err(StorageError::BufferBudgetExhausted); + }; + Ok(Self { + reservation, + reserved_bytes: bytes, + }) + } + + fn grow_to(&mut self, required_bytes: usize) -> Result<(), StorageError> { + if required_bytes <= self.reserved_bytes { + return Ok(()); + } + let additional = required_bytes - self.reserved_bytes; + if !self.reservation.try_grow(additional) { + return Err(StorageError::BufferBudgetExhausted); + } + self.reserved_bytes = required_bytes; + Ok(()) + } + + fn shrink_to(&mut self, bytes: usize) { + self.reservation.shrink_to(bytes); + self.reserved_bytes = bytes; + } + + fn into_reservation(self) -> ByteReservation { + self.reservation + } +} + +fn provider_status_error(status: StatusCode) -> StorageError { + if status == StatusCode::NOT_FOUND { + return StorageError::ObjectChanged; + } + StorageError::ObjectStorage(anyhow::anyhow!( + "object storage provider rejected a read with status {status}" + )) +} + +pub(super) async fn read_response_bytes( + mut response: reqwest::Response, + expected_length: usize, + budget: &ByteBudget, +) -> Result { + let _transport_chunk_reservation = budget + .try_reserve(response_body_limit::RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX) + .ok_or(StorageError::BufferBudgetExhausted)?; + let mut buffer_budget = ReadBufferBudget::reserve(budget, expected_length)?; + let mut body = Vec::new(); + body.try_reserve_exact(expected_length) + .map_err(|_| StorageError::BufferAllocationFailed)?; + buffer_budget.grow_to(body.capacity())?; + let mut chunks_read = 0_u64; + let chunks_max = response_body_limit::response_body_chunk_limit(expected_length as u64); + while let Some(chunk) = response.chunk().await? { + if chunk.len() > response_body_limit::RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX { + return Err(StorageError::ObjectStorage(anyhow::anyhow!( + "object storage response transport chunk exceeded its byte bound" + ))); + } + chunks_read = chunks_read + .checked_add(1) + .filter(|chunks| *chunks <= chunks_max) + .ok_or_else(|| { + StorageError::ObjectStorage(anyhow::anyhow!( + "object storage response exceeded its chunk limit" + )) + })?; + let next_length = body + .len() + .checked_add(chunk.len()) + .filter(|length| *length <= expected_length) + .ok_or(StorageError::ObjectChanged)?; + body.extend_from_slice(&chunk); + assert_eq!(body.len(), next_length); + } + if body.len() != expected_length { + return Err(StorageError::ObjectChanged); + } + buffer_budget.shrink_to(body.capacity()); + Ok(BudgetedBytes::budgeted( + Bytes::from(body), + buffer_budget.into_reservation(), + )) +} + +pub(super) async fn read_exact_bytes( + reader: impl AsyncRead + Unpin, + expected_length: usize, + budget: &ByteBudget, +) -> Result { + let mut buffer_budget = ReadBufferBudget::reserve(budget, expected_length)?; + let mut body = Vec::new(); + body.try_reserve_exact(expected_length) + .map_err(|_| StorageError::BufferAllocationFailed)?; + buffer_budget.grow_to(body.capacity())?; + let mut limited = reader.take(expected_length as u64); + while body.len() < expected_length { + if limited.read_buf(&mut body).await? == 0 { + return Err(StorageError::ObjectChanged); + } + assert!(body.len() <= expected_length); + } + let mut reader = limited.into_inner(); + let mut extra = [0u8; 1]; + if reader.read(&mut extra).await? != 0 { + return Err(StorageError::ObjectChanged); + } + buffer_budget.shrink_to(body.capacity()); + Ok(BudgetedBytes::budgeted( + Bytes::from(body), + buffer_budget.into_reservation(), + )) +} + +pub(super) struct StreamResponseValidation<'a> { + pub(super) status: StatusCode, + pub(super) headers: &'a HeaderMap, + pub(super) total_length: u64, + pub(super) expected_length: u64, + pub(super) byte_range: Option, +} + +pub(super) fn validate_stream_response( + validation: StreamResponseValidation<'_>, +) -> Result<(), StorageError> { + let StreamResponseValidation { + status, + headers, + total_length, + expected_length, + byte_range, + } = validation; + if !status.is_success() { + return Err(provider_status_error(status)); + } + let content_length = http_headers::parse_content_length(headers).ok_or_else(|| { + StorageError::ObjectStorage(anyhow::anyhow!( + "object storage provider omitted a single valid Content-Length" + )) + })?; + if content_length != expected_length { + return Err(StorageError::ObjectChanged); + } + match byte_range { + None if status == StatusCode::OK && expected_length == total_length => Ok(()), + Some(range) if status == StatusCode::PARTIAL_CONTENT => { + let mut content_ranges = headers.get_all(header::CONTENT_RANGE).iter(); + let content_range = content_ranges.next().and_then(|value| value.to_str().ok()); + if content_ranges.next().is_some() { + return Err(StorageError::ObjectStorage(anyhow::anyhow!( + "object storage provider returned multiple Content-Range values" + ))); + } + let actual = range::parse_content_range(content_range).ok_or_else(|| { + StorageError::ObjectStorage(anyhow::anyhow!( + "object storage provider returned an invalid Content-Range" + )) + })?; + let total_length = + usize::try_from(total_length).map_err(|_| StorageError::StreamTooLong)?; + if actual.start != range.start + || actual.end != range.end + || actual.size != Some(total_length) + { + return Err(StorageError::ObjectChanged); + } + Ok(()) + } + Some(range) + if status == StatusCode::OK + && range.start == 0 + && range.end.checked_add(1) == usize::try_from(total_length).ok() => + { + Ok(()) + } + _ => Err(StorageError::ObjectStorage(anyhow::anyhow!( + "object storage provider returned an unexpected successful status {status}" + ))), + } +} + +pub(super) type ByteStream = + Pin> + Send + 'static>>; + +struct LocalStreamBufferData { + data: Vec, + _reservation: Option, + _slot: Option, +} + +#[derive(Clone)] +pub(super) struct LocalStreamBufferPool { + inner: Arc, +} + +const LOCAL_STREAM_FALLBACK_BUFFER_BYTES: usize = 4 * 1024; + +impl LocalStreamBufferData { + fn unpooled(data: Vec) -> Self { + Self { + data, + _reservation: None, + _slot: None, + } + } +} + +struct LocalStreamBufferPoolInner { + available: Mutex>, + budget: ByteBudget, + slots: Arc, + max_buffer_bytes: usize, + max_buffers: usize, +} + +struct LocalStreamBuffer { + data: Option, + pool: LocalStreamBufferPool, +} + +impl AsRef<[u8]> for LocalStreamBuffer { + fn as_ref(&self) -> &[u8] { + &self + .data + .as_ref() + .expect("local stream buffer owner must retain its data") + .data + } +} + +struct LocalReaderStream { + reader: R, + buffer: Option, + buffer_pool: LocalStreamBufferPool, + capacity: usize, + state: LocalReaderState, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LocalReaderState { + Active, + Terminated, +} + +impl LocalReaderStream { + fn new( + reader: R, + buffer_pool: LocalStreamBufferPool, + capacity: usize, + ) -> Result { + assert!(capacity > 0, "local reader stream capacity must be nonzero"); + let buffer = buffer_pool.acquire(capacity).map_err(|error| match error { + LocalStreamBufferError::AllocationFailed => StorageError::BufferAllocationFailed, + })?; + Ok(Self { + reader, + buffer: Some(buffer), + buffer_pool, + capacity, + state: LocalReaderState::Active, + }) + } + + fn replenish_buffer(&mut self) -> Result<(), io::Error> { + assert!(self.buffer.is_none()); + let buffer = self + .buffer_pool + .acquire(self.capacity) + .map_err(|error| match error { + LocalStreamBufferError::AllocationFailed => { + io::Error::from(io::ErrorKind::OutOfMemory) + } + })?; + self.buffer = Some(buffer); + Ok(()) + } + + fn terminate(&mut self) { + self.state = LocalReaderState::Terminated; + self.buffer = None; + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LocalStreamBufferError { + AllocationFailed, +} + +impl LocalStreamBufferPool { + pub(super) fn new(max_buffer_bytes: usize, max_buffers: usize) -> anyhow::Result { + anyhow::ensure!( + max_buffer_bytes > 0, + "local stream buffer size must be nonzero" + ); + anyhow::ensure!( + max_buffers > 0, + "local stream buffer pool capacity must be nonzero" + ); + let budget_bytes = max_buffer_bytes + .checked_mul(max_buffers) + .ok_or_else(|| anyhow::anyhow!("object storage stream buffer budget overflowed"))?; + let mut available = Vec::new(); + available + .try_reserve_exact(max_buffers) + .map_err(|_| anyhow::anyhow!("object storage stream buffer pool allocation failed"))?; + Ok(Self { + inner: Arc::new(LocalStreamBufferPoolInner { + available: Mutex::new(available), + budget: ByteBudget::new(budget_bytes), + slots: Arc::new(Semaphore::new(max_buffers)), + max_buffer_bytes, + max_buffers, + }), + }) + } + + fn acquire(&self, capacity: usize) -> Result { + assert!(capacity > 0); + assert!(capacity <= self.inner.max_buffer_bytes); + let available = self.inner.available.lock().pop(); + if let Some(data) = available { + if data.data.capacity() >= capacity { + return Ok(LocalStreamBuffer { + data: Some(data), + pool: self.clone(), + }); + } + drop(data); + } + let data = allocate_local_stream_buffer(&self.inner.slots, &self.inner.budget, capacity)?; + Ok(LocalStreamBuffer { + data: Some(data), + pool: self.clone(), + }) + } + + fn release(&self, mut data: LocalStreamBufferData) { + if data._slot.is_none() { + return; + } + data.data.clear(); + let mut available = self.inner.available.lock(); + if available.len() < self.inner.max_buffers { + available.push(data); + } + } +} + +impl LocalStreamBuffer { + fn capacity(&self) -> usize { + self.data + .as_ref() + .expect("local stream buffer owner must retain its data") + .data + .capacity() + } + + fn read_buffer(&mut self, capacity: usize) -> ReadBuf<'_> { + let data = &mut self + .data + .as_mut() + .expect("local stream buffer owner must retain its data") + .data; + assert!(data.is_empty()); + assert!(capacity > 0); + assert!(capacity <= data.capacity()); + let spare = data.spare_capacity_mut(); + ReadBuf::uninit(&mut spare[..capacity]) + } + + fn finish_read(&mut self, length: usize, capacity: usize) { + let data = &mut self + .data + .as_mut() + .expect("local stream buffer owner must retain its data") + .data; + assert!(data.is_empty()); + assert!(length > 0); + assert!(length <= capacity); + assert!(capacity <= data.capacity()); + unsafe { data.set_len(length) }; + } +} + +impl Drop for LocalStreamBuffer { + fn drop(&mut self) { + if let Some(data) = self.data.take() { + self.pool.release(data); + } + } +} + +fn unpooled_local_stream_buffer( + capacity: usize, +) -> Result { + let capacity = capacity.min(LOCAL_STREAM_FALLBACK_BUFFER_BYTES); + let mut data = Vec::new(); + data.try_reserve_exact(capacity) + .map_err(|_| LocalStreamBufferError::AllocationFailed)?; + Ok(LocalStreamBufferData::unpooled(data)) +} + +fn allocate_local_stream_buffer( + slots: &Arc, + budget: &ByteBudget, + capacity: usize, +) -> Result { + let pooled = match Arc::clone(slots).try_acquire_owned() { + Ok(slot) => budget + .try_reserve(capacity) + .map(|reservation| (slot, reservation)), + Err(TryAcquireError::NoPermits) => None, + Err(TryAcquireError::Closed) => { + unreachable!("local stream buffer capacity semaphore is never closed") + } + }; + let Some((slot, mut reservation)) = pooled else { + return unpooled_local_stream_buffer(capacity); + }; + let mut data = Vec::new(); + data.try_reserve_exact(capacity) + .map_err(|_| LocalStreamBufferError::AllocationFailed)?; + if data.capacity() > reservation.amount() + && !reservation.try_grow(data.capacity() - reservation.amount()) + { + return unpooled_local_stream_buffer(capacity); + } + reservation.shrink_to(data.capacity()); + Ok(LocalStreamBufferData { + data, + _reservation: Some(reservation), + _slot: Some(slot), + }) +} + +impl Stream for LocalReaderStream +where + R: AsyncRead + Unpin, +{ + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { + if self.state == LocalReaderState::Terminated { + return Poll::Ready(None); + } + if self.buffer.is_none() + && let Err(error) = self.replenish_buffer() + { + self.terminate(); + return Poll::Ready(Some(Err(error))); + } + + let this = self.as_mut().get_mut(); + let mut capacity = this.capacity; + let (read_result, bytes_read) = { + let buffer = this + .buffer + .as_mut() + .expect("active local reader must retain its stream buffer"); + capacity = capacity.min(buffer.capacity()); + let mut read_buffer = buffer.read_buffer(capacity); + let read_result = Pin::new(&mut this.reader).poll_read(context, &mut read_buffer); + (read_result, read_buffer.filled().len()) + }; + match read_result { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(error)) => { + this.terminate(); + Poll::Ready(Some(Err(error))) + } + Poll::Ready(Ok(())) if bytes_read == 0 => { + this.terminate(); + Poll::Ready(None) + } + Poll::Ready(Ok(())) => { + let mut buffer = this + .buffer + .take() + .expect("active local reader must retain its stream buffer"); + buffer.finish_read(bytes_read, capacity); + Poll::Ready(Some(Ok(Bytes::from_owner(buffer)))) + } + } + } +} + +pub(super) fn local_reader_stream( + reader: impl AsyncRead + Unpin + Send + 'static, + buffer_pool: LocalStreamBufferPool, + capacity: usize, +) -> Result { + Ok(Box::pin(LocalReaderStream::new( + reader, + buffer_pool, + capacity, + )?)) +} + +pub(super) fn exact_response_stream( + response: reqwest::Response, + expected_length: u64, +) -> impl Stream> + Send + 'static { + let stream = response + .bytes_stream() + .map(|result| result.map_err(|error| std::io::Error::other(error.without_url()))); + exact_stream( + Box::pin(stream), + expected_length, + ExactStreamEnd::RequireSourceEnd, + ) +} + +pub(super) fn exact_byte_stream( + stream: ByteStream, + expected_length: u64, +) -> impl Stream> + Send + 'static { + exact_stream(stream, expected_length, ExactStreamEnd::LengthBounded) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ExactStreamEnd { + RequireSourceEnd, + LengthBounded, +} + +fn exact_stream( + stream: ByteStream, + expected_length: u64, + end: ExactStreamEnd, +) -> impl Stream> + Send + 'static { + // Only a chunk that carries no bytes needs a count bound. The byte accounting below already + // bounds every other chunk, and counting them all aborts a legitimate transfer whenever the + // transport hands over reads smaller than the assumed average chunk size. + let empty_chunks_remaining = response_body_limit::response_body_chunk_limit(expected_length); + futures_util::stream::try_unfold( + (stream, expected_length, empty_chunks_remaining, end), + |(mut stream, remaining, mut empty_chunks_remaining, end)| async move { + if remaining == 0 && end == ExactStreamEnd::LengthBounded { + return Ok(None); + } + loop { + let next = stream.next().await; + match next { + Some(Ok(chunk)) if chunk.is_empty() => { + empty_chunks_remaining = + empty_chunks_remaining.checked_sub(1).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "object storage stream exceeded its empty chunk limit", + ) + })?; + } + Some(Ok(chunk)) => { + let chunk_length = chunk.len() as u64; + if remaining == 0 || chunk_length > remaining { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "object storage stream exceeded its mapped content length", + )); + } + return Ok(Some(( + chunk, + ( + stream, + remaining - chunk_length, + empty_chunks_remaining, + end, + ), + ))); + } + Some(Err(error)) => return Err(error), + None if remaining == 0 => return Ok(None), + None => { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "object storage stream ended before its mapped content length", + )); + } + } + } + }, + ) +} diff --git a/fluxer_media_proxy/src/storage/s3.rs b/fluxer_media_proxy/src/storage/s3.rs new file mode 100644 index 000000000..df4e8ac02 --- /dev/null +++ b/fluxer_media_proxy/src/storage/s3.rs @@ -0,0 +1,499 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{ + BufferedObjectReadRequest, BufferedStorageObject, ContentDigestRequest, HeadResult, + ObjectStreamPlan, StorageError, Store, StreamObject, StreamRange, + identity::{RemoteSourceObject, SourceObjectIdentity, remote_source_object_identity}, + relay_body::{RelayBody, RelayPutOptions, SizedFileBody}, + response_body::{ + StreamResponseValidation, exact_response_stream, read_response_bytes, + validate_stream_response, + }, +}; +use crate::{aws_sigv4, byte_budget::BudgetedBytes, config::Config, http_headers, range}; +use axum::body::Body; +use http::{HeaderMap, HeaderName, StatusCode, header}; +use percent_encoding::{AsciiSet, CONTROLS, percent_encode}; +use reqwest::Method; +use sha2::{Digest as _, Sha256}; +use std::time::Duration; +use tokio::io::AsyncSeekExt as _; + +pub(super) const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; + +const QUERY_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'&') + .add(b'+') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + +impl Store { + pub(super) async fn ensure_bucket_s3(&self, bucket: &str) -> Result<(), StorageError> { + let url = self.s3_bucket_url(bucket)?; + let signed = self.sign(Method::PUT, &url, &[], None, &[])?; + let response = self + .client + .put(&url) + .headers(signed_headers(&signed, &self.cfg)) + .send() + .await?; + if response.status().is_success() || response.status() == StatusCode::CONFLICT { + Ok(()) + } else { + Err(StorageError::S3(response.status().to_string())) + } + } + + pub(super) async fn read_s3( + &self, + request: BufferedObjectReadRequest<'_>, + ) -> Result { + let url = self.s3_read_url(request.bucket, request.key)?; + let if_match = request + .expected_identity + .and_then(SourceObjectIdentity::etag); + let extra = if_match.map(|value| aws_sigv4::Header { + name: "If-Match", + value, + }); + let mut headers = self.read_headers(request.bucket, Method::GET, &url, extra.as_slice())?; + if let Some(value) = if_match { + headers.insert( + header::IF_MATCH, + value.parse().map_err(|_| StorageError::ObjectChanged)?, + ); + } + let response = self.client.get(&url).headers(headers).send().await?; + let status = response.status(); + if let Some(error) = + self.read_status_error(request.bucket, status, request.expected_identity.is_some()) + { + return Err(error); + } + if !status.is_success() { + return Err(StorageError::S3(s3_error_summary(response).await)); + } + let content_length = http_headers::parse_content_length(response.headers()); + if let Some(content_length) = content_length + && content_length > request.limit as u64 + { + return Err(StorageError::StreamTooLong); + } + let content_type = response_content_type(response.headers()); + if let Some(expected) = request.expected_identity + && expected.etag().is_none() + { + let Some(content_length) = content_length else { + return Err(StorageError::ObjectChanged); + }; + let identity = remote_source_object_identity(RemoteSourceObject { + bucket: request.bucket, + key: request.key, + content_length, + content_type: &content_type, + etag: header_str(response.headers(), header::ETAG).as_deref(), + last_modified: header_str(response.headers(), header::LAST_MODIFIED).as_deref(), + }); + if identity != *expected { + return Err(StorageError::ObjectChanged); + } + } + let data = match content_length { + Some(content_length) => { + let expected_length = + usize::try_from(content_length).map_err(|_| StorageError::StreamTooLong)?; + read_response_bytes(response, expected_length, request.budget).await? + } + None => { + let data = response.bytes().await?; + if data.len() > request.limit { + return Err(StorageError::StreamTooLong); + } + BudgetedBytes::unbudgeted(data) + } + }; + Ok(BufferedStorageObject { + content_digest: match request.content_digest { + ContentDigestRequest::Omit => None, + ContentDigestRequest::Include => Some(Sha256::digest(data.as_ref()).into()), + }, + data, + content_type, + }) + } + + pub(super) async fn head_s3( + &self, + bucket: &str, + key: &str, + max_bytes: usize, + ) -> Result { + // A HEAD is a read, so it follows the same endpoint and signing policy as a body read. + // Sending it to the write origin instead would bypass the configured read endpoint and + // attach credentials to a read the operator configured as unsigned. + let url = self.s3_read_url(bucket, key)?; + let headers = self.read_headers(bucket, Method::HEAD, &url, &[])?; + let response = self.client.head(&url).headers(headers).send().await?; + let status = response.status(); + if let Some(error) = self.read_status_error(bucket, status, false) { + return Err(error); + } + if !status.is_success() { + return Err(StorageError::S3(s3_error_summary(response).await)); + } + let content_length = http_headers::parse_content_length(response.headers()).unwrap_or(0); + if content_length > max_bytes as u64 { + return Err(StorageError::StreamTooLong); + } + let content_type = response_content_type(response.headers()); + let identity = remote_source_object_identity(RemoteSourceObject { + bucket, + key, + content_length, + content_type: &content_type, + etag: header_str(response.headers(), header::ETAG).as_deref(), + last_modified: header_str(response.headers(), header::LAST_MODIFIED).as_deref(), + }); + Ok(HeadResult { + content_length, + content_type, + identity, + }) + } + + pub(super) async fn stream_s3( + &self, + plan: ObjectStreamPlan<'_>, + ) -> Result { + let url = self.s3_read_url(plan.bucket, plan.key)?; + let range_value = match plan.range { + StreamRange::Full => None, + StreamRange::Header(header) => Some(header.to_owned()), + StreamRange::Bytes(byte_range) => { + Some(format!("bytes={}-{}", byte_range.start, byte_range.end)) + } + }; + let if_match = plan.expected_identity.and_then(SourceObjectIdentity::etag); + let mut extra = Vec::new(); + if let Some(value) = range_value.as_deref() { + extra.push(aws_sigv4::Header { + name: "Range", + value, + }); + } + if let Some(value) = if_match { + extra.push(aws_sigv4::Header { + name: "If-Match", + value, + }); + } + let mut headers = self.read_headers(plan.bucket, Method::GET, &url, &extra)?; + if let Some(value) = range_value.as_deref() { + headers.insert( + header::RANGE, + value + .parse() + .map_err(|_| StorageError::S3("invalid Range header".to_owned()))?, + ); + } + if let Some(value) = if_match { + headers.insert( + header::IF_MATCH, + value.parse().map_err(|_| StorageError::ObjectChanged)?, + ); + } + let response = self.client.get(&url).headers(headers).send().await?; + let status = response.status(); + if let Some(error) = + self.read_status_error(plan.bucket, status, plan.expected_identity.is_some()) + { + return Err(error); + } + // An upstream 416 is an answer about the range, not a transport failure. Reporting it as + // a storage error would surface 502 to a client that merely asked for a span past the end + // of the object, which is a routine thing for a video player to do while seeking. + if status == StatusCode::RANGE_NOT_SATISFIABLE { + let total_length = header_str(response.headers(), header::CONTENT_RANGE) + .and_then(|value| range::parse_unsatisfiable_content_range(Some(&value))) + .map(|total| total as u64); + return Ok(StreamObject { + body: Body::empty(), + status, + content_length: Some(0), + content_type: String::new(), + byte_range: None, + total_length, + }); + } + if !status.is_success() { + return Err(StorageError::S3(s3_error_summary(response).await)); + } + if let Some(expected) = plan.expected_identity { + return versioned_stream_object(response, plan, expected); + } + let content_length = http_headers::parse_content_length(response.headers()); + if let Some(content_length) = content_length + && matches!(plan.range, StreamRange::Full) + && content_length > plan.max_bytes as u64 + { + return Err(StorageError::StreamTooLong); + } + let content_type = response_content_type(response.headers()); + let content_range = header_str(response.headers(), header::CONTENT_RANGE) + .and_then(|value| range::parse_content_range(Some(&value))); + let byte_range = content_range.map(|cr| range::ByteRange { + start: cr.start, + end: cr.end, + }); + let total_length = content_range + .and_then(|cr| cr.size) + .or(content_length.map(|len| len as usize)) + .map(|len| len as u64); + let body = match content_length { + Some(content_length) => { + Body::from_stream(exact_response_stream(response, content_length)) + } + None => Body::from_stream(response.bytes_stream()), + }; + Ok(StreamObject { + body, + status, + content_length, + content_type, + byte_range, + total_length, + }) + } + + pub(super) async fn write_s3( + &self, + bucket: &str, + key: &str, + data: &[u8], + content_type: &str, + ) -> Result<(), StorageError> { + let url = self.s3_url(bucket, key)?; + let extra = [aws_sigv4::Header { + name: "Content-Type", + value: content_type, + }]; + let signed = self.sign(Method::PUT, &url, data, None, &extra)?; + let mut headers = signed_headers(&signed, &self.cfg); + headers.insert(header::CONTENT_TYPE, content_type_header(content_type)); + let response = self + .client + .put(&url) + .headers(headers) + .body(data.to_vec()) + .send() + .await?; + if !response.status().is_success() { + return Err(StorageError::S3(response.status().to_string())); + } + Ok(()) + } + + pub(super) async fn relay_put_s3( + &self, + bucket: &str, + key: &str, + options: RelayPutOptions, + ) -> Result, StorageError> { + let mut url = self.s3_url(bucket, key)?; + if let (Some(upload_id), Some(part_number)) = (&options.upload_id, options.part_number) { + url.push_str(if url.contains('?') { "&" } else { "?" }); + url.push_str("partNumber="); + url.push_str( + &percent_encode(part_number.to_string().as_bytes(), QUERY_ENCODE_SET).to_string(), + ); + url.push_str("&uploadId="); + url.push_str(&percent_encode(upload_id.as_bytes(), QUERY_ENCODE_SET).to_string()); + } + let content_type = options + .content_type + .as_deref() + .unwrap_or("application/octet-stream"); + let extra = [aws_sigv4::Header { + name: "Content-Type", + value: content_type, + }]; + let signed = self.sign(Method::PUT, &url, &[], Some(UNSIGNED_PAYLOAD), &extra)?; + let mut headers = signed_headers(&signed, &self.cfg); + headers.insert(header::CONTENT_TYPE, content_type_header(content_type)); + headers.insert( + header::CONTENT_LENGTH, + header::HeaderValue::from(options.content_length), + ); + let body = match options.body { + RelayBody::Spooled(mut file) => { + file.seek(std::io::SeekFrom::Start(0)).await?; + reqwest::Body::wrap(SizedFileBody::new(file, options.content_length)) + } + RelayBody::Streamed(chunks) => reqwest::Body::wrap_stream(chunks), + }; + let response = self + .raw_client + .put(&url) + .headers(headers) + .timeout(Duration::from_millis(options.timeout_ms.max(1))) + .body(body) + .send() + .await?; + if !response.status().is_success() { + return Err(StorageError::S3(s3_error_summary(response).await)); + } + Ok(header_str(response.headers(), header::ETAG)) + } + + pub(super) fn sign( + &self, + method: Method, + url: &str, + payload: &[u8], + payload_hash_override: Option<&str>, + extra_signed_headers: &[aws_sigv4::Header<'_>], + ) -> Result { + let mut options = aws_sigv4::Options::new( + method.as_str(), + url, + &self.cfg.storage.s3_region, + &self.cfg.storage.s3_access_key_id, + &self.cfg.storage.s3_secret_access_key, + ); + options.payload = payload; + options.payload_hash_override = payload_hash_override; + options.extra_signed_headers = extra_signed_headers; + options.session_token = &self.cfg.storage.s3_session_token; + Ok(aws_sigv4::sign(options)?) + } +} + +fn versioned_stream_object( + response: reqwest::Response, + plan: ObjectStreamPlan<'_>, + expected: &SourceObjectIdentity, +) -> Result { + let status = response.status(); + let total_length = expected.content_length(); + let byte_range = match plan.range { + StreamRange::Bytes(byte_range) => Some(byte_range), + StreamRange::Full | StreamRange::Header(_) => None, + }; + let expected_length = byte_range.map_or(total_length, |byte_range| { + (byte_range.end - byte_range.start + 1) as u64 + }); + validate_stream_response(StreamResponseValidation { + status, + headers: response.headers(), + total_length, + expected_length, + byte_range, + })?; + if expected.etag().is_none() { + let identity = remote_source_object_identity(RemoteSourceObject { + bucket: plan.bucket, + key: plan.key, + content_length: total_length, + content_type: &response_content_type(response.headers()), + etag: header_str(response.headers(), header::ETAG).as_deref(), + last_modified: header_str(response.headers(), header::LAST_MODIFIED).as_deref(), + }); + if identity != *expected { + return Err(StorageError::ObjectChanged); + } + } + Ok(StreamObject { + body: Body::from_stream(exact_response_stream(response, expected_length)), + status: if byte_range.is_some() { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }, + content_length: Some(expected_length), + content_type: expected.content_type().to_owned(), + byte_range, + total_length: Some(total_length), + }) +} + +fn content_type_header(content_type: &str) -> header::HeaderValue { + content_type + .parse() + .unwrap_or_else(|_| header::HeaderValue::from_static("application/octet-stream")) +} + +fn response_content_type(headers: &HeaderMap) -> String { + header_str(headers, header::CONTENT_TYPE) + .unwrap_or_else(|| "application/octet-stream".to_owned()) +} + +fn header_str(headers: &HeaderMap, name: HeaderName) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned) +} + +pub(super) fn signed_headers( + signed: &aws_sigv4::SignedRequest, + cfg: &Config, +) -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + header::HOST, + signed.host.parse().expect("signed host is a valid header"), + ); + headers.insert( + "x-amz-content-sha256", + signed.payload_hash.parse().expect("payload hash is ASCII"), + ); + headers.insert( + "x-amz-date", + signed.amz_date.parse().expect("date is ASCII"), + ); + headers.insert( + header::AUTHORIZATION, + signed + .authorization + .parse() + .expect("authorization is ASCII"), + ); + if !cfg.storage.s3_session_token.is_empty() { + headers.insert( + "x-amz-security-token", + cfg.storage + .s3_session_token + .parse() + .expect("session token is ASCII"), + ); + } + headers +} + +pub(super) async fn s3_error_summary(response: reqwest::Response) -> String { + let status = response.status(); + let body = response + .bytes() + .await + .map(|b| b.to_vec()) + .unwrap_or_default(); + let snippet: String = String::from_utf8_lossy(&body) + .chars() + .filter(|c| !c.is_control() || *c == ' ') + .take(512) + .collect(); + if snippet.is_empty() { + status.to_string() + } else { + format!("{status}: {snippet}") + } +} diff --git a/fluxer_media_proxy/src/storage/s3_endpoint.rs b/fluxer_media_proxy/src/storage/s3_endpoint.rs new file mode 100644 index 000000000..6c5d07aa6 --- /dev/null +++ b/fluxer_media_proxy/src/storage/s3_endpoint.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{StorageError, Store, s3::signed_headers}; +use crate::{ + aws_sigv4, + config::{BucketStyle, Config}, +}; +use http::StatusCode; +use percent_encoding::{AsciiSet, percent_encode}; +use reqwest::Method; + +const PATH_ENCODE_SET: &AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~') + .remove(b'/'); + +impl Store { + pub(super) fn s3_url(&self, bucket: &str, key: &str) -> Result { + super::keys::safe_bucket(bucket)?; + super::keys::safe_key(key)?; + object_url( + &self.cfg.storage.s3_endpoint, + write_bucket_style(&self.cfg), + bucket, + key, + ) + } + + pub(super) fn s3_read_url(&self, bucket: &str, key: &str) -> Result { + let Some(endpoint) = self.read_endpoint_for(bucket) else { + return self.s3_url(bucket, key); + }; + super::keys::safe_bucket(bucket)?; + super::keys::safe_key(key)?; + object_url(endpoint, self.cfg.storage.s3_read_bucket_style, bucket, key) + } + + pub(super) fn read_endpoint_for(&self, bucket: &str) -> Option<&str> { + self.cfg + .storage + .s3_read_endpoint + .as_deref() + .filter(|_| bucket == self.cfg.storage.s3_read_bucket) + } + + pub(super) fn read_is_unsigned(&self, bucket: &str) -> bool { + self.read_endpoint_for(bucket).is_some() && !self.cfg.storage.s3_read_signed + } + + pub(super) fn read_headers( + &self, + bucket: &str, + method: Method, + url: &str, + extra_signed_headers: &[aws_sigv4::Header<'_>], + ) -> Result { + if self.read_is_unsigned(bucket) { + return Ok(reqwest::header::HeaderMap::new()); + } + let signed = self.sign(method, url, &[], None, extra_signed_headers)?; + Ok(signed_headers(&signed, &self.cfg)) + } + + pub(super) fn read_status_error( + &self, + bucket: &str, + status: StatusCode, + versioned: bool, + ) -> Option { + if status == StatusCode::NOT_FOUND + || (status == StatusCode::FORBIDDEN && self.read_is_unsigned(bucket)) + { + return Some(if versioned { + StorageError::ObjectChanged + } else { + StorageError::NotFound + }); + } + if status == StatusCode::PRECONDITION_FAILED { + return Some(StorageError::ObjectChanged); + } + None + } + + pub(super) fn s3_bucket_url(&self, bucket: &str) -> Result { + super::keys::safe_bucket(bucket)?; + let endpoint = &self.cfg.storage.s3_endpoint; + if endpoint.is_empty() { + return Err(StorageError::InvalidS3Endpoint); + } + let endpoint = endpoint.trim_end_matches('/'); + if self.cfg.storage.s3_force_path_style { + return Ok(format!("{endpoint}/{bucket}")); + } + let (scheme, host, port, base_path) = virtual_hosted_endpoint(endpoint, bucket)?; + Ok(format!("{scheme}://{bucket}.{host}{port}{base_path}")) + } +} + +fn write_bucket_style(cfg: &Config) -> BucketStyle { + if cfg.storage.s3_force_path_style { + BucketStyle::Path + } else { + BucketStyle::VirtualHosted + } +} + +fn object_url( + endpoint: &str, + style: BucketStyle, + bucket: &str, + key: &str, +) -> Result { + if endpoint.is_empty() { + return Err(StorageError::InvalidS3Endpoint); + } + let endpoint = endpoint.trim_end_matches('/'); + let encoded_key = percent_encode(key.as_bytes(), PATH_ENCODE_SET).to_string(); + match style { + BucketStyle::Path => Ok(format!("{endpoint}/{bucket}/{encoded_key}")), + BucketStyle::Rooted => Ok(format!("{endpoint}/{encoded_key}")), + BucketStyle::VirtualHosted => { + let (scheme, host, port, base_path) = virtual_hosted_endpoint(endpoint, bucket)?; + Ok(format!( + "{scheme}://{bucket}.{host}{port}{base_path}/{encoded_key}" + )) + } + } +} + +fn virtual_hosted_endpoint( + endpoint: &str, + bucket: &str, +) -> Result<(String, String, String, String), StorageError> { + super::keys::validate_virtual_hosted_bucket(bucket)?; + let parsed = url::Url::parse(endpoint).map_err(|_| StorageError::InvalidS3Endpoint)?; + let host = parsed + .host_str() + .ok_or(StorageError::InvalidS3Endpoint)? + .to_owned(); + let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default(); + Ok(( + parsed.scheme().to_owned(), + host, + port, + parsed.path().trim_end_matches('/').to_owned(), + )) +} diff --git a/fluxer_media_proxy/src/storage/source_read.rs b/fluxer_media_proxy/src/storage/source_read.rs new file mode 100644 index 000000000..0539bb498 --- /dev/null +++ b/fluxer_media_proxy/src/storage/source_read.rs @@ -0,0 +1,212 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{BufferedStorageObject, StorageError}; +use parking_lot::Mutex; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; +use tokio::sync::Notify; + +const SOURCE_READ_MAX_IN_FLIGHT: usize = 256; +const SOURCE_READ_MAX_WAITERS: usize = 4_096; + +#[derive(Clone)] +pub(super) struct SourceReadCoordinator { + in_flight: Arc>>>, + active_waiters: Arc, +} + +struct SourceReadSlot { + state: Mutex>>, + notify: Notify, +} + +#[derive(Clone)] +enum SourceReadFailure { + LeaderDropped, + NotFound, + InvalidKey, + InvalidBucket, + ReadOnlyStorage, + StreamTooLong, + InvalidS3Endpoint, + ObjectChanged, + BufferBudgetExhausted, + BufferAllocationFailed, + Backend(String), +} + +pub(super) struct SourceReadLeader { + in_flight: Arc>>>, + key: String, + slot: Arc, + state: SourceReadLeaderState, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SourceReadLeaderState { + Active, + Published, +} + +pub(super) struct SourceReadWaiter { + slot: Arc, + active_waiters: Arc, +} + +pub(super) enum SourceReadClaim { + Leader(SourceReadLeader), + Waiter(SourceReadWaiter), +} + +pub(super) enum SourceReadWaitOutcome { + Retry, + Completed(Result), +} + +impl SourceReadCoordinator { + pub(super) fn new() -> Self { + Self { + in_flight: Arc::new(Mutex::new(HashMap::new())), + active_waiters: Arc::new(AtomicUsize::new(0)), + } + } + + pub(super) fn claim(&self, key: String) -> Result { + let mut in_flight = self.in_flight.lock(); + if let Some(existing) = in_flight.get(&key) { + self.active_waiters + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |active| { + (active < SOURCE_READ_MAX_WAITERS).then_some(active + 1) + }) + .map_err(|_| StorageError::SourceReadWaiterCapacityExhausted)?; + return Ok(SourceReadClaim::Waiter(SourceReadWaiter { + slot: Arc::clone(existing), + active_waiters: Arc::clone(&self.active_waiters), + })); + } + if in_flight.len() >= SOURCE_READ_MAX_IN_FLIGHT { + return Err(StorageError::SourceReadCapacityExhausted); + } + let slot = Arc::new(SourceReadSlot { + state: Mutex::new(None), + notify: Notify::new(), + }); + in_flight.insert(key.clone(), Arc::clone(&slot)); + Ok(SourceReadClaim::Leader(SourceReadLeader { + in_flight: Arc::clone(&self.in_flight), + key, + slot, + state: SourceReadLeaderState::Active, + })) + } +} + +impl Drop for SourceReadWaiter { + fn drop(&mut self) { + let previous = self.active_waiters.fetch_sub(1, Ordering::AcqRel); + assert!(previous > 0); + } +} + +impl SourceReadLeader { + pub(super) fn publish(mut self, result: &Result) { + let result = match result { + Ok(object) => Ok(object.clone()), + Err(error) => Err(SourceReadFailure::from(error)), + }; + { + let mut state = self.slot.state.lock(); + assert!(state.is_none()); + *state = Some(result); + } + let removed = self.in_flight.lock().remove(&self.key); + assert!(removed.is_some()); + self.state = SourceReadLeaderState::Published; + self.slot.notify.notify_waiters(); + } +} + +impl Drop for SourceReadLeader { + fn drop(&mut self) { + if self.state == SourceReadLeaderState::Published { + return; + } + { + let mut state = self.slot.state.lock(); + if state.is_none() { + *state = Some(Err(SourceReadFailure::LeaderDropped)); + } + } + let removed = self.in_flight.lock().remove(&self.key); + assert!(removed.is_some()); + self.slot.notify.notify_waiters(); + } +} + +impl SourceReadWaiter { + pub(super) async fn wait(self) -> SourceReadWaitOutcome { + loop { + let notified = self.slot.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if let Some(result) = self.slot.state.lock().as_ref().cloned() { + return match result { + Err(SourceReadFailure::LeaderDropped) => SourceReadWaitOutcome::Retry, + result => SourceReadWaitOutcome::Completed( + result.map_err(SourceReadFailure::into_storage_error), + ), + }; + } + notified.await; + } + } +} + +impl SourceReadFailure { + fn into_storage_error(self) -> StorageError { + match self { + Self::LeaderDropped => unreachable!("dropped source read leaders are re-elected"), + Self::NotFound => StorageError::NotFound, + Self::InvalidKey => StorageError::InvalidKey, + Self::InvalidBucket => StorageError::InvalidBucket, + Self::ReadOnlyStorage => StorageError::ReadOnlyStorage, + Self::StreamTooLong => StorageError::StreamTooLong, + Self::InvalidS3Endpoint => StorageError::InvalidS3Endpoint, + Self::ObjectChanged => StorageError::ObjectChanged, + Self::BufferBudgetExhausted => StorageError::BufferBudgetExhausted, + Self::BufferAllocationFailed => StorageError::BufferAllocationFailed, + Self::Backend(error) => StorageError::CoalescedSourceReadFailed(error), + } + } +} + +impl From<&StorageError> for SourceReadFailure { + fn from(error: &StorageError) -> Self { + match error { + StorageError::NotFound => Self::NotFound, + StorageError::InvalidKey => Self::InvalidKey, + StorageError::InvalidBucket => Self::InvalidBucket, + StorageError::ReadOnlyStorage => Self::ReadOnlyStorage, + StorageError::StreamTooLong => Self::StreamTooLong, + StorageError::InvalidS3Endpoint => Self::InvalidS3Endpoint, + StorageError::ObjectChanged => Self::ObjectChanged, + StorageError::BufferBudgetExhausted => Self::BufferBudgetExhausted, + StorageError::BufferAllocationFailed => Self::BufferAllocationFailed, + StorageError::SourceReadCapacityExhausted + | StorageError::SourceReadWaiterCapacityExhausted + | StorageError::SourceReadLeaderEnded + | StorageError::CoalescedSourceReadFailed(_) + | StorageError::ObjectStorage(_) + | StorageError::S3(_) + | StorageError::Io(_) + | StorageError::Http(_) + | StorageError::HttpMiddleware(_) + | StorageError::Sign(_) => Self::Backend(error.to_string()), + } + } +} diff --git a/fluxer_media_proxy/src/storage/tests/keys.rs b/fluxer_media_proxy/src/storage/tests/keys.rs new file mode 100644 index 000000000..5f111c7f2 --- /dev/null +++ b/fluxer_media_proxy/src/storage/tests/keys.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::storage::keys::{safe_bucket, safe_key}; + +#[test] +fn safe_key_rejects_traversal() { + assert!(safe_key("a/b").is_ok()); + assert!(safe_key("../x").is_err()); + assert!(safe_key("a//b").is_err()); + assert!(safe_key("/a").is_err()); + assert!(safe_key("users/1/file").is_ok()); + assert!(safe_key("users/../file").is_err()); + assert!(safe_key("users//file").is_err()); + assert!(safe_key("users/1\\file").is_ok()); + assert!(safe_key("users/1\0file").is_err()); + let longest_key = vec!["a".repeat(204); 5].join("/"); + assert_eq!(1_024, longest_key.len()); + assert!(safe_key(&longest_key).is_ok()); + assert!(safe_key(&format!("{longest_key}a")).is_ok()); + assert!(safe_key(&vec!["a"; 128].join("/")).is_ok()); + assert!(safe_key(&vec!["a"; 129].join("/")).is_ok()); + assert!(safe_key(&format!("a/{}", "b".repeat(256))).is_ok()); + + assert!(safe_bucket("cdn").is_ok()); + assert!(safe_bucket("").is_err()); + assert!(safe_bucket(".").is_err()); + assert!(safe_bucket("..").is_err()); + assert!(safe_bucket("buck/et").is_err()); + assert!(safe_bucket("buck\\et").is_ok()); + assert!(safe_bucket("buck\0et").is_err()); + assert!(safe_bucket(&"a".repeat(255)).is_ok()); + assert!(safe_bucket(&"a".repeat(256)).is_ok()); +} + +#[test] +fn safe_key_accepts_the_longest_attachment_key_the_schema_can_produce() { + let filename = format!("{}.png", "\u{597d}".repeat(255)); + assert_eq!(769, filename.len()); + let key = format!("attachments/1234567890123456789/9876543210987654321/{filename}"); + assert_eq!(821, key.len()); + assert!(safe_key(&key).is_ok()); +} + +#[test] +fn safe_key_accepts_a_backslash_and_still_rejects_traversal_and_nul() { + assert!(safe_key("attachments/1/2/back\\slash.png").is_ok()); + assert!(safe_key("attachments/1/2/\\").is_ok()); + assert!(safe_bucket("buck\\et").is_ok()); + + assert!(safe_key("attachments/1/2/nul\0.png").is_err()); + assert!(safe_bucket("buck\0et").is_err()); + assert!(safe_key("attachments/../secret.png").is_err()); + assert!(safe_key("attachments/./secret.png").is_err()); + assert!(safe_key("attachments/..\\secret.png").is_ok()); + assert!(safe_key("/attachments/1.png").is_err()); + assert!(safe_key("attachments//1.png").is_err()); +} diff --git a/fluxer_media_proxy/src/storage/tests/local.rs b/fluxer_media_proxy/src/storage/tests/local.rs new file mode 100644 index 000000000..fd37aae93 --- /dev/null +++ b/fluxer_media_proxy/src/storage/tests/local.rs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{rendered_counter, store, store_with_storage_metrics, test_config}; +use crate::{ + metrics::Metrics, + storage::{ObjectStreamRequest, StorageError}, +}; +use http::StatusCode; + +#[tokio::test] +async fn local_write_read_head_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let store = store(test_config(&tmp.path().canonicalize().unwrap())); + store + .write_object("cdn", "a/b.txt", b"hello", "text/plain") + .await + .unwrap(); + let head = store.head_object("cdn", "a/b.txt").await.unwrap(); + assert_eq!(5, head.content_length); + let object = store.read_object("cdn", "a/b.txt").await.unwrap(); + assert_eq!(b"hello", &object.data[..]); +} + +#[tokio::test] +async fn local_stream_honors_range_without_buffered_read() { + let tmp = tempfile::tempdir().unwrap(); + let store = store(test_config(&tmp.path().canonicalize().unwrap())); + store + .write_object("cdn", "a/b.txt", b"hello world", "text/plain") + .await + .unwrap(); + let object = store + .stream_object("cdn", "a/b.txt", Some("bytes=6-10")) + .await + .unwrap(); + assert_eq!(StatusCode::PARTIAL_CONTENT, object.status); + assert_eq!(Some(5), object.content_length); + let body = axum::body::to_bytes(object.body, 16).await.unwrap(); + assert_eq!(b"world", &body[..]); +} + +#[tokio::test] +async fn local_versioned_reads_reject_an_object_rewritten_after_its_head() { + let tmp = tempfile::tempdir().unwrap(); + let store = store(test_config(&tmp.path().canonicalize().unwrap())); + store + .write_object("cdn", "a/b.txt", b"hello world", "text/plain") + .await + .unwrap(); + let head = store.head_object("cdn", "a/b.txt").await.unwrap(); + let streamed = store + .stream_object_limited(ObjectStreamRequest { + bucket: "cdn", + key: "a/b.txt", + max_bytes: 1 << 20, + byte_range: None, + expected_identity: &head.identity, + }) + .await + .unwrap(); + assert_eq!(Some(11), streamed.content_length); + + store + .write_object("cdn", "a/b.txt", b"goodbye world", "text/plain") + .await + .unwrap(); + let changed = store + .stream_object_limited(ObjectStreamRequest { + bucket: "cdn", + key: "a/b.txt", + max_bytes: 1 << 20, + byte_range: None, + expected_identity: &head.identity, + }) + .await; + assert!(matches!(changed, Err(StorageError::ObjectChanged))); +} + +#[tokio::test] +async fn local_reads_refuse_to_follow_a_symlinked_key() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().canonicalize().unwrap(); + let store = store(test_config(&root)); + store + .write_object("cdn", "a/b.txt", b"hello", "text/plain") + .await + .unwrap(); + std::os::unix::fs::symlink(root.join("cdn/a/b.txt"), root.join("cdn/a/link.txt")).unwrap(); + assert!(matches!( + store.read_object("cdn", "a/link.txt").await, + Err(StorageError::InvalidKey) + )); +} + +#[tokio::test] +async fn head_object_leaves_the_storage_counters_untouched() { + let tmp = tempfile::tempdir().unwrap(); + let metrics = Metrics::new(); + let store = store_with_storage_metrics( + test_config(&tmp.path().canonicalize().unwrap()), + metrics.storage(), + ); + store + .write_object("cdn", "a/b.txt", b"hello", "text/plain") + .await + .unwrap(); + + store.head_object("cdn", "a/b.txt").await.unwrap(); + assert!(store.head_object("cdn", "missing.txt").await.is_err()); + assert_eq!( + 0, + rendered_counter(&metrics, "fluxer_media_proxy_storage_hits_total") + ); + assert_eq!( + 0, + rendered_counter(&metrics, "fluxer_media_proxy_storage_misses_total") + ); + assert_eq!( + 0, + rendered_counter(&metrics, "fluxer_media_proxy_storage_errors_total") + ); + + store.read_object("cdn", "a/b.txt").await.unwrap(); + assert_eq!( + 1, + rendered_counter(&metrics, "fluxer_media_proxy_storage_hits_total") + ); + assert!(matches!( + store.read_object("cdn", "missing.txt").await, + Err(StorageError::NotFound) + )); + assert_eq!( + 1, + rendered_counter(&metrics, "fluxer_media_proxy_storage_misses_total") + ); + assert_eq!( + 0, + rendered_counter(&metrics, "fluxer_media_proxy_storage_errors_total") + ); +} diff --git a/fluxer_media_proxy/src/storage/tests/mod.rs b/fluxer_media_proxy/src/storage/tests/mod.rs new file mode 100644 index 000000000..1e0bde18c --- /dev/null +++ b/fluxer_media_proxy/src/storage/tests/mod.rs @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod keys; +mod local; +mod relay; +mod response_body; +mod s3; +mod s3_read_endpoint; + +use crate::{ + config::{ + BucketStyle, Config, DeploymentMode, MediaServingConfig, StorageBackend, StorageConfig, + UploadRelayConfig, + }, + constants, + metrics::{Metrics, http_client::HTTPClientMetrics, storage::StorageMetrics}, + secret::{SecretBytes, SecretString}, + storage::Store, +}; +use axum::response::Response; +use bytes::Bytes; +use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header}; +use parking_lot::Mutex; +use std::{collections::HashMap, path::Path, sync::Arc, time::Duration}; + +pub(crate) type CapturedRequest = (Method, http::Uri, HeaderMap, Bytes); + +fn test_config(root: &Path) -> Config { + Config { + node_env: "test".to_owned(), + bind_host: "127.0.0.1".to_owned(), + port: 0, + secret_key: SecretString::new("secret".to_owned()), + mode: DeploymentMode::Mp, + read_only: false, + shutdown_grace_ms: 0, + socket_io_timeout_ms: 0, + storage: StorageConfig { + backend: StorageBackend::Local, + root: root.display().to_string(), + s3_endpoint: String::new(), + s3_region: "us-east-1".to_owned(), + s3_access_key_id: String::new(), + s3_secret_access_key: String::new(), + s3_session_token: String::new(), + s3_force_path_style: true, + s3_read_endpoint: None, + s3_read_bucket: "cdn".to_owned(), + s3_read_bucket_style: BucketStyle::Path, + s3_read_signed: false, + bucket_cdn: "cdn".to_owned(), + bucket_uploads: "uploads".to_owned(), + bucket_static: "static".to_owned(), + }, + media: MediaServingConfig { + max_native_transforms: 2, + worker_queue_capacity: 16, + nsfw_service_endpoint: String::new(), + nsfw_threshold: 0.85, + transform_cache_capacity_bytes: 0, + transform_cache_max_entry_bytes: 0, + transform_cache_ttl_ms: 0, + transform_timeout_ms: 1000, + max_encode_frames: constants::MAX_ANIMATED_FRAMES_DEFAULT, + max_encode_duration_ms: 30_000, + }, + upload_relay: UploadRelayConfig { + secret: SecretBytes::new(Vec::new()), + max_body_bytes: 1024, + token_ttl_secs: 3600, + s3_timeout_ms: 1000, + buffered_retry_max_bytes: 0, + buffered_retry_total_bytes: 0, + spool_dir: std::env::temp_dir(), + spool_chunk_bytes: 64 * 1024, + spool_max_total_bytes: 1 << 30, + }, + bunny_ip_gate_enabled: false, + bunny_ip_gate_trusted_proxies: Vec::new(), + bunny_ip_gate_refresh_secs: 3_600, + } +} + +fn store(cfg: Config) -> Store { + store_with_storage_metrics(cfg, Arc::new(StorageMetrics::new())) +} + +fn store_with_storage_metrics(cfg: Config, metrics: Arc) -> Store { + Store::new(cfg, metrics, Arc::new(HTTPClientMetrics::new())) +} + +fn rendered_counter(metrics: &Metrics, name: &str) -> u64 { + let rendered = metrics.render(); + rendered + .lines() + .find_map(|line| line.strip_prefix(name)?.trim().parse().ok()) + .expect("counter is rendered") +} + +#[derive(Clone, Default)] +pub(crate) struct FakeObject { + pub(crate) body: Vec, + pub(crate) head_length: Option, + pub(crate) etag: Option, + pub(crate) content_type: Option, + pub(crate) last_modified: Option, + pub(crate) status: Option, + pub(crate) read_status: Option, + pub(crate) delay: Option, +} + +#[derive(Clone)] +pub(crate) struct FakeS3 { + endpoint: String, + objects: Arc>>, + requests: Arc>>, + put_etag: Arc>>, +} + +impl FakeS3 { + pub(crate) fn put_object(&self, path: &str, object: FakeObject) { + self.objects.lock().insert(path.to_owned(), object); + } + + pub(crate) fn set_put_etag(&self, etag: &str) { + *self.put_etag.lock() = Some(etag.to_owned()); + } + + pub(crate) fn endpoint(&self) -> &str { + &self.endpoint + } + + pub(crate) fn requests(&self) -> Vec { + self.requests.lock().clone() + } + + pub(crate) fn last_request(&self) -> CapturedRequest { + self.requests + .lock() + .last() + .cloned() + .expect("fake s3 recorded a request") + } + + pub(crate) fn config(&self, root: &Path) -> Config { + let mut cfg = test_config(root); + cfg.storage.backend = StorageBackend::S3; + cfg.storage.s3_endpoint = self.endpoint.clone(); + cfg.storage.s3_access_key_id = "AKIAIOSFODNN7EXAMPLE".to_owned(); + cfg.storage.s3_secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".to_owned(); + cfg + } +} + +pub(crate) async fn fake_s3() -> FakeS3 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let fake = FakeS3 { + endpoint: format!("http://{addr}"), + objects: Arc::new(Mutex::new(HashMap::new())), + requests: Arc::new(Mutex::new(Vec::new())), + put_etag: Arc::new(Mutex::new(None)), + }; + let handler_state = fake.clone(); + let app = axum::Router::new().fallback(axum::routing::any( + move |request: axum::extract::Request| { + let state = handler_state.clone(); + async move { serve_fake_s3(state, request).await } + }, + )); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + fake +} + +async fn serve_fake_s3(state: FakeS3, request: axum::extract::Request) -> Response { + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, 1 << 20) + .await + .unwrap_or_default(); + state.requests.lock().push(( + parts.method.clone(), + parts.uri.clone(), + parts.headers.clone(), + bytes, + )); + if parts.method == Method::PUT { + let mut response = Response::new(axum::body::Body::empty()); + if let Some(etag) = state.put_etag.lock().clone() { + response.headers_mut().insert( + header::ETAG, + HeaderValue::from_str(&etag).expect("fake etag is ASCII"), + ); + } + return response; + } + let key = parts.uri.path().trim_start_matches('/').to_owned(); + let Some(object) = state.objects.lock().get(&key).cloned() else { + return fake_s3_response( + StatusCode::NOT_FOUND, + Vec::new(), + b"NoSuchKey", + ); + }; + if let Some(delay) = object.delay { + tokio::time::sleep(delay).await; + } + if let Some(status) = object.status { + return fake_s3_response( + StatusCode::from_u16(status).expect("fake status is valid"), + Vec::new(), + b"InternalError", + ); + } + if let Some(status) = object.read_status + && parts.method != Method::HEAD + { + return fake_s3_response( + StatusCode::from_u16(status).expect("fake read status is valid"), + Vec::new(), + b"NoSuchKey", + ); + } + let total_length = object.head_length.unwrap_or(object.body.len() as u64); + let mut headers = vec![( + header::CONTENT_TYPE, + object + .content_type + .clone() + .unwrap_or_else(|| "application/octet-stream".to_owned()), + )]; + if let Some(etag) = object.etag.clone() { + headers.push((header::ETAG, etag)); + } + if let Some(last_modified) = object.last_modified.clone() { + headers.push((header::LAST_MODIFIED, last_modified)); + } + if let Some(requested) = parts.headers.get(header::IF_MATCH) + && object.etag.as_deref() != requested.to_str().ok() + { + return fake_s3_response( + StatusCode::PRECONDITION_FAILED, + Vec::new(), + b"PreconditionFailed", + ); + } + if parts.method == Method::HEAD { + let advertised = vec![0u8; usize::try_from(total_length).expect("fake length fits usize")]; + return fake_s3_response(StatusCode::OK, headers, &advertised); + } + let Some(requested_range) = parts.headers.get(header::RANGE) else { + return fake_s3_response(StatusCode::OK, headers, &object.body); + }; + let (start, end) = parse_fake_range(requested_range.to_str().expect("range is ASCII")); + headers.push(( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{total_length}"), + )); + fake_s3_response( + StatusCode::PARTIAL_CONTENT, + headers, + &object.body[start..=end.min(object.body.len().saturating_sub(1))], + ) +} + +fn parse_fake_range(value: &str) -> (usize, usize) { + let spec = value + .strip_prefix("bytes=") + .expect("fake s3 only serves byte ranges"); + let (start, end) = spec.split_once('-').expect("fake range has both bounds"); + ( + start.parse().expect("range start is a number"), + end.parse().expect("range end is a number"), + ) +} + +fn fake_s3_response( + status: StatusCode, + headers: Vec<(HeaderName, String)>, + body: &[u8], +) -> Response { + let mut response = Response::new(axum::body::Body::from(body.to_vec())); + *response.status_mut() = status; + for (name, value) in headers { + response.headers_mut().insert( + name, + HeaderValue::from_str(&value).expect("fake header value is ASCII"), + ); + } + response +} diff --git a/fluxer_media_proxy/src/storage/tests/relay.rs b/fluxer_media_proxy/src/storage/tests/relay.rs new file mode 100644 index 000000000..a8fe870b9 --- /dev/null +++ b/fluxer_media_proxy/src/storage/tests/relay.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{fake_s3, store}; +use crate::storage::{RelayBody, RelayBodyChunks, RelayPutOptions, s3::UNSIGNED_PAYLOAD}; +use bytes::Bytes; +use http::header; + +fn chunks(frames: &'static [&'static [u8]]) -> RelayBodyChunks { + Box::pin(futures_util::stream::iter( + frames + .iter() + .map(|frame| Ok(Bytes::from_static(frame))) + .collect::>(), + )) +} + +#[tokio::test] +async fn relay_put_s3_streams_body_with_unsigned_payload() { + let fake = fake_s3().await; + fake.set_put_etag("\"etag-123\""); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + + let etag = store + .relay_put_object( + "uploads", + "guild/streamed.bin", + RelayPutOptions { + body: RelayBody::Streamed(chunks(&[b"hello ", b"world"])), + content_length: 11, + content_type: Some("application/octet-stream".to_owned()), + upload_id: Some("upload-1".to_owned()), + part_number: Some(2), + timeout_ms: 5_000, + }, + ) + .await + .unwrap(); + + assert_eq!(Some("\"etag-123\"".to_owned()), etag); + let (_, uri, headers, body) = fake.last_request(); + assert_eq!("/uploads/guild/streamed.bin", uri.path()); + assert_eq!(Some("partNumber=2&uploadId=upload-1"), uri.query()); + assert_eq!( + UNSIGNED_PAYLOAD, + headers.get("x-amz-content-sha256").unwrap() + ); + assert_eq!("11", headers.get(header::CONTENT_LENGTH).unwrap()); + assert!(headers.get(header::TRANSFER_ENCODING).is_none()); + assert!( + headers + .get(header::AUTHORIZATION) + .unwrap() + .to_str() + .unwrap() + .starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") + ); + assert_eq!(b"hello world", body.as_ref()); +} + +#[tokio::test] +async fn relay_put_s3_fails_when_stream_ends_short() { + let fake = fake_s3().await; + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + + let result = store + .relay_put_object( + "uploads", + "guild/short.bin", + RelayPutOptions { + body: RelayBody::Streamed(chunks(&[b"only"])), + content_length: 32, + content_type: None, + upload_id: None, + part_number: None, + timeout_ms: 5_000, + }, + ) + .await; + assert!(result.is_err()); +} diff --git a/fluxer_media_proxy/src/storage/tests/response_body.rs b/fluxer_media_proxy/src/storage/tests/response_body.rs new file mode 100644 index 000000000..db0421414 --- /dev/null +++ b/fluxer_media_proxy/src/storage/tests/response_body.rs @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::ByteBudget, + range::ByteRange, + storage::{ + StorageError, + response_body::{ + ByteStream, LocalStreamBufferPool, StreamResponseValidation, exact_byte_stream, + exact_response_stream, local_reader_stream, read_exact_bytes, read_response_bytes, + validate_stream_response, + }, + }, +}; +use bytes::Bytes; +use futures_util::{TryStreamExt as _, stream}; +use http::{HeaderMap, HeaderValue, StatusCode, header}; + +fn byte_stream(chunks: Vec>) -> ByteStream { + Box::pin(stream::iter(chunks)) +} + +fn provider_response(body: Vec) -> reqwest::Response { + reqwest::Response::from(http::Response::new(body)) +} + +#[tokio::test] +async fn exact_reader_accepts_exact_length_and_rejects_short_or_long_sources() { + let budget = ByteBudget::new(64); + let exact = read_exact_bytes(&b"abcdef"[..], 6, &budget) + .await + .expect("exact reader"); + assert_eq!(exact.as_ref(), b"abcdef"); + + assert!(matches!( + read_exact_bytes(&b"abc"[..], 4, &budget).await, + Err(StorageError::ObjectChanged) + )); + assert!(matches!( + read_exact_bytes(&b"abcde"[..], 4, &budget).await, + Err(StorageError::ObjectChanged) + )); + assert!(matches!( + read_exact_bytes(&b"a"[..], 1, &ByteBudget::new(0)).await, + Err(StorageError::BufferBudgetExhausted) + )); +} + +#[tokio::test] +async fn local_reader_stream_replenishes_owned_buffers_and_terminates() { + let pool = LocalStreamBufferPool::new(4, 1).expect("local stream buffer pool"); + let stream = + local_reader_stream(&b"abcdefgh"[..], pool.clone(), 4).expect("local reader stream"); + let data = stream + .try_fold(Vec::new(), |mut data, chunk| async move { + data.extend_from_slice(&chunk); + Ok(data) + }) + .await + .expect("stream bytes"); + assert_eq!(data, b"abcdefgh"); + + let held = + local_reader_stream(&b"abcd"[..], pool.clone(), 4).expect("held local reader stream"); + let saturated = local_reader_stream(&b"xyz"[..], pool, 4) + .expect("a saturated pool still serves the stream from an unpooled buffer"); + let data = saturated + .try_fold(Vec::new(), |mut data, chunk| async move { + data.extend_from_slice(&chunk); + Ok(data) + }) + .await + .expect("stream bytes"); + assert_eq!(data, b"xyz"); + drop(held); +} + +#[tokio::test] +async fn a_saturated_buffer_pool_serves_a_live_stream_from_a_small_fallback_buffer() { + use futures_util::StreamExt as _; + + const CHUNK: usize = 16 * 1024; + static SOURCE: [u8; CHUNK * 2] = [b'z'; CHUNK * 2]; + + let pool = LocalStreamBufferPool::new(CHUNK, 1).expect("local stream buffer pool"); + let mut stream = local_reader_stream(&SOURCE[..], pool, CHUNK).expect("local reader stream"); + let pooled = stream + .next() + .await + .expect("a first chunk") + .expect("a readable first chunk"); + assert_eq!(CHUNK, pooled.len()); + let fallback = stream + .next() + .await + .expect("a second chunk") + .expect("a saturated pool must not fail a stream that already sent a chunk"); + assert!( + fallback.len() < pooled.len(), + "a fallback buffer must not hold a full pooled chunk: {}", + fallback.len() + ); + drop(pooled); +} + +#[tokio::test] +async fn a_saturated_pool_still_replenishes_mid_stream_instead_of_truncating() { + let pool = LocalStreamBufferPool::new(8, 1).expect("local stream buffer pool"); + let held = local_reader_stream(&b"12345678"[..], pool.clone(), 8).expect("held local stream"); + + let source = vec![b'z'; 64 * 1024]; + let stream = local_reader_stream(std::io::Cursor::new(source.clone()), pool, 8) + .expect("a saturated pool still starts the stream"); + let data = stream + .try_fold(Vec::new(), |mut data, chunk| async move { + data.extend_from_slice(&chunk); + Ok(data) + }) + .await + .expect("a saturated pool must stream the whole body, not fail partway"); + assert_eq!(data.len(), source.len()); + assert_eq!(data, source); + drop(held); +} + +#[tokio::test] +async fn exact_stream_rejects_short_long_and_erroring_sources() { + let exact = exact_byte_stream( + byte_stream(vec![ + Ok(Bytes::new()), + Ok(Bytes::from_static(b"ab")), + Ok(Bytes::from_static(b"cd")), + ]), + 4, + ); + let chunks = exact.try_collect::>().await.expect("exact stream"); + assert_eq!( + chunks, + vec![Bytes::from_static(b"ab"), Bytes::from_static(b"cd")] + ); + + let short = exact_byte_stream(byte_stream(vec![Ok(Bytes::from_static(b"abc"))]), 4); + let error = short + .try_collect::>() + .await + .expect_err("short stream"); + assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof); + + let long = exact_byte_stream(byte_stream(vec![Ok(Bytes::from_static(b"abcde"))]), 4); + let error = long.try_collect::>().await.expect_err("long stream"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + + let source_error = exact_byte_stream( + byte_stream(vec![Err(std::io::Error::other("source failed"))]), + 1, + ); + let error = source_error + .try_collect::>() + .await + .expect_err("source error"); + assert_eq!(error.kind(), std::io::ErrorKind::Other); +} + +#[test] +fn stream_response_validation_requires_exact_headers_status_and_range() { + let mut full_headers = HeaderMap::new(); + full_headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("10")); + assert!( + validate_stream_response(StreamResponseValidation { + status: StatusCode::OK, + headers: &full_headers, + total_length: 10, + expected_length: 10, + byte_range: None, + }) + .is_ok() + ); + + let range = ByteRange { start: 2, end: 5 }; + let mut partial_headers = HeaderMap::new(); + partial_headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("4")); + partial_headers.insert( + header::CONTENT_RANGE, + HeaderValue::from_static("bytes 2-5/10"), + ); + assert!( + validate_stream_response(StreamResponseValidation { + status: StatusCode::PARTIAL_CONTENT, + headers: &partial_headers, + total_length: 10, + expected_length: 4, + byte_range: Some(range), + }) + .is_ok() + ); + + partial_headers.insert( + header::CONTENT_RANGE, + HeaderValue::from_static("bytes 3-6/10"), + ); + assert!(matches!( + validate_stream_response(StreamResponseValidation { + status: StatusCode::PARTIAL_CONTENT, + headers: &partial_headers, + total_length: 10, + expected_length: 4, + byte_range: Some(range), + }), + Err(StorageError::ObjectChanged) + )); + + full_headers.insert(header::CONTENT_LENGTH, HeaderValue::from_static("9")); + assert!(matches!( + validate_stream_response(StreamResponseValidation { + status: StatusCode::OK, + headers: &full_headers, + total_length: 10, + expected_length: 10, + byte_range: None, + }), + Err(StorageError::ObjectChanged) + )); +} + +#[tokio::test] +async fn provider_response_readers_hold_the_declared_length_and_the_byte_budget() { + let budget = ByteBudget::new(1 << 20); + let exact = read_response_bytes(provider_response(b"abcdef".to_vec()), 6, &budget) + .await + .expect("response bytes"); + assert_eq!(exact.as_ref(), b"abcdef"); + + assert!(matches!( + read_response_bytes(provider_response(b"abc".to_vec()), 4, &budget).await, + Err(StorageError::ObjectChanged) + )); + assert!(matches!( + read_response_bytes(provider_response(b"abcde".to_vec()), 4, &budget).await, + Err(StorageError::ObjectChanged) + )); + assert!(matches!( + read_response_bytes(provider_response(b"a".to_vec()), 1, &ByteBudget::new(0)).await, + Err(StorageError::BufferBudgetExhausted) + )); + + let streamed = exact_response_stream(provider_response(b"abcdef".to_vec()), 6) + .try_fold(Vec::new(), |mut data, chunk| async move { + data.extend_from_slice(&chunk); + Ok(data) + }) + .await + .expect("response stream bytes"); + assert_eq!(streamed, b"abcdef"); + + let error = exact_response_stream(provider_response(b"abc".to_vec()), 6) + .try_collect::>() + .await + .expect_err("truncated response stream"); + assert_eq!(error.kind(), std::io::ErrorKind::UnexpectedEof); +} + +#[tokio::test] +async fn exact_stream_accepts_small_transport_chunks_and_bounds_empty_ones() { + const CHUNK_BYTES: usize = 64; + const BODY_BYTES: usize = 8 * 1024; + let chunks: Vec> = (0..BODY_BYTES / CHUNK_BYTES) + .map(|_| Ok(Bytes::from(vec![7u8; CHUNK_BYTES]))) + .collect(); + let streamed = exact_byte_stream(byte_stream(chunks), BODY_BYTES as u64) + .try_fold(Vec::new(), |mut data, chunk| async move { + data.extend_from_slice(&chunk); + Ok(data) + }) + .await + .expect("small transport chunks"); + assert_eq!(streamed.len(), BODY_BYTES); + + let empty: Vec> = (0..4096).map(|_| Ok(Bytes::new())).collect(); + let error = exact_byte_stream(byte_stream(empty), 4) + .try_collect::>() + .await + .expect_err("empty chunk flood"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); +} diff --git a/fluxer_media_proxy/src/storage/tests/s3.rs b/fluxer_media_proxy/src/storage/tests/s3.rs new file mode 100644 index 000000000..d092ea41d --- /dev/null +++ b/fluxer_media_proxy/src/storage/tests/s3.rs @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{FakeObject, FakeS3, fake_s3, store}; +use crate::{ + byte_budget::ByteBudget, + range::ByteRange, + response_body_limit, + storage::{ObjectReadRequest, ObjectStreamRequest, StorageError}, +}; +use http::{Method, StatusCode, header}; +use std::time::Duration; + +const LAST_MODIFIED: &str = "Wed, 21 Oct 2015 07:28:00 GMT"; + +fn stored_object() -> FakeObject { + FakeObject { + body: b"hello world".to_vec(), + etag: Some("\"v1\"".to_owned()), + content_type: Some("text/plain".to_owned()), + last_modified: Some(LAST_MODIFIED.to_owned()), + ..FakeObject::default() + } +} + +#[tokio::test] +async fn head_and_ranged_get_match_the_stored_object() { + let fake = fake_s3().await; + fake.put_object("cdn/a/b.txt", stored_object()); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + + let head = store.head_object("cdn", "a/b.txt").await.unwrap(); + assert_eq!(11, head.content_length); + assert_eq!("text/plain", head.content_type); + assert_eq!(64, head.identity.cache_identity().len()); + + let ranged = store + .stream_object("cdn", "a/b.txt", Some("bytes=6-10")) + .await + .unwrap(); + assert_eq!(StatusCode::PARTIAL_CONTENT, ranged.status); + assert_eq!(Some(5), ranged.content_length); + let body = axum::body::to_bytes(ranged.body, 16).await.unwrap(); + assert_eq!(b"world", &body[..]); + + let versioned = store + .stream_object_limited(ObjectStreamRequest { + bucket: "cdn", + key: "a/b.txt", + max_bytes: 1 << 20, + byte_range: Some(ByteRange { start: 6, end: 10 }), + expected_identity: &head.identity, + }) + .await + .unwrap(); + assert_eq!(StatusCode::PARTIAL_CONTENT, versioned.status); + assert_eq!(Some(5), versioned.content_length); + let body = axum::body::to_bytes(versioned.body, 16).await.unwrap(); + assert_eq!(b"world", &body[..]); + assert_eq!( + "\"v1\"", + fake.last_request().2.get(header::IF_MATCH).unwrap() + ); +} + +#[tokio::test] +async fn missing_and_failing_objects_map_to_storage_errors() { + let fake = fake_s3().await; + fake.put_object( + "cdn/broken.txt", + FakeObject { + status: Some(500), + ..FakeObject::default() + }, + ); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + + assert!(matches!( + store.read_object("cdn", "gone.txt").await, + Err(StorageError::NotFound) + )); + assert!(matches!( + store.head_object("cdn", "gone.txt").await, + Err(StorageError::NotFound) + )); + let failure = store.read_object("cdn", "broken.txt").await; + assert!(matches!(failure, Err(StorageError::S3(_)))); +} + +#[tokio::test] +async fn a_truncated_get_body_fails_a_versioned_stream() { + let fake = fake_s3().await; + fake.put_object( + "cdn/a/b.txt", + FakeObject { + body: b"hello world".to_vec(), + content_type: Some("text/plain".to_owned()), + ..FakeObject::default() + }, + ); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + let head = store.head_object("cdn", "a/b.txt").await.unwrap(); + assert_eq!(11, head.content_length); + + fake.put_object( + "cdn/a/b.txt", + FakeObject { + body: b"hell".to_vec(), + head_length: Some(11), + content_type: Some("text/plain".to_owned()), + ..FakeObject::default() + }, + ); + let truncated = store + .stream_object_limited(ObjectStreamRequest { + bucket: "cdn", + key: "a/b.txt", + max_bytes: 1 << 20, + byte_range: None, + expected_identity: &head.identity, + }) + .await; + assert!(matches!(truncated, Err(StorageError::ObjectChanged))); +} + +#[tokio::test] +async fn a_mutated_object_between_head_and_get_yields_object_changed() { + let fake = fake_s3().await; + fake.put_object("cdn/a/b.txt", stored_object()); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + let head = store.head_object("cdn", "a/b.txt").await.unwrap(); + + fake.put_object( + "cdn/a/b.txt", + FakeObject { + body: b"replaced!!!".to_vec(), + etag: Some("\"v2\"".to_owned()), + content_type: Some("text/plain".to_owned()), + last_modified: Some(LAST_MODIFIED.to_owned()), + ..FakeObject::default() + }, + ); + let budget = ByteBudget::new(1 << 20); + let changed = store + .read_object_versioned(ObjectReadRequest { + bucket: "cdn", + key: "a/b.txt", + max_bytes: 1 << 20, + budget: &budget, + expected_identity: &head.identity, + }) + .await; + assert!(matches!(changed, Err(StorageError::ObjectChanged))); + assert_eq!( + "\"v1\"", + fake.last_request().2.get(header::IF_MATCH).unwrap() + ); +} + +#[tokio::test] +async fn concurrent_versioned_reads_share_one_upstream_get() { + let fake = fake_s3().await; + fake.put_object( + "cdn/a/b.txt", + FakeObject { + delay: Some(Duration::from_millis(150)), + ..stored_object() + }, + ); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + let head = store.head_object("cdn", "a/b.txt").await.unwrap(); + + let budget = ByteBudget::new(1 << 20); + let request = || { + store.read_object_versioned(ObjectReadRequest { + bucket: "cdn", + key: "a/b.txt", + max_bytes: 1 << 20, + budget: &budget, + expected_identity: &head.identity, + }) + }; + let (first, second) = tokio::join!(request(), request()); + assert_eq!(b"hello world", first.unwrap().data.as_ref()); + assert_eq!(b"hello world", second.unwrap().data.as_ref()); + let gets = fake + .requests() + .into_iter() + .filter(|(method, ..)| *method == Method::GET) + .count(); + assert_eq!(1, gets); +} + +#[tokio::test] +async fn coalesced_versioned_readers_charge_the_byte_budget_for_one_buffer() { + const PAYLOAD_BYTES: usize = 256 * 1024; + const TRANSPORT_BYTES: usize = response_body_limit::RESPONSE_BODY_TRANSPORT_CHUNK_BYTES_MAX; + let fake = fake_s3().await; + fake.put_object( + "cdn/a/big.bin", + FakeObject { + body: vec![7u8; PAYLOAD_BYTES], + delay: Some(Duration::from_millis(150)), + ..stored_object() + }, + ); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + let head = store.head_object("cdn", "a/big.bin").await.unwrap(); + + let budget = ByteBudget::new(TRANSPORT_BYTES + PAYLOAD_BYTES); + let request = || { + store.read_object_versioned(ObjectReadRequest { + bucket: "cdn", + key: "a/big.bin", + max_bytes: 1 << 20, + budget: &budget, + expected_identity: &head.identity, + }) + }; + let (first, second, third, fourth) = tokio::join!(request(), request(), request(), request()); + let objects = [ + first.expect("first reader"), + second.expect("second reader"), + third.expect("third reader"), + fourth.expect("fourth reader"), + ]; + for object in &objects { + assert_eq!(PAYLOAD_BYTES, object.data.len()); + assert_eq!(objects[0].data.as_ptr(), object.data.as_ptr()); + } + assert!(budget.try_reserve(TRANSPORT_BYTES).is_some()); + assert!(budget.try_reserve(TRANSPORT_BYTES + 1).is_none()); + drop(objects); + assert!( + budget + .try_reserve(TRANSPORT_BYTES + PAYLOAD_BYTES) + .is_some() + ); +} + +#[tokio::test] +async fn a_transient_origin_status_is_retried_while_a_missing_object_is_not() { + let fake = fake_s3().await; + fake.put_object( + "cdn/broken.txt", + FakeObject { + status: Some(500), + ..FakeObject::default() + }, + ); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + + assert!(matches!( + store.read_object("cdn", "broken.txt").await, + Err(StorageError::S3(_)) + )); + assert_eq!(3, fake_gets(&fake, "/cdn/broken.txt")); + + assert!(matches!( + store.read_object("cdn", "gone.txt").await, + Err(StorageError::NotFound) + )); + assert_eq!(1, fake_gets(&fake, "/cdn/gone.txt")); + + assert!(matches!( + store.head_object("cdn", "broken.txt").await, + Err(StorageError::S3(_)) + )); + assert_eq!( + 3, + fake.requests() + .iter() + .filter(|(method, uri, ..)| *method == Method::HEAD && uri.path() == "/cdn/broken.txt") + .count() + ); +} + +#[tokio::test] +async fn a_ranged_read_asks_the_origin_for_exactly_the_requested_bytes() { + let fake = fake_s3().await; + fake.put_object("cdn/a/b.txt", stored_object()); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + let head = store.head_object("cdn", "a/b.txt").await.unwrap(); + + let ranged = store + .stream_object_limited(ObjectStreamRequest { + bucket: "cdn", + key: "a/b.txt", + max_bytes: 1 << 20, + byte_range: Some(ByteRange { start: 0, end: 4 }), + expected_identity: &head.identity, + }) + .await + .unwrap(); + assert_eq!(StatusCode::PARTIAL_CONTENT, ranged.status); + assert_eq!(Some(5), ranged.content_length); + let body = axum::body::to_bytes(ranged.body, 16).await.unwrap(); + assert_eq!(b"hello", &body[..]); + assert_eq!( + "bytes=0-4", + fake.last_request().2.get(header::RANGE).unwrap() + ); + + let suffix = store + .stream_object("cdn", "a/b.txt", Some("bytes=6-10")) + .await + .unwrap(); + assert_eq!(Some(5), suffix.content_length); + assert_eq!( + "bytes=6-10", + fake.last_request().2.get(header::RANGE).unwrap() + ); +} + +#[tokio::test] +async fn stream_s3_surfaces_an_upstream_416_instead_of_a_storage_error() { + // A range past the end of the object is a routine thing for a video player to ask for while + // seeking. Mapping the upstream 416 onto a storage error would answer the client 502. + let fake = fake_s3().await; + fake.put_object( + "cdn/video.mp4", + FakeObject { + read_status: Some(416), + ..stored_object() + }, + ); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fake.config(tmp.path())); + + let object = store + .stream_object("cdn", "video.mp4", Some("bytes=900-999")) + .await + .expect("an upstream 416 is an answer about the range, not a storage failure"); + + assert_eq!(StatusCode::RANGE_NOT_SATISFIABLE, object.status); + assert_eq!(None, object.byte_range); + assert_eq!(Some(0), object.content_length); +} + +fn fake_gets(fake: &FakeS3, path: &str) -> usize { + fake.requests() + .iter() + .filter(|(method, uri, ..)| *method == Method::GET && uri.path() == path) + .count() +} diff --git a/fluxer_media_proxy/src/storage/tests/s3_read_endpoint.rs b/fluxer_media_proxy/src/storage/tests/s3_read_endpoint.rs new file mode 100644 index 000000000..5b593dbf7 --- /dev/null +++ b/fluxer_media_proxy/src/storage/tests/s3_read_endpoint.rs @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::{FakeObject, FakeS3, fake_s3, store}; +use crate::{ + config::{BucketStyle, Config}, + storage::{RelayBody, RelayBodyChunks, RelayPutOptions, StorageError, Store}, +}; +use bytes::Bytes; +use http::{Method, header}; +use std::path::Path; + +const PAYLOAD: &[u8] = b"hello world, this is a payload."; + +fn payload_object() -> FakeObject { + FakeObject { + body: PAYLOAD.to_vec(), + content_type: Some("image/png".to_owned()), + ..FakeObject::default() + } +} + +fn denied_object(status: u16) -> FakeObject { + FakeObject { + status: Some(status), + ..FakeObject::default() + } +} + +fn fronted_config(origin: &FakeS3, cdn: &FakeS3, root: &Path) -> Config { + let mut cfg = origin.config(root); + cfg.storage.s3_read_endpoint = Some(cdn.endpoint().to_owned()); + cfg +} + +fn only_request(fake: &FakeS3) -> super::CapturedRequest { + let mut requests = fake.requests(); + assert_eq!(1, requests.len(), "expected exactly one captured request"); + requests.remove(0) +} + +fn assert_unsigned(headers: &http::HeaderMap) { + assert!(headers.get(header::AUTHORIZATION).is_none()); + assert!(headers.get("x-amz-date").is_none()); + assert!(headers.get("x-amz-content-sha256").is_none()); +} + +fn assert_signed(headers: &http::HeaderMap) { + assert!( + headers + .get(header::AUTHORIZATION) + .unwrap() + .to_str() + .unwrap() + .starts_with("AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/") + ); + assert!(headers.get("x-amz-date").is_some()); + assert!(headers.get("x-amz-content-sha256").is_some()); +} + +fn url_store(root: &Path, read_endpoint: &str, style: BucketStyle) -> Store { + let mut cfg = super::test_config(root); + cfg.storage.backend = crate::config::StorageBackend::S3; + cfg.storage.s3_endpoint = "https://s3.example.test".to_owned(); + cfg.storage.s3_read_endpoint = Some(read_endpoint.to_owned()); + cfg.storage.s3_read_bucket_style = style; + store(cfg) +} + +#[tokio::test] +async fn read_without_read_endpoint_hits_s3_endpoint_signed() { + let origin = fake_s3().await; + origin.put_object("cdn/attachments/1/2/a.png", payload_object()); + let tmp = tempfile::tempdir().unwrap(); + let store = store(origin.config(tmp.path())); + + let object = store + .read_object("cdn", "attachments/1/2/a.png") + .await + .unwrap(); + + assert_eq!(PAYLOAD, object.data.as_ref()); + let (method, uri, headers, _) = only_request(&origin); + assert_eq!(Method::GET, method); + assert_eq!("/cdn/attachments/1/2/a.png", uri.path()); + assert_signed(&headers); +} + +#[tokio::test] +async fn read_endpoint_routes_fronted_bucket_to_cdn_unsigned() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("cdn/attachments/1/2/a.png", payload_object()); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fronted_config(&origin, &cdn, tmp.path())); + + let object = store + .read_object("cdn", "attachments/1/2/a.png") + .await + .unwrap(); + + assert_eq!(PAYLOAD, object.data.as_ref()); + assert!( + origin.requests().is_empty(), + "S3 endpoint must not be touched" + ); + let (method, uri, headers, _) = only_request(&cdn); + assert_eq!(Method::GET, method); + assert_eq!("/cdn/attachments/1/2/a.png", uri.path()); + assert_unsigned(&headers); +} + +#[tokio::test] +async fn read_endpoint_rooted_style_omits_bucket_segment() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("attachments/1/2/a.png", payload_object()); + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_read_bucket_style = BucketStyle::Rooted; + let store = store(cfg); + + store + .read_object("cdn", "attachments/1/2/a.png") + .await + .unwrap(); + + let (_, uri, ..) = only_request(&cdn); + assert_eq!("/attachments/1/2/a.png", uri.path()); +} + +#[test] +fn read_endpoint_virtual_style_uses_bucket_subdomain() { + let tmp = tempfile::tempdir().unwrap(); + let store = url_store( + tmp.path(), + "https://cdn.example.net", + BucketStyle::VirtualHosted, + ); + + assert_eq!( + "https://cdn.cdn.example.net/attachments/1/2/a.png", + store.s3_read_url("cdn", "attachments/1/2/a.png").unwrap() + ); +} + +#[tokio::test] +async fn read_endpoint_never_redirects_other_buckets() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + origin.put_object("uploads/fresh-upload-key", payload_object()); + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_read_bucket_style = BucketStyle::Rooted; + let store = store(cfg); + + store + .read_object("uploads", "fresh-upload-key") + .await + .unwrap(); + + assert!(cdn.requests().is_empty(), "uploads must not hit the CDN"); + let (_, uri, headers, _) = only_request(&origin); + assert_eq!("/uploads/fresh-upload-key", uri.path()); + assert_signed(&headers); +} + +#[tokio::test] +async fn read_endpoint_never_affects_writes() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_read_bucket = "uploads".to_owned(); + let store = store(cfg); + + let body: RelayBodyChunks = Box::pin(futures_util::stream::iter(vec![Ok(Bytes::from_static( + b"body", + ))])); + store + .relay_put_object( + "uploads", + "guild/x.bin", + RelayPutOptions { + body: RelayBody::Streamed(body), + content_length: 4, + content_type: Some("application/octet-stream".to_owned()), + upload_id: None, + part_number: None, + timeout_ms: 5_000, + }, + ) + .await + .unwrap(); + + assert!(cdn.requests().is_empty(), "writes must not hit the CDN"); + let (method, uri, headers, _) = only_request(&origin); + assert_eq!(Method::PUT, method); + assert_eq!("/uploads/guild/x.bin", uri.path()); + assert_signed(&headers); +} + +#[tokio::test] +async fn read_endpoint_signs_when_read_signed_enabled() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("cdn/a.png", payload_object()); + let cdn_host = cdn.endpoint().trim_start_matches("http://").to_owned(); + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_read_signed = true; + let store = store(cfg); + + store.read_object("cdn", "a.png").await.unwrap(); + + let (_, _, headers, _) = only_request(&cdn); + assert_signed(&headers); + assert_eq!( + cdn_host, + headers.get(header::HOST).unwrap().to_str().unwrap() + ); +} + +#[tokio::test] +async fn unsigned_read_still_sends_range_header() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("cdn/video.mp4", payload_object()); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fronted_config(&origin, &cdn, tmp.path())); + + store + .stream_object("cdn", "video.mp4", Some("bytes=10-19")) + .await + .unwrap(); + + let (_, _, headers, _) = only_request(&cdn); + assert_eq!("bytes=10-19", headers.get(header::RANGE).unwrap()); + assert_unsigned(&headers); +} + +#[tokio::test] +async fn head_object_uses_the_read_endpoint_like_every_other_read() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("a.png", payload_object()); + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_read_bucket_style = BucketStyle::Rooted; + let store = store(cfg); + + store.head_object("cdn", "a.png").await.unwrap(); + + // A HEAD is a read: routing it to the write origin would bypass the configured read + // endpoint and attach credentials to a read the operator configured as unsigned. + assert!( + origin.requests().is_empty(), + "HEAD must not hit the write origin" + ); + let (method, uri, headers, _) = only_request(&cdn); + assert_eq!(Method::HEAD, method); + assert_eq!("/a.png", uri.path()); + assert_unsigned(&headers); +} + +#[tokio::test] +async fn head_and_body_reads_both_use_the_read_endpoint() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("video.mp4", payload_object()); + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_read_bucket_style = BucketStyle::Rooted; + let store = store(cfg); + + store.head_object("cdn", "video.mp4").await.unwrap(); + store + .stream_object("cdn", "video.mp4", Some("bytes=0-3")) + .await + .unwrap(); + + assert!( + origin.requests().is_empty(), + "neither read may reach the write origin" + ); + let cdn_requests = cdn.requests(); + assert_eq!(2, cdn_requests.len()); + assert_eq!(Method::HEAD, cdn_requests[0].0); + assert_eq!(Method::GET, cdn_requests[1].0); + assert_eq!("bytes=0-3", cdn_requests[1].2.get(header::RANGE).unwrap()); +} + +#[tokio::test] +async fn unsigned_read_works_without_credentials() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("cdn/a.png", payload_object()); + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_access_key_id = String::new(); + cfg.storage.s3_secret_access_key = String::new(); + let store = store(cfg); + + store.read_object("cdn", "a.png").await.unwrap(); + + let (_, _, headers, _) = only_request(&cdn); + assert_unsigned(&headers); +} + +#[test] +fn read_url_matches_write_url_shape_for_encoding_and_trailing_slash() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = super::test_config(tmp.path()); + cfg.storage.backend = crate::config::StorageBackend::S3; + cfg.storage.s3_endpoint = "https://s3.example.test/".to_owned(); + cfg.storage.s3_read_endpoint = Some("https://cdn.example.net/".to_owned()); + let store = store(cfg); + let key = "attachments/1/2/na me+ü.png"; + + assert_eq!( + "https://s3.example.test/uploads/attachments/1/2/na%20me%2B%C3%BC.png", + store.s3_url("uploads", key).unwrap() + ); + assert_eq!( + "https://cdn.example.net/cdn/attachments/1/2/na%20me%2B%C3%BC.png", + store.s3_read_url("cdn", key).unwrap() + ); +} + +#[test] +fn read_url_rejects_unsafe_keys_and_buckets() { + let tmp = tempfile::tempdir().unwrap(); + let store = url_store(tmp.path(), "https://cdn.example.net", BucketStyle::Rooted); + + assert!(store.s3_read_url("cdn", "../escape").is_err()); + assert!(store.s3_read_url("cdn", "/leading").is_err()); +} + +#[test] +fn read_url_validates_bucket_inside_the_read_endpoint_branch() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = super::test_config(tmp.path()); + cfg.storage.backend = crate::config::StorageBackend::S3; + cfg.storage.s3_endpoint = "https://s3.example.test".to_owned(); + cfg.storage.s3_read_endpoint = Some("https://cdn.example.net".to_owned()); + cfg.storage.s3_read_bucket = "..".to_owned(); + cfg.storage.s3_read_bucket_style = BucketStyle::Rooted; + let store = store(cfg); + + assert!(store.read_endpoint_for("..").is_some()); + assert!(store.s3_read_url("..", "a.png").is_err()); +} + +#[tokio::test] +async fn unsigned_cdn_read_treats_403_as_not_found() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("avatars/1/hash", denied_object(403)); + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_read_bucket_style = BucketStyle::Rooted; + let store = store(cfg); + + let err = store + .read_object("cdn", "avatars/1/hash") + .await + .unwrap_err(); + assert!(matches!(err, StorageError::NotFound), "got {err:?}"); + assert_eq!(1, cdn.requests().len()); + + let stream = store.stream_object("cdn", "avatars/1/hash", None).await; + assert!( + matches!(stream, Err(StorageError::NotFound)), + "stream_object should map 403 to NotFound too" + ); +} + +#[tokio::test] +async fn signed_cdn_read_keeps_403_as_an_error() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + cdn.put_object("cdn/avatars/1/hash", denied_object(403)); + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = fronted_config(&origin, &cdn, tmp.path()); + cfg.storage.s3_read_signed = true; + let store = store(cfg); + + let err = store + .read_object("cdn", "avatars/1/hash") + .await + .unwrap_err(); + assert!(!matches!(err, StorageError::NotFound), "got {err:?}"); +} + +#[tokio::test] +async fn origin_read_keeps_403_as_an_error() { + let origin = fake_s3().await; + origin.put_object("cdn/avatars/1/hash", denied_object(403)); + let tmp = tempfile::tempdir().unwrap(); + let store = store(origin.config(tmp.path())); + + let err = store + .read_object("cdn", "avatars/1/hash") + .await + .unwrap_err(); + assert!(!matches!(err, StorageError::NotFound), "got {err:?}"); +} + +#[tokio::test] +async fn unfronted_bucket_keeps_403_as_an_error() { + let origin = fake_s3().await; + let cdn = fake_s3().await; + origin.put_object("uploads/fresh", denied_object(403)); + let tmp = tempfile::tempdir().unwrap(); + let store = store(fronted_config(&origin, &cdn, tmp.path())); + + let err = store.read_object("uploads", "fresh").await.unwrap_err(); + assert!(!matches!(err, StorageError::NotFound), "got {err:?}"); + assert!(cdn.requests().is_empty()); +} diff --git a/fluxer_media_proxy/src/test_fixtures/adversarial.rs b/fluxer_media_proxy/src/test_fixtures/adversarial.rs new file mode 100644 index 000000000..f8b7e23c7 --- /dev/null +++ b/fluxer_media_proxy/src/test_fixtures/adversarial.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use super::images::{apng_header, minimal_gif}; + +pub fn adversarial_media_bytes() -> Vec> { + let mut truncated_apng = apng_header(2); + truncated_apng.truncate(20); + + let mut oversized_bmff = Vec::new(); + oversized_bmff.extend_from_slice(&u32::MAX.to_be_bytes()); + oversized_bmff.extend_from_slice(b"ftyp"); + oversized_bmff.extend_from_slice(b"isom"); + + let mut webp_without_payload = vec![0_u8; 20]; + webp_without_payload[0..4].copy_from_slice(b"RIFF"); + webp_without_payload[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + webp_without_payload[8..12].copy_from_slice(b"WEBP"); + webp_without_payload[12..16].copy_from_slice(b"VP8X"); + webp_without_payload[16..20].copy_from_slice(&u32::MAX.to_le_bytes()); + + vec![ + Vec::new(), + vec![0x00], + vec![0x00; 1024], + vec![0xff; 1024], + b"GIF".to_vec(), + b"GIF89a".to_vec(), + b"GIF89a\x01\x00".to_vec(), + minimal_gif(), + b"\x89PNG\r\n\x1a\n".to_vec(), + apng_header(0), + apng_header(u32::MAX), + truncated_apng, + b"RIFF".to_vec(), + webp_without_payload, + oversized_bmff, + b"ftypM4A ".to_vec(), + b"\xff\xd8\xff\xe0".to_vec(), + ] +} + +pub const ADVERSARIAL_RANGE_HEADERS: &[&str] = &[ + "", + " ", + "bytes", + "bytes=", + "bytes=-", + "bytes=--1", + "bytes=0-", + "bytes=-0", + "bytes=0-0", + "bytes=0-1", + "bytes=1-0", + "bytes=18446744073709551615-18446744073709551615", + "bytes=-18446744073709551615", + "bytes=18446744073709551616-", + "bytes=1-2,3-4", + "bytes = 0-1", + "BYTES=0-1", + "items=0-1", + "bytes 0-0/0", + "bytes 0-1/1", + "bytes 1-0/2", + "bytes 0-1/*", + "bytes */2", + "bytes 0-1/18446744073709551616", + "bytes\t0-1/2", + "bytes 0-1/2 ", +]; + +pub const ADVERSARIAL_TEXT_INPUTS: &[&str] = &[ + "", + " ", + "%", + "%0", + "%00", + "%2F", + "%2f", + "%5C", + "%FF", + "%C3", + "%C3%A9", + "%%%%", + "%20%2e%2e%2f", + "a_deadbeef", + "A_DEADBEEF", + "deadbeef", + "deadbee", + "deadbeef0", + "0123456789", + "a_", + "\"quoted\"", + "photo name.png", + "r\u{e9}sum\u{e9}.png", + "token=", + "token=abc&token=def", + "\u{7f}", + "~!@#$^&*()_+", +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adversarial_corpora_hold_every_documented_input() { + assert_eq!(17, adversarial_media_bytes().len()); + assert_eq!(26, ADVERSARIAL_RANGE_HEADERS.len()); + assert_eq!(27, ADVERSARIAL_TEXT_INPUTS.len()); + } +} diff --git a/fluxer_media_proxy/src/test_fixtures/ffmpeg_cli.rs b/fluxer_media_proxy/src/test_fixtures/ffmpeg_cli.rs new file mode 100644 index 000000000..c84815bbc --- /dev/null +++ b/fluxer_media_proxy/src/test_fixtures/ffmpeg_cli.rs @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub const REQUIRE_MEDIA_FIXTURES_ENV: &str = "FLUXER_REQUIRE_MEDIA_FIXTURES"; + +pub fn media_fixtures_are_required() -> bool { + std::env::var_os(REQUIRE_MEDIA_FIXTURES_ENV).is_some_and(|value| !value.is_empty()) +} + +fn ffmpeg_fixture(description: &str, produced: Option>) -> Option> { + assert!( + produced.is_some() || !media_fixtures_are_required(), + "{REQUIRE_MEDIA_FIXTURES_ENV} is set but the ffmpeg CLI could not produce {description}" + ); + produced +} + +pub fn ffmpeg_gen_media(file_name: &str, args: &[&str]) -> Option> { + ffmpeg_fixture(file_name, run_ffmpeg_gen_media(file_name, args)) +} + +fn run_ffmpeg_gen_media(file_name: &str, args: &[&str]) -> Option> { + let dir = tempfile::tempdir().ok()?; + let out = dir.path().join(file_name); + let status = std::process::Command::new("ffmpeg") + .args(["-nostdin", "-loglevel", "error", "-y"]) + .args(args) + .arg(out.to_str()?) + .status() + .ok()?; + if !status.success() { + return None; + } + std::fs::read(&out).ok() +} + +pub fn ffmpeg_gen_mp4(args: &[&str]) -> Option> { + ffmpeg_gen_media("fixture.mp4", args) +} + +pub fn ffmpeg_gen_rotated_mp4(display_rotation: &str, source_args: &[&str]) -> Option> { + ffmpeg_fixture( + "rotated.mp4", + run_ffmpeg_gen_rotated_mp4(display_rotation, source_args), + ) +} + +fn run_ffmpeg_gen_rotated_mp4(display_rotation: &str, source_args: &[&str]) -> Option> { + let dir = tempfile::tempdir().ok()?; + let source = dir.path().join("source.mp4"); + let out = dir.path().join("rotated.mp4"); + let source_status = std::process::Command::new("ffmpeg") + .args(["-nostdin", "-loglevel", "error", "-y"]) + .args(source_args) + .arg(source.to_str()?) + .status() + .ok()?; + if !source_status.success() { + return None; + } + let rotate_status = std::process::Command::new("ffmpeg") + .args(["-nostdin", "-loglevel", "error", "-y", "-noautorotate"]) + .args(["-display_rotation", display_rotation]) + .args(["-i", source.to_str()?]) + .args(["-c", "copy", "-f", "mp4"]) + .arg(out.to_str()?) + .status() + .ok()?; + if !rotate_status.success() { + return None; + } + std::fs::read(&out).ok() +} + +pub fn ffmpeg_mirror_mp4(source_mp4: &[u8]) -> Option> { + ffmpeg_fixture("mirrored.mp4", run_ffmpeg_mirror_mp4(source_mp4)) +} + +fn run_ffmpeg_mirror_mp4(source_mp4: &[u8]) -> Option> { + let dir = tempfile::tempdir().ok()?; + let source = dir.path().join("source.mp4"); + let out = dir.path().join("mirrored.mp4"); + std::fs::write(&source, source_mp4).ok()?; + let mirror_status = std::process::Command::new("ffmpeg") + .args(["-nostdin", "-loglevel", "error", "-y", "-noautorotate"]) + .arg("-display_hflip") + .args(["-i", source.to_str()?]) + .args(["-c", "copy", "-f", "mp4"]) + .arg(out.to_str()?) + .status() + .ok()?; + if !mirror_status.success() { + return None; + } + std::fs::read(&out).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE_PROBE_TEST: &str = + "test_fixtures::ffmpeg_cli::tests::ffmpeg_fixture_probe_reports_its_outcome"; + + fn run_fixture_probe(require: bool, empty_path: bool) -> (bool, String) { + let exe = std::env::current_exe().expect("test binary path"); + let empty = tempfile::tempdir().expect("empty path directory"); + let mut command = std::process::Command::new(exe); + command.args(["--exact", "--ignored", "--nocapture", FIXTURE_PROBE_TEST]); + if require { + command.env(REQUIRE_MEDIA_FIXTURES_ENV, "1"); + } else { + command.env_remove(REQUIRE_MEDIA_FIXTURES_ENV); + } + if empty_path { + command.env("PATH", empty.path()); + } + let output = command.output().expect("probe run"); + let mut text = String::from_utf8_lossy(&output.stdout).into_owned(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + (output.status.success(), text) + } + + fn ffmpeg_cli_is_on_path() -> bool { + std::process::Command::new("ffmpeg") + .arg("-version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } + + #[test] + #[ignore] + fn ffmpeg_fixture_probe_reports_its_outcome() { + let fixture = ffmpeg_gen_mp4(&[ + "-f", + "lavfi", + "-i", + "testsrc=size=16x16:rate=1:duration=1", + "-pix_fmt", + "yuv420p", + "-f", + "mp4", + ]); + println!( + "FIXTURE_OUTCOME={}", + if fixture.is_some() { + "produced" + } else { + "absent" + } + ); + } + + #[test] + fn missing_ffmpeg_fixtures_are_fatal_when_the_requirement_is_set() { + let (succeeded, output) = run_fixture_probe(true, true); + assert!(!succeeded, "the probe must fail loudly, got: {output}"); + assert!( + output.contains( + "FLUXER_REQUIRE_MEDIA_FIXTURES is set but the ffmpeg CLI could not produce" + ), + "the failure must name the requirement, got: {output}" + ); + } + + #[test] + fn missing_ffmpeg_fixtures_still_skip_when_the_requirement_is_unset() { + let (succeeded, output) = run_fixture_probe(false, true); + assert!(succeeded, "the probe must skip, got: {output}"); + assert!(output.contains("FIXTURE_OUTCOME=absent"), "got: {output}"); + } + + #[test] + fn the_requirement_lets_fixtures_run_when_the_ffmpeg_cli_is_on_path() { + let (succeeded, output) = run_fixture_probe(true, false); + if ffmpeg_cli_is_on_path() { + assert!(succeeded, "got: {output}"); + assert!(output.contains("FIXTURE_OUTCOME=produced"), "got: {output}"); + } else { + assert!(!succeeded, "got: {output}"); + assert!(output.contains(REQUIRE_MEDIA_FIXTURES_ENV), "got: {output}"); + } + } +} diff --git a/fluxer_media_proxy/src/test_fixtures/images.rs b/fluxer_media_proxy/src/test_fixtures/images.rs new file mode 100644 index 000000000..f7d369fec --- /dev/null +++ b/fluxer_media_proxy/src/test_fixtures/images.rs @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{media_process::native_runtime::ensure_vips_init, native}; +use base64::Engine as _; +use libc::{c_int, c_void, size_t}; +use std::{ffi::CString, ptr}; + +pub fn minimal_gif() -> Vec { + vec![ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, + 0x02, 0x44, 0x01, 0x00, 0x3b, + ] +} + +pub fn apng_header(frames: u32) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(b"\x89PNG\r\n\x1a\n"); + data.extend_from_slice(&13_u32.to_be_bytes()); + data.extend_from_slice(b"IHDR"); + data.extend_from_slice(&2_u32.to_be_bytes()); + data.extend_from_slice(&3_u32.to_be_bytes()); + data.extend_from_slice(&[8, 6, 0, 0, 0]); + data.extend_from_slice(&[0; 4]); + data.extend_from_slice(&8_u32.to_be_bytes()); + data.extend_from_slice(b"acTL"); + data.extend_from_slice(&frames.to_be_bytes()); + data.extend_from_slice(&0_u32.to_be_bytes()); + data.extend_from_slice(&[0; 4]); + data +} + +pub fn synthetic_png(width: u32, height: u32) -> Vec { + ensure_vips_init().unwrap(); + let mut pixels = vec![0u8; width as usize * height as usize * 4]; + for y in 0..height { + for x in 0..width { + let i = (y as usize * width as usize + x as usize) * 4; + pixels[i] = (x * 255 / width.max(1)) as u8; + pixels[i + 1] = (y * 255 / height.max(1)) as u8; + pixels[i + 2] = 120; + pixels[i + 3] = 255; + } + } + let image = unsafe { + native::fluxer_vips_image_new_from_memory_copy( + pixels.as_ptr().cast(), + pixels.len(), + width as c_int, + height as c_int, + 4, + native::fluxer_vips_format_uchar, + ) + }; + let image = unsafe { native::VipsImageHandle::from_raw_owned(image) }.unwrap(); + let suffix = CString::new(".png[strip]").unwrap(); + let mut out_ptr: *mut c_void = ptr::null_mut(); + let mut out_size: size_t = 0; + let rc = unsafe { + native::fluxer_vips_image_write_to_buffer( + image.as_ptr(), + suffix.as_ptr(), + &mut out_ptr, + &mut out_size, + ) + }; + assert_eq!(0, rc); + unsafe { native::buffer::NativeBuffer::from_vips_owned(out_ptr, out_size) } + .unwrap() + .try_to_vec() + .unwrap() +} + +pub fn synthetic_bmp(width: u32, height: u32) -> Vec { + assert!(width > 0 && height > 0); + let row_bytes = (width as usize * 3).next_multiple_of(4); + let pixel_bytes = row_bytes * height as usize; + let file_bytes = 54 + pixel_bytes; + let mut bytes = Vec::with_capacity(file_bytes); + bytes.extend_from_slice(b"BM"); + bytes.extend_from_slice(&(file_bytes as u32).to_le_bytes()); + bytes.extend_from_slice(&0u16.to_le_bytes()); + bytes.extend_from_slice(&0u16.to_le_bytes()); + bytes.extend_from_slice(&54u32.to_le_bytes()); + bytes.extend_from_slice(&40u32.to_le_bytes()); + bytes.extend_from_slice(&(width as i32).to_le_bytes()); + bytes.extend_from_slice(&(height as i32).to_le_bytes()); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&24u16.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&(pixel_bytes as u32).to_le_bytes()); + bytes.extend_from_slice(&2835i32.to_le_bytes()); + bytes.extend_from_slice(&2835i32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); + for y in (0..height).rev() { + let mut row = Vec::with_capacity(row_bytes); + for x in 0..width { + row.push(120); + row.push((y * 255 / height) as u8); + row.push((x * 255 / width) as u8); + } + row.resize(row_bytes, 0); + bytes.extend_from_slice(&row); + } + bytes +} + +pub fn animated_gif_fixture() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("R0lGODlhIAAgAPEAAAAAAP8AAP///wAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQJFAAAACwAAAAAIAAgAAACPYyPGcDtD5Q8sLY5rcVSV654EJiID4mYDkqpDGu4LyxHtAwv+O3mtb9j1YbEovGITCqXzKbzCY1Kp9QqsQAAIfkECRQAAAAsAAAAACAAIACDAAAAAAD/AAD/AAD/AAD/AAD/AAD/AAD/AAD/AAD/////AAAAAAAAAAAAAAAAAAAABFsQyEmrvTjrzbv/YCiOZGmeaKqubOu+MBUIA1EQgxCYBpH8wATBQDoEj7+DyIBsEj8BX/NIACGmTdAAiwRJuUBQARz0ksOf7TlhXbOhX24VxCQ/QUauctRrDiURADs=") + .unwrap() +} + +pub fn animated_gif_frames(frames: usize, delay_cs: u16) -> Vec { + let mut gif = Vec::new(); + gif.extend_from_slice(b"GIF89a"); + gif.extend_from_slice(&16u16.to_le_bytes()); + gif.extend_from_slice(&16u16.to_le_bytes()); + gif.extend_from_slice(&[0x80, 0x00, 0x00]); + gif.extend_from_slice(&[0x00, 0x00, 0x00, 0xff, 0xff, 0xff]); + gif.extend_from_slice(b"\x21\xff\x0bNETSCAPE2.0"); + gif.extend_from_slice(&[0x03, 0x01, 0x00, 0x00, 0x00]); + for index in 0..frames { + gif.extend_from_slice(&[0x21, 0xf9, 0x04, 0x00]); + gif.extend_from_slice(&delay_cs.to_le_bytes()); + gif.extend_from_slice(&[0x00, 0x00]); + gif.extend_from_slice(&[0x2c, 0x00, 0x00, 0x00, 0x00]); + gif.extend_from_slice(&1u16.to_le_bytes()); + gif.extend_from_slice(&1u16.to_le_bytes()); + gif.extend_from_slice(&[0x00, 0x02, 0x02]); + gif.extend_from_slice(if index % 2 == 0 { + &[0x44, 0x01] + } else { + &[0x4c, 0x01] + }); + gif.push(0x00); + } + gif.push(0x3b); + gif +} + +pub fn read_u24_le(bytes: &[u8]) -> Option { + (bytes.len() >= 3) + .then(|| bytes[0] as u32 | ((bytes[1] as u32) << 8) | ((bytes[2] as u32) << 16)) +} + +pub fn webp_chunk_payloads<'a>(bytes: &'a [u8], fourcc: &[u8; 4]) -> Vec<&'a [u8]> { + if bytes.len() < 12 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { + return Vec::new(); + } + let mut chunks = Vec::new(); + let mut offset = 12usize; + while offset + 8 <= bytes.len() { + let chunk_size = u32::from_le_bytes([ + bytes[offset + 4], + bytes[offset + 5], + bytes[offset + 6], + bytes[offset + 7], + ]) as usize; + let payload_start = offset + 8; + let Some(payload_end) = payload_start.checked_add(chunk_size) else { + break; + }; + if payload_end > bytes.len() { + break; + } + if &bytes[offset..offset + 4] == fourcc { + chunks.push(&bytes[payload_start..payload_end]); + } + offset = payload_end + (chunk_size & 1); + } + chunks +} + +pub fn webp_canvas_size(bytes: &[u8]) -> Option<(u32, u32, u8)> { + let vp8x = webp_chunk_payloads(bytes, b"VP8X").into_iter().next()?; + if vp8x.len() < 10 { + return None; + } + let width = read_u24_le(&vp8x[4..7])? + 1; + let height = read_u24_le(&vp8x[7..10])? + 1; + Some((width, height, vp8x[0])) +} + +pub fn first_webp_anim_frame_size(bytes: &[u8]) -> Option<(u32, u32)> { + let anmf = webp_chunk_payloads(bytes, b"ANMF").into_iter().next()?; + if anmf.len() < 16 { + return None; + } + let width = read_u24_le(&anmf[6..9])? + 1; + let height = read_u24_le(&anmf[9..12])? + 1; + Some((width, height)) +} + +pub fn webp_animation_loop_count(bytes: &[u8]) -> Option { + let anim = webp_chunk_payloads(bytes, b"ANIM").into_iter().next()?; + (anim.len() >= 6).then(|| u16::from_le_bytes([anim[4], anim[5]])) +} + +pub fn webp_with_metadata_chunk(bytes: &[u8], fourcc: &[u8; 4], flag: u8) -> Option> { + if bytes.len() < 20 || &bytes[0..4] != b"RIFF" || &bytes[8..12] != b"WEBP" { + return None; + } + if &bytes[12..16] != b"VP8X" { + return None; + } + let vp8x_size = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) as usize; + let vp8x_end = 20usize.checked_add(vp8x_size + (vp8x_size & 1))?; + if vp8x_end > bytes.len() { + return None; + } + let payload: [u8; 4] = [0x00, 0x01, 0x02, 0x03]; + let mut out = Vec::with_capacity(bytes.len() + 12); + out.extend_from_slice(&bytes[..vp8x_end]); + out[20] |= flag; + out.extend_from_slice(fourcc); + out.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + out.extend_from_slice(&payload); + out.extend_from_slice(&bytes[vp8x_end..]); + let riff_size = u32::try_from(out.len() - 8).ok()?; + out[4..8].copy_from_slice(&riff_size.to_le_bytes()); + Some(out) +} + +pub fn gif_frame_delays_cs(bytes: &[u8]) -> Vec { + if bytes.len() < 13 || (&bytes[..6] != b"GIF89a" && &bytes[..6] != b"GIF87a") { + return Vec::new(); + } + let mut offset = 13usize; + if bytes[10] & 0x80 != 0 { + let entries = 1usize << ((bytes[10] & 0x07) + 1); + offset = offset.saturating_add(entries.saturating_mul(3)); + } + let mut delays = Vec::new(); + while offset < bytes.len() { + match bytes[offset] { + 0x21 => { + if offset + 1 >= bytes.len() { + break; + } + if bytes[offset + 1] == 0xf9 { + if offset + 7 >= bytes.len() || bytes[offset + 2] != 4 { + break; + } + delays.push(u16::from_le_bytes([bytes[offset + 4], bytes[offset + 5]])); + offset += 8; + continue; + } + offset += 2; + while offset < bytes.len() { + let len = bytes[offset] as usize; + offset += 1; + if len == 0 { + break; + } + offset = offset.saturating_add(len); + } + } + 0x2c => { + if offset + 9 >= bytes.len() { + break; + } + let local_entries = if bytes[offset + 9] & 0x80 != 0 { + 1usize << ((bytes[offset + 9] & 0x07) + 1) + } else { + 0 + }; + offset += 10 + local_entries * 3 + 1; + while offset < bytes.len() { + let len = bytes[offset] as usize; + offset += 1; + if len == 0 { + break; + } + offset = offset.saturating_add(len); + } + } + 0x3b => break, + _ => break, + } + } + delays +} + +pub fn gif_loop_count(bytes: &[u8]) -> Option { + if bytes.len() < 13 || (&bytes[..6] != b"GIF89a" && &bytes[..6] != b"GIF87a") { + return None; + } + let mut offset = 13usize; + if bytes[10] & 0x80 != 0 { + let entries = 1usize << ((bytes[10] & 0x07) + 1); + offset = offset.saturating_add(entries.saturating_mul(3)); + } + while offset + 19 <= bytes.len() { + if bytes[offset] == 0x21 + && bytes[offset + 1] == 0xff + && bytes[offset + 2] == 11 + && (&bytes[offset + 3..offset + 14] == b"NETSCAPE2.0" + || &bytes[offset + 3..offset + 14] == b"ANIMEXTS1.0") + && bytes[offset + 14] == 3 + && bytes[offset + 15] == 1 + { + return Some(u16::from_le_bytes([bytes[offset + 16], bytes[offset + 17]])); + } + offset += 1; + } + None +} + +pub fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.len() < 24 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" { + return None; + } + let width = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); + let height = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); + Some((width, height)) +} diff --git a/fluxer_media_proxy/src/test_fixtures/media.rs b/fluxer_media_proxy/src/test_fixtures/media.rs new file mode 100644 index 000000000..f80ae4738 --- /dev/null +++ b/fluxer_media_proxy/src/test_fixtures/media.rs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use base64::Engine as _; + +pub fn synthetic_wav() -> Vec { + let sample_rate = 8_000u32; + let channels = 1u16; + let bits_per_sample = 8u16; + let data_len = sample_rate; + let byte_rate = sample_rate * u32::from(channels) * u32::from(bits_per_sample) / 8; + let block_align = channels * bits_per_sample / 8; + let mut wav = Vec::with_capacity(44 + data_len as usize); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + data_len).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); + wav.extend_from_slice(&channels.to_le_bytes()); + wav.extend_from_slice(&sample_rate.to_le_bytes()); + wav.extend_from_slice(&byte_rate.to_le_bytes()); + wav.extend_from_slice(&block_align.to_le_bytes()); + wav.extend_from_slice(&bits_per_sample.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_len.to_le_bytes()); + wav.extend(std::iter::repeat_n(128u8, data_len as usize)); + wav +} + +pub fn fixture_audio_only_mp4() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAxptb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAA+gABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAACRXRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAA+gAAAAAAAAAAAAAAAQEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAPoAAAQAAAEAAAAAAb1tZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAAB9AAAAL0FXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAAFobWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAAEsc3RibAAAAH5zdHNkAAAAAAAAAAEAAABubXA0YQAAAAAAAAABAAAAAAAAAAAAAQAQAAAAAB9AAAAAAAA2ZXNkcwAAAAADgICAJQABAASAgIAXQBUAAAAAAD6AAAACZQWAgIAFFYhW5QAGgICAAQIAAAAUYnRydAAAAAAAAD6AAAACZQAAACBzdHRzAAAAAAAAAAIAAAACAAAEAAAAAAEAAAPQAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAADAAAAAQAAACBzdHN6AAAAAAAAAAAAAAADAAAAFQAAAAQAAAAEAAAAFHN0Y28AAAAAAAAAAQAAA0YAAAAac2dwZAEAAAByb2xsAAAAAgAAAAH//wAAABxzYmdwAAAAAHJvbGwAAAABAAAAAwAAAAEAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYxLjcuMTAyAAAACGZyZWUAAAAlbWRhdN4CAExhdmM2MS4xOS4xMDEAAjBADgEYIAcBGCAH") + .unwrap() +} + +pub fn fixture_audio_mp4_with_attached_picture() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAABBNtb292AAAAbG12aGQAAAAAAAAAAAAAAAAAAAPoAAAA+gABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAACRXRyYWsAAABcdGtoZAAAAAMAAAAAAAAAAAAAAAEAAAAAAAAA+gAAAAAAAAAAAAAAAQEAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAAPoAAAQAAAEAAAAAAb1tZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAAB9AAAAL0FXEAAAAAAAtaGRscgAAAAAAAAAAc291bgAAAAAAAAAAAAAAAFNvdW5kSGFuZGxlcgAAAAFobWluZgAAABBzbWhkAAAAAAAAAAAAAAAkZGluZgAAABxkcmVmAAAAAAAAAAEAAAAMdXJsIAAAAAEAAAEsc3RibAAAAH5zdHNkAAAAAAAAAAEAAABubXA0YQAAAAAAAAABAAAAAAAAAAAAAQAQAAAAAB9AAAAAAAA2ZXNkcwAAAAADgICAJQABAASAgIAXQBUAAAAAAAJlAAACZQWAgIAFFYhW5QAGgICAAQIAAAAUYnRydAAAAAAAAAJlAAACZQAAACBzdHRzAAAAAAAAAAIAAAACAAAEAAAAAAEAAAPQAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAADAAAAAQAAACBzdHN6AAAAAAAAAAAAAAADAAAAFQAAAAQAAAAEAAAAFHN0Y28AAAAAAAAAAQAABD8AAAAac2dwZAEAAAByb2xsAAAAAgAAAAH//wAAABxzYmdwAAAAAHJvbGwAAAABAAAAAwAAAAEAAAFadWR0YQAAAVJtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAASVpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYxLjcuMTAyAAAA+WNvdnIAAADxZGF0YQAAAA0AAAAA/9j/4AAQSkZJRgABAgAAAQABAAD//gAQTGF2YzYxLjE5LjEwMQD/2wBDAAgEBAQEBAUFBQUFBQYGBgYGBgYGBgYGBgYHBwcICAgHBwcGBgcHCAgICAkJCQgICAgJCQoKCgwMCwsODg4RERT/xABMAAEBAAAAAAAAAAAAAAAAAAAABgEBAQAAAAAAAAAAAAAAAAAABgcQAQAAAAAAAAAAAAAAAAAAAAARAQAAAAAAAAAAAAAAAAAAAAD/wAARCAACAAIDASIAAhEAAxEA/9oADAMBAAIRAxEAPwCLAFF/f//ZAAAACGZyZWUAAAAlbWRhdN4CAExhdmM2MS4xOS4xMDEAAjBADgEYIAcBGCAH") + .unwrap() +} + +pub fn fixture_audio_mp3_with_png_cover_art() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("SUQzAwAAAAABGFRTU0UAAAAPAAAATGF2ZjYyLjEyLjEwMgBBUElDAAAAawAAAGltYWdlL3BuZwAAAIlQTkcNChoKAAAADUlIRFIAAAACAAAAAggCAAAA/dSacwAAAAlwSFlzAAAAAQAAAAEATyXE1gAAABBJREFUeJxj/MMAAixgkgEADQQBAr9QFbMAAAAASUVORK5CYIIAAAAAAAAAAAAA//tAwAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAACAAABhwC7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7//////////////////////////////////////////////////////////////////8AAAAATGF2YzYyLjI4AAAAAAAAAAAAAAAAJAJAAAAAAAAAAYfrk5ZRAAAAAAD/+xDEAAAEdBNVVJCAMKYJrzcaIAIAAa05QAABWTo9UFAIBgkB8HwfB8oCAIBhEHwf1Ag7E4f4g3AEk/bAYDgcDgAAAAAAKIkqmRRkCOkCSBaj94UB8BMb8CKUL6gaEvwkDSoAAFON4P/7EsQCgESkLVOdkwA4kAVoQPwwGQAEbhuG4bgcx5yaFqMmo5FPRenp43T04AIECAAAK/mHqYbyYxdYDKFIny4QlL1iQJBUZGRkuXLlq1aCgoKFBQUFBIKC1UxBTUU0LjBVVVVVVVVVVQ==") + .unwrap() +} + +pub fn fixture_jpeg() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("/9j/2wBDAAoHBwgHBgoICAgLCgoLDhgQDg0NDh0VFhEYIx8lJCIfIiEmKzcvJik0KSEiMEExNDk7Pj4+JS5ESUM8SDc9Pjv/2wBDAQoLCw4NDhwQEBw7KCIoOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozv/wAARCAAbADADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwCLyfakMB9Kd/a8B+7Y3B/Kj+1rcplrO5B9lFe77VHgcnmQtD61XeP2q3PqlnHCZPs9ycYBAjB5P44qtPqUC3QgjtpZcjkqORxnp/8AXqfbw7lqjNq5WeP2qB4TV5b2OQgfY5F3EhS5Cg468n60yxheaaR1QGFizHL8/gOKmWJhFlRoSktC0tlcEZa5Ix7mmTw3W1Y4b2NVByxOQW+pweKLm6mjVtj4x04Bq/YSvPp8MkjbmYHJ6ZrzalRuNzWlT5XcoxWZkRXkkEjR/wDPLkdf7vrU7Wz6ltaUynyycK4Ix71fUc4ycD3rL8TzyxaJIsblQ7qjY7g9axdRvQ6Iwv1Kt/qul6aDGG+0SLwUiIIGfU9PyrJHiooT5dhGsZ52lufrWckafZAdozj/ABqrdHBwOwFPmbNY0oLof//Z") + .unwrap() +} + +pub fn fixture_h264_mp4() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAA6NtZGF0AAACUwYF//9P3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE2NSByMzIyMiBiMzU2MDVhIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAyNSAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTAgcmVmPTEgZGVibG9jaz0wOjA6MCBhbmFseXNlPTA6MCBtZT1kaWEgc3VibWU9MCBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0wIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MCA4eDhkY3Q9MCBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0wIHRocmVhZHM9MSBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTAgd2VpZ2h0cD0wIGtleWludD0yNTAga2V5aW50X21pbj0yIHNjZW5lY3V0PTAgaW50cmFfcmVmcmVzaD0wIHJjPWNyZiBtYnRyZWU9MCBjcmY9MjMuMCBxY29tcD0wLjYwIHFwbWluPTAgcXBtYXg9NjkgcXBzdGVwPTQgaXBfcmF0aW89MS40MCBhcT0wAIAAAAEBZYiEOgxgAdAAEGcOUC6tg8te9SsWN+AAs2arfzICYaAt+5aCoVx8XBoo0twCICCvVzlQiI236uxABAEYoiEcOXp48ylu1oBFBSnPhBrlPBwq/4sAQBGOGEUVzUGmHuF+U8QAAIC4AAgDg+4OAIAjAArHYAtVUBQ4Tv6aBGUhp4xD7BwBAEYEAEARgDsuA/EQEGiV/bc5ABvXIAQF9f8GAAIBAACWBmbAAKghWdfMNGmRqiwhFXMqKOBK3MQH0myFsA4m2nHAIBEsl5JIFtgAFg8AoF0jrJrKkude4AcAUPpaesClGE9uemRgACAYoa/gOEZI9H/tBuAEQ/meHS1nAm0AAAA7QZohLwxwAbUeYlQEdfNie819MGuyiGigr74xMKbJ7oIoRAJRwBbLFSz5BXuhyELsAYweSF50MhtsenUAAAMpbW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAlN0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAABAAAAAQAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAAAAABAAAAAAHLbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABdm1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAATZzdGJsAAAAtnN0c2QAAAAAAAAAAQAAAKZhdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAABAAEABIAAAASAAAAAAAAAABFUxhdmM2Mi4yOC4xMDIgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAALGF2Y0MBQsAK/+EAFWdCwAraewEQAAADABAAAAMAQPEiagEABGjOD8gAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAc2AAAAAAAAAAYc3R0cwAAAAAAAAABAAAAAgAAIAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAABxzdHNjAAAAAAAAAAEAAAABAAAAAgAAAAEAAAAcc3RzegAAAAAAAAAAAAAAAgAAA1wAAAA/AAAAFHN0Y28AAAAAAAAAAQAAADAAAABidWR0YQAAAFptZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAAC1pbHN0AAAAJal0b28AAAAdZGF0YQAAAAEAAAAATGF2ZjYyLjEyLjEwMg==") + .unwrap() +} + +pub fn fixture_mkv_with_png_video_stream() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("GkXfo6NChoEBQveBAULygQRC84EIQoKIbWF0cm9za2FCh4EEQoWBAhhTgGcBAAAAAAADKRFNm3TAv4TRygGCTbuLU6uEFUmpZlOsgaFNu4tTq4QWVK5rU6yB8U27jFOrhBJUw2dTrIIBjU27jFOrhBxTu2tTrIIC++wBAAAAAAAAUwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFUmpZsu/hDfQZgQq17GDD0JATYCNTGF2ZjYyLjEyLjEwMldBjUxhdmY2Mi4xMi4xMDJzpJCjpT5oFuxsQH+IIFgtzSyDRImIQI9AAAAAAAAWVK5rQJa/hBL4yYmuAQAAAAAAAIfXgQFzxYhQd4Ha+R0+q5yBACK1nIN1bmSIgQCDgQEj44OEHc1lAIaPVl9NUy9WRlcvRk9VUkND4JSwgRC6gRCagQJVsIhVsYEAVbmBAlXugQDsAQAAAAAAAAIAAGOiqCgAAAAQAAAAEAAAAAEAGABNUE5HAAMAAAAAAAAAAAAAAAAAAAAAAAASVMNnQH+/hDz+Y4Vzc6BjwIBnyJpFo4dFTkNPREVSRIeNTGF2ZjYyLjEyLjEwMnNz02PAi2PFiFB3gdr5HT6rZ8ieRaOHRU5DT0RFUkSHkUxhdmM2Mi4yOC4xMDIgcG5nZ8ihRaOIRFVSQVRJT05Eh5MwMDowMDowMS4wMDAwMDAwMDAAH0O2dUDjv4S0YLXX54EAo+uBAACAiVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAABAAAAAQBPJcTWAAAAGUlEQVR4nGNkYPjPQApgIUn1qIZRDUNKAwBsIAE94xryDgAAAABJRU5ErkJggqPrgQH0gIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgCAAAAkJFoNgAAAAlwSFlzAAAAAQAAAAEATyXE1gAAABlJREFUeJxjZGD4z0AKYCFJ9aiGUQ1DSgMAbCABPeMa8g4AAAAASUVORK5CYIIcU7trqb+E2z26zruPs4EAt4r3gQHxggIS8IEJu5CzggH0t4r3gQHxggIS8IF2") + .unwrap() +} + +pub fn fixture_mp4_with_undecodable_video() -> Vec { + let mut mp4 = fixture_h264_mp4(); + let mdat = mp4 + .windows(4) + .position(|window| window == b"mdat") + .filter(|offset| *offset >= 4) + .expect("fixture mp4 has an mdat box"); + let box_start = mdat - 4; + let box_size = u32::from_be_bytes([ + mp4[box_start], + mp4[box_start + 1], + mp4[box_start + 2], + mp4[box_start + 3], + ]) as usize; + let payload_end = box_start + box_size; + assert!(payload_end <= mp4.len() && box_size > 8); + mp4[mdat + 4..payload_end].fill(0); + mp4 +} diff --git a/fluxer_media_proxy/src/test_fixtures/mod.rs b/fluxer_media_proxy/src/test_fixtures/mod.rs new file mode 100644 index 000000000..ef83266a0 --- /dev/null +++ b/fluxer_media_proxy/src/test_fixtures/mod.rs @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod adversarial; +mod ffmpeg_cli; +mod images; +mod media; + +pub use adversarial::{ + ADVERSARIAL_RANGE_HEADERS, ADVERSARIAL_TEXT_INPUTS, adversarial_media_bytes, +}; +pub use ffmpeg_cli::{ffmpeg_gen_media, ffmpeg_gen_mp4, ffmpeg_gen_rotated_mp4, ffmpeg_mirror_mp4}; +pub use images::{ + animated_gif_fixture, animated_gif_frames, apng_header, first_webp_anim_frame_size, + gif_frame_delays_cs, gif_loop_count, minimal_gif, png_dimensions, synthetic_bmp, synthetic_png, + webp_animation_loop_count, webp_canvas_size, webp_chunk_payloads, webp_with_metadata_chunk, +}; +pub use media::{ + fixture_audio_mp3_with_png_cover_art, fixture_audio_mp4_with_attached_picture, + fixture_audio_only_mp4, fixture_h264_mp4, fixture_jpeg, fixture_mkv_with_png_video_stream, + fixture_mp4_with_undecodable_video, synthetic_wav, +}; diff --git a/fluxer_media_proxy/src/tests/mod.rs b/fluxer_media_proxy/src/tests/mod.rs new file mode 100644 index 000000000..cd279f35e --- /dev/null +++ b/fluxer_media_proxy/src/tests/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod native_stack; +mod provisioning; +mod source_hygiene; diff --git a/fluxer_media_proxy/src/tests/native_stack.rs b/fluxer_media_proxy/src/tests/native_stack.rs new file mode 100644 index 000000000..2875ea494 --- /dev/null +++ b/fluxer_media_proxy/src/tests/native_stack.rs @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::HashSet; + +use super::provisioning::{ + dockerfile_stage, installs_package, linux_region, repository_file, shell_function, + shell_variable, workflow_step, +}; + +const SHIPPED_MALLOC_CONF: &str = "ENV MALLOC_CONF=\"background_thread:true,metadata_thp:auto,dirty_decay_ms:5000,muzzy_decay_ms:5000\""; + +fn copy_sources(stage: &str, from: &str) -> Vec { + let prefix = format!("COPY --from={from} "); + stage + .lines() + .filter_map(|line| line.strip_prefix(prefix.as_str())) + .filter_map(|rest| rest.split_whitespace().next()) + .map(str::to_owned) + .collect() +} + +#[test] +fn the_runtime_image_takes_only_shared_objects_from_the_native_prefix() { + let dockerfile = repository_file("Dockerfile"); + let native = dockerfile_stage(&dockerfile, "native"); + let runtime = dockerfile_stage(&dockerfile, "runtime"); + let sources = copy_sources(&runtime, "native"); + assert!( + !sources.is_empty(), + "the runtime image never receives the source-built libraries" + ); + for source in &sources { + assert!( + source.as_str() != "/usr/local", + "the runtime image ships the whole native prefix, so the ffmpeg CLI, the headers and the pkgconfig files land in production" + ); + assert!( + native.contains(source.as_str()), + "the native stage never assembles {source}" + ); + } + assert!( + native.contains("/usr/local/lib/*.so.*"), + "the native stage stopped selecting the shared objects on their own" + ); +} + +#[test] +fn the_runtime_image_resolves_exactly_one_libheif_and_asserts_which_one() { + let runtime = dockerfile_stage(&repository_file("Dockerfile"), "runtime"); + assert!( + runtime.contains("rm -f /usr/lib/*/libheif.so.1*"), + "trixie's libheif still sits beside the source-built one, so which copy libvips binds to is left to ld.so cache order" + ); + assert!( + runtime.contains("ldconfig -p | grep -c 'libheif\\.so\\.1 '"), + "nothing counts the resolvable libheif copies at build time" + ); + assert!( + runtime.contains("/usr/local/lib/libheif\\.so\\.1"), + "nothing pins the surviving libheif to the source-built one" + ); +} + +#[test] +fn the_devcontainer_keeps_one_libheif_header_set_and_proves_the_compiler_picks_it() { + let devcontainer = repository_file("../.devcontainer/Dockerfile"); + assert!( + !installs_package(&devcontainer, "libheif-dev"), + "the devcontainer apt-installs libheif headers beside the source-built ones" + ); + assert!( + devcontainer.contains("LIBHEIF_HAVE_VERSION"), + "nothing proves the source-built libheif headers win the include search" + ); + assert!( + devcontainer.contains("heif_get_version()"), + "nothing proves the libheif the loader picks matches the headers it was compiled against" + ); +} + +#[test] +fn the_runtime_image_ships_the_allocator_tuning_the_service_was_deployed_with() { + let runtime = dockerfile_stage(&repository_file("Dockerfile"), "runtime"); + assert!( + runtime.contains(SHIPPED_MALLOC_CONF), + "the jemalloc tuning drifted from the one production runs" + ); +} + +const FFMPEG_GPL_LIBRARIES: [&str; 13] = [ + "avisynth", + "frei0r", + "libcdio", + "libdavs2", + "libdvdnav", + "libdvdread", + "librubberband", + "libvidstab", + "libx264", + "libx265", + "libxavs", + "libxavs2", + "libxvid", +]; + +const FFMPEG_RELICENSING_FLAGS: [&str; 3] = ["gpl", "nonfree", "version3"]; + +const DEBIAN_ESSENTIAL: &str = ""; + +const LINUX_INSTALLER_COMMAND_PACKAGES: [(&str, &str); 6] = [ + ("cc", "build-essential"), + ("cmake", "cmake"), + ("curl", "curl"), + ("make", "build-essential"), + ("pkg-config", "pkg-config"), + ("tar", DEBIAN_ESSENTIAL), +]; + +const LIBHEIF_CMAKE_OPTIONS: [&str; 38] = [ + "BUILD_DEVELOPMENT_TOOLS", + "BUILD_DOCUMENTATION", + "BUILD_SHARED_LIBS", + "BUILD_TESTING", + "ENABLE_COVERAGE", + "ENABLE_MULTITHREADING_SUPPORT", + "ENABLE_PARALLEL_TILE_DECODING", + "ENABLE_PLUGIN_LOADING", + "WITH_AOM_DECODER", + "WITH_AOM_ENCODER", + "WITH_DAV1D", + "WITH_EXAMPLES", + "WITH_EXAMPLE_HEIF_THUMB", + "WITH_EXAMPLE_HEIF_VIEW", + "WITH_FFMPEG_DECODER", + "WITH_FUZZERS", + "WITH_GDK_PIXBUF", + "WITH_HEADER_COMPRESSION", + "WITH_JPEG_DECODER", + "WITH_JPEG_ENCODER", + "WITH_KVAZAAR", + "WITH_LIBDE265", + "WITH_LIBSHARPYUV", + "WITH_LIBSHARPYUV_INTERNAL", + "WITH_OPENJPH_ENCODER", + "WITH_OpenH264_DECODER", + "WITH_OpenJPEG_DECODER", + "WITH_OpenJPEG_ENCODER", + "WITH_RAV1E", + "WITH_REDUCED_VISIBILITY", + "WITH_SvtEnc", + "WITH_UNCOMPRESSED_CODEC", + "WITH_UVG266", + "WITH_VVDEC", + "WITH_VVENC", + "WITH_WEBCODECS", + "WITH_X264", + "WITH_X265", +]; + +fn ffmpeg_configure_enables(script: &str) -> Vec { + shell_function(script, "build_ffmpeg") + .split_whitespace() + .filter_map(|token| token.strip_prefix("--enable-")) + .map(str::to_owned) + .collect() +} + +fn need_commands(script: &str) -> Vec { + let mut commands: Vec = linux_region(script) + .lines() + .filter_map(|line| line.trim().strip_prefix("need_command ")) + .map(|name| name.trim().to_owned()) + .collect(); + commands.sort(); + commands.dedup(); + commands +} + +fn cmake_feature_definitions(invocation: &str) -> HashSet { + invocation + .split_whitespace() + .filter_map(|token| token.strip_prefix("-D")) + .filter_map(|definition| definition.split('=').next()) + .filter(|name| { + name.starts_with("WITH_") || name.starts_with("ENABLE_") || name.starts_with("BUILD_") + }) + .map(str::to_owned) + .collect() +} + +#[test] +fn the_source_built_ffmpeg_keeps_the_lgpl_licence_it_is_shipped_under() { + let script = repository_file("tools/install-native-deps.sh"); + let enabled = ffmpeg_configure_enables(&script); + assert!(!enabled.is_empty(), "the installer configures FFmpeg"); + for flag in FFMPEG_RELICENSING_FLAGS { + assert!( + !enabled.iter().any(|enable| enable == flag), + "--enable-{flag} relicenses every shipped FFmpeg library away from LGPL without enabling anything the media proxy asks for" + ); + } + for enable in &enabled { + assert!( + !FFMPEG_GPL_LIBRARIES.contains(&enable.as_str()), + "--enable-{enable} is on FFmpeg's EXTERNAL_LIBRARY_GPL_LIST, so the build can no longer stay LGPL" + ); + } +} + +#[test] +fn every_command_the_linux_installer_runs_is_installed_before_it_runs() { + let script = repository_file("tools/install-native-deps.sh"); + let commands = need_commands(&script); + assert!( + commands.iter().any(|command| command == "curl"), + "fetch_source downloads every pinned tarball with curl" + ); + let native = dockerfile_stage(&repository_file("Dockerfile"), "native"); + let step = workflow_step( + &repository_file("../.github/workflows/tests.yaml"), + "Install native dependencies", + ); + let devcontainer = repository_file("../.devcontainer/Dockerfile"); + for command in commands { + let package = LINUX_INSTALLER_COMMAND_PACKAGES + .iter() + .find(|entry| entry.0 == command) + .unwrap_or_else(|| { + panic!("{command} needs an entry in LINUX_INSTALLER_COMMAND_PACKAGES") + }) + .1; + if package == DEBIAN_ESSENTIAL { + continue; + } + assert!( + installs_package(&native, package), + "the image native stage runs the installer without {package}, which provides {command}" + ); + assert!( + installs_package(&step, package), + "the CI native dependency step runs the installer without {package}, which provides {command}" + ); + assert!( + installs_package(&devcontainer, package), + "the devcontainer runs the installer without {package}, which provides {command}" + ); + } +} + +#[test] +fn the_libheif_build_pins_every_feature_flag_instead_of_autodetecting_it() { + let script = repository_file("tools/install-native-deps.sh"); + let version = shell_variable(&script, "LIBHEIF_VERSION"); + let build = shell_function(&script, "build_libheif"); + let pinned = cmake_feature_definitions(&build); + for option in LIBHEIF_CMAKE_OPTIONS { + assert!( + pinned.contains(option), + "libheif {option} is left to autodetection, so CI, the devcontainer and production build differently featured libraries from the same source" + ); + } + for option in &pinned { + assert!( + LIBHEIF_CMAKE_OPTIONS.contains(&option.as_str()), + "-D{option} is not an option libheif {version} declares" + ); + } + assert!( + build.contains("-DENABLE_PLUGIN_LOADING=OFF"), + "every WITH_*_PLUGIN variant only changes the build while plugin loading is on" + ); +} diff --git a/fluxer_media_proxy/src/tests/provisioning.rs b/fluxer_media_proxy/src/tests/provisioning.rs new file mode 100644 index 000000000..f41e093ee --- /dev/null +++ b/fluxer_media_proxy/src/tests/provisioning.rs @@ -0,0 +1,514 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::{collections::HashSet, fs, path::PathBuf}; + +use super::source_hygiene::sources_under; + +const NATIVE_INSTALLER_HASH: &str = "hashFiles('fluxer_media_proxy/tools/install-native-deps.sh')"; + +const FFMPEG_LIBRARY_PACKAGES: [(&str, &str, &str, &str); 5] = [ + ("libaom", "aom", "libaom-dev", "libaom3"), + ("libdav1d", "dav1d", "libdav1d-dev", "libdav1d7"), + ("libde265", "libde265", "libde265-dev", "libde265-0"), + ("libvpx", "vpx", "libvpx-dev", "libvpx9"), + ("libwebp", "libwebp", "libwebp-dev", "libwebp7"), +]; + +pub(super) fn repository_file(relative: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(relative); + fs::read_to_string(&path).unwrap_or_else(|_| panic!("{} is readable", path.display())) +} + +pub(super) fn dockerfile_stage(dockerfile: &str, stage: &str) -> String { + let header = format!("AS {stage}"); + let mut collecting = false; + let mut collected = String::new(); + for line in dockerfile.lines() { + if line.starts_with("FROM ") { + collecting = line.trim_end().ends_with(&header); + continue; + } + if collecting { + collected.push_str(line); + collected.push('\n'); + } + } + assert!(!collected.is_empty(), "the {stage} stage exists"); + collected +} + +fn apt_packages(stage: &str) -> HashSet { + let mut packages = HashSet::new(); + let mut collecting = false; + for line in stage.lines() { + let trimmed = line.trim(); + if trimmed.contains("apt-get install") { + collecting = true; + continue; + } + if !collecting { + continue; + } + if trimmed.starts_with("&&") { + collecting = false; + continue; + } + for token in trimmed.split_whitespace() { + if token != "\\" { + packages.insert(token.to_owned()); + } + } + } + assert!(!packages.is_empty(), "the stage installs packages"); + packages +} + +fn ffmpeg_library_flags(script: &str) -> Vec { + script + .split_whitespace() + .filter_map(|token| token.strip_prefix("--enable-lib")) + .map(|suffix| format!("lib{suffix}")) + .collect() +} + +fn ffmpeg_build_modules(script: &str) -> Vec { + script + .lines() + .find_map(|line| line.strip_prefix("FFMPEG_BUILD_MODULES=")) + .expect("the installer declares FFMPEG_BUILD_MODULES") + .trim_matches('"') + .split_whitespace() + .map(str::to_owned) + .collect() +} + +fn library_entry(flag: &str) -> (&'static str, &'static str, &'static str, &'static str) { + *FFMPEG_LIBRARY_PACKAGES + .iter() + .find(|entry| entry.0 == flag) + .unwrap_or_else(|| panic!("--enable-{flag} needs an entry in FFMPEG_LIBRARY_PACKAGES")) +} + +#[test] +fn every_enabled_ffmpeg_library_is_packaged_in_all_three_image_stages() { + let script = repository_file("tools/install-native-deps.sh"); + let dockerfile = repository_file("Dockerfile"); + let native = apt_packages(&dockerfile_stage(&dockerfile, "native")); + let builder = apt_packages(&dockerfile_stage(&dockerfile, "builder")); + let runtime = apt_packages(&dockerfile_stage(&dockerfile, "runtime")); + for flag in ffmpeg_library_flags(&script) { + let (_, _, development, shared) = library_entry(&flag); + assert!( + native.contains(development), + "the native stage cannot build --enable-{flag} without {development}" + ); + assert!( + builder.contains(development), + "the builder stage cannot link --enable-{flag} without {development}" + ); + assert!( + runtime.contains(shared), + "the runtime stage cannot load --enable-{flag} without {shared}" + ); + } +} + +#[test] +fn the_ffmpeg_build_modules_match_the_enabled_ffmpeg_libraries() { + let script = repository_file("tools/install-native-deps.sh"); + let flags = ffmpeg_library_flags(&script); + let modules = ffmpeg_build_modules(&script); + for flag in &flags { + let (_, module, ..) = library_entry(flag); + assert!( + modules.iter().any(|declared| declared == module), + "FFMPEG_BUILD_MODULES is missing {module} for --enable-{flag}" + ); + } + for module in modules.iter().filter(|module| *module != "zlib") { + let entry = FFMPEG_LIBRARY_PACKAGES + .iter() + .find(|entry| entry.1 == module) + .unwrap_or_else(|| panic!("{module} needs an entry in FFMPEG_LIBRARY_PACKAGES")); + assert!( + flags.iter().any(|flag| flag == entry.0), + "FFMPEG_BUILD_MODULES probes {module} but FFmpeg is not configured with --enable-{}", + entry.0 + ); + } +} + +pub(super) fn shell_variable(script: &str, name: &str) -> String { + let prefix = format!("{name}="); + script + .lines() + .find_map(|line| line.strip_prefix(prefix.as_str())) + .unwrap_or_else(|| panic!("the installer declares {name}")) + .trim_matches('"') + .to_owned() +} + +fn declared_floors(script: &str) -> Vec { + script + .lines() + .filter_map(|line| line.split('=').next()) + .filter(|name| name.ends_with("_FLOOR")) + .map(str::to_owned) + .collect() +} + +fn shell_function_names(script: &str) -> Vec { + script + .lines() + .filter_map(|line| line.strip_suffix("() {")) + .filter(|name| !name.starts_with(' ')) + .map(str::to_owned) + .collect() +} + +pub(super) fn shell_function(script: &str, name: &str) -> String { + let header = format!("\n{name}() {{"); + let start = script + .find(&header) + .unwrap_or_else(|| panic!("the installer declares {name}")); + let body = &script[start + header.len()..]; + let end = body + .find("\n}") + .unwrap_or_else(|| panic!("{name} is a closed shell function")); + body[..end].to_owned() +} + +fn reachable_region(script: &str, seed: &str) -> String { + let names = shell_function_names(script); + let mut region = seed.to_owned(); + let mut expanded: HashSet = HashSet::new(); + loop { + let pending: Vec = names + .iter() + .filter(|name| region.contains(name.as_str()) && !expanded.contains(*name)) + .cloned() + .collect(); + if pending.is_empty() { + return region; + } + for name in pending { + region.push_str(&shell_function(script, &name)); + expanded.insert(name); + } + } +} + +pub(super) fn linux_region(script: &str) -> String { + let branch = script + .find("if [ \"$(uname -s)\" = \"Darwin\" ]; then") + .expect("the installer branches on the platform"); + let tail = &script[branch..]; + let closing = tail + .find("\nfi\n") + .expect("the platform branch is closed before the Linux path"); + reachable_region(script, &tail[closing..]) +} + +fn enforces_floor(region: &str, floor: &str) -> bool { + region.contains(&format!("\"${floor}\"")) || region.contains(&format!("${{{floor}}}")) +} + +fn version_parts(version: &str) -> Vec { + let mut parts: Vec = version + .split('.') + .map(|part| part.parse().expect("a numeric version component")) + .collect(); + parts.resize(3, 0); + parts +} + +fn libheif_guard_versions(source: &str) -> Vec> { + source + .match_indices("LIBHEIF_HAVE_VERSION(") + .filter_map(|(index, needle)| source[index + needle.len()..].split(')').next()) + .map(|arguments| { + arguments + .split(',') + .map(|part| part.trim().parse().expect("a numeric guard component")) + .collect() + }) + .collect() +} + +#[test] +fn every_native_floor_is_enforced_on_linux_and_on_macos() { + let script = repository_file("tools/install-native-deps.sh"); + let floors = declared_floors(&script); + assert!( + floors.len() >= 5, + "the installer declares the native floors" + ); + let macos = reachable_region(&script, &shell_function(&script, "verify_macos_floors")); + let linux = linux_region(&script); + for floor in floors { + assert!( + enforces_floor(&macos, &floor), + "the macOS path stopped enforcing {floor}" + ); + if floor == "LIBHEIF_FLOOR" { + continue; + } + assert!( + enforces_floor(&linux, &floor), + "the Linux path enforces {floor} by nothing" + ); + } + assert!( + linux.contains("pc --exact-version=\"$LIBHEIF_VERSION\" libheif"), + "the Linux path stopped pinning libheif to LIBHEIF_VERSION" + ); +} + +fn required_development_packages(script: &str) -> Vec { + let mut packages: Vec = linux_region(script) + .split(|character: char| !character.is_alphanumeric() && character != '-') + .filter(|token| token.ends_with("-dev")) + .map(str::to_owned) + .collect(); + packages.sort(); + packages.dedup(); + packages +} + +pub(super) fn installs_package(text: &str, package: &str) -> bool { + text.split_whitespace().any(|token| token == package) +} + +#[test] +fn every_package_the_linux_gate_needs_is_installed_before_the_installer_runs() { + let script = repository_file("tools/install-native-deps.sh"); + let packages = required_development_packages(&script); + assert!( + packages.len() >= 8, + "the Linux gate names the development packages it needs: {packages:?}" + ); + let native = dockerfile_stage(&repository_file("Dockerfile"), "native"); + let workflow = repository_file("../.github/workflows/tests.yaml"); + let step = workflow_step(&workflow, "Install native dependencies"); + let devcontainer = repository_file("../.devcontainer/Dockerfile"); + for package in packages { + assert!( + installs_package(&native, &package), + "the image native stage runs the installer without {package}" + ); + assert!( + installs_package(&step, &package), + "the CI native dependency step runs the installer without {package}" + ); + assert!( + installs_package(&devcontainer, &package), + "the devcontainer runs the installer without {package}" + ); + } +} + +#[test] +fn the_libheif_floor_compiles_the_shim_guards_the_same_way_everywhere() { + let script = repository_file("tools/install-native-deps.sh"); + let floor = version_parts(&shell_variable(&script, "LIBHEIF_FLOOR")); + let pinned = version_parts(&shell_variable(&script, "LIBHEIF_VERSION")); + let guards = libheif_guard_versions(&repository_file("src/native_shim/heif_pixels.c")); + assert!( + !guards.is_empty(), + "the HEIF shim guards on libheif versions" + ); + for guard in guards { + assert!( + floor >= guard, + "libheif {floor:?} is below the {guard:?} guard in heif_pixels.c, so a developer machine compiles a different HEIF decode path than CI" + ); + } + assert!( + pinned >= floor, + "the source-built libheif {pinned:?} is below the floor {floor:?} every other environment must clear" + ); +} + +fn native_shim_sources() -> Vec { + sources_under( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/native_shim"), + "c", + ) +} + +fn shim_string_macro(header: &str, name: &str) -> String { + let declaration = format!("#define {name}"); + let mut body = String::new(); + let mut found = false; + for line in header.lines() { + let trimmed = line.trim(); + if !found { + if !trimmed.starts_with(&declaration) { + continue; + } + found = true; + } + let fragment = trimmed + .strip_prefix(declaration.as_str()) + .unwrap_or(trimmed); + let continues = fragment.ends_with('\\'); + body.push_str(fragment.trim_end_matches('\\')); + if !continues { + break; + } + } + assert!(found, "the shim defines {name}"); + body.split('"').skip(1).step_by(2).collect() +} + +fn forced_input_formats(header: &str) -> Vec { + let mut formats = Vec::new(); + for path in native_shim_sources() { + let source = fs::read_to_string(&path).expect("a shim source file is utf8"); + for fragment in source.split("av_find_input_format(").skip(1) { + let argument = fragment + .split(')') + .next() + .expect("the call is closed") + .trim(); + let name = match argument.strip_prefix('"') { + Some(literal) => literal + .split('"') + .next() + .expect("the literal is closed") + .to_owned(), + None => shim_string_macro(header, argument), + }; + assert!( + !name.is_empty(), + "{} forces an empty demuxer name", + path.display() + ); + formats.push(name); + } + } + formats.sort(); + formats.dedup(); + formats +} + +fn shell_words(script: &str, name: &str) -> Vec { + shell_variable(script, name) + .split_whitespace() + .map(str::to_owned) + .collect() +} + +#[test] +fn the_installer_requires_the_bmp_decoder_libvips_can_no_longer_provide() { + let vips = repository_file("src/native_shim/vips_image.c"); + assert!( + vips.contains("vips_block_untrusted_set"), + "the vips shim stopped blocking untrusted loaders" + ); + assert!( + !vips.contains("Magick"), + "unblocking an ImageMagick loader contradicts the decision that FFmpeg decodes BMP" + ); + let header = repository_file("src/native_shim/native_shim_internal.h"); + let allowed = shim_string_macro(&header, "FLUXER_ALLOWED_VIDEO_DECODERS"); + assert!( + allowed.split(',').any(|name| name == "bmp"), + "the shim stopped whitelisting the bmp decoder" + ); + let script = repository_file("tools/install-native-deps.sh"); + assert!( + shell_words(&script, "REQUIRED_DECODERS") + .iter() + .any(|name| name == "bmp"), + "libvips blocks every ImageMagick loader, so a build without FFmpeg's bmp decoder serves no BMP at all" + ); +} + +#[test] +fn the_installer_requires_every_demuxer_the_shim_forces_by_name() { + let script = repository_file("tools/install-native-deps.sh"); + let header = repository_file("src/native_shim/native_shim_internal.h"); + let forced = forced_input_formats(&header); + assert!( + forced.iter().any(|name| name == "bmp_pipe"), + "the shim stopped forcing the bmp_pipe demuxer" + ); + let required = shell_words(&script, "REQUIRED_DEMUXERS"); + for name in &forced { + assert!( + required.contains(name), + "the shim forces the {name} demuxer by name but REQUIRED_DEMUXERS does not require it" + ); + } + let verify = shell_function(&script, "verify_ffmpeg_codecs"); + assert!( + verify.contains("-demuxers") && verify.contains("$REQUIRED_DEMUXERS"), + "verify_ffmpeg_codecs declares REQUIRED_DEMUXERS without checking the built ffmpeg for them" + ); +} + +pub(super) fn workflow_step(workflow: &str, name: &str) -> String { + let header = format!("- name: {name}"); + let mut collecting = false; + let mut collected = String::new(); + for line in workflow.lines() { + if line.trim_start().starts_with("- name: ") { + collecting = line.trim_start() == header; + continue; + } + if collecting { + collected.push_str(line); + collected.push('\n'); + } + } + assert!(!collected.is_empty(), "the {name} step exists"); + collected +} + +fn cache_keys(step: &str) -> (String, Vec) { + let mut key = None; + let mut restore_keys = Vec::new(); + let mut collecting = false; + for line in step.lines() { + let trimmed = line.trim(); + if let Some(value) = trimmed.strip_prefix("key: ") { + key = Some(value.to_owned()); + collecting = false; + } else if trimmed == "restore-keys: |" { + collecting = true; + } else if collecting { + if trimmed.is_empty() { + collecting = false; + } else { + restore_keys.push(trimmed.to_owned()); + } + } + } + (key.expect("the cache step declares a key"), restore_keys) +} + +#[test] +fn the_cargo_cache_is_keyed_on_the_native_dependency_installer() { + let workflow = repository_file("../.github/workflows/tests.yaml"); + let (native_key, _) = cache_keys(&workflow_step(&workflow, "Cache native media dependencies")); + assert!( + native_key.contains(NATIVE_INSTALLER_HASH), + "the native dependency cache stopped keying on the installer: {native_key}" + ); + let (cargo_key, restore_keys) = cache_keys(&workflow_step(&workflow, "Cache cargo")); + assert!( + cargo_key.contains(NATIVE_INSTALLER_HASH), + "target/ carries the native shim archive built against the installed headers, so the cargo cache key must move with {NATIVE_INSTALLER_HASH}: {cargo_key}" + ); + assert!( + !restore_keys.is_empty(), + "the cargo cache declares fallbacks" + ); + for restore_key in restore_keys { + assert!( + restore_key.contains(NATIVE_INSTALLER_HASH), + "restore-key {restore_key} resurrects a target/ built against different native headers" + ); + } +} diff --git a/fluxer_media_proxy/src/tests/source_hygiene.rs b/fluxer_media_proxy/src/tests/source_hygiene.rs new file mode 100644 index 000000000..fa58a97e5 --- /dev/null +++ b/fluxer_media_proxy/src/tests/source_hygiene.rs @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, +}; + +use super::provisioning::repository_file; + +const REQUIRED_PKG_CONFIG_ENVIRONMENT: [&str; 4] = [ + "PKG_CONFIG", + "PKG_CONFIG_LIBDIR", + "PKG_CONFIG_PATH", + "PKG_CONFIG_SYSROOT_DIR", +]; + +pub(super) fn sources_under(root: PathBuf, extension: &str) -> Vec { + let mut pending = vec![root]; + let mut sources = Vec::new(); + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(&dir).expect("the source tree is readable") { + let path = entry.expect("a source entry is readable").path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().is_some_and(|ext| ext == extension) { + sources.push(path); + } + } + } + sources +} + +fn server_sources() -> Vec { + sources_under( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/server"), + "rs", + ) +} + +fn crate_sources() -> Vec { + sources_under(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"), "rs") +} + +#[test] +fn the_server_module_tree_suppresses_no_dead_code() { + let sources = server_sources(); + assert!(!sources.is_empty()); + for path in sources { + let source = fs::read_to_string(&path).expect("a server source file is utf8"); + assert!( + !source.contains("dead_code"), + "{} suppresses dead code instead of deleting it", + path.display() + ); + } +} + +#[test] +fn no_crate_source_file_suppresses_dead_code() { + let sources = crate_sources(); + assert!(!sources.is_empty()); + for path in sources { + let source = fs::read_to_string(&path).expect("a crate source file is utf8"); + for line in source.lines() { + let attribute = line.trim_start(); + let is_attribute = attribute.starts_with("#[") || attribute.starts_with("#!["); + assert!( + !(is_attribute && attribute.contains("dead_code")), + "{} suppresses dead code instead of deleting it", + path.display() + ); + } + } +} + +fn string_array(build_script: &str, name: &str) -> HashSet { + let declaration = format!("const {name}"); + let start = build_script + .find(&declaration) + .unwrap_or_else(|| panic!("build.rs declares {name}")); + let body = &build_script[start..]; + let end = body + .find("];") + .unwrap_or_else(|| panic!("{name} is a closed array")); + body[..end] + .lines() + .filter_map(|line| line.trim().strip_prefix('"')) + .filter_map(|entry| entry.split('"').next()) + .map(str::to_owned) + .collect() +} + +fn crate_relative(manifest: &Path, path: &Path) -> String { + path.strip_prefix(manifest) + .expect("the file lives inside the crate") + .to_str() + .expect("the path is utf8") + .to_owned() +} + +fn local_includes(path: &Path) -> Vec { + fs::read_to_string(path) + .unwrap_or_else(|_| panic!("{} is readable", path.display())) + .lines() + .filter_map(|line| line.trim().strip_prefix("#include \"")) + .filter_map(|rest| rest.split('"').next()) + .map(str::to_owned) + .collect() +} + +fn resolve_include(manifest: &Path, includer: &Path, include: &str) -> PathBuf { + let sibling = includer + .parent() + .expect("a compiled source has a parent directory") + .join(include); + if sibling.is_file() { + return sibling; + } + manifest.join("src").join(include) +} + +fn direct_environment_reads(build_script: &str) -> Vec { + build_script + .match_indices("env::var(\"") + .filter_map(|(index, needle)| build_script[index + needle.len()..].split('"').next()) + .map(str::to_owned) + .collect() +} + +#[test] +fn build_rs_reruns_for_every_compiled_source_and_header() { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let build_script = repository_file("build.rs"); + assert!( + build_script.contains("println!(\"cargo:rerun-if-changed={source}\");"), + "build.rs stopped emitting a rerun line for every compiled source" + ); + assert!( + build_script.contains("println!(\"cargo:rerun-if-changed={header}\");"), + "build.rs stopped emitting a rerun line for every tracked header" + ); + let compiled: HashSet = sources_under(manifest.join("src"), "c") + .iter() + .map(|path| crate_relative(&manifest, path)) + .collect(); + assert_eq!( + string_array(&build_script, "NATIVE_SHIM_SOURCES"), + compiled, + "NATIVE_SHIM_SOURCES no longer matches the C sources cc compiles" + ); + let mut headers: HashSet = HashSet::new(); + let mut pending: Vec = compiled.iter().map(|path| manifest.join(path)).collect(); + while let Some(path) = pending.pop() { + for include in local_includes(&path) { + let resolved = resolve_include(&manifest, &path, &include); + assert!( + resolved.is_file(), + "{} includes {include}, which is not in the tree", + path.display() + ); + if headers.insert(crate_relative(&manifest, &resolved)) { + pending.push(resolved); + } + } + } + assert_eq!( + string_array(&build_script, "NATIVE_SHIM_HEADERS"), + headers, + "NATIVE_SHIM_HEADERS no longer matches the headers the compiled sources include" + ); +} + +#[test] +fn build_rs_reruns_when_the_native_toolchain_environment_changes() { + let build_script = repository_file("build.rs"); + assert!( + build_script.contains("println!(\"cargo:rerun-if-env-changed={variable}\");"), + "build.rs stopped emitting a rerun line for every tracked variable" + ); + let declared = string_array(&build_script, "NATIVE_BUILD_ENVIRONMENT"); + for variable in REQUIRED_PKG_CONFIG_ENVIRONMENT { + assert!( + declared.contains(variable), + "build.rs resolves every native library through {variable} without tracking it" + ); + } + for variable in direct_environment_reads(&build_script) { + assert!( + declared.contains(&variable), + "build.rs reads {variable} without tracking it" + ); + } +} diff --git a/fluxer_media_proxy/src/thumbhash.rs b/fluxer_media_proxy/src/thumbhash.rs index ff31018fd..d2b0a4507 100644 --- a/fluxer_media_proxy/src/thumbhash.rs +++ b/fluxer_media_proxy/src/thumbhash.rs @@ -4,6 +4,8 @@ use std::f64::consts::PI; use thiserror::Error; pub const MAX_DIM: u32 = 100; +const OPAQUE_ASPECT_LIMIT: u32 = 7; +const ALPHA_ASPECT_LIMIT: u32 = 5; #[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] pub enum EncodeError { @@ -117,6 +119,20 @@ pub fn encode_rgba(pixels: &[u8], w: u32, h: u32) -> Result, EncodeError Ok(out) } +pub fn represents_aspect_ratio(hash: &[u8], width: u32, height: u32) -> bool { + if hash.len() < 5 || width == 0 || height == 0 { + return false; + } + let limit = u64::from(if hash[2] & 0x80 != 0 { + ALPHA_ASPECT_LIMIT + } else { + OPAQUE_ASPECT_LIMIT + }); + let width = u64::from(width); + let height = u64::from(height); + width <= height * limit && height <= width * limit +} + fn append_acs(out: &mut [u8], idx: &mut u32, acs: &[f64]) { for ac in acs { let q = (15.0 * ac).round().clamp(0.0, 15.0) as u32; @@ -235,6 +251,27 @@ mod tests { assert_ne!(0, trans_out[2] & 0x80); } + #[test] + fn aspect_ratio_representability_follows_the_alpha_limit() { + let mut opaque = [0u8; 4 * 4 * 4]; + let mut translucent = [0u8; 4 * 4 * 4]; + for px in opaque.chunks_exact_mut(4) { + px[3] = 255; + } + for px in translucent.chunks_exact_mut(4) { + px[3] = 64; + } + let opaque_out = encode_rgba(&opaque, 4, 4).unwrap(); + let trans_out = encode_rgba(&translucent, 4, 4).unwrap(); + assert!(represents_aspect_ratio(&opaque_out, 64, 64)); + assert!(represents_aspect_ratio(&opaque_out, 700, 100)); + assert!(!represents_aspect_ratio(&opaque_out, 800, 100)); + assert!(represents_aspect_ratio(&trans_out, 500, 100)); + assert!(!represents_aspect_ratio(&trans_out, 700, 100)); + assert!(!represents_aspect_ratio(&opaque_out, 64, 0)); + assert!(!represents_aspect_ratio(&opaque_out[..4], 64, 64)); + } + #[test] fn synthetic_gradient_is_wire_stable() { let mut pixels = [0u8; 8 * 8 * 4]; diff --git a/fluxer_media_proxy/src/timed_semaphore.rs b/fluxer_media_proxy/src/timed_semaphore.rs index 69d6e2525..1f8232135 100644 --- a/fluxer_media_proxy/src/timed_semaphore.rs +++ b/fluxer_media_proxy/src/timed_semaphore.rs @@ -1,11 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later +use std::sync::Arc; use std::time::Instant; use thiserror::Error; use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError}; #[derive(Debug, Error, Eq, PartialEq)] pub enum TimedSemaphoreError { + #[error("admission queue is full")] + QueueFull, #[error("request timed out")] RequestTimeout, #[error("semaphore closed")] @@ -14,21 +17,98 @@ pub enum TimedSemaphoreError { #[derive(Clone, Debug)] pub struct TimedSemaphore { - inner: std::sync::Arc, + execution: Arc, + admission: Option>, +} + +#[derive(Debug)] +pub struct TimedSemaphoreAdmission { + _permit: Option, +} + +#[derive(Debug)] +pub struct TimedSemaphorePermit { + _execution: OwnedSemaphorePermit, + _admission: Option, } impl TimedSemaphore { pub fn new(permits: usize) -> Self { Self { - inner: std::sync::Arc::new(Semaphore::new(permits)), + execution: Arc::new(Semaphore::new(permits)), + admission: None, } } + pub fn with_queue_capacity(permits: usize, queue_capacity: usize) -> Self { + let admission_capacity = permits + .checked_add(queue_capacity) + .expect("timed semaphore admission capacity overflow"); + Self { + execution: Arc::new(Semaphore::new(permits)), + admission: Some(Arc::new(Semaphore::new(admission_capacity))), + } + } + + pub fn try_admit(&self) -> Result { + let permit = + match &self.admission { + Some(admission) => Some(admission.clone().try_acquire_owned().map_err( + |error| match error { + TryAcquireError::NoPermits => TimedSemaphoreError::QueueFull, + TryAcquireError::Closed => TimedSemaphoreError::Closed, + }, + )?), + None => None, + }; + Ok(TimedSemaphoreAdmission { _permit: permit }) + } + pub async fn wait_until( &self, deadline: Option, + ) -> Result { + let admission = self.try_admit()?; + let execution = self.acquire_execution(deadline).await?; + Ok(TimedSemaphorePermit { + _execution: execution, + _admission: Some(admission), + }) + } + + pub async fn wait_until_admitted( + &self, + _admission: &TimedSemaphoreAdmission, + deadline: Option, + ) -> Result { + let execution = self.acquire_execution(deadline).await?; + Ok(TimedSemaphorePermit { + _execution: execution, + _admission: None, + }) + } + + pub fn try_wait(&self) -> Result { + let admission = self.try_admit()?; + let execution = + self.execution + .clone() + .try_acquire_owned() + .map_err(|error| match error { + TryAcquireError::NoPermits => TimedSemaphoreError::RequestTimeout, + TryAcquireError::Closed => TimedSemaphoreError::Closed, + })?; + Ok(TimedSemaphorePermit { + _execution: execution, + _admission: Some(admission), + }) + } + + async fn acquire_execution( + &self, + deadline: Option, ) -> Result { - let acquire = self.inner.clone().acquire_owned(); + let acquire = self.execution.clone().acquire_owned(); if let Some(deadline) = deadline { if Instant::now() >= deadline { return Err(TimedSemaphoreError::RequestTimeout); @@ -41,16 +121,6 @@ impl TimedSemaphore { acquire.await.map_err(|_| TimedSemaphoreError::Closed) } } - - pub fn try_wait(&self) -> Result { - self.inner - .clone() - .try_acquire_owned() - .map_err(|error| match error { - TryAcquireError::NoPermits => TimedSemaphoreError::RequestTimeout, - TryAcquireError::Closed => TimedSemaphoreError::Closed, - }) - } } #[cfg(test)] @@ -86,4 +156,71 @@ mod tests { .await .unwrap(); } + + #[tokio::test] + async fn queue_capacity_rejects_the_waiter_past_the_bound() { + let sem = TimedSemaphore::with_queue_capacity(1, 2); + let _held = sem.wait_until(None).await.unwrap(); + let first = sem.try_admit().unwrap(); + let _second = sem.try_admit().unwrap(); + assert_eq!(TimedSemaphoreError::QueueFull, sem.try_admit().unwrap_err()); + assert_eq!( + TimedSemaphoreError::QueueFull, + sem.wait_until(Some(Instant::now() + Duration::from_millis(50))) + .await + .unwrap_err() + ); + drop(first); + let _third = sem.try_admit().unwrap(); + } + + #[tokio::test] + async fn dropping_a_waiting_future_restores_the_permit_and_the_queue_slot() { + let sem = TimedSemaphore::with_queue_capacity(1, 1); + let held = sem.wait_until(None).await.unwrap(); + let mut waiting = Box::pin(sem.wait_until(None)); + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut waiting) + .await + .is_err() + ); + assert_eq!(TimedSemaphoreError::QueueFull, sem.try_admit().unwrap_err()); + drop(waiting); + drop(held); + let _reacquired = sem.try_wait().unwrap(); + let _queued = sem.try_admit().unwrap(); + assert_eq!(TimedSemaphoreError::QueueFull, sem.try_admit().unwrap_err()); + } + + #[tokio::test] + async fn wait_until_admitted_reuses_the_admission_the_caller_holds() { + let sem = TimedSemaphore::with_queue_capacity(1, 1); + let admission = sem.try_admit().unwrap(); + let permit = sem + .wait_until_admitted( + &admission, + Some(Instant::now() + Duration::from_millis(100)), + ) + .await + .unwrap(); + let _queued = sem.try_admit().unwrap(); + assert_eq!(TimedSemaphoreError::QueueFull, sem.try_admit().unwrap_err()); + drop(permit); + drop(admission); + let _readmitted = sem.try_admit().unwrap(); + } + + #[tokio::test] + async fn new_leaves_the_admission_queue_unbounded() { + let sem = TimedSemaphore::new(1); + let _held = sem.wait_until(None).await.unwrap(); + let admissions = (0..1024) + .map(|_| sem.try_admit().unwrap()) + .collect::>(); + assert_eq!(1024, admissions.len()); + assert_eq!( + TimedSemaphoreError::RequestTimeout, + sem.try_wait().unwrap_err() + ); + } } diff --git a/fluxer_media_proxy/src/transform_cache.rs b/fluxer_media_proxy/src/transform_cache.rs new file mode 100644 index 000000000..8ce51c7b1 --- /dev/null +++ b/fluxer_media_proxy/src/transform_cache.rs @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{ + byte_budget::{BudgetedBytes, ByteBudget}, + byte_cache::{ByteCache, ByteCacheInsertOutcome, ByteCacheSettings, ByteCacheValue}, + coalescer::{ByteCoalescer, CoalescerError}, + media_process::MediaBytes, + metrics::cache::{CoalescerMetrics, TransformCacheMetrics}, + output_format::OutputFormat, +}; +use std::{future::Future, sync::Arc, time::Instant}; + +#[derive(Clone, Debug)] +pub struct TransformCache { + cached: Arc>, + in_flight: Arc, + metrics: Arc, +} + +#[derive(Clone, Debug)] +pub struct CachedTransform { + pub data: BudgetedBytes, + pub format: OutputFormat, +} + +impl ByteCacheValue for CachedTransform { + fn bytes(&self) -> &BudgetedBytes { + &self.data + } +} + +pub struct TransformCacheSettings { + pub cache: ByteCacheSettings, + pub budget: ByteBudget, + pub max_in_flight: usize, + pub max_waiters: usize, + pub cache_metrics: Arc, + pub coalescer_metrics: Arc, +} + +impl TransformCache { + pub fn new(settings: TransformCacheSettings) -> Self { + let eviction_metrics = Arc::clone(&settings.cache_metrics); + Self { + cached: Arc::new(ByteCache::new(settings.cache, move || { + eviction_metrics.record_eviction(); + })), + in_flight: Arc::new(ByteCoalescer::with_budget( + settings.budget, + settings.max_in_flight, + settings.max_waiters, + settings.coalescer_metrics, + )), + metrics: settings.cache_metrics, + } + } + + pub fn get(&self, key: &str) -> Option { + let cached = self.cached.get(key)?; + self.metrics.record_hit(); + Some(cached) + } + + pub fn begin_shutdown(&self) { + self.in_flight.begin_shutdown(); + } + + pub async fn wait_for_shutdown(&self) { + self.in_flight.wait_for_shutdown().await; + } + + pub async fn get_or_run( + &self, + key: String, + format: OutputFormat, + deadline: Option, + work: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + if let Some(cached) = self.get(&key) { + assert_eq!(cached.format, format); + return Ok(cached.data); + } + self.metrics.record_miss(); + let cached = Arc::clone(&self.cached); + let metrics = Arc::clone(&self.metrics); + self.in_flight + .run_once_until(key.clone(), deadline, work, { + let cache_key = key; + move |bytes| { + if let ByteCacheInsertOutcome::Rejected(_) = cached.put( + cache_key, + CachedTransform { + data: bytes.clone(), + format, + }, + ) { + metrics.record_insert_rejected(); + } + } + }) + .await + } + + #[cfg(test)] + fn settle(&self) { + self.cached.settle(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metrics::Metrics; + use std::sync::atomic::{AtomicU32, Ordering}; + + fn counter(rendered: &str, name: &str) -> u64 { + rendered + .lines() + .find_map(|line| line.strip_prefix(name)?.trim().parse().ok()) + .expect("counter series is rendered") + } + + fn transform_cache( + metrics: &Metrics, + capacity_bytes: usize, + max_entry_bytes: usize, + ) -> TransformCache { + TransformCache::new(TransformCacheSettings { + cache: ByteCacheSettings::clamped(capacity_bytes, max_entry_bytes, 60_000), + budget: ByteBudget::new(1 << 20), + max_in_flight: 8, + max_waiters: 8, + cache_metrics: metrics.transform_cache(), + coalescer_metrics: metrics.coalescer(), + }) + } + + #[tokio::test] + async fn the_second_request_for_a_key_is_served_from_the_cache() { + let metrics = Metrics::new(); + let cache = transform_cache(&metrics, 65_536, 65_536); + let runs = AtomicU32::new(0); + for _ in 0..2 { + let bytes = cache + .get_or_run( + "stored:abc|fmt=webp".to_owned(), + OutputFormat::WebP, + None, + || async { + runs.fetch_add(1, Ordering::SeqCst); + Ok(MediaBytes::from(vec![1, 2, 3, 4])) + }, + ) + .await + .expect("transform result"); + assert_eq!(&[1, 2, 3, 4], bytes.as_ref()); + } + assert_eq!(1, runs.load(Ordering::SeqCst)); + let rendered = metrics.render(); + assert!(rendered.contains("fluxer_media_proxy_transform_cache_hits_total 1\n")); + assert!(rendered.contains("fluxer_media_proxy_transform_cache_misses_total 1\n")); + } + + #[tokio::test] + async fn an_output_over_the_entry_ceiling_is_returned_but_not_cached() { + let metrics = Metrics::new(); + let cache = transform_cache(&metrics, 65_536, 1_024); + let bytes = cache + .get_or_run( + "stored:big|fmt=png".to_owned(), + OutputFormat::PNG, + None, + || async { Ok(MediaBytes::from(vec![0u8; 4_096])) }, + ) + .await + .expect("transform result"); + assert_eq!(4_096, bytes.len()); + assert!(cache.get("stored:big|fmt=png").is_none()); + assert!( + metrics + .render() + .contains("fluxer_media_proxy_transform_cache_insert_rejected_total 1\n") + ); + } + + #[tokio::test] + async fn entries_evicted_by_the_capacity_bound_are_counted() { + let metrics = Metrics::new(); + let cache = transform_cache(&metrics, 8_192, 8_192); + for index in 0..64 { + cache + .get_or_run( + format!("stored:{index}|fmt=gif"), + OutputFormat::GIF, + None, + || async { Ok(MediaBytes::from(vec![0u8; 512])) }, + ) + .await + .expect("transform result"); + } + cache.settle(); + let rendered = metrics.render(); + assert!( + counter( + &rendered, + "fluxer_media_proxy_transform_cache_evictions_total" + ) > 0 + ); + assert_eq!( + 0, + counter( + &rendered, + "fluxer_media_proxy_transform_cache_insert_rejected_total" + ) + ); + } + + #[tokio::test] + async fn a_disabled_cache_still_runs_and_returns_the_transform() { + let metrics = Metrics::new(); + let cache = transform_cache(&metrics, 0, 0); + let bytes = cache + .get_or_run( + "stored:off|fmt=jpeg".to_owned(), + OutputFormat::JPEG, + None, + || async { Ok(MediaBytes::from(vec![5, 6])) }, + ) + .await + .expect("transform result"); + assert_eq!(&[5, 6], bytes.as_ref()); + assert!(cache.get("stored:off|fmt=jpeg").is_none()); + cache.begin_shutdown(); + cache.wait_for_shutdown().await; + } +} diff --git a/fluxer_media_proxy/src/upload_relay.rs b/fluxer_media_proxy/src/upload_relay.rs deleted file mode 100644 index 05269f3bb..000000000 --- a/fluxer_media_proxy/src/upload_relay.rs +++ /dev/null @@ -1,388 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; -use hmac::{Hmac, KeyInit, Mac}; -use http::Method; -use serde::{Deserialize, Serialize}; -use sha2::Sha256; -use std::{ - sync::atomic::{AtomicU64, Ordering}, - time::{SystemTime, UNIX_EPOCH}, -}; -use thiserror::Error; - -type HmacSha256 = Hmac; - -pub const RELAY_PATH_PREFIX: &str = "/v1/relay/"; - -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum TokenMethod { - Put, -} - -impl TokenMethod { - pub fn http(self) -> Method { - match self { - Self::Put => Method::PUT, - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct TokenPayload { - pub b: String, - pub k: String, - pub m: TokenMethod, - #[serde(skip_serializing_if = "Option::is_none")] - pub u: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub p: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ct: Option, - pub mb: u64, - pub e: u64, -} - -#[derive(Debug, Error, Eq, PartialEq)] -pub enum TokenError { - #[error("malformed token")] - Malformed, - #[error("bad token encoding")] - BadEncoding, - #[error("bad token JSON")] - BadJson, - #[error("bad token signature")] - BadSignature, - #[error("expired token")] - Expired, -} - -#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] -pub enum RelayError { - #[error("missing relay token")] - MissingToken, - #[error("invalid relay token")] - InvalidToken, - #[error("relay token expired")] - RelayTokenExpired, - #[error("wrong bucket")] - WrongBucket, - #[error("key mismatch")] - KeyMismatch, - #[error("method mismatch")] - MethodMismatch, - #[error("part number mismatch")] - PartNumberMismatch, - #[error("upload id mismatch")] - UploadIdMismatch, - #[error("payload too large")] - PayloadTooLarge, - #[error("bad query")] - BadQuery, - #[error("client upload failed")] - ClientUploadFailed, - #[error("upstream S3 error")] - UpstreamS3Error, - #[error("upstream retryable error")] - UpstreamRetryable, - #[error("internal relay error")] - InternalError, -} - -static BUFFERED_RETRY_IN_FLIGHT: AtomicU64 = AtomicU64::new(0); -static SPOOL_IN_FLIGHT_BYTES: AtomicU64 = AtomicU64::new(0); - -pub fn try_reserve_buffer_budget(needed: u64, ceiling: u64) -> bool { - if ceiling == 0 || needed > ceiling { - return false; - } - let mut current = BUFFERED_RETRY_IN_FLIGHT.load(Ordering::Acquire); - loop { - if current.saturating_add(needed) > ceiling { - return false; - } - match BUFFERED_RETRY_IN_FLIGHT.compare_exchange_weak( - current, - current + needed, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => return true, - Err(next) => current = next, - } - } -} - -pub fn release_buffer_budget(amount: u64) { - BUFFERED_RETRY_IN_FLIGHT.fetch_sub(amount, Ordering::AcqRel); -} - -pub fn try_reserve_spool_budget(needed: u64, ceiling: u64) -> bool { - if ceiling == 0 || needed > ceiling { - return false; - } - let mut current = SPOOL_IN_FLIGHT_BYTES.load(Ordering::Acquire); - loop { - if current.saturating_add(needed) > ceiling { - return false; - } - match SPOOL_IN_FLIGHT_BYTES.compare_exchange_weak( - current, - current + needed, - Ordering::AcqRel, - Ordering::Acquire, - ) { - Ok(_) => return true, - Err(next) => current = next, - } - } -} - -pub fn release_spool_budget(amount: u64) { - SPOOL_IN_FLIGHT_BYTES.fetch_sub(amount, Ordering::AcqRel); -} - -pub fn spool_in_flight_bytes() -> u64 { - SPOOL_IN_FLIGHT_BYTES.load(Ordering::Relaxed) -} - -pub fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -pub fn encode_token(token: &TokenPayload, secret: &[u8]) -> anyhow::Result { - let payload = serde_json::to_vec(token)?; - let encoded_payload = URL_SAFE_NO_PAD.encode(payload); - let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); - mac.update(encoded_payload.as_bytes()); - let sig = mac.finalize().into_bytes(); - Ok(format!( - "{}.{}", - encoded_payload, - URL_SAFE_NO_PAD.encode(sig) - )) -} - -pub fn decode_token(raw: &str, secret: &[u8], now_unix: u64) -> Result { - let (payload_b64, sig_b64) = raw.split_once('.').ok_or(TokenError::Malformed)?; - let sig = URL_SAFE_NO_PAD - .decode(sig_b64) - .map_err(|_| TokenError::BadEncoding)?; - if sig.len() != 32 { - return Err(TokenError::BadSignature); - } - let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); - mac.update(payload_b64.as_bytes()); - let expected = mac.finalize().into_bytes(); - let mut diff = 0u8; - for (a, b) in sig.iter().zip(expected.iter()) { - diff |= a ^ b; - } - if diff != 0 { - return Err(TokenError::BadSignature); - } - let payload = URL_SAFE_NO_PAD - .decode(payload_b64) - .map_err(|_| TokenError::BadEncoding)?; - let parsed: TokenPayload = serde_json::from_slice(&payload).map_err(|_| TokenError::BadJson)?; - if now_unix >= parsed.e { - return Err(TokenError::Expired); - } - Ok(parsed) -} - -pub fn map_token_error(err: TokenError) -> RelayError { - match err { - TokenError::Expired => RelayError::RelayTokenExpired, - _ => RelayError::InvalidToken, - } -} - -#[derive(Clone, Copy, Debug)] -pub struct RelayRequest<'a> { - pub uploads_bucket: &'a str, - pub request_key: &'a str, - pub request_method: &'a Method, - pub query_upload_id: Option<&'a str>, - pub query_part_number: Option, - pub content_length: Option, - pub max_body_bytes: u64, -} - -pub fn validate_relay_request( - token: &TokenPayload, - request: RelayRequest<'_>, -) -> Result<(), RelayError> { - if token.b != request.uploads_bucket { - return Err(RelayError::WrongBucket); - } - if token.k != request.request_key { - return Err(RelayError::KeyMismatch); - } - if token.m != TokenMethod::Put || request.request_method != Method::PUT { - return Err(RelayError::MethodMismatch); - } - switch_upload_id(token.u.as_deref(), request.query_upload_id)?; - switch_part_number(token.p, request.query_part_number)?; - if let Some(declared) = request.content_length - && (declared > token.mb || declared > request.max_body_bytes) - { - return Err(RelayError::PayloadTooLarge); - } - Ok(()) -} - -fn switch_upload_id( - token_upload_id: Option<&str>, - request_upload_id: Option<&str>, -) -> Result<(), RelayError> { - match (token_upload_id, request_upload_id) { - (Some(expected), Some(actual)) if expected == actual => Ok(()), - (Some(_), _) => Err(RelayError::UploadIdMismatch), - (None, Some(actual)) if !actual.is_empty() => Err(RelayError::UploadIdMismatch), - _ => Ok(()), - } -} - -fn switch_part_number( - token_part_number: Option, - request_part_number: Option, -) -> Result<(), RelayError> { - match (token_part_number, request_part_number) { - (None, None) => Ok(()), - (Some(a), Some(b)) if a == b => Ok(()), - _ => Err(RelayError::PartNumberMismatch), - } -} - -pub fn query_part_number(raw: Option<&str>) -> Result, RelayError> { - let Some(raw) = raw else { - return Ok(None); - }; - if raw.is_empty() { - return Err(RelayError::BadQuery); - } - raw.parse().map(Some).map_err(|_| RelayError::BadQuery) -} - -pub fn is_relay_path(path: &str) -> bool { - path.starts_with(RELAY_PATH_PREFIX) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn token() -> TokenPayload { - TokenPayload { - b: "uploads".to_owned(), - k: "guild/file.bin".to_owned(), - m: TokenMethod::Put, - u: Some("upload-id".to_owned()), - p: Some(7), - ct: Some("application/octet-stream".to_owned()), - mb: 100, - e: 2_000, - } - } - - #[test] - fn token_roundtrip_and_tamper_detection() { - let secret = [3u8; 32]; - let encoded = encode_token(&token(), &secret).unwrap(); - assert_eq!(token(), decode_token(&encoded, &secret, 1_000).unwrap()); - let mut tampered = encoded.clone(); - tampered.push('x'); - assert_eq!( - Err(TokenError::BadSignature), - decode_token(&tampered, &secret, 1_000) - ); - } - - #[test] - fn expired_token_rejected() { - let secret = [3u8; 32]; - let encoded = encode_token(&token(), &secret).unwrap(); - assert_eq!( - Err(TokenError::Expired), - decode_token(&encoded, &secret, 2_000) - ); - } - - #[test] - fn validates_matching_relay_request() { - validate_relay_request( - &token(), - RelayRequest { - uploads_bucket: "uploads", - request_key: "guild/file.bin", - request_method: &Method::PUT, - query_upload_id: Some("upload-id"), - query_part_number: Some(7), - content_length: Some(99), - max_body_bytes: 100, - }, - ) - .unwrap(); - } - - #[test] - fn validates_relay_mismatches() { - assert_eq!( - Err(RelayError::WrongBucket), - validate_relay_request( - &token(), - RelayRequest { - uploads_bucket: "cdn", - request_key: "guild/file.bin", - request_method: &Method::PUT, - query_upload_id: Some("upload-id"), - query_part_number: Some(7), - content_length: Some(99), - max_body_bytes: 100, - } - ) - ); - assert_eq!( - Err(RelayError::PayloadTooLarge), - validate_relay_request( - &token(), - RelayRequest { - uploads_bucket: "uploads", - request_key: "guild/file.bin", - request_method: &Method::PUT, - query_upload_id: Some("upload-id"), - query_part_number: Some(7), - content_length: Some(101), - max_body_bytes: 100, - } - ) - ); - validate_relay_request( - &token(), - RelayRequest { - uploads_bucket: "uploads", - request_key: "guild/file.bin", - request_method: &Method::PUT, - query_upload_id: Some("upload-id"), - query_part_number: Some(7), - content_length: None, - max_body_bytes: 100, - }, - ) - .unwrap(); - } - - #[test] - fn buffer_budget_is_bounded() { - assert!(try_reserve_buffer_budget(4, 8)); - assert!(!try_reserve_buffer_budget(5, 8)); - release_buffer_budget(4); - assert!(try_reserve_buffer_budget(8, 8)); - release_buffer_budget(8); - } -} diff --git a/fluxer_media_proxy/src/upload_relay/mod.rs b/fluxer_media_proxy/src/upload_relay/mod.rs new file mode 100644 index 000000000..a4ba2ad79 --- /dev/null +++ b/fluxer_media_proxy/src/upload_relay/mod.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub mod target; +#[cfg(test)] +mod tests; +pub mod token; + +use std::sync::atomic::{AtomicU64, Ordering}; +use thiserror::Error; + +pub const RELAY_PATH_PREFIX: &str = "/v1/relay/"; + +#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)] +pub enum RelayError { + #[error("missing relay token")] + MissingToken, + #[error("invalid relay token")] + InvalidToken, + #[error("relay token expired")] + RelayTokenExpired, + #[error("wrong bucket")] + WrongBucket, + #[error("key mismatch")] + KeyMismatch, + #[error("method mismatch")] + MethodMismatch, + #[error("part number mismatch")] + PartNumberMismatch, + #[error("upload id mismatch")] + UploadIdMismatch, + #[error("payload too large")] + PayloadTooLarge, + #[error("bad query")] + BadQuery, + #[error("client upload failed")] + ClientUploadFailed, + #[error("upstream S3 error")] + UpstreamS3Error, + #[error("upstream retryable error")] + UpstreamRetryable, + #[error("internal relay error")] + InternalError, +} + +static BUFFERED_RETRY_IN_FLIGHT: AtomicU64 = AtomicU64::new(0); +static SPOOL_IN_FLIGHT_BYTES: AtomicU64 = AtomicU64::new(0); + +pub fn try_reserve_buffer_budget(needed: u64, ceiling: u64) -> bool { + try_reserve(&BUFFERED_RETRY_IN_FLIGHT, needed, ceiling) +} + +pub fn release_buffer_budget(amount: u64) { + BUFFERED_RETRY_IN_FLIGHT.fetch_sub(amount, Ordering::AcqRel); +} + +pub fn try_reserve_spool_budget(needed: u64, ceiling: u64) -> bool { + try_reserve(&SPOOL_IN_FLIGHT_BYTES, needed, ceiling) +} + +pub fn release_spool_budget(amount: u64) { + SPOOL_IN_FLIGHT_BYTES.fetch_sub(amount, Ordering::AcqRel); +} + +pub fn spool_in_flight_bytes() -> u64 { + SPOOL_IN_FLIGHT_BYTES.load(Ordering::Relaxed) +} + +fn try_reserve(counter: &AtomicU64, needed: u64, ceiling: u64) -> bool { + if ceiling == 0 || needed > ceiling { + return false; + } + let mut current = counter.load(Ordering::Acquire); + loop { + if current.saturating_add(needed) > ceiling { + return false; + } + match counter.compare_exchange_weak( + current, + current + needed, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return true, + Err(next) => current = next, + } + } +} + +pub fn is_relay_path(path: &str) -> bool { + path.starts_with(RELAY_PATH_PREFIX) +} diff --git a/fluxer_media_proxy/src/upload_relay/target.rs b/fluxer_media_proxy/src/upload_relay/target.rs new file mode 100644 index 000000000..edbbd85df --- /dev/null +++ b/fluxer_media_proxy/src/upload_relay/target.rs @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::upload_relay::{RelayError, token::TokenMethod, token::TokenPayload}; +use http::{HeaderValue, Method}; + +const CONTENT_TYPE_MAX_BYTES: usize = 255; +const CONTENT_TYPE_PARAMETER_MAX_COUNT: usize = 32; +const CONTENT_TYPE_TOKEN_MAX_BYTES: usize = 127; + +#[derive(Clone, Copy, Debug)] +pub struct RelayRequest<'a> { + pub uploads_bucket: &'a str, + pub request_key: &'a str, + pub request_method: &'a Method, + pub query_upload_id: Option<&'a str>, + pub query_part_number: Option, + pub content_length: Option, + pub max_body_bytes: u64, +} + +pub fn validate_relay_request( + token: &TokenPayload, + request: RelayRequest<'_>, +) -> Result<(), RelayError> { + if token.b != request.uploads_bucket { + return Err(RelayError::WrongBucket); + } + if token.k != request.request_key { + return Err(RelayError::KeyMismatch); + } + if token.m != TokenMethod::Put || request.request_method != Method::PUT { + return Err(RelayError::MethodMismatch); + } + match_upload_id(token.u.as_deref(), request.query_upload_id)?; + match_part_number(token.p, request.query_part_number)?; + if let Some(declared) = request.content_length + && (declared > token.mb || declared > request.max_body_bytes) + { + return Err(RelayError::PayloadTooLarge); + } + Ok(()) +} + +fn match_upload_id( + token_upload_id: Option<&str>, + request_upload_id: Option<&str>, +) -> Result<(), RelayError> { + match (token_upload_id, request_upload_id) { + (Some(expected), Some(actual)) if expected == actual => Ok(()), + (Some(_), _) => Err(RelayError::UploadIdMismatch), + (None, Some(actual)) if !actual.is_empty() => Err(RelayError::UploadIdMismatch), + _ => Ok(()), + } +} + +fn match_part_number( + token_part_number: Option, + request_part_number: Option, +) -> Result<(), RelayError> { + match (token_part_number, request_part_number) { + (None, None) => Ok(()), + (Some(a), Some(b)) if a == b => Ok(()), + _ => Err(RelayError::PartNumberMismatch), + } +} + +pub fn query_part_number(raw: Option<&str>) -> Result, RelayError> { + let Some(raw) = raw else { + return Ok(None); + }; + if raw.is_empty() { + return Err(RelayError::BadQuery); + } + raw.parse().map(Some).map_err(|_| RelayError::BadQuery) +} + +pub fn valid_content_type(value: &str) -> bool { + if value.is_empty() + || value.len() > CONTENT_TYPE_MAX_BYTES + || HeaderValue::from_bytes(value.as_bytes()).is_err() + { + return false; + } + let bytes = value.as_bytes(); + let parameter_start = bytes.iter().position(|byte| *byte == b';'); + let media_end = parameter_start.unwrap_or(bytes.len()); + let media_start = skip_optional_whitespace(bytes, 0, media_end); + let media_end = trim_optional_whitespace_end(bytes, media_start, media_end); + let media_type = &bytes[media_start..media_end]; + let Some(slash) = media_type.iter().position(|byte| *byte == b'/') else { + return false; + }; + let kind = &media_type[..slash]; + let subtype = &media_type[slash + 1..]; + if !valid_media_type_token(kind) || !valid_media_type_token(subtype) { + return false; + } + let Some(mut index) = parameter_start else { + return true; + }; + let mut parameter_names = [(0usize, 0usize); CONTENT_TYPE_PARAMETER_MAX_COUNT]; + let mut parameter_count = 0usize; + while index < bytes.len() { + if bytes[index] != b';' { + return false; + } + index = skip_optional_whitespace(bytes, index + 1, bytes.len()); + let name_start = index; + while index < bytes.len() && is_content_type_token_byte(bytes[index]) { + index += 1; + } + if index == name_start || index - name_start > CONTENT_TYPE_TOKEN_MAX_BYTES { + return false; + } + if parameter_count == CONTENT_TYPE_PARAMETER_MAX_COUNT { + return false; + } + if parameter_names[..parameter_count] + .iter() + .any(|(start, end)| bytes[*start..*end].eq_ignore_ascii_case(&bytes[name_start..index])) + { + return false; + } + parameter_names[parameter_count] = (name_start, index); + parameter_count += 1; + index = skip_optional_whitespace(bytes, index, bytes.len()); + if bytes.get(index) != Some(&b'=') { + return false; + } + index = skip_optional_whitespace(bytes, index + 1, bytes.len()); + let Some(value_end) = content_type_parameter_value_end(bytes, index) else { + return false; + }; + index = skip_optional_whitespace(bytes, value_end, bytes.len()); + if index < bytes.len() && bytes[index] != b';' { + return false; + } + } + true +} + +fn valid_media_type_token(value: &[u8]) -> bool { + !value.is_empty() + && value != b"*" + && value.len() <= CONTENT_TYPE_TOKEN_MAX_BYTES + && value.iter().copied().all(is_content_type_token_byte) +} + +fn content_type_parameter_value_end(bytes: &[u8], start: usize) -> Option { + if bytes.get(start) == Some(&b'"') { + return quoted_content_type_parameter_value_end(bytes, start + 1); + } + let mut index = start; + while index < bytes.len() && is_content_type_token_byte(bytes[index]) { + index += 1; + } + (index > start && index - start <= CONTENT_TYPE_TOKEN_MAX_BYTES).then_some(index) +} + +fn quoted_content_type_parameter_value_end(bytes: &[u8], mut index: usize) -> Option { + loop { + let byte = *bytes.get(index)?; + match byte { + b'"' => return Some(index + 1), + b'\\' => { + index += 1; + if !is_quoted_pair_byte(*bytes.get(index)?) { + return None; + } + } + _ if !is_quoted_text_byte(byte) => return None, + _ => {} + } + index += 1; + } +} + +fn skip_optional_whitespace(bytes: &[u8], mut index: usize, end: usize) -> usize { + while index < end && matches!(bytes[index], b' ' | b'\t') { + index += 1; + } + index +} + +fn trim_optional_whitespace_end(bytes: &[u8], start: usize, mut end: usize) -> usize { + while end > start && matches!(bytes[end - 1], b' ' | b'\t') { + end -= 1; + } + end +} + +fn is_content_type_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn is_quoted_text_byte(byte: u8) -> bool { + matches!(byte, b'\t' | b' ' | b'!' | b'#'..=b'[' | b']'..=b'~' | 0x80..=0xff) +} + +fn is_quoted_pair_byte(byte: u8) -> bool { + matches!(byte, b'\t' | b' '..=b'~' | 0x80..=0xff) +} diff --git a/fluxer_media_proxy/src/upload_relay/tests.rs b/fluxer_media_proxy/src/upload_relay/tests.rs new file mode 100644 index 000000000..c037bb3e3 --- /dev/null +++ b/fluxer_media_proxy/src/upload_relay/tests.rs @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::upload_relay::{ + RelayError, release_buffer_budget, + target::{RelayRequest, query_part_number, valid_content_type, validate_relay_request}, + token::{TokenError, TokenMethod, TokenPayload, decode_token, encode_token, token_from_query}, + try_reserve_buffer_budget, +}; +use http::Method; + +const BASE64_URL_ALPHABET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +fn token() -> TokenPayload { + TokenPayload { + b: "uploads".to_owned(), + k: "guild/file.bin".to_owned(), + m: TokenMethod::Put, + u: Some("upload-id".to_owned()), + p: Some(7), + ct: Some("application/octet-stream".to_owned()), + mb: 100, + e: 2_000, + } +} + +fn with_noncanonical_tail(encoded: &str) -> String { + let mut bytes = encoded.as_bytes().to_vec(); + let last = bytes.last_mut().expect("encoded token is not empty"); + let index = BASE64_URL_ALPHABET + .iter() + .position(|candidate| candidate == last) + .expect("encoded token ends with a base64url character"); + *last = BASE64_URL_ALPHABET[index ^ 1]; + String::from_utf8(bytes).expect("base64url alphabet is ascii") +} + +#[test] +fn token_roundtrip_and_tamper_detection() { + let secret = [3u8; 32]; + let encoded = encode_token(&token(), &secret).unwrap(); + assert_eq!(token(), decode_token(&encoded, &secret, 1_000).unwrap()); + let mut tampered = encoded.clone(); + tampered.push('x'); + assert_eq!( + Err(TokenError::BadSignature), + decode_token(&tampered, &secret, 1_000) + ); +} + +#[test] +fn expired_token_rejected() { + let secret = [3u8; 32]; + let encoded = encode_token(&token(), &secret).unwrap(); + assert_eq!( + Err(TokenError::Expired), + decode_token(&encoded, &secret, 2_000) + ); +} + +#[test] +fn validates_matching_relay_request() { + validate_relay_request( + &token(), + RelayRequest { + uploads_bucket: "uploads", + request_key: "guild/file.bin", + request_method: &Method::PUT, + query_upload_id: Some("upload-id"), + query_part_number: Some(7), + content_length: Some(99), + max_body_bytes: 100, + }, + ) + .unwrap(); +} + +#[test] +fn validates_relay_mismatches() { + assert_eq!( + Err(RelayError::WrongBucket), + validate_relay_request( + &token(), + RelayRequest { + uploads_bucket: "cdn", + request_key: "guild/file.bin", + request_method: &Method::PUT, + query_upload_id: Some("upload-id"), + query_part_number: Some(7), + content_length: Some(99), + max_body_bytes: 100, + } + ) + ); + assert_eq!( + Err(RelayError::PayloadTooLarge), + validate_relay_request( + &token(), + RelayRequest { + uploads_bucket: "uploads", + request_key: "guild/file.bin", + request_method: &Method::PUT, + query_upload_id: Some("upload-id"), + query_part_number: Some(7), + content_length: Some(101), + max_body_bytes: 100, + } + ) + ); + validate_relay_request( + &token(), + RelayRequest { + uploads_bucket: "uploads", + request_key: "guild/file.bin", + request_method: &Method::PUT, + query_upload_id: Some("upload-id"), + query_part_number: Some(7), + content_length: None, + max_body_bytes: 100, + }, + ) + .unwrap(); +} + +#[test] +fn buffer_budget_is_bounded() { + assert!(try_reserve_buffer_budget(4, 8)); + assert!(!try_reserve_buffer_budget(5, 8)); + release_buffer_budget(4); + assert!(try_reserve_buffer_budget(8, 8)); + release_buffer_budget(8); +} + +#[test] +fn token_decoder_rejects_noncanonical_wrong_secret_and_oversized_tokens() { + let secret = [3u8; 32]; + let encoded = encode_token(&token(), &secret).unwrap(); + assert_eq!( + Err(TokenError::BadEncoding), + decode_token(&with_noncanonical_tail(&encoded), &secret, 1_000) + ); + assert_eq!( + Err(TokenError::BadSignature), + decode_token(&encoded, &[9u8; 32], 1_000) + ); + assert_eq!( + Err(TokenError::Malformed), + decode_token(&"x".repeat(16 * 1024 + 1), &secret, 1_000) + ); + assert_eq!( + Err(TokenError::Malformed), + decode_token("no-separator", &secret, 1_000) + ); +} + +#[test] +fn token_query_requires_a_present_and_bounded_token() { + assert_eq!(Err(RelayError::MissingToken), token_from_query(None)); + assert_eq!(Err(RelayError::InvalidToken), token_from_query(Some(""))); + assert_eq!( + Err(RelayError::InvalidToken), + token_from_query(Some(&"x".repeat(16 * 1024 + 1))) + ); + assert_eq!(Ok("value"), token_from_query(Some("value"))); +} + +#[test] +fn relay_request_requires_the_exact_signed_key() { + for request_key in [ + "guild/../file.bin", + "guild/file.bin/", + "/guild/file.bin", + "guild//file.bin", + ] { + assert_eq!( + Err(RelayError::KeyMismatch), + validate_relay_request( + &token(), + RelayRequest { + uploads_bucket: "uploads", + request_key, + request_method: &Method::PUT, + query_upload_id: Some("upload-id"), + query_part_number: Some(7), + content_length: Some(99), + max_body_bytes: 100, + } + ) + ); + } +} + +#[test] +fn part_number_query_is_optional_but_never_empty() { + assert_eq!(Ok(None), query_part_number(None)); + assert_eq!(Ok(Some(3)), query_part_number(Some("3"))); + assert_eq!(Err(RelayError::BadQuery), query_part_number(Some(""))); + assert_eq!(Err(RelayError::BadQuery), query_part_number(Some("two"))); +} + +#[test] +fn content_type_gate_rejects_header_injection_and_malformed_media_types() { + for accepted in [ + "image/png", + "application/octet-stream; charset=binary", + "text/plain;charset=utf-8", + "text/plain; filename=\"a b\"", + "video/mp4; codecs=\"avc1.42E01E, mp4a.40.2\"", + ] { + assert!(valid_content_type(accepted), "rejected {accepted:?}"); + } + for rejected in [ + "", + "image", + "image/(png)", + "image/png\r\nInjected: yes", + "image/png\nInjected: yes", + "*/*", + "image/png; charset=a; charset=b", + "image/png; charset", + "image/png/extra", + "image /png", + ] { + assert!(!valid_content_type(rejected), "accepted {rejected:?}"); + } + assert!(!valid_content_type(&format!("image/{}", "p".repeat(255)))); +} diff --git a/fluxer_media_proxy/src/upload_relay/token.rs b/fluxer_media_proxy/src/upload_relay/token.rs new file mode 100644 index 000000000..4d7e2e176 --- /dev/null +++ b/fluxer_media_proxy/src/upload_relay/token.rs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +use crate::{secret::SecretBytes, upload_relay::RelayError}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::{Hmac, KeyInit, Mac}; +use http::Method; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; + +type HmacSha256 = Hmac; + +const MAX_TOKEN_BYTES: usize = 16 * 1024; +const SIGNATURE_BYTES: usize = 32; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TokenMethod { + Put, +} + +impl TokenMethod { + pub fn http(self) -> Method { + match self { + Self::Put => Method::PUT, + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TokenPayload { + pub b: String, + pub k: String, + pub m: TokenMethod, + #[serde(skip_serializing_if = "Option::is_none")] + pub u: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ct: Option, + pub mb: u64, + pub e: u64, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum TokenError { + #[error("malformed token")] + Malformed, + #[error("bad token encoding")] + BadEncoding, + #[error("bad token JSON")] + BadJson, + #[error("bad token signature")] + BadSignature, + #[error("expired token")] + Expired, +} + +pub fn encode_token(token: &TokenPayload, secret: &[u8]) -> anyhow::Result { + let payload = serde_json::to_vec(token)?; + let encoded_payload = URL_SAFE_NO_PAD.encode(payload); + let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); + mac.update(encoded_payload.as_bytes()); + let sig = mac.finalize().into_bytes(); + Ok(format!( + "{}.{}", + encoded_payload, + URL_SAFE_NO_PAD.encode(sig) + )) +} + +pub fn decode_token(raw: &str, secret: &[u8], now_unix: u64) -> Result { + if raw.len() > MAX_TOKEN_BYTES { + return Err(TokenError::Malformed); + } + let (payload_b64, sig_b64) = raw.split_once('.').ok_or(TokenError::Malformed)?; + let sig = decode_canonical_base64(sig_b64)?; + if sig.expose().len() != SIGNATURE_BYTES { + return Err(TokenError::BadSignature); + } + let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); + mac.update(payload_b64.as_bytes()); + let expected = mac.finalize().into_bytes(); + let mut diff = 0u8; + for (a, b) in sig.expose().iter().zip(expected.iter()) { + diff |= a ^ b; + } + if diff != 0 { + return Err(TokenError::BadSignature); + } + let payload = decode_canonical_base64(payload_b64)?; + let parsed: TokenPayload = + serde_json::from_slice(payload.expose()).map_err(|_| TokenError::BadJson)?; + if now_unix >= parsed.e { + return Err(TokenError::Expired); + } + Ok(parsed) +} + +fn decode_canonical_base64(raw: &str) -> Result { + let mut decoded = Vec::new(); + decoded + .try_reserve_exact(raw.len()) + .map_err(|_| TokenError::BadEncoding)?; + decoded.resize(raw.len(), 0); + let decoded_len = URL_SAFE_NO_PAD + .decode_slice(raw, decoded.as_mut_slice()) + .map_err(|_| TokenError::BadEncoding)?; + decoded.truncate(decoded_len); + let decoded = SecretBytes::new(decoded); + let mut canonical = Vec::new(); + canonical + .try_reserve_exact(raw.len()) + .map_err(|_| TokenError::BadEncoding)?; + canonical.resize(raw.len(), 0); + let canonical_len = URL_SAFE_NO_PAD + .encode_slice(decoded.expose(), canonical.as_mut_slice()) + .map_err(|_| TokenError::BadEncoding)?; + if canonical.get(..canonical_len) != Some(raw.as_bytes()) { + return Err(TokenError::BadEncoding); + } + Ok(decoded) +} + +pub fn map_token_error(err: TokenError) -> RelayError { + match err { + TokenError::Expired => RelayError::RelayTokenExpired, + _ => RelayError::InvalidToken, + } +} + +pub fn token_from_query(raw: Option<&str>) -> Result<&str, RelayError> { + let token = raw.ok_or(RelayError::MissingToken)?; + if token.is_empty() { + return Err(RelayError::InvalidToken); + } + if token.len() > MAX_TOKEN_BYTES { + return Err(RelayError::InvalidToken); + } + Ok(token) +} + +pub fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} diff --git a/fluxer_media_proxy/src/vips_shim.c b/fluxer_media_proxy/src/vips_shim.c deleted file mode 100644 index 69df4a0c8..000000000 --- a/fluxer_media_proxy/src/vips_shim.c +++ /dev/null @@ -1,2984 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later - -#define _GNU_SOURCE -#define _DARWIN_C_SOURCE -#define _POSIX_C_SOURCE 200809L - -#include "vips_shim.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include "srgb_profile.h" - -const int fluxer_vips_format_uchar = VIPS_FORMAT_UCHAR; - -#define FLUXER_MAX_VIDEO_FRAME_DIMENSION 16384 -#define FLUXER_MAX_VIDEO_RGBA_BYTES ((size_t)512 * 1024 * 1024) -#define FLUXER_SWS_ROW_PADDING 64 - -int fluxer_vips_init(const char *argv0) { - return vips_init(argv0); -} - -void fluxer_vips_error_clear(void) { - vips_error_clear(); -} - -const char *fluxer_vips_error_buffer(void) { - return vips_error_buffer(); -} - -void fluxer_vips_tune_for_server(int per_pipeline_threads) { - if (per_pipeline_threads <= 0) per_pipeline_threads = 1; - vips_concurrency_set(per_pipeline_threads); - vips_cache_set_max(0); - vips_cache_set_max_mem(0); - vips_cache_set_max_files(0); - vips_leak_set(FALSE); -} - -int fluxer_vips_probe_animated(const void *buf, size_t len, int *width, int *height, int *pages) { - if (buf == NULL || len == 0 || width == NULL || height == NULL || pages == NULL) { - return -1; - } - VipsImage *header = vips_image_new_from_buffer(buf, len, "n=1", NULL); - if (header == NULL) { - return -1; - } - *width = vips_image_get_width(header); - *height = vips_image_get_height(header); - int n_pages = 1; - if (vips_image_get_typeof(header, "n-pages") != 0) { - if (vips_image_get_int(header, "n-pages", &n_pages) != 0) { - n_pages = 1; - } - } - *pages = n_pages > 0 ? n_pages : 1; - g_object_unref(header); - return 0; -} - -VipsImage *fluxer_vips_image_new_from_buffer(const void *buf, size_t len, const char *option_string) { - return vips_image_new_from_buffer(buf, len, option_string, NULL); -} - -VipsImage *fluxer_vips_image_new_from_memory_copy(const void *data, size_t size, int width, int height, int bands, int format) { - return vips_image_new_from_memory_copy(data, size, width, height, bands, format); -} - -int fluxer_vips_image_write_to_buffer(VipsImage *image, const char *suffix, void **buf, size_t *size) { - return vips_image_write_to_buffer(image, suffix, buf, size, NULL); -} - -int fluxer_vips_image_get_width(VipsImage *image) { - return vips_image_get_width(image); -} - -int fluxer_vips_image_get_height(VipsImage *image) { - return vips_image_get_height(image); -} - -int fluxer_vips_image_get_bands(VipsImage *image) { - return vips_image_get_bands(image); -} - -int fluxer_vips_image_get_int(VipsImage *image, const char *field, int *out) { - return vips_image_get_int(image, field, out); -} - -void fluxer_vips_set_page_height(VipsImage *image, int page_height) { - if (image != NULL && page_height > 0) { - vips_image_set_int(image, "page-height", page_height); - } -} - -int fluxer_vips_autorot(VipsImage *in, VipsImage **out) { - return vips_autorot(in, out, NULL); -} - -int fluxer_vips_extract_area(VipsImage *in, VipsImage **out, int left, int top, int width, int height) { - return vips_extract_area(in, out, left, top, width, height, NULL); -} - -int fluxer_vips_resize(VipsImage *in, VipsImage **out, double scale) { - return vips_resize(in, out, scale, NULL); -} - -int fluxer_vips_thumbnail_buffer(const void *buf, size_t len, VipsImage **out, int width, int height, int n) { - return fluxer_vips_thumbnail_buffer_ex(buf, len, out, width, height, n, FLUXER_THUMB_CROP_NONE); -} - -int fluxer_vips_thumbnail_buffer_ex(const void *buf, size_t len, VipsImage **out, int width, int height, int n, int crop_mode) { - if (out == NULL) return -1; - *out = NULL; - char option_string[64]; - const unsigned char *bytes = (const unsigned char *)buf; - int is_jpeg = len >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff; - if (n == -1) { - snprintf(option_string, sizeof(option_string), is_jpeg ? "n=-1,access=sequential" : "n=-1,access=sequential,fail=true"); - } else { - snprintf(option_string, sizeof(option_string), is_jpeg ? "access=sequential" : "access=sequential,fail=true"); - } - - int effective_crop = crop_mode; - if (n == -1 && crop_mode == FLUXER_THUMB_CROP_CENTRE) effective_crop = FLUXER_THUMB_CROP_NONE; - - if (effective_crop != FLUXER_THUMB_CROP_CENTRE) { - int target_w = width > 0 ? width : VIPS_MAX_COORD; - int height_cap = height > 0 ? height : VIPS_MAX_COORD; - return vips_thumbnail_buffer( - (void *)buf, len, out, target_w, - "height", height_cap, - "size", VIPS_SIZE_DOWN, - "no_rotate", FALSE, - "option_string", option_string, - NULL - ); - } - - if (width <= 0 || height <= 0) return -1; - - VipsImage *loaded = vips_image_new_from_buffer(buf, len, option_string, NULL); - if (loaded == NULL) return -1; - - VipsImage *oriented = NULL; - if (vips_autorot(loaded, &oriented, NULL) != 0) { - g_object_unref(loaded); - return -1; - } - g_object_unref(loaded); - - int src_w = vips_image_get_width(oriented); - int total_h = vips_image_get_height(oriented); - int page_height = total_h; - int n_pages = 1; - if (vips_image_get_typeof(oriented, "page-height") != 0) { - if (vips_image_get_int(oriented, "page-height", &page_height) != 0 || page_height <= 0) { - page_height = total_h; - } - } - if (page_height > 0 && total_h > page_height && (total_h % page_height) == 0) { - n_pages = total_h / page_height; - } else { - page_height = total_h; - n_pages = 1; - } - int src_h = page_height; - if (src_w <= 0 || src_h <= 0) { - g_object_unref(oriented); - return -1; - } - - double scale_w = (double)width / (double)src_w; - double scale_h = (double)height / (double)src_h; - double scale = scale_w > scale_h ? scale_w : scale_h; - if (scale > 1.0) scale = 1.0; - - VipsImage *resized = NULL; - if (scale < 0.999) { - if (vips_resize(oriented, &resized, scale, NULL) != 0) { - g_object_unref(oriented); - return -1; - } - g_object_unref(oriented); - } else { - resized = oriented; - } - - int scaled_w = vips_image_get_width(resized); - int scaled_h_total = vips_image_get_height(resized); - int scaled_page_h = scaled_h_total / n_pages; - int final_w = scaled_w < width ? scaled_w : width; - int final_h = scaled_page_h < height ? scaled_page_h : height; - - if (final_w == scaled_w && final_h == scaled_page_h) { - *out = resized; - return 0; - } - - int left = (scaled_w - final_w) / 2; - int top = (scaled_page_h - final_h) / 2; - - if (n_pages == 1) { - VipsImage *cropped = NULL; - int rc = vips_extract_area(resized, &cropped, left, top, final_w, final_h, NULL); - g_object_unref(resized); - if (rc != 0) return -1; - *out = cropped; - return 0; - } - - VipsImage **pages = g_alloca(sizeof(VipsImage *) * n_pages); - for (int i = 0; i < n_pages; i++) pages[i] = NULL; - int rc = 0; - for (int i = 0; i < n_pages; i++) { - if (vips_extract_area(resized, &pages[i], left, top + i * scaled_page_h, final_w, final_h, NULL) != 0) { - rc = -1; - break; - } - } - g_object_unref(resized); - if (rc != 0) { - for (int i = 0; i < n_pages; i++) if (pages[i]) g_object_unref(pages[i]); - return -1; - } - VipsImage *joined = NULL; - if (vips_arrayjoin(pages, &joined, n_pages, "across", 1, NULL) != 0) { - for (int i = 0; i < n_pages; i++) if (pages[i]) g_object_unref(pages[i]); - return -1; - } - for (int i = 0; i < n_pages; i++) if (pages[i]) g_object_unref(pages[i]); - vips_image_set_int(joined, "page-height", final_h); - *out = joined; - return 0; -} - -struct fluxer_vips_write_ctx { - fluxer_vips_write_cb cb; - void *user_data; - int err; -}; - -static gint64 fluxer_vips_target_write_adapter(VipsTargetCustom *target, const void *bytes, gint64 length, void *gp) { - (void)target; - struct fluxer_vips_write_ctx *c = gp; - if (length <= 0) return 0; - if (c->cb(c->user_data, bytes, (size_t)length) != 0) { - c->err = -1; - return -1; - } - return length; -} - -int fluxer_vips_image_write_to_callback(VipsImage *image, const char *suffix, fluxer_vips_write_cb cb, void *user_data) { - if (image == NULL || suffix == NULL || cb == NULL) return -1; - VipsTargetCustom *target = vips_target_custom_new(); - if (target == NULL) return -1; - - struct fluxer_vips_write_ctx ctx = { .cb = cb, .user_data = user_data, .err = 0 }; - g_signal_connect(target, "write", G_CALLBACK(fluxer_vips_target_write_adapter), &ctx); - - int rc = vips_image_write_to_target(image, suffix, (VipsTarget *)target, NULL); - g_object_unref(target); - if (rc != 0) return -1; - if (ctx.err != 0) return ctx.err; - return 0; -} - -int fluxer_vips_extract_rgba(VipsImage *in, void **out_buf, size_t *out_size) { - if (out_buf == NULL || out_size == NULL) { - return -1; - } - *out_buf = NULL; - *out_size = 0; - VipsImage *srgb = NULL; - if (vips_colourspace(in, &srgb, VIPS_INTERPRETATION_sRGB, NULL)) { - return -1; - } - VipsImage *rgba = NULL; - int bands = vips_image_get_bands(srgb); - if (bands < 4) { - if (vips_addalpha(srgb, &rgba, NULL)) { - g_object_unref(srgb); - return -1; - } - g_object_unref(srgb); - } else if (bands > 4) { - if (vips_extract_band(srgb, &rgba, 0, "n", 4, NULL)) { - g_object_unref(srgb); - return -1; - } - g_object_unref(srgb); - } else { - rgba = srgb; - } - - - VipsImage *uchar_img = NULL; - if (vips_cast_uchar(rgba, &uchar_img, NULL)) { - g_object_unref(rgba); - return -1; - } - g_object_unref(rgba); - - size_t size = 0; - void *buf = vips_image_write_to_memory(uchar_img, &size); - g_object_unref(uchar_img); - if (buf == NULL) { - return -1; - } - *out_buf = buf; - *out_size = size; - return 0; -} - -void fluxer_vips_unref(VipsImage *image) { - if (image != NULL) { - g_object_unref(image); - } -} - -void fluxer_vips_free(void *mem) { - g_free(mem); -} - -void fluxer_webp_free(void *mem) { - if (mem) free(mem); -} - -static long long fluxer_monotonic_ms(void) { - struct timespec ts; - if (clock_gettime(CLOCK_MONOTONIC, &ts) != 0) return 0; - return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; -} - -static int fluxer_deadline_expired(long long deadline_unix_ms) { - if (deadline_unix_ms <= 0) return 0; - long long now_ms = fluxer_monotonic_ms(); - return now_ms > 0 && now_ms >= deadline_unix_ms; -} - -struct ff_mem_reader { - const uint8_t *data; - size_t len; - size_t offset; -}; - -static int ff_mem_read_packet(void *opaque, uint8_t *buf, int buf_size) { - struct ff_mem_reader *r = (struct ff_mem_reader *)opaque; - if (buf_size <= 0) return 0; - if (r->offset >= r->len) return AVERROR_EOF; - size_t remaining = r->len - r->offset; - size_t n = remaining < (size_t)buf_size ? remaining : (size_t)buf_size; - memcpy(buf, r->data + r->offset, n); - r->offset += n; - return (int)n; -} - -static int64_t ff_mem_seek(void *opaque, int64_t offset, int whence) { - struct ff_mem_reader *r = (struct ff_mem_reader *)opaque; - if (whence == AVSEEK_SIZE) return (int64_t)r->len; - int mode = whence & ~AVSEEK_FORCE; - int64_t base = 0; - if (mode == SEEK_SET) { - base = 0; - } else if (mode == SEEK_CUR) { - base = (int64_t)r->offset; - } else if (mode == SEEK_END) { - base = (int64_t)r->len; - } else { - return AVERROR(EINVAL); - } - int64_t next = base + offset; - if (next < 0 || (uint64_t)next > r->len) return AVERROR(EINVAL); - r->offset = (size_t)next; - return next; -} - -static int write_encoded_gif_packets(AVFormatContext *out_fmt, AVCodecContext *enc_ctx, - AVStream *out_stream, AVFrame *frame) { - int rc = avcodec_send_frame(enc_ctx, frame); - if (rc < 0) return rc; - AVPacket *pkt = av_packet_alloc(); - if (pkt == NULL) return AVERROR(ENOMEM); - while (1) { - rc = avcodec_receive_packet(enc_ctx, pkt); - if (rc == AVERROR(EAGAIN) || rc == AVERROR_EOF) { - av_packet_free(&pkt); - return 0; - } - if (rc < 0) { - av_packet_free(&pkt); - return rc; - } - av_packet_rescale_ts(pkt, enc_ctx->time_base, out_stream->time_base); - pkt->stream_index = out_stream->index; - rc = av_interleaved_write_frame(out_fmt, pkt); - av_packet_unref(pkt); - if (rc < 0) { - av_packet_free(&pkt); - return rc; - } - } -} - -static int push_frame_delay_cs(int **values, int *len, int *cap, int delay_cs) { - if (values == NULL || len == NULL || cap == NULL) return -1; - if (*len >= *cap) { - int next_cap = *cap > 0 ? *cap * 2 : 16; - if (next_cap <= *len) next_cap = *len + 1; - int *next = (int *)realloc(*values, (size_t)next_cap * sizeof(int)); - if (next == NULL) return -1; - *values = next; - *cap = next_cap; - } - if (delay_cs < 1) delay_cs = 1; - if (delay_cs > 65535) delay_cs = 65535; - (*values)[*len] = delay_cs; - *len += 1; - return 0; -} - -static void skip_gif_sub_blocks(uint8_t *bytes, size_t len, size_t *offset) { - while (*offset < len) { - uint8_t block_len = bytes[*offset]; - *offset += 1; - if (block_len == 0) return; - if (*offset > len || (size_t)block_len > len - *offset) { - *offset = len; - return; - } - *offset += block_len; - } -} - -static int patch_gif_frame_delays(uint8_t *bytes, size_t len, const int *delays_cs, int n_delays) { - if (bytes == NULL || len < 13 || delays_cs == NULL || n_delays <= 0) return 0; - if (memcmp(bytes, "GIF87a", 6) != 0 && memcmp(bytes, "GIF89a", 6) != 0) return 0; - - size_t off = 13; - if (bytes[10] & 0x80) { - size_t gct_entries = (size_t)1 << ((bytes[10] & 0x07) + 1); - size_t gct_bytes = gct_entries * 3; - if (gct_bytes > len - off) return 0; - off += gct_bytes; - } - - int patched = 0; - while (off < len && patched < n_delays) { - uint8_t introducer = bytes[off]; - if (introducer == 0x3b) break; - if (introducer == 0x21) { - if (off + 1 >= len) break; - uint8_t label = bytes[off + 1]; - if (label == 0xf9) { - if (off + 7 >= len) break; - if (bytes[off + 2] == 4) { - int delay = delays_cs[patched]; - if (delay < 1) delay = 1; - if (delay > 65535) delay = 65535; - bytes[off + 4] = (uint8_t)(delay & 0xff); - bytes[off + 5] = (uint8_t)((delay >> 8) & 0xff); - patched++; - off += 8; - continue; - } - } - off += 2; - skip_gif_sub_blocks(bytes, len, &off); - continue; - } - if (introducer == 0x2c) { - if (off + 9 >= len) break; - uint8_t packed = bytes[off + 9]; - off += 10; - if (packed & 0x80) { - size_t lct_entries = (size_t)1 << ((packed & 0x07) + 1); - size_t lct_bytes = lct_entries * 3; - if (lct_bytes > len - off) break; - off += lct_bytes; - } - if (off >= len) break; - off += 1; - skip_gif_sub_blocks(bytes, len, &off); - continue; - } - break; - } - return patched; -} - -static int fluxer_gif_setup_filter_graph( - AVFilterGraph **out_graph, - AVFilterContext **out_src, - AVFilterContext **out_sink, - int src_w, int src_h, - enum AVPixelFormat src_fmt, - AVRational src_tb, - int dst_w, int dst_h -) { - AVFilterGraph *graph = avfilter_graph_alloc(); - if (graph == NULL) return -1; - AVFilterContext *src_ctx = NULL; - AVFilterContext *sink_ctx = NULL; - char src_args[512]; - snprintf(src_args, sizeof(src_args), - "video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=1/1", - src_w, src_h, (int)src_fmt, src_tb.num, src_tb.den > 0 ? src_tb.den : 100); - if (avfilter_graph_create_filter(&src_ctx, avfilter_get_by_name("buffer"), - "in", src_args, NULL, graph) < 0) - goto fail; - sink_ctx = avfilter_graph_alloc_filter(graph, avfilter_get_by_name("buffersink"), "out"); - if (sink_ctx == NULL) goto fail; - enum AVPixelFormat sink_fmt = AV_PIX_FMT_PAL8; -#if LIBAVFILTER_VERSION_INT >= AV_VERSION_INT(10, 6, 100) - if (av_opt_set_array(sink_ctx, "pixel_formats", AV_OPT_SEARCH_CHILDREN, - 0, 1, AV_OPT_TYPE_PIXEL_FMT, &sink_fmt) < 0) -#else - if (av_opt_set_bin(sink_ctx, "pix_fmts", (const uint8_t *)&sink_fmt, - sizeof(sink_fmt), AV_OPT_SEARCH_CHILDREN) < 0) -#endif - goto fail; - if (avfilter_init_dict(sink_ctx, NULL) < 0) goto fail; - - char descr[256]; - snprintf(descr, sizeof(descr), - "scale=%d:%d:flags=lanczos,format=rgba," - "split[a][b];" - "[a]palettegen=reserve_transparent=1:stats_mode=full[p];" - "[b][p]paletteuse=alpha_threshold=128:dither=none", - dst_w, dst_h); - AVFilterInOut *outputs = avfilter_inout_alloc(); - AVFilterInOut *inputs = avfilter_inout_alloc(); - if (outputs == NULL || inputs == NULL) { - avfilter_inout_free(&outputs); - avfilter_inout_free(&inputs); - goto fail; - } - outputs->name = av_strdup("in"); - outputs->filter_ctx = src_ctx; - outputs->pad_idx = 0; - outputs->next = NULL; - inputs->name = av_strdup("out"); - inputs->filter_ctx = sink_ctx; - inputs->pad_idx = 0; - inputs->next = NULL; - if (avfilter_graph_parse_ptr(graph, descr, &inputs, &outputs, NULL) < 0) { - avfilter_inout_free(&outputs); - avfilter_inout_free(&inputs); - goto fail; - } - avfilter_inout_free(&outputs); - avfilter_inout_free(&inputs); - if (avfilter_graph_config(graph, NULL) < 0) goto fail; - - *out_graph = graph; - *out_src = src_ctx; - *out_sink = sink_ctx; - return 0; -fail: - avfilter_graph_free(&graph); - return -1; -} - -int fluxer_ffmpeg_resize_gif( - const void *gif_data, - size_t gif_len, - int target_width, - int target_height, - long long deadline_unix_ms, - long long max_frames, - long long max_total_pixels, - void **out_buf, - size_t *out_size -) { - if (gif_data == NULL || gif_len == 0 || target_width <= 0 || target_height <= 0 || - out_buf == NULL || out_size == NULL) { - return -1; - } - *out_buf = NULL; - *out_size = 0; - - int rc = -1; - struct ff_mem_reader reader = { .data = (const uint8_t *)gif_data, .len = gif_len, .offset = 0 }; - unsigned char *input_avio_buffer = NULL; - AVIOContext *input_avio = NULL; - AVFormatContext *in_fmt = NULL; - AVCodecContext *dec_ctx = NULL; - AVFormatContext *out_fmt = NULL; - AVIOContext *out_avio = NULL; - AVCodecContext *enc_ctx = NULL; - AVFilterGraph *filter_graph = NULL; - AVFilterContext *filter_src = NULL; - AVFilterContext *filter_sink = NULL; - AVPacket *packet = NULL; - AVFrame *frame = NULL; - AVFrame *scaled = NULL; - uint8_t *dyn_buf = NULL; - int *frame_delays_cs = NULL; - int frame_delays_len = 0; - int frame_delays_cap = 0; - - input_avio_buffer = av_malloc(64 * 1024); - if (input_avio_buffer == NULL) goto cleanup; - input_avio = avio_alloc_context(input_avio_buffer, 64 * 1024, 0, &reader, - ff_mem_read_packet, NULL, ff_mem_seek); - if (input_avio == NULL) { - av_free(input_avio_buffer); - input_avio_buffer = NULL; - goto cleanup; - } - in_fmt = avformat_alloc_context(); - if (in_fmt == NULL) goto cleanup; - in_fmt->pb = input_avio; - in_fmt->flags |= AVFMT_FLAG_CUSTOM_IO; - in_fmt->probesize = 5 * 1024 * 1024; - in_fmt->max_analyze_duration = 5 * AV_TIME_BASE; - if (avformat_open_input(&in_fmt, NULL, NULL, NULL) < 0) goto cleanup; - if (avformat_find_stream_info(in_fmt, NULL) < 0) goto cleanup; - - const AVCodec *decoder = NULL; - int stream_index = av_find_best_stream(in_fmt, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0); - if (stream_index < 0) goto cleanup; - AVStream *in_stream = in_fmt->streams[stream_index]; - if (decoder == NULL) decoder = avcodec_find_decoder(in_stream->codecpar->codec_id); - if (decoder == NULL) goto cleanup; - dec_ctx = avcodec_alloc_context3(decoder); - if (dec_ctx == NULL) goto cleanup; - if (avcodec_parameters_to_context(dec_ctx, in_stream->codecpar) < 0) goto cleanup; - if (avcodec_open2(dec_ctx, decoder, NULL) < 0) goto cleanup; - - if (avformat_alloc_output_context2(&out_fmt, NULL, "gif", NULL) < 0 || out_fmt == NULL) goto cleanup; - if (avio_open_dyn_buf(&out_avio) < 0 || out_avio == NULL) goto cleanup; - out_fmt->pb = out_avio; - - const AVCodec *encoder = avcodec_find_encoder(AV_CODEC_ID_GIF); - if (encoder == NULL) goto cleanup; - AVStream *out_stream = avformat_new_stream(out_fmt, NULL); - if (out_stream == NULL) goto cleanup; - enc_ctx = avcodec_alloc_context3(encoder); - if (enc_ctx == NULL) goto cleanup; - enc_ctx->width = target_width; - enc_ctx->height = target_height; - enc_ctx->pix_fmt = AV_PIX_FMT_PAL8; - enc_ctx->time_base = (AVRational){ 1, 100 }; - enc_ctx->framerate = (AVRational){ 25, 1 }; - if (out_fmt->oformat != NULL && (out_fmt->oformat->flags & AVFMT_GLOBALHEADER)) { - enc_ctx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER; - } - AVDictionary *enc_opts = NULL; - av_dict_set(&enc_opts, "gifflags", "-offsetting", 0); - if (avcodec_open2(enc_ctx, encoder, &enc_opts) < 0) { - av_dict_free(&enc_opts); - goto cleanup; - } - av_dict_free(&enc_opts); - if (avcodec_parameters_from_context(out_stream->codecpar, enc_ctx) < 0) goto cleanup; - out_stream->time_base = enc_ctx->time_base; - if (avformat_write_header(out_fmt, NULL) < 0) goto cleanup; - - if (fluxer_gif_setup_filter_graph(&filter_graph, &filter_src, &filter_sink, - dec_ctx->width, dec_ctx->height, dec_ctx->pix_fmt, - in_stream->time_base, - target_width, target_height) < 0) - goto cleanup; - - packet = av_packet_alloc(); - frame = av_frame_alloc(); - scaled = av_frame_alloc(); - if (packet == NULL || frame == NULL || scaled == NULL) goto cleanup; - - int64_t next_pts = 0; - int64_t last_packet_duration = 0; - long long frames_decoded = 0; - long long decoded_pixels = 0; - AVRational out_tb = (AVRational){ 1, 100 }; - int read_rc = 0; - while ((read_rc = av_read_frame(in_fmt, packet)) >= 0) { - if (fluxer_deadline_expired(deadline_unix_ms)) { - rc = -2; - goto cleanup; - } - if (packet->stream_index != stream_index) { - av_packet_unref(packet); - continue; - } - last_packet_duration = packet->duration; - int send_rc = avcodec_send_packet(dec_ctx, packet); - av_packet_unref(packet); - if (send_rc < 0) continue; - while (1) { - int recv_rc = avcodec_receive_frame(dec_ctx, frame); - if (recv_rc == AVERROR(EAGAIN) || recv_rc == AVERROR_EOF) break; - if (recv_rc < 0) goto cleanup; - frames_decoded++; - if (max_frames > 0 && frames_decoded > max_frames) { rc = -3; goto cleanup; } - { - long long fw = frame->width > 0 ? frame->width : dec_ctx->width; - long long fh = frame->height > 0 ? frame->height : dec_ctx->height; - decoded_pixels += fw * fh; - } - if (max_total_pixels > 0 && decoded_pixels > max_total_pixels) { rc = -3; goto cleanup; } - frame->pts = next_pts; - int64_t duration = frame->duration > 0 ? frame->duration : last_packet_duration; - int64_t duration_cs = duration > 0 ? av_rescale_q(duration, in_stream->time_base, out_tb) : 2; - if (duration_cs < 2) duration_cs = 2; - if (push_frame_delay_cs(&frame_delays_cs, &frame_delays_len, &frame_delays_cap, (int)duration_cs) != 0) - goto cleanup; - frame->duration = duration_cs; - next_pts += duration_cs; - if (av_buffersrc_add_frame_flags(filter_src, frame, AV_BUFFERSRC_FLAG_KEEP_REF) < 0) - goto cleanup; - av_frame_unref(frame); - } - } - if (read_rc != AVERROR_EOF) goto cleanup; - if (avcodec_send_packet(dec_ctx, NULL) >= 0) { - while (1) { - int recv_rc = avcodec_receive_frame(dec_ctx, frame); - if (recv_rc == AVERROR(EAGAIN) || recv_rc == AVERROR_EOF) break; - if (recv_rc < 0) goto cleanup; - frames_decoded++; - if (max_frames > 0 && frames_decoded > max_frames) { rc = -3; goto cleanup; } - { - long long fw = frame->width > 0 ? frame->width : dec_ctx->width; - long long fh = frame->height > 0 ? frame->height : dec_ctx->height; - decoded_pixels += fw * fh; - } - if (max_total_pixels > 0 && decoded_pixels > max_total_pixels) { rc = -3; goto cleanup; } - frame->pts = next_pts; - if (push_frame_delay_cs(&frame_delays_cs, &frame_delays_len, &frame_delays_cap, 2) != 0) - goto cleanup; - frame->duration = 2; - next_pts += 2; - if (av_buffersrc_add_frame_flags(filter_src, frame, AV_BUFFERSRC_FLAG_KEEP_REF) < 0) - goto cleanup; - av_frame_unref(frame); - } - } - if (next_pts <= 0) goto cleanup; - if (av_buffersrc_add_frame_flags(filter_src, NULL, 0) < 0) goto cleanup; - while (1) { - if (fluxer_deadline_expired(deadline_unix_ms)) { - rc = -2; - goto cleanup; - } - int sink_rc = av_buffersink_get_frame(filter_sink, scaled); - if (sink_rc == AVERROR_EOF) break; - if (sink_rc == AVERROR(EAGAIN)) break; - if (sink_rc < 0) goto cleanup; - if (write_encoded_gif_packets(out_fmt, enc_ctx, out_stream, scaled) < 0) { - av_frame_unref(scaled); - goto cleanup; - } - av_frame_unref(scaled); - } - if (write_encoded_gif_packets(out_fmt, enc_ctx, out_stream, NULL) < 0) goto cleanup; - if (av_write_trailer(out_fmt) < 0) goto cleanup; - - int dyn_len = avio_close_dyn_buf(out_avio, &dyn_buf); - out_avio = NULL; - out_fmt->pb = NULL; - if (dyn_len <= 0 || dyn_buf == NULL) goto cleanup; - (void)patch_gif_frame_delays(dyn_buf, (size_t)dyn_len, frame_delays_cs, frame_delays_len); - void *copy = malloc((size_t)dyn_len); - if (copy == NULL) goto cleanup; - memcpy(copy, dyn_buf, (size_t)dyn_len); - *out_buf = copy; - *out_size = (size_t)dyn_len; - rc = 0; - -cleanup: - if (dyn_buf != NULL) av_free(dyn_buf); - if (out_avio != NULL) { - uint8_t *discard = NULL; - avio_close_dyn_buf(out_avio, &discard); - if (discard != NULL) av_free(discard); - if (out_fmt != NULL) out_fmt->pb = NULL; - } - if (scaled != NULL) av_frame_free(&scaled); - if (frame != NULL) av_frame_free(&frame); - if (packet != NULL) av_packet_free(&packet); - if (filter_graph != NULL) avfilter_graph_free(&filter_graph); - if (frame_delays_cs != NULL) free(frame_delays_cs); - if (enc_ctx != NULL) avcodec_free_context(&enc_ctx); - if (out_fmt != NULL) avformat_free_context(out_fmt); - if (dec_ctx != NULL) avcodec_free_context(&dec_ctx); - if (in_fmt != NULL) { - avformat_close_input(&in_fmt); - } - if (input_avio != NULL) { - if (input_avio->buffer != NULL) av_freep(&input_avio->buffer); - avio_context_free(&input_avio); - } - return rc; -} - -static VipsAngle ff_display_matrix_angle(const AVFrame *frame, const AVStream *stream) { - const int32_t *matrix = NULL; - const AVFrameSideData *fsd = av_frame_get_side_data(frame, AV_FRAME_DATA_DISPLAYMATRIX); - if (fsd != NULL && fsd->data != NULL && fsd->size >= 9 * sizeof(int32_t)) { - matrix = (const int32_t *)fsd->data; - } else if (stream != NULL && stream->codecpar != NULL) { - const AVPacketSideData *psd = av_packet_side_data_get( - stream->codecpar->coded_side_data, stream->codecpar->nb_coded_side_data, - AV_PKT_DATA_DISPLAYMATRIX); - if (psd != NULL && psd->data != NULL && psd->size >= 9 * sizeof(int32_t)) { - matrix = (const int32_t *)psd->data; - } - } - if (matrix == NULL) return VIPS_ANGLE_D0; - double ccw = av_display_rotation_get(matrix); - if (isnan(ccw)) return VIPS_ANGLE_D0; - long quarter = lround(-ccw / 90.0) % 4; - if (quarter < 0) quarter += 4; - switch (quarter) { - case 1: return VIPS_ANGLE_D90; - case 2: return VIPS_ANGLE_D180; - case 3: return VIPS_ANGLE_D270; - default: return VIPS_ANGLE_D0; - } -} - -static int ff_validate_rgba_geometry(int width, int height, size_t *out_size) { - if (width <= 0 || height <= 0 || - width > FLUXER_MAX_VIDEO_FRAME_DIMENSION || - height > FLUXER_MAX_VIDEO_FRAME_DIMENSION) { - return -1; - } - size_t row_bytes = (size_t)width * 4; - if (row_bytes == 0 || (size_t)height > SIZE_MAX / row_bytes) return -1; - size_t rgba_size = row_bytes * (size_t)height; - if (rgba_size == 0 || rgba_size > FLUXER_MAX_VIDEO_RGBA_BYTES) return -1; - if (out_size != NULL) *out_size = rgba_size; - return 0; -} - -static int ff_validate_vips_image_bounds(VipsImage *image) { - if (image == NULL) return -1; - size_t ignored = 0; - return ff_validate_rgba_geometry( - vips_image_get_width(image), - vips_image_get_height(image), - &ignored); -} - -static int ff_convert_frame_to_rgba( - AVFrame *frame, - int width, - int height, - struct SwsContext **sws, - uint8_t *dst -) { - if (frame == NULL || sws == NULL || dst == NULL) return -1; - size_t rgba_size = 0; - if (ff_validate_rgba_geometry(width, height, &rgba_size) != 0) return -1; - int packed_linesize = width * 4; - int padded_linesize = packed_linesize + FLUXER_SWS_ROW_PADDING; - if (padded_linesize <= packed_linesize) return -1; - if ((size_t)height > (SIZE_MAX - FLUXER_SWS_ROW_PADDING) / (size_t)padded_linesize) return -1; - size_t padded_size = (size_t)padded_linesize * (size_t)height + FLUXER_SWS_ROW_PADDING; - uint8_t *padded = (uint8_t *)malloc(padded_size); - if (padded == NULL) return -1; - - *sws = sws_getCachedContext(*sws, - width, height, (enum AVPixelFormat)frame->format, - width, height, AV_PIX_FMT_RGBA, - SWS_FAST_BILINEAR, NULL, NULL, NULL); - if (*sws == NULL) { - free(padded); - return -1; - } - uint8_t *dst_data[4] = { padded, NULL, NULL, NULL }; - int dst_linesize[4] = { padded_linesize, 0, 0, 0 }; - int scaled_rows = sws_scale(*sws, (const uint8_t * const *)frame->data, frame->linesize, - 0, height, dst_data, dst_linesize); - if (scaled_rows != height) { - free(padded); - return -1; - } - for (int y = 0; y < height; y++) { - memcpy(dst + (size_t)y * (size_t)packed_linesize, - padded + (size_t)y * (size_t)padded_linesize, - (size_t)packed_linesize); - } - free(padded); - (void)rgba_size; - return 0; -} - -static VipsImage *ff_frame_to_rgba_image(AVFrame *frame, int fallback_width, int fallback_height) { - int width = frame != NULL && frame->width > 0 ? frame->width : fallback_width; - int height = frame != NULL && frame->height > 0 ? frame->height : fallback_height; - size_t rgba_size = 0; - if (ff_validate_rgba_geometry(width, height, &rgba_size) != 0) return NULL; - uint8_t *rgba = (uint8_t *)malloc(rgba_size); - if (rgba == NULL) return NULL; - struct SwsContext *sws = NULL; - if (ff_convert_frame_to_rgba(frame, width, height, &sws, rgba) != 0) { - if (sws != NULL) sws_freeContext(sws); - free(rgba); - return NULL; - } - if (sws != NULL) sws_freeContext(sws); - VipsImage *image = vips_image_new_from_memory_copy(rgba, rgba_size, width, height, 4, VIPS_FORMAT_UCHAR); - free(rgba); - return image; -} - -static int ff_apply_display_geometry(VipsImage **image, AVFormatContext *fmt, AVStream *stream, AVFrame *frame) { - if (image == NULL || *image == NULL) return -1; - AVRational sar = av_guess_sample_aspect_ratio(fmt, stream, frame); - if (sar.num > 0 && sar.den > 0 && sar.num != sar.den) { - double hscale = 1.0; - double vscale = 1.0; - if (sar.num > sar.den) { - hscale = (double)sar.num / (double)sar.den; - } else { - vscale = (double)sar.den / (double)sar.num; - } - if (!isfinite(hscale) || hscale <= 0.0 || - !isfinite(vscale) || vscale <= 0.0) { - return -1; - } - double projected_width = (double)vips_image_get_width(*image) * hscale; - double projected_height = (double)vips_image_get_height(*image) * vscale; - if (!isfinite(projected_width) || - !isfinite(projected_height) || - projected_width < 1.0 || - projected_height < 1.0 || - projected_width > (double)FLUXER_MAX_VIDEO_FRAME_DIMENSION || - projected_height > (double)FLUXER_MAX_VIDEO_FRAME_DIMENSION) { - return -1; - } - if (ff_validate_rgba_geometry((int)ceil(projected_width), (int)ceil(projected_height), NULL) != 0) { - return -1; - } - VipsImage *scaled = NULL; - if (vips_resize(*image, &scaled, hscale, "vscale", vscale, NULL) != 0) return -1; - if (ff_validate_vips_image_bounds(scaled) != 0) { - g_object_unref(scaled); - return -1; - } - g_object_unref(*image); - *image = scaled; - } - VipsAngle angle = ff_display_matrix_angle(frame, stream); - if (angle != VIPS_ANGLE_D0) { - VipsImage *rotated = NULL; - if (vips_rot(*image, &rotated, angle, NULL) != 0) return -1; - if (ff_validate_vips_image_bounds(rotated) != 0) { - g_object_unref(rotated); - return -1; - } - g_object_unref(*image); - *image = rotated; - } - return 0; -} - -static int ff_emit_frame_thumbnail( - AVFrame *frame, AVCodecContext *dec_ctx, AVFormatContext *fmt, AVStream *stream, - const char *suffix, void **out_buf, size_t *out_size -) { - if (frame == NULL || dec_ctx == NULL || suffix == NULL || out_buf == NULL || out_size == NULL) { - return -1; - } - *out_buf = NULL; - *out_size = 0; - int width = frame->width > 0 ? frame->width : dec_ctx->width; - int height = frame->height > 0 ? frame->height : dec_ctx->height; - VipsImage *image = ff_frame_to_rgba_image(frame, width, height); - if (image == NULL) return -1; - if (ff_apply_display_geometry(&image, fmt, stream, frame) != 0) { - g_object_unref(image); - return -1; - } - int write_rc = vips_image_write_to_buffer(image, suffix, out_buf, out_size, NULL); - g_object_unref(image); - if (write_rc != 0 || *out_buf == NULL || *out_size == 0) { - if (*out_buf != NULL) { - g_free(*out_buf); - *out_buf = NULL; - } - *out_size = 0; - return -1; - } - return 0; -} - -int fluxer_ffmpeg_video_thumbnail( - const void *media_data, - size_t media_len, - const char *suffix, - int max_packets, - void **out_buf, - size_t *out_size -) { - if (media_data == NULL || media_len == 0 || suffix == NULL || out_buf == NULL || out_size == NULL) { - return -1; - } - *out_buf = NULL; - *out_size = 0; - if (max_packets <= 0) { - max_packets = 512; - } - - int rc = -1; - struct ff_mem_reader reader = { .data = (const uint8_t *)media_data, .len = media_len, .offset = 0 }; - unsigned char *input_avio_buffer = NULL; - AVIOContext *input_avio = NULL; - AVFormatContext *in_fmt = NULL; - AVCodecContext *dec_ctx = NULL; - AVPacket *packet = NULL; - AVFrame *frame = NULL; - - input_avio_buffer = av_malloc(64 * 1024); - if (input_avio_buffer == NULL) goto cleanup; - input_avio = avio_alloc_context(input_avio_buffer, 64 * 1024, 0, &reader, - ff_mem_read_packet, NULL, ff_mem_seek); - if (input_avio == NULL) { - av_free(input_avio_buffer); - input_avio_buffer = NULL; - goto cleanup; - } - in_fmt = avformat_alloc_context(); - if (in_fmt == NULL) goto cleanup; - in_fmt->pb = input_avio; - in_fmt->flags |= AVFMT_FLAG_CUSTOM_IO; - in_fmt->probesize = 5 * 1024 * 1024; - in_fmt->max_analyze_duration = 5 * AV_TIME_BASE; - if (avformat_open_input(&in_fmt, NULL, NULL, NULL) < 0) goto cleanup; - if (avformat_find_stream_info(in_fmt, NULL) < 0) goto cleanup; - - const AVCodec *decoder = NULL; - int stream_index = av_find_best_stream(in_fmt, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0); - if (stream_index < 0) goto cleanup; - AVStream *in_stream = in_fmt->streams[stream_index]; - if (decoder == NULL) decoder = avcodec_find_decoder(in_stream->codecpar->codec_id); - if (decoder == NULL) goto cleanup; - dec_ctx = avcodec_alloc_context3(decoder); - if (dec_ctx == NULL) goto cleanup; - if (avcodec_parameters_to_context(dec_ctx, in_stream->codecpar) < 0) goto cleanup; - if (avcodec_open2(dec_ctx, decoder, NULL) < 0) goto cleanup; - - packet = av_packet_alloc(); - frame = av_frame_alloc(); - if (packet == NULL || frame == NULL) goto cleanup; - - int packets_seen = 0; - int draining = 0; - while (rc != 0) { - if (!draining) { - if (packets_seen >= max_packets || av_read_frame(in_fmt, packet) < 0) { - avcodec_send_packet(dec_ctx, NULL); - draining = 1; - } else { - if (packet->stream_index != stream_index) { - av_packet_unref(packet); - continue; - } - packets_seen++; - int send_rc = avcodec_send_packet(dec_ctx, packet); - av_packet_unref(packet); - if (send_rc < 0) continue; - } - } - int recv_rc = avcodec_receive_frame(dec_ctx, frame); - if (recv_rc == 0) { - if (ff_emit_frame_thumbnail(frame, dec_ctx, in_fmt, in_stream, suffix, out_buf, out_size) == 0) { - rc = 0; - } - goto cleanup; - } - if (recv_rc == AVERROR(EAGAIN) && !draining) { - continue; - } - goto cleanup; - } - -cleanup: - if (frame != NULL) av_frame_free(&frame); - if (packet != NULL) av_packet_free(&packet); - if (dec_ctx != NULL) avcodec_free_context(&dec_ctx); - if (in_fmt != NULL) avformat_close_input(&in_fmt); - if (input_avio != NULL) { - if (input_avio->buffer != NULL) av_freep(&input_avio->buffer); - avio_context_free(&input_avio); - } else if (input_avio_buffer != NULL) { - av_free(input_avio_buffer); - } - if (rc != 0 && out_buf != NULL && *out_buf != NULL) { - g_free(*out_buf); - *out_buf = NULL; - *out_size = 0; - } - return rc; -} - -static int append_ffmpeg_rgba_frame( - AVFrame *frame, - AVStream *stream, - int64_t packet_duration, - int max_frames, - size_t max_total_pixels, - struct SwsContext **sws, - uint8_t **pixels, - int **delays, - int *capacity, - int *frames, - int *canvas_w, - int *canvas_h -) { - if (frame == NULL || stream == NULL || sws == NULL || pixels == NULL || delays == NULL || - capacity == NULL || frames == NULL || canvas_w == NULL || canvas_h == NULL) { - return -1; - } - if (max_frames > 0 && *frames >= max_frames) return 1; - - int width = frame->width > 0 ? frame->width : *canvas_w; - int height = frame->height > 0 ? frame->height : *canvas_h; - if (width <= 0 || height <= 0 || width > 16384 || height > 16384) return -1; - if (*frames == 0) { - *canvas_w = width; - *canvas_h = height; - } else if (width != *canvas_w || height != *canvas_h) { - return -1; - } - - size_t frame_pixels = (size_t)width * (size_t)height; - if (frame_pixels == 0 || frame_pixels > SIZE_MAX / 4) return -1; - if (max_total_pixels > 0 && frame_pixels > max_total_pixels) return -1; - if (max_total_pixels > 0 && (size_t)(*frames + 1) > max_total_pixels / frame_pixels) { - return *frames > 0 ? 1 : -1; - } - size_t frame_bytes = frame_pixels * 4; - if (*capacity <= *frames) { - int new_capacity = *capacity > 0 ? *capacity * 2 : 8; - if (max_frames > 0 && new_capacity > max_frames) new_capacity = max_frames; - if (new_capacity <= *frames) new_capacity = *frames + 1; - if ((size_t)new_capacity > SIZE_MAX / frame_bytes) return -1; - uint8_t *new_pixels = (uint8_t *)realloc(*pixels, (size_t)new_capacity * frame_bytes); - if (new_pixels == NULL) return -1; - *pixels = new_pixels; - int *new_delays = (int *)realloc(*delays, (size_t)new_capacity * sizeof(int)); - if (new_delays == NULL) return -1; - *delays = new_delays; - *capacity = new_capacity; - } - - uint8_t *dst = *pixels + (size_t)(*frames) * frame_bytes; - if (ff_convert_frame_to_rgba(frame, width, height, sws, dst) != 0) return -1; - - int delay = 100; - int64_t duration = frame->duration > 0 ? frame->duration : packet_duration; - if (duration > 0 && stream->time_base.num > 0 && stream->time_base.den > 0) { - int64_t ms = av_rescale_q(duration, stream->time_base, (AVRational){ 1, 1000 }); - if (ms >= 20) { - delay = ms > INT_MAX ? INT_MAX : (int)ms; - } - } - (*delays)[*frames] = delay; - *frames += 1; - return 0; -} - -int fluxer_ffmpeg_decode_apng( - const void *apng_data, - size_t apng_len, - VipsImage **out, - int max_frames, - size_t max_total_pixels -) { - if (apng_data == NULL || apng_len == 0 || out == NULL) return -1; - *out = NULL; - - int rc = -1; - struct ff_mem_reader reader = { .data = (const uint8_t *)apng_data, .len = apng_len, .offset = 0 }; - unsigned char *input_avio_buffer = NULL; - AVIOContext *input_avio = NULL; - AVFormatContext *in_fmt = NULL; - AVCodecContext *dec_ctx = NULL; - struct SwsContext *sws = NULL; - AVPacket *packet = NULL; - AVFrame *frame = NULL; - uint8_t *pixels = NULL; - int *delays = NULL; - VipsImage *image = NULL; - int capacity = 0; - int frames = 0; - int canvas_w = 0; - int canvas_h = 0; - - input_avio_buffer = av_malloc(64 * 1024); - if (input_avio_buffer == NULL) goto cleanup; - input_avio = avio_alloc_context(input_avio_buffer, 64 * 1024, 0, &reader, - ff_mem_read_packet, NULL, ff_mem_seek); - if (input_avio == NULL) { - av_free(input_avio_buffer); - input_avio_buffer = NULL; - goto cleanup; - } - in_fmt = avformat_alloc_context(); - if (in_fmt == NULL) goto cleanup; - in_fmt->pb = input_avio; - in_fmt->flags |= AVFMT_FLAG_CUSTOM_IO; - in_fmt->probesize = 5 * 1024 * 1024; - in_fmt->max_analyze_duration = 5 * AV_TIME_BASE; - const AVInputFormat *apng_format = av_find_input_format("apng"); - if (avformat_open_input(&in_fmt, NULL, apng_format, NULL) < 0) goto cleanup; - if (avformat_find_stream_info(in_fmt, NULL) < 0) goto cleanup; - - const AVCodec *decoder = NULL; - int stream_index = av_find_best_stream(in_fmt, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0); - if (stream_index < 0) goto cleanup; - AVStream *in_stream = in_fmt->streams[stream_index]; - if (decoder == NULL) decoder = avcodec_find_decoder(in_stream->codecpar->codec_id); - if (decoder == NULL) goto cleanup; - dec_ctx = avcodec_alloc_context3(decoder); - if (dec_ctx == NULL) goto cleanup; - if (avcodec_parameters_to_context(dec_ctx, in_stream->codecpar) < 0) goto cleanup; - if (avcodec_open2(dec_ctx, decoder, NULL) < 0) goto cleanup; - - packet = av_packet_alloc(); - frame = av_frame_alloc(); - if (packet == NULL || frame == NULL) goto cleanup; - - int stop = 0; - while (!stop && av_read_frame(in_fmt, packet) >= 0) { - if (packet->stream_index != stream_index) { - av_packet_unref(packet); - continue; - } - int64_t packet_duration = packet->duration; - int send_rc = avcodec_send_packet(dec_ctx, packet); - av_packet_unref(packet); - if (send_rc < 0) continue; - while (1) { - int recv_rc = avcodec_receive_frame(dec_ctx, frame); - if (recv_rc == AVERROR(EAGAIN) || recv_rc == AVERROR_EOF) break; - if (recv_rc < 0) goto cleanup; - int append_rc = append_ffmpeg_rgba_frame(frame, in_stream, packet_duration, - max_frames, max_total_pixels, &sws, - &pixels, &delays, &capacity, &frames, - &canvas_w, &canvas_h); - av_frame_unref(frame); - if (append_rc < 0) goto cleanup; - if (append_rc > 0) { stop = 1; break; } - } - } - - if (!stop && avcodec_send_packet(dec_ctx, NULL) >= 0) { - while (1) { - int recv_rc = avcodec_receive_frame(dec_ctx, frame); - if (recv_rc == AVERROR(EAGAIN) || recv_rc == AVERROR_EOF) break; - if (recv_rc < 0) goto cleanup; - int append_rc = append_ffmpeg_rgba_frame(frame, in_stream, 0, - max_frames, max_total_pixels, &sws, - &pixels, &delays, &capacity, &frames, - &canvas_w, &canvas_h); - av_frame_unref(frame); - if (append_rc < 0) goto cleanup; - if (append_rc > 0) break; - } - } - - if (frames <= 0 || canvas_w <= 0 || canvas_h <= 0) goto cleanup; - size_t frame_bytes = (size_t)canvas_w * (size_t)canvas_h * 4; - if (frame_bytes == 0 || (size_t)frames > SIZE_MAX / frame_bytes) goto cleanup; - image = vips_image_new_from_memory_copy(pixels, frame_bytes * (size_t)frames, - canvas_w, canvas_h * frames, 4, VIPS_FORMAT_UCHAR); - if (image == NULL) goto cleanup; - vips_image_set_int(image, "page-height", canvas_h); - if (frames > 1) vips_image_set_int(image, "n-pages", frames); - vips_image_set_array_int(image, "delay", delays, frames); - *out = image; - image = NULL; - rc = 0; - -cleanup: - if (image != NULL) g_object_unref(image); - if (pixels != NULL) free(pixels); - if (delays != NULL) free(delays); - if (sws != NULL) sws_freeContext(sws); - if (frame != NULL) av_frame_free(&frame); - if (packet != NULL) av_packet_free(&packet); - if (dec_ctx != NULL) avcodec_free_context(&dec_ctx); - if (in_fmt != NULL) avformat_close_input(&in_fmt); - if (input_avio != NULL) { - if (input_avio->buffer != NULL) av_freep(&input_avio->buffer); - avio_context_free(&input_avio); - } else if (input_avio_buffer != NULL) { - av_free(input_avio_buffer); - } - if (rc != 0) *out = NULL; - return rc; -} - -static int extract_rgba_strip_into( - VipsImage *in, - uint8_t *dst, - size_t dst_cap, - uint8_t **out_alloc, - size_t *out_size -) { - *out_alloc = NULL; - *out_size = 0; - - VipsImage *srgb = NULL; - if (vips_colourspace(in, &srgb, VIPS_INTERPRETATION_sRGB, NULL)) return -1; - - VipsImage *rgba = NULL; - int bands = vips_image_get_bands(srgb); - if (bands < 4) { - if (vips_addalpha(srgb, &rgba, NULL)) { g_object_unref(srgb); return -1; } - g_object_unref(srgb); - } else if (bands > 4) { - if (vips_extract_band(srgb, &rgba, 0, "n", 4, NULL)) { g_object_unref(srgb); return -1; } - g_object_unref(srgb); - } else { - rgba = srgb; - } - - VipsImage *uchar_img = NULL; - if (vips_cast_uchar(rgba, &uchar_img, NULL)) { g_object_unref(rgba); return -1; } - g_object_unref(rgba); - - size_t vips_size = 0; - void *vips_buf = vips_image_write_to_memory(uchar_img, &vips_size); - int w = vips_image_get_width(uchar_img); - int h = vips_image_get_height(uchar_img); - g_object_unref(uchar_img); - if (vips_buf == NULL) return -1; - - size_t need = (size_t)w * (size_t)h * 4; - if (vips_size < need) { g_free(vips_buf); return -1; } - - if (dst != NULL && dst_cap >= need) { - memcpy(dst, vips_buf, need); - g_free(vips_buf); - *out_size = need; - return 0; - } - - uint8_t *copy = (uint8_t *)malloc(need); - if (copy == NULL) { g_free(vips_buf); return -1; } - memcpy(copy, vips_buf, need); - g_free(vips_buf); - *out_alloc = copy; - *out_size = need; - return 0; -} - -static int *read_delays_ms(VipsImage *image, int n_pages) { - int *out = (int *)calloc((size_t)n_pages, sizeof(int)); - if (out == NULL) return NULL; - for (int i = 0; i < n_pages; i++) out[i] = 100; - - if (vips_image_get_typeof(image, "delay") != 0) { - int *arr = NULL; - int n = 0; - if (vips_image_get_array_int(image, "delay", &arr, &n) == 0 && arr != NULL && n > 0) { - int copy_n = n < n_pages ? n : n_pages; - for (int i = 0; i < copy_n; i++) { - int v = arr[i]; - if (v < 20) v = 100; - out[i] = v; - } - return out; - } - } - if (vips_image_get_typeof(image, "gif-delay") != 0) { - int v = 0; - if (vips_image_get_int(image, "gif-delay", &v) == 0 && v > 0) { - int ms = v * 10; - if (ms < 20) ms = 100; - for (int i = 0; i < n_pages; i++) out[i] = ms; - } - } - return out; -} - -int fluxer_vips_read_delays_ms(VipsImage *image, int n_pages, int **out_delays, int *out_len) { - if (image == NULL || n_pages <= 0 || out_delays == NULL || out_len == NULL) return -1; - *out_delays = NULL; - *out_len = 0; - int *delays = read_delays_ms(image, n_pages); - if (delays == NULL) return -1; - *out_delays = delays; - *out_len = n_pages; - return 0; -} - -void fluxer_free_int_array(int *values) { - free(values); -} - -static int configure_webp_encoder( - WebPConfig *config, - int quality, - int lossless, - int effort, - int alpha_q, - int smart_subsample -) { - if (!WebPConfigInit(config)) return -1; - if (lossless) { - if (!WebPConfigLosslessPreset(config, effort > 9 ? 9 : (effort < 0 ? 0 : effort))) { - return -1; - } - config->quality = (float)quality; - } else { - config->lossless = 0; - config->quality = (float)quality; - config->method = effort > 6 ? 6 : (effort < 0 ? 0 : effort); - config->alpha_quality = alpha_q; - config->use_sharp_yuv = smart_subsample ? 1 : 0; - } - return WebPValidateConfig(config) ? 0 : -1; -} - -static int encode_rgba_webp_frame( - const uint8_t *rgba, - int width, - int height, - const WebPConfig *config, - WebPMemoryWriter *writer, - WebPData *bitstream -) { - WebPPicture pic; - if (!WebPPictureInit(&pic)) return -1; - WebPMemoryWriterInit(writer); - WebPDataInit(bitstream); - - pic.width = width; - pic.height = height; - pic.use_argb = 1; - pic.writer = WebPMemoryWrite; - pic.custom_ptr = writer; - if (!WebPPictureImportRGBA(&pic, rgba, width * 4)) { - WebPPictureFree(&pic); - WebPMemoryWriterClear(writer); - return -1; - } - if (!WebPEncode(config, &pic)) { - WebPPictureFree(&pic); - WebPMemoryWriterClear(writer); - return -1; - } - WebPPictureFree(&pic); - if (writer->mem == NULL || writer->size == 0) { - WebPMemoryWriterClear(writer); - return -1; - } - bitstream->bytes = writer->mem; - bitstream->size = writer->size; - return 0; -} - -static int copy_webp_data_to_malloc(WebPData *webp_data, void **out_buf, size_t *out_size) { - if (webp_data == NULL || webp_data->bytes == NULL || webp_data->size == 0 || - out_buf == NULL || out_size == NULL) { - return -1; - } - uint8_t *out = (uint8_t *)malloc(webp_data->size); - if (out == NULL) return -1; - memcpy(out, webp_data->bytes, webp_data->size); - *out_buf = out; - *out_size = webp_data->size; - return 0; -} - -static int encode_animated_webp_full_canvas( - VipsImage *image, - int width, - int page_height, - int n_pages, - const int *delays, - const WebPConfig *config, - int loop_count, - int max_frames, - int max_duration_ms, - long long deadline_unix_ms, - unsigned char *scratch, - size_t scratch_cap, - void **out_buf, - size_t *out_size -) { - WebPMux *mux = WebPMuxNew(); - if (mux == NULL) return -1; - if (WebPMuxSetCanvasSize(mux, width, page_height) != WEBP_MUX_OK) { - WebPMuxDelete(mux); - return -1; - } - WebPMuxAnimParams anim_params = { - .bgcolor = 0x00000000, - .loop_count = loop_count, - }; - if (WebPMuxSetAnimationParams(mux, &anim_params) != WEBP_MUX_OK) { - WebPMuxDelete(mux); - return -1; - } - - int timestamp = 0; - int frames_added = 0; - for (int i = 0; i < n_pages; i++) { - if (max_frames > 0 && frames_added >= max_frames) break; - if (max_duration_ms > 0 && timestamp >= max_duration_ms) break; - if (deadline_unix_ms > 0) { - struct timespec ts; - if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) { - long long now_ms = (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; - if (now_ms >= deadline_unix_ms) break; - } - } - - VipsImage *strip = NULL; - if (vips_extract_area(image, &strip, 0, i * page_height, width, page_height, NULL) != 0) { - break; - } - uint8_t *frame_alloc = NULL; - size_t rgba_size = 0; - int erc = extract_rgba_strip_into(strip, scratch, scratch_cap, &frame_alloc, &rgba_size); - g_object_unref(strip); - if (erc != 0) { - break; - } - - const uint8_t *rgba = (frame_alloc != NULL) ? frame_alloc : scratch; - WebPMemoryWriter writer; - WebPData frame_data; - if (encode_rgba_webp_frame(rgba, width, page_height, config, &writer, &frame_data) != 0) { - if (frame_alloc) free(frame_alloc); - break; - } - if (frame_alloc) free(frame_alloc); - - WebPMuxFrameInfo frame; - memset(&frame, 0, sizeof(frame)); - frame.bitstream = frame_data; - frame.x_offset = 0; - frame.y_offset = 0; - frame.duration = delays[i]; - frame.id = WEBP_CHUNK_ANMF; - frame.dispose_method = WEBP_MUX_DISPOSE_NONE; - frame.blend_method = WEBP_MUX_NO_BLEND; - WebPMuxError push_rc = WebPMuxPushFrame(mux, &frame, 1); - WebPMemoryWriterClear(&writer); - if (push_rc != WEBP_MUX_OK) { - break; - } - timestamp += delays[i]; - frames_added++; - } - - if (frames_added == 0) { - WebPMuxDelete(mux); - return -1; - } - - WebPData webp_data; - WebPDataInit(&webp_data); - WebPMuxError assemble_rc = WebPMuxAssemble(mux, &webp_data); - WebPMuxDelete(mux); - if (assemble_rc != WEBP_MUX_OK || webp_data.bytes == NULL || webp_data.size == 0) { - WebPDataClear(&webp_data); - return -1; - } - int copy_rc = copy_webp_data_to_malloc(&webp_data, out_buf, out_size); - WebPDataClear(&webp_data); - if (copy_rc != 0) return -1; - return 0; -} - -int fluxer_webp_encode_animated( - VipsImage *image, - int quality, - int lossless, - int effort, - int alpha_q, - int smart_subsample, - int loop_count, - int full_canvas_frames, - const struct fluxer_webp_anim_limits *limits, - unsigned char *scratch, - size_t scratch_cap, - void **out_buf, - size_t *out_size -) { - if (image == NULL || out_buf == NULL || out_size == NULL) return -1; - *out_buf = NULL; - *out_size = 0; - - int total_h = vips_image_get_height(image); - int width = vips_image_get_width(image); - if (width <= 0 || total_h <= 0) return -1; - - int page_height = total_h; - if (vips_image_get_typeof(image, "page-height") != 0) { - if (vips_image_get_int(image, "page-height", &page_height) != 0 || page_height <= 0) { - page_height = total_h; - } - } - if (page_height <= 0 || page_height > total_h || (total_h % page_height) != 0) { - page_height = total_h; - } - int n_pages = total_h / page_height; - if (n_pages < 1) n_pages = 1; - - int *delays = read_delays_ms(image, n_pages); - if (delays == NULL) return -1; - - int max_frames = (limits != NULL) ? limits->max_frames : 0; - int max_duration_ms = (limits != NULL) ? limits->max_duration_ms : 0; - long long deadline_unix_ms = (limits != NULL) ? limits->deadline_unix_ms : 0; - - WebPConfig config; - if (configure_webp_encoder(&config, quality, lossless, effort, alpha_q, smart_subsample) != 0) { - free(delays); - return -1; - } - - if (full_canvas_frames) { - int rc = encode_animated_webp_full_canvas( - image, - width, - page_height, - n_pages, - delays, - &config, - loop_count, - max_frames, - max_duration_ms, - deadline_unix_ms, - scratch, - scratch_cap, - out_buf, - out_size - ); - free(delays); - return rc; - } - - WebPAnimEncoderOptions anim_opts; - if (!WebPAnimEncoderOptionsInit(&anim_opts)) { free(delays); return -1; } - anim_opts.anim_params.loop_count = loop_count; - anim_opts.anim_params.bgcolor = 0x00000000; - anim_opts.kmin = 3; - anim_opts.kmax = 4; - anim_opts.allow_mixed = 0; - - WebPAnimEncoder *enc = WebPAnimEncoderNew(width, page_height, &anim_opts); - if (enc == NULL) { free(delays); return -1; } - - int timestamp = 0; - int rc = 0; - int frames_added = 0; - for (int i = 0; i < n_pages; i++) { - if (max_frames > 0 && frames_added >= max_frames) break; - if (max_duration_ms > 0 && timestamp >= max_duration_ms) break; - if (deadline_unix_ms > 0) { - struct timespec ts; - if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) { - long long now_ms = (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000; - if (now_ms >= deadline_unix_ms) break; - } - } - VipsImage *strip = NULL; - if (vips_extract_area(image, &strip, 0, i * page_height, width, page_height, NULL) != 0) { - rc = -1; break; - } - uint8_t *frame_alloc = NULL; - size_t rgba_size = 0; - int erc = extract_rgba_strip_into(strip, scratch, scratch_cap, &frame_alloc, &rgba_size); - g_object_unref(strip); - if (erc != 0) { rc = -1; break; } - - const uint8_t *rgba = (frame_alloc != NULL) ? frame_alloc : scratch; - - WebPPicture pic; - if (!WebPPictureInit(&pic)) { if (frame_alloc) free(frame_alloc); rc = -1; break; } - pic.width = width; - pic.height = page_height; - pic.use_argb = 1; - if (!WebPPictureImportRGBA(&pic, rgba, width * 4)) { - if (frame_alloc) { - free(frame_alloc); - } - WebPPictureFree(&pic); - rc = -1; - break; - } - if (frame_alloc) free(frame_alloc); - - if (!WebPAnimEncoderAdd(enc, &pic, timestamp, &config)) { - WebPPictureFree(&pic); rc = -1; break; - } - WebPPictureFree(&pic); - timestamp += delays[i]; - frames_added++; - } - free(delays); - - if (rc != 0 && frames_added == 0) { WebPAnimEncoderDelete(enc); return -1; } - if (frames_added == 0) { WebPAnimEncoderDelete(enc); return -1; } - - if (!WebPAnimEncoderAdd(enc, NULL, timestamp, NULL)) { - WebPAnimEncoderDelete(enc); - return -1; - } - - WebPData webp_data; - WebPDataInit(&webp_data); - if (!WebPAnimEncoderAssemble(enc, &webp_data)) { - WebPAnimEncoderDelete(enc); - return -1; - } - WebPAnimEncoderDelete(enc); - - if (copy_webp_data_to_malloc(&webp_data, out_buf, out_size) != 0) { - WebPDataClear(&webp_data); - return -1; - } - WebPDataClear(&webp_data); - return 0; -} - -static char fluxer_srgb_profile_path[64] = {0}; -static pthread_once_t fluxer_srgb_profile_once = PTHREAD_ONCE_INIT; - -static void fluxer_init_srgb_profile_path(void) { - char tmpl[] = "/tmp/fluxer-srgb-XXXXXX.icc"; - int fd = mkstemps(tmpl, 4); - if (fd < 0) return; - ssize_t n = write(fd, srgb_profile, srgb_profile_size); - close(fd); - if (n != (ssize_t)srgb_profile_size) { - unlink(tmpl); - return; - } - strncpy(fluxer_srgb_profile_path, tmpl, sizeof(fluxer_srgb_profile_path) - 1); -} - -static const char *fluxer_get_srgb_profile_path(void) { - pthread_once(&fluxer_srgb_profile_once, fluxer_init_srgb_profile_path); - return fluxer_srgb_profile_path[0] ? fluxer_srgb_profile_path : NULL; -} - -int fluxer_vips_image_is_hdr(VipsImage *image) { - if (image == NULL) return 0; - VipsInterpretation interp = vips_image_get_interpretation(image); - if (interp == VIPS_INTERPRETATION_scRGB) return 1; - - if (vips_image_get_typeof(image, VIPS_META_ICC_NAME) == 0) return 0; - const void *profile_data = NULL; - size_t profile_len = 0; - if (vips_image_get_blob(image, VIPS_META_ICC_NAME, &profile_data, &profile_len) != 0) { - vips_error_clear(); - return 0; - } - if (profile_data == NULL || profile_len < 128) return 0; - - const uint8_t *p = profile_data; - size_t scan_len = profile_len > 4096 ? 4096 : profile_len; - static const char *markers[] = { - "PQ", "HLG", "Rec. 2100", "Rec.2100", "ITU-R BT.2100", - "SMPTE ST 2084", "ST 2084", "Hybrid Log", - }; - for (size_t m = 0; m < sizeof(markers) / sizeof(markers[0]); m++) { - const char *needle = markers[m]; - size_t nl = strlen(needle); - if (nl > scan_len) continue; - for (size_t i = 0; i + nl <= scan_len; i++) { - if (memcmp(p + i, needle, nl) == 0) return 1; - } - if (nl * 2 + 1 > scan_len) continue; - for (size_t i = 0; i + nl * 2 <= scan_len; i++) { - int match = 1; - for (size_t j = 0; j < nl; j++) { - if (p[i + j * 2] != 0 || p[i + j * 2 + 1] != (uint8_t)needle[j]) { - match = 0; - break; - } - } - if (match) return 1; - } - } - return 0; -} - -int fluxer_vips_tone_map_hdr_to_sdr(VipsImage *in, VipsImage **out) { - if (in == NULL || out == NULL) return -1; - *out = NULL; - const char *target = fluxer_get_srgb_profile_path(); - if (target == NULL) return -1; - - int has_profile = vips_image_get_typeof(in, VIPS_META_ICC_NAME) != 0; - if (has_profile) { - if (vips_icc_transform(in, out, target, - "embedded", TRUE, - "intent", VIPS_INTENT_PERCEPTUAL, - NULL) != 0) { - vips_error_clear(); - return -1; - } - return 0; - } - if (vips_icc_transform(in, out, target, - "input_profile", target, - "intent", VIPS_INTENT_PERCEPTUAL, - NULL) != 0) { - vips_error_clear(); - return -1; - } - return 0; -} - - -#define FLUXER_HDR_PQ_LUT_SIZE 4096 -static float fluxer_pq_lut[FLUXER_HDR_PQ_LUT_SIZE]; -#define FLUXER_HDR_HLG_LUT_SIZE 4096 -static float fluxer_hlg_lut[FLUXER_HDR_HLG_LUT_SIZE]; -static pthread_once_t fluxer_hdr_lut_once = PTHREAD_ONCE_INIT; - -#define FLUXER_PQ_SDR_TARGET_NORM 0.0203f -#define FLUXER_HLG_SDR_TARGET_NORM 0.075f - -static float fluxer_pq_sdr_target_perc; -static float fluxer_hlg_sdr_target_perc; - -static inline float fluxer_pq_oetf(float l); - -static void fluxer_init_hdr_luts(void) { - const double m1 = 0.1593017578125; - const double m2 = 78.84375; - const double c1 = 0.8359375; - const double c2 = 18.8515625; - const double c3 = 18.6875; - for (int i = 0; i < FLUXER_HDR_PQ_LUT_SIZE; i++) { - double e = (double)i / (double)(FLUXER_HDR_PQ_LUT_SIZE - 1); - double ep = pow(e, 1.0 / m2); - double num = ep - c1; - if (num < 0.0) num = 0.0; - double den = c2 - c3 * ep; - double l = (den > 0.0) ? pow(num / den, 1.0 / m1) : 0.0; - if (l < 0.0) l = 0.0; - if (l > 1.0) l = 1.0; - fluxer_pq_lut[i] = (float)l; - } - const double a = 0.17883277; - const double b = 0.28466892; - const double c = 0.55991073; - const double gamma = 1.2; - for (int i = 0; i < FLUXER_HDR_HLG_LUT_SIZE; i++) { - double ep = (double)i / (double)(FLUXER_HDR_HLG_LUT_SIZE - 1); - double e_scene; - if (ep <= 0.5) { - e_scene = (ep * ep) / 3.0; - } else { - e_scene = (exp((ep - c) / a) + b) / 12.0; - } - if (e_scene < 0.0) e_scene = 0.0; - if (e_scene > 1.0) e_scene = 1.0; - double e_display = pow(e_scene, gamma); - if (e_display > 1.0) e_display = 1.0; - fluxer_hlg_lut[i] = (float)e_display; - } - fluxer_pq_sdr_target_perc = fluxer_pq_oetf(FLUXER_PQ_SDR_TARGET_NORM); - fluxer_hlg_sdr_target_perc = fluxer_pq_oetf(FLUXER_HLG_SDR_TARGET_NORM); -} - -static inline float fluxer_pq_inv_eotf(uint16_t code, int bit_depth) { - int idx; - if (bit_depth >= 12) { - idx = code & 0x0FFF; - } else { - idx = ((int)(code & 0x03FF)) << 2; - } - if (idx < 0) idx = 0; - if (idx >= FLUXER_HDR_PQ_LUT_SIZE) idx = FLUXER_HDR_PQ_LUT_SIZE - 1; - return fluxer_pq_lut[idx]; -} - -static inline float fluxer_hlg_inv_eotf(uint16_t code, int bit_depth) { - int idx; - if (bit_depth >= 12) { - idx = code & 0x0FFF; - } else { - idx = ((int)(code & 0x03FF)) << 2; - } - if (idx < 0) idx = 0; - if (idx >= FLUXER_HDR_HLG_LUT_SIZE) idx = FLUXER_HDR_HLG_LUT_SIZE - 1; - return fluxer_hlg_lut[idx]; -} - -static inline float fluxer_bt2390_eetf_perceptual(float x, float max_lum) { - if (x <= 0.0f) return 0.0f; - if (max_lum >= 1.0f) return (x > 1.0f) ? 1.0f : x; - float ks = 1.5f * max_lum - 0.5f; - if (x < ks) return x; - if (x >= 1.0f) return max_lum; - float t = (x - ks) / (1.0f - ks); - float t2 = t * t; - float t3 = t2 * t; - float h00 = 2.0f * t3 - 3.0f * t2 + 1.0f; - float h10 = t3 - 2.0f * t2 + t; - float h01 = -2.0f * t3 + 3.0f * t2; - float p = h00 * ks + h10 * (1.0f - ks) + h01 * max_lum; - if (p > max_lum) p = max_lum; - if (p < 0.0f) p = 0.0f; - return p; -} - -static inline float fluxer_pq_oetf(float l) { - if (l <= 0.0f) return 0.0f; - if (l >= 1.0f) l = 1.0f; - const float m1 = 0.1593017578125f; - const float m2 = 78.84375f; - const float c1 = 0.8359375f; - const float c2 = 18.8515625f; - const float c3 = 18.6875f; - float lm1 = powf(l, m1); - float num = c1 + c2 * lm1; - float den = 1.0f + c3 * lm1; - return powf(num / den, m2); -} - -static inline float fluxer_srgb_oetf(float e) { - if (e <= 0.0f) return 0.0f; - if (e >= 1.0f) return 1.0f; - if (e <= 0.0031308f) return 12.92f * e; - return 1.055f * powf(e, 1.0f / 2.4f) - 0.055f; -} - -static inline uint8_t fluxer_quantize8(float v) { - if (v <= 0.0f) return 0; - if (v >= 1.0f) return 255; - int q = (int)(v * 255.0f + 0.5f); - if (q < 0) return 0; - if (q > 255) return 255; - return (uint8_t)q; -} - -static inline void fluxer_bt2020_to_bt709_linear(float r, float g, float b, - float *or_, float *og, float *ob) { - float r_ = 1.6605f * r - 0.5876f * g - 0.0728f * b; - float g_ = -0.1246f * r + 1.1329f * g - 0.0083f * b; - float b_ = -0.0182f * r - 0.1006f * g + 1.1187f * b; - *or_ = r_; - *og = g_; - *ob = b_; -} - -static inline void fluxer_hdr_pipeline_pixel(float r, float g, float b, - float sdr_target_norm, - float sdr_target_perceptual, - int do_gamut_conv, - uint8_t *out_rgb) { - float m = r; - if (g > m) m = g; - if (b > m) m = b; - if (m <= 0.0f) { - out_rgb[0] = out_rgb[1] = out_rgb[2] = 0; - return; - } - float m_perc = fluxer_pq_oetf(m); - float m_perc_mapped = fluxer_bt2390_eetf_perceptual(m_perc, sdr_target_perceptual); - float m_mapped; - { - const float m1 = 0.1593017578125f; - const float m2 = 78.84375f; - const float c1 = 0.8359375f; - const float c2 = 18.8515625f; - const float c3 = 18.6875f; - float ep = powf(m_perc_mapped, 1.0f / m2); - float num = ep - c1; - if (num < 0.0f) num = 0.0f; - float den = c2 - c3 * ep; - m_mapped = (den > 0.0f) ? powf(num / den, 1.0f / m1) : 0.0f; - if (m_mapped < 0.0f) m_mapped = 0.0f; - } - float scale = (m_mapped / m) / sdr_target_norm; - (void)sdr_target_perceptual; - float dr = r * scale; - float dg = g * scale; - float db = b * scale; - if (dr < 0.0f) dr = 0.0f; - if (dg < 0.0f) dg = 0.0f; - if (db < 0.0f) db = 0.0f; - if (dr > 1.0f) dr = 1.0f; - if (dg > 1.0f) dg = 1.0f; - if (db > 1.0f) db = 1.0f; - - float lr = dr, lg = dg, lb = db; - if (do_gamut_conv) { - fluxer_bt2020_to_bt709_linear(dr, dg, db, &lr, &lg, &lb); - } - if (lr < 0.0f) lr = 0.0f; - if (lg < 0.0f) lg = 0.0f; - if (lb < 0.0f) lb = 0.0f; - if (lr > 1.0f) lr = 1.0f; - if (lg > 1.0f) lg = 1.0f; - if (lb > 1.0f) lb = 1.0f; - - out_rgb[0] = fluxer_quantize8(fluxer_srgb_oetf(lr)); - out_rgb[1] = fluxer_quantize8(fluxer_srgb_oetf(lg)); - out_rgb[2] = fluxer_quantize8(fluxer_srgb_oetf(lb)); -} - -int fluxer_hdr_to_sdr_test(uint16_t r, uint16_t g, uint16_t b, - int bit_depth, int transfer, - uint8_t out_rgb[3]) { - if (out_rgb == NULL) return -1; - if (bit_depth != 10 && bit_depth != 12) return -1; - pthread_once(&fluxer_hdr_lut_once, fluxer_init_hdr_luts); - float lr, lg, lb, target_norm, target_perc; - if (transfer == 16) { - lr = fluxer_pq_inv_eotf(r, bit_depth); - lg = fluxer_pq_inv_eotf(g, bit_depth); - lb = fluxer_pq_inv_eotf(b, bit_depth); - target_norm = FLUXER_PQ_SDR_TARGET_NORM; - target_perc = fluxer_pq_sdr_target_perc; - } else if (transfer == 18) { - lr = fluxer_hlg_inv_eotf(r, bit_depth); - lg = fluxer_hlg_inv_eotf(g, bit_depth); - lb = fluxer_hlg_inv_eotf(b, bit_depth); - target_norm = FLUXER_HLG_SDR_TARGET_NORM; - target_perc = fluxer_hlg_sdr_target_perc; - } else { - return -1; - } - fluxer_hdr_pipeline_pixel(lr, lg, lb, target_norm, target_perc,1, out_rgb); - return 0; -} - -static unsigned char fluxer_ascii_lower(unsigned char c) { - if (c >= 'A' && c <= 'Z') return (unsigned char)(c + ('a' - 'A')); - return c; -} - -static int fluxer_ascii_contains_folded(const char *haystack, const char *needle) { - if (haystack == NULL || needle == NULL || needle[0] == '\0') return 0; - size_t needle_len = strlen(needle); - for (const char *p = haystack; *p != '\0'; p++) { - size_t i = 0; - while (i < needle_len && p[i] != '\0' && - fluxer_ascii_lower((unsigned char)p[i]) == - fluxer_ascii_lower((unsigned char)needle[i])) { - i++; - } - if (i == needle_len) return 1; - } - return 0; -} - -static int heif_aux_type_is_hdr_gain_map(const char *type) { - if (type == NULL || type[0] == '\0') return 0; - - if (fluxer_ascii_contains_folded(type, "hdrgainmap") || - fluxer_ascii_contains_folded(type, "hdr_gain_map") || - fluxer_ascii_contains_folded(type, "hdr-gain-map")) { - return 1; - } - if (fluxer_ascii_contains_folded(type, "gainmap") && - (fluxer_ascii_contains_folded(type, "hdr") || - fluxer_ascii_contains_folded(type, "21496") || - fluxer_ascii_contains_folded(type, "iso"))) { - return 1; - } - return 0; -} - -int fluxer_heif_aux_type_is_hdr_gain_map_for_test(const char *type) { - return heif_aux_type_is_hdr_gain_map(type); -} - -static int heif_handle_has_hdr_gain_map(struct heif_image_handle *handle) { - if (handle == NULL) return 0; - const int filter = LIBHEIF_AUX_IMAGE_FILTER_OMIT_ALPHA | - LIBHEIF_AUX_IMAGE_FILTER_OMIT_DEPTH; - int count = heif_image_handle_get_number_of_auxiliary_images(handle, filter); - if (count <= 0) return 0; - if (count > 4096) return 0; - - heif_item_id *ids = (heif_item_id *)calloc((size_t)count, sizeof(heif_item_id)); - if (ids == NULL) return 0; - int got = heif_image_handle_get_list_of_auxiliary_image_IDs(handle, filter, ids, count); - if (got <= 0) { - free(ids); - return 0; - } - if (got > count) got = count; - - int found = 0; - for (int i = 0; i < got; i++) { - struct heif_image_handle *aux_handle = NULL; - struct heif_error err = heif_image_handle_get_auxiliary_image_handle(handle, ids[i], &aux_handle); - if (err.code != heif_error_Ok || aux_handle == NULL) continue; - - const char *aux_type = NULL; - err = heif_image_handle_get_auxiliary_type(aux_handle, &aux_type); - if (err.code == heif_error_Ok && aux_type != NULL) { - found = heif_aux_type_is_hdr_gain_map(aux_type); - heif_image_handle_release_auxiliary_type(aux_handle, &aux_type); - } - heif_image_handle_release(aux_handle); - if (found) break; - } - - free(ids); - return found; -} - -static int decode_heif_image_to_sdr_rgba8(struct heif_image_handle *handle, - uint8_t *dst, - size_t dst_cap, - int width, - int height, - int *out_was_hdr) { - if (handle == NULL || dst == NULL || width <= 0 || height <= 0) return -1; - if (dst_cap < (size_t)width * (size_t)height * 4u) return -1; - if (out_was_hdr) *out_was_hdr = 0; - - int transfer = 0; - int primaries = 0; - { - struct heif_color_profile_nclx *nclx = NULL; - if (heif_image_handle_get_nclx_color_profile(handle, &nclx).code == heif_error_Ok && nclx != NULL) { - transfer = (int)nclx->transfer_characteristics; - primaries = (int)nclx->color_primaries; - heif_nclx_color_profile_free(nclx); - } - } - int is_hdr = (transfer == 16 || transfer == 18); - - if (!is_hdr) { - struct heif_image *img = NULL; - struct heif_decoding_options *opts = heif_decoding_options_alloc(); - struct heif_error derr = heif_decode_image(handle, &img, - heif_colorspace_RGB, - heif_chroma_interleaved_RGBA, opts); - if (opts) heif_decoding_options_free(opts); - if (derr.code != heif_error_Ok || img == NULL) return -1; - - int img_w = heif_image_get_primary_width(img); - int img_h = heif_image_get_primary_height(img); - int stride = 0; - const uint8_t *plane = heif_image_get_plane_readonly(img, heif_channel_interleaved, &stride); - int copied = 0; - if (plane != NULL && stride > 0 && img_w > 0 && img_h > 0) { - int copy_w = img_w < width ? img_w : width; - int copy_h = img_h < height ? img_h : height; - size_t row_bytes = (size_t)width * 4u; - for (int y = 0; y < copy_h; y++) { - memcpy(dst + (size_t)y * row_bytes, - plane + (size_t)y * (size_t)stride, - (size_t)copy_w * 4u); - } - copied = copy_h; - } - heif_image_release(img); - return copied; - } - - pthread_once(&fluxer_hdr_lut_once, fluxer_init_hdr_luts); - if (out_was_hdr) *out_was_hdr = 1; - - int luma_bpp = heif_image_handle_get_luma_bits_per_pixel(handle); - if (luma_bpp != 10 && luma_bpp != 12) { - if (out_was_hdr) *out_was_hdr = 0; - struct heif_image *img = NULL; - struct heif_decoding_options *opts = heif_decoding_options_alloc(); - struct heif_error derr = heif_decode_image(handle, &img, - heif_colorspace_RGB, - heif_chroma_interleaved_RGBA, opts); - if (opts) heif_decoding_options_free(opts); - if (derr.code != heif_error_Ok || img == NULL) return -1; - int img_w = heif_image_get_primary_width(img); - int img_h = heif_image_get_primary_height(img); - int stride = 0; - const uint8_t *plane = heif_image_get_plane_readonly(img, heif_channel_interleaved, &stride); - int copied = 0; - if (plane != NULL && stride > 0) { - int copy_w = img_w < width ? img_w : width; - int copy_h = img_h < height ? img_h : height; - size_t row_bytes = (size_t)width * 4u; - for (int y = 0; y < copy_h; y++) { - memcpy(dst + (size_t)y * row_bytes, - plane + (size_t)y * (size_t)stride, - (size_t)copy_w * 4u); - } - copied = copy_h; - } - heif_image_release(img); - return copied; - } - - struct heif_image *img = NULL; - struct heif_decoding_options *opts = heif_decoding_options_alloc(); - struct heif_error derr = heif_decode_image(handle, &img, - heif_colorspace_RGB, - heif_chroma_interleaved_RRGGBBAA_LE, opts); - if (opts) heif_decoding_options_free(opts); - if (derr.code != heif_error_Ok || img == NULL) return -1; - - int img_w = heif_image_get_primary_width(img); - int img_h = heif_image_get_primary_height(img); - int stride = 0; - const uint8_t *plane = heif_image_get_plane_readonly(img, heif_channel_interleaved, &stride); - int copied = 0; - if (plane != NULL && stride > 0 && img_w > 0 && img_h > 0) { - int copy_w = img_w < width ? img_w : width; - int copy_h = img_h < height ? img_h : height; - size_t row_bytes = (size_t)width * 4u; - const float target_norm = (transfer == 16) - ? FLUXER_PQ_SDR_TARGET_NORM - : FLUXER_HLG_SDR_TARGET_NORM; - const float target_perc = (transfer == 16) - ? fluxer_pq_sdr_target_perc - : fluxer_hlg_sdr_target_perc; - const int do_gamut_conv = (primaries == 9); - - const int mask = (1 << luma_bpp) - 1; - - for (int y = 0; y < copy_h; y++) { - const uint16_t *src_row = (const uint16_t *)(plane + (size_t)y * (size_t)stride); - uint8_t *dst_row = dst + (size_t)y * row_bytes; - for (int x = 0; x < copy_w; x++) { - uint16_t r16 = src_row[(size_t)x * 4 + 0]; - uint16_t g16 = src_row[(size_t)x * 4 + 1]; - uint16_t b16 = src_row[(size_t)x * 4 + 2]; - uint16_t a16 = src_row[(size_t)x * 4 + 3]; - uint16_t rc = (uint16_t)(r16 & mask); - uint16_t gc = (uint16_t)(g16 & mask); - uint16_t bc = (uint16_t)(b16 & mask); - float lr, lg, lb; - if (transfer == 16) { - lr = fluxer_pq_inv_eotf(rc, luma_bpp); - lg = fluxer_pq_inv_eotf(gc, luma_bpp); - lb = fluxer_pq_inv_eotf(bc, luma_bpp); - } else { - lr = fluxer_hlg_inv_eotf(rc, luma_bpp); - lg = fluxer_hlg_inv_eotf(gc, luma_bpp); - lb = fluxer_hlg_inv_eotf(bc, luma_bpp); - } - uint8_t out3[3]; - fluxer_hdr_pipeline_pixel(lr, lg, lb, target_norm, target_perc, - do_gamut_conv, out3); - dst_row[(size_t)x * 4 + 0] = out3[0]; - dst_row[(size_t)x * 4 + 1] = out3[1]; - dst_row[(size_t)x * 4 + 2] = out3[2]; - uint16_t ac = (uint16_t)(a16 & mask); - dst_row[(size_t)x * 4 + 3] = (uint8_t)((ac * 255 + (mask >> 1)) / mask); - } - } - copied = copy_h; - } - heif_image_release(img); - return copied; -} - - -typedef struct { - const uint8_t *data; - size_t len; -} bmff_buf; - -static uint32_t bmff_read_u32(const uint8_t *p) { - return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | - ((uint32_t)p[2] << 8) | (uint32_t)p[3]; -} -static uint16_t bmff_read_u16(const uint8_t *p) { - return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]); -} -static uint64_t bmff_read_u64(const uint8_t *p) { - return ((uint64_t)bmff_read_u32(p) << 32) | (uint64_t)bmff_read_u32(p + 4); -} - -typedef int (*bmff_box_cb)(const uint8_t *payload, size_t payload_len, void *user); - -static int bmff_walk(const uint8_t *data, size_t start, size_t end, - const char *target, bmff_box_cb cb, void *user, - int meta_full_box, int depth) { - if (depth > 32) return 0; - size_t off = start; - if (meta_full_box) { - if (off + 4 > end) return 0; - off += 4; - } - while (off + 8 <= end) { - uint32_t size32 = bmff_read_u32(data + off); - const uint8_t *btype = data + off + 4; - uint64_t box_size = size32; - size_t header_len = 8; - if (size32 == 1) { - if (off + 16 > end) return 0; - box_size = bmff_read_u64(data + off + 8); - header_len = 16; - } else if (size32 == 0) { - box_size = (uint64_t)(end - off); - } - if (box_size < header_len || box_size > (uint64_t)(end - off)) return 0; - size_t child_start = off + header_len; - size_t child_end = (size_t)(off + box_size); - if (child_end < child_start) return 0; - - if (memcmp(btype, target, 4) == 0) { - int r = cb(data + child_start, child_end - child_start, user); - if (r) return r; - } - if (memcmp(btype, "moov", 4) == 0 || - memcmp(btype, "trak", 4) == 0 || - memcmp(btype, "mdia", 4) == 0 || - memcmp(btype, "minf", 4) == 0 || - memcmp(btype, "stbl", 4) == 0 || - memcmp(btype, "edts", 4) == 0 || - memcmp(btype, "dinf", 4) == 0) { - int r = bmff_walk(data, child_start, child_end, target, cb, user, 0, depth + 1); - if (r) return r; - } else if (memcmp(btype, "meta", 4) == 0) { - int r = bmff_walk(data, child_start, child_end, target, cb, user, 1, depth + 1); - if (r) return r; - } - - off = child_end; - } - return 0; -} - -typedef struct { - int have_handler; - uint8_t handler_type[4]; - int have_mdhd; - uint32_t timescale; - int have_stts; - const uint8_t *stts_payload; - size_t stts_len; -} trak_info; - -static int cb_collect_mdhd(const uint8_t *payload, size_t len, void *user) { - trak_info *t = (trak_info *)user; - if (len < 1) return 0; - uint8_t version = payload[0]; - uint32_t timescale; - if (version == 0) { - if (len < 16) return 0; - timescale = bmff_read_u32(payload + 12); - } else if (version == 1) { - if (len < 24) return 0; - timescale = bmff_read_u32(payload + 20); - } else { - return 0; - } - t->timescale = timescale; - t->have_mdhd = timescale > 0; - return 0; -} - -static int cb_collect_hdlr(const uint8_t *payload, size_t len, void *user) { - trak_info *t = (trak_info *)user; - if (len < 12) return 0; - memcpy(t->handler_type, payload + 8, 4); - t->have_handler = 1; - return 0; -} - -static int cb_collect_stts(const uint8_t *payload, size_t len, void *user) { - trak_info *t = (trak_info *)user; - if (len < 8) return 0; - t->stts_payload = payload; - t->stts_len = len; - t->have_stts = 1; - return 0; -} - -typedef struct { - trak_info *traks; - size_t count; - size_t cap; -} trak_list; - -static int cb_each_trak(const uint8_t *payload, size_t len, void *user) { - trak_list *list = (trak_list *)user; - if (list->count >= list->cap) { - size_t new_cap = list->cap == 0 ? 4 : list->cap * 2; - trak_info *grown = (trak_info *)realloc(list->traks, new_cap * sizeof(trak_info)); - if (grown == NULL) return -1; - list->traks = grown; - list->cap = new_cap; - } - trak_info *t = &list->traks[list->count]; - memset(t, 0, sizeof(*t)); - bmff_walk(payload, 0, len, "mdhd", cb_collect_mdhd, t, 0, 0); - bmff_walk(payload, 0, len, "hdlr", cb_collect_hdlr, t, 0, 0); - bmff_walk(payload, 0, len, "stts", cb_collect_stts, t, 0, 0); - list->count++; - return 0; -} - -static int parse_isobmff_track_delays(const void *buf, size_t len, - int **out_delays_ms, int *out_n_samples) { - if (out_delays_ms) *out_delays_ms = NULL; - if (out_n_samples) *out_n_samples = 0; - if (buf == NULL || len < 16 || out_delays_ms == NULL || out_n_samples == NULL) { - return -1; - } - - const uint8_t *data = (const uint8_t *)buf; - trak_list list = { NULL, 0, 0 }; - if (bmff_walk(data, 0, len, "trak", cb_each_trak, &list, 0, 0) != 0) { - free(list.traks); - return -1; - } - if (list.count == 0) { - free(list.traks); - return -1; - } - - trak_info *picked = NULL; - for (size_t i = 0; i < list.count; i++) { - trak_info *t = &list.traks[i]; - if (!t->have_handler || !t->have_mdhd || !t->have_stts) continue; - int is_pict = memcmp(t->handler_type, "pict", 4) == 0; - int is_vide = memcmp(t->handler_type, "vide", 4) == 0; - if (is_pict || is_vide) { picked = t; break; } - } - if (picked == NULL) { - free(list.traks); - return -1; - } - - if (picked->stts_len < 8) { free(list.traks); return -1; } - uint32_t entry_count = bmff_read_u32(picked->stts_payload + 4); - if (entry_count == 0) { free(list.traks); return -1; } - if ((size_t)8 + (size_t)entry_count * 8u > picked->stts_len) { - free(list.traks); - return -1; - } - - uint64_t total_samples = 0; - for (uint32_t i = 0; i < entry_count; i++) { - uint32_t cnt = bmff_read_u32(picked->stts_payload + 8 + (size_t)i * 8u); - total_samples += cnt; - if (total_samples > 16384) { - free(list.traks); - return -1; - } - } - if (total_samples == 0) { free(list.traks); return -1; } - - uint32_t timescale = picked->timescale; - if (timescale == 0) { free(list.traks); return -1; } - int *delays = (int *)malloc((size_t)total_samples * sizeof(int)); - if (delays == NULL) { free(list.traks); return -1; } - - size_t out_idx = 0; - for (uint32_t i = 0; i < entry_count; i++) { - uint32_t cnt = bmff_read_u32(picked->stts_payload + 8 + (size_t)i * 8u); - uint32_t delta = bmff_read_u32(picked->stts_payload + 12 + (size_t)i * 8u); - int delta_ms = (int)(((uint64_t)delta * 1000ULL) / (uint64_t)timescale); - if (delta_ms < 0) delta_ms = 0; - for (uint32_t k = 0; k < cnt; k++) { - delays[out_idx++] = delta_ms; - } - } - - free(list.traks); - *out_delays_ms = delays; - *out_n_samples = (int)total_samples; - return 0; -} - -static int cb_find_iinf_tmap(const uint8_t *payload, size_t len, void *user) { - int *found = (int *)user; - if (found == NULL || len < 6) return 0; - - uint8_t version = payload[0]; - size_t off = 4; - uint32_t entry_count = 0; - if (version == 0) { - if (len < off + 2) return 0; - entry_count = bmff_read_u16(payload + off); - off += 2; - } else { - if (len < off + 4) return 0; - entry_count = bmff_read_u32(payload + off); - off += 4; - } - - for (uint32_t entry = 0; entry < entry_count && off + 8 <= len; entry++) { - uint32_t size32 = bmff_read_u32(payload + off); - const uint8_t *btype = payload + off + 4; - uint64_t box_size = size32; - size_t header_len = 8; - if (size32 == 1) { - if (off + 16 > len) return 0; - box_size = bmff_read_u64(payload + off + 8); - header_len = 16; - } else if (size32 == 0) { - box_size = (uint64_t)(len - off); - } - if (box_size < header_len || box_size > (uint64_t)(len - off)) return 0; - - size_t child_start = off + header_len; - size_t child_end = (size_t)(off + box_size); - if (child_end < child_start) return 0; - if (memcmp(btype, "infe", 4) == 0 && child_end > child_start) { - const uint8_t *infe = payload + child_start; - size_t infe_len = child_end - child_start; - if (infe_len >= 12) { - uint8_t infe_version = infe[0]; - size_t pos = 4; - if (infe_version == 2) { - if (infe_len < pos + 2 + 2 + 4) return 0; - pos += 2; - } else if (infe_version == 3) { - if (infe_len < pos + 4 + 2 + 4) return 0; - pos += 4; - } else { - off = child_end; - continue; - } - pos += 2; - if (pos + 4 <= infe_len && memcmp(infe + pos, "tmap", 4) == 0) { - *found = 1; - return 1; - } - } - } - off = child_end; - } - return 0; -} - -static int parse_isobmff_has_tmap_item(const void *buf, size_t len) { - if (buf == NULL || len < 16) return 0; - int found = 0; - const uint8_t *data = (const uint8_t *)buf; - bmff_walk(data, 0, len, "iinf", cb_find_iinf_tmap, &found, 0, 0); - return found; -} - -int fluxer_heif_has_tmap_item_for_test(const void *buf, size_t len) { - return parse_isobmff_has_tmap_item(buf, len); -} - -int fluxer_avif_parse_track_delays_for_test(const void *buf, size_t len, - int **out_delays_ms, int *out_n_samples) { - return parse_isobmff_track_delays(buf, len, out_delays_ms, out_n_samples); -} - -void fluxer_avif_free_delays(int *delays) { - free(delays); -} - -void fluxer_vips_set_anim_metadata_for_test(VipsImage *image, int page_height, - int n_pages, const int *delays_ms) { - if (image == NULL) return; - if (page_height > 0) vips_image_set_int(image, "page-height", page_height); - if (n_pages > 1) vips_image_set_int(image, "n-pages", n_pages); - if (delays_ms != NULL && n_pages > 0) { - vips_image_set_array_int(image, "delay", delays_ms, n_pages); - } -} - -int fluxer_heif_decode_animated(const void *buf, size_t len, VipsImage **out, - int n_max_pages, size_t max_total_pixels) { - return fluxer_heif_decode_animated_ex2(buf, len, out, n_max_pages, max_total_pixels, NULL, NULL); -} - -int fluxer_heif_decode_animated_ex(const void *buf, size_t len, VipsImage **out, - int n_max_pages, size_t max_total_pixels, - int *was_hdr) { - return fluxer_heif_decode_animated_ex2(buf, len, out, n_max_pages, max_total_pixels, was_hdr, NULL); -} - -int fluxer_heif_decode_animated_ex2(const void *buf, size_t len, VipsImage **out, - int n_max_pages, size_t max_total_pixels, - int *was_hdr, int *had_hdr_gain_map) { - if (buf == NULL || len == 0 || out == NULL) return -1; - *out = NULL; - if (was_hdr) *was_hdr = 0; - if (had_hdr_gain_map) *had_hdr_gain_map = 0; - if (had_hdr_gain_map && parse_isobmff_has_tmap_item(buf, len)) { - *had_hdr_gain_map = 1; - } - - struct heif_context *ctx = heif_context_alloc(); - if (ctx == NULL) return -1; - - struct heif_error err = heif_context_read_from_memory_without_copy(ctx, buf, len, NULL); - if (err.code != heif_error_Ok) { - heif_context_free(ctx); - return -1; - } - - int total_imgs = heif_context_get_number_of_top_level_images(ctx); - if (total_imgs <= 0) { - heif_context_free(ctx); - return -1; - } - int n_pages = total_imgs; - if (n_max_pages > 0 && n_max_pages < n_pages) n_pages = n_max_pages; - if (n_pages > 1024) n_pages = 1024; - - heif_item_id *ids = (heif_item_id *)calloc((size_t)total_imgs, sizeof(heif_item_id)); - if (ids == NULL) { - heif_context_free(ctx); - return -1; - } - int got_ids = heif_context_get_list_of_top_level_image_IDs(ctx, ids, total_imgs); - if (got_ids <= 0) { - free(ids); - heif_context_free(ctx); - return -1; - } - - int canvas_w = 0; - int canvas_h = 0; - { - struct heif_image_handle *handle = NULL; - if (heif_context_get_image_handle(ctx, ids[0], &handle).code != heif_error_Ok || handle == NULL) { - free(ids); - heif_context_free(ctx); - return -1; - } - canvas_w = heif_image_handle_get_width(handle); - canvas_h = heif_image_handle_get_height(handle); - heif_image_handle_release(handle); - } - if (canvas_w <= 0 || canvas_h <= 0) { - free(ids); - heif_context_free(ctx); - return -1; - } - if (max_total_pixels > 0) { - size_t per_frame = (size_t)canvas_w * (size_t)canvas_h; - if (per_frame == 0 || per_frame > max_total_pixels / (size_t)n_pages) { - free(ids); - heif_context_free(ctx); - return -1; - } - } - - size_t row_bytes = (size_t)canvas_w * 4u; - size_t per_page_bytes = row_bytes * (size_t)canvas_h; - size_t total_bytes = per_page_bytes * (size_t)n_pages; - uint8_t *stacked = (uint8_t *)g_try_malloc0(total_bytes); - if (stacked == NULL) { - free(ids); - heif_context_free(ctx); - return -1; - } - - int decoded = 0; - for (int i = 0; i < n_pages; i++) { - struct heif_image_handle *handle = NULL; - if (heif_context_get_image_handle(ctx, ids[i], &handle).code != heif_error_Ok || handle == NULL) { - continue; - } - uint8_t *dst = stacked + (size_t)i * per_page_bytes; - int page_was_hdr = 0; - if (had_hdr_gain_map && *had_hdr_gain_map == 0 && - heif_handle_has_hdr_gain_map(handle)) { - *had_hdr_gain_map = 1; - } - int copied = decode_heif_image_to_sdr_rgba8(handle, dst, per_page_bytes, - canvas_w, canvas_h, - &page_was_hdr); - if (copied > 0) { - decoded++; - if (was_hdr && page_was_hdr) *was_hdr = 1; - } - heif_image_handle_release(handle); - } - - free(ids); - heif_context_free(ctx); - - if (decoded == 0) { - g_free(stacked); - return -1; - } - - VipsImage *image = vips_image_new_from_memory(stacked, total_bytes, - canvas_w, - canvas_h * decoded, - 4, - VIPS_FORMAT_UCHAR); - if (image == NULL) { - g_free(stacked); - return -1; - } - g_signal_connect_swapped(image, "postclose", G_CALLBACK(g_free), stacked); - - vips_image_set_int(image, "page-height", canvas_h); - if (decoded > 1) vips_image_set_int(image, "n-pages", decoded); - - if (decoded > 1) { - int *delays = NULL; - int n_samples = 0; - if (parse_isobmff_track_delays(buf, len, &delays, &n_samples) == 0 && - delays != NULL && n_samples >= decoded) { - vips_image_set_array_int(image, "delay", delays, decoded); - free(delays); - } else { - if (delays != NULL) free(delays); - g_warning("fluxer_heif_decode_animated_ex: stts parse failed " - "(decoded=%d, n_samples=%d) — falling back to uniform " - "100ms delay", decoded, n_samples); - } - } - - *out = image; - return 0; -} - -int fluxer_av_probe( - const void *media_data, - size_t media_len, - int *out_has_video, - int *out_has_audio, - double *out_duration_seconds -) { - if (media_data == NULL || media_len == 0 || - out_has_video == NULL || out_has_audio == NULL || out_duration_seconds == NULL) { - return -1; - } - *out_has_video = 0; - *out_has_audio = 0; - *out_duration_seconds = 0.0; - - int rc = -1; - struct ff_mem_reader reader = { .data = (const uint8_t *)media_data, .len = media_len, .offset = 0 }; - unsigned char *avio_buffer = NULL; - AVIOContext *avio = NULL; - AVFormatContext *in_fmt = NULL; - - avio_buffer = av_malloc(64 * 1024); - if (avio_buffer == NULL) goto cleanup; - avio = avio_alloc_context(avio_buffer, 64 * 1024, 0, &reader, - ff_mem_read_packet, NULL, ff_mem_seek); - if (avio == NULL) { - av_free(avio_buffer); - avio_buffer = NULL; - goto cleanup; - } - in_fmt = avformat_alloc_context(); - if (in_fmt == NULL) goto cleanup; - in_fmt->pb = avio; - in_fmt->flags |= AVFMT_FLAG_CUSTOM_IO; - in_fmt->probesize = 5 * 1024 * 1024; - in_fmt->max_analyze_duration = 5 * AV_TIME_BASE; - if (avformat_open_input(&in_fmt, NULL, NULL, NULL) < 0) goto cleanup; - if (avformat_find_stream_info(in_fmt, NULL) < 0) goto cleanup; - - for (unsigned i = 0; i < in_fmt->nb_streams; i++) { - AVStream *st = in_fmt->streams[i]; - if (st == NULL || st->codecpar == NULL) continue; - int is_attached_picture = 0; -#ifdef AV_DISPOSITION_ATTACHED_PIC - if ((st->disposition & AV_DISPOSITION_ATTACHED_PIC) != 0) is_attached_picture = 1; -#endif -#ifdef AV_DISPOSITION_TIMED_THUMBNAILS - if ((st->disposition & AV_DISPOSITION_TIMED_THUMBNAILS) != 0) is_attached_picture = 1; -#endif -#ifdef AV_DISPOSITION_STILL_IMAGE - if ((st->disposition & AV_DISPOSITION_STILL_IMAGE) != 0) is_attached_picture = 1; -#endif - if (st->codecpar->codec_type == AVMEDIA_TYPE_VIDEO && !is_attached_picture) *out_has_video = 1; - if (st->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) *out_has_audio = 1; - } - if (in_fmt->duration > 0) { - *out_duration_seconds = (double)in_fmt->duration / (double)AV_TIME_BASE; - } - if (*out_has_video || *out_has_audio) rc = 0; - -cleanup: - if (in_fmt != NULL) avformat_close_input(&in_fmt); - if (avio != NULL) { - if (avio->buffer != NULL) { - av_free(avio->buffer); - avio->buffer = NULL; - } - avio_context_free(&avio); - } else if (avio_buffer != NULL) { - av_free(avio_buffer); - } - return rc; -} - -#define FLUXER_MAX_VIDEO_PACKETS_FOR_NSFW 512 - -void fluxer_nsfw_frames_free(struct fluxer_nsfw_frame_out *frames, size_t n) { - if (frames == NULL) return; - for (size_t i = 0; i < n; i++) { - if (frames[i].data != NULL) { - g_free(frames[i].data); - frames[i].data = NULL; - } - frames[i].len = 0; - } -} - -int fluxer_av_extract_frames_for_nsfw( - const void *media_data, - size_t media_len, - const double *timestamps_secs, - size_t n_timestamps, - struct fluxer_nsfw_frame_out *out_frames -) { - if (media_data == NULL || media_len == 0 || timestamps_secs == NULL || - out_frames == NULL || n_timestamps == 0) { - return -1; - } - for (size_t i = 0; i < n_timestamps; i++) { - out_frames[i].data = NULL; - out_frames[i].len = 0; - } - - struct ff_mem_reader reader = { .data = (const uint8_t *)media_data, .len = media_len, .offset = 0 }; - unsigned char *avio_buffer = NULL; - AVIOContext *avio = NULL; - AVFormatContext *in_fmt = NULL; - AVCodecContext *dec_ctx = NULL; - AVPacket *packet = NULL; - AVFrame *frame = NULL; - int produced = 0; - - avio_buffer = av_malloc(64 * 1024); - if (avio_buffer == NULL) goto cleanup; - avio = avio_alloc_context(avio_buffer, 64 * 1024, 0, &reader, - ff_mem_read_packet, NULL, ff_mem_seek); - if (avio == NULL) { - av_free(avio_buffer); - avio_buffer = NULL; - goto cleanup; - } - in_fmt = avformat_alloc_context(); - if (in_fmt == NULL) goto cleanup; - in_fmt->pb = avio; - in_fmt->flags |= AVFMT_FLAG_CUSTOM_IO; - in_fmt->probesize = 5 * 1024 * 1024; - in_fmt->max_analyze_duration = 5 * AV_TIME_BASE; - if (avformat_open_input(&in_fmt, NULL, NULL, NULL) < 0) goto cleanup; - if (avformat_find_stream_info(in_fmt, NULL) < 0) goto cleanup; - - const AVCodec *decoder = NULL; - int stream_index = av_find_best_stream(in_fmt, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0); - if (stream_index < 0) goto cleanup; - AVStream *in_stream = in_fmt->streams[stream_index]; - if (in_stream == NULL || in_stream->codecpar == NULL) goto cleanup; - int vw = in_stream->codecpar->width; - int vh = in_stream->codecpar->height; - if (vw <= 0 || vh <= 0 || vw > 16384 || vh > 16384) goto cleanup; - if (decoder == NULL) decoder = avcodec_find_decoder(in_stream->codecpar->codec_id); - if (decoder == NULL) goto cleanup; - dec_ctx = avcodec_alloc_context3(decoder); - if (dec_ctx == NULL) goto cleanup; - if (avcodec_parameters_to_context(dec_ctx, in_stream->codecpar) < 0) goto cleanup; - if (avcodec_open2(dec_ctx, decoder, NULL) < 0) goto cleanup; - - packet = av_packet_alloc(); - frame = av_frame_alloc(); - if (packet == NULL || frame == NULL) goto cleanup; - - double tb_num = (double)in_stream->time_base.num; - double tb_den = (double)in_stream->time_base.den; - int have_tb = (tb_num > 0.0 && tb_den > 0.0); - - for (size_t i = 0; i < n_timestamps; i++) { - double ts_s = timestamps_secs[i]; - if (!isfinite(ts_s) || ts_s < 0.0) ts_s = 0.0; - - int64_t target_pts; - if (have_tb) { - target_pts = (int64_t)(ts_s / tb_num * tb_den); - } else { - target_pts = (int64_t)(ts_s * (double)AV_TIME_BASE); - } - (void)avformat_seek_file(in_fmt, stream_index, INT64_MIN, target_pts, - target_pts, AVSEEK_FLAG_BACKWARD); - avcodec_flush_buffers(dec_ctx); - - int packet_count = 0; - int got_frame = 0; - while (av_read_frame(in_fmt, packet) >= 0) { - packet_count++; - if (packet_count > FLUXER_MAX_VIDEO_PACKETS_FOR_NSFW) { - av_packet_unref(packet); - break; - } - if (packet->stream_index != stream_index) { - av_packet_unref(packet); - continue; - } - int send_rc = avcodec_send_packet(dec_ctx, packet); - av_packet_unref(packet); - if (send_rc < 0) continue; - int loop_break = 0; - while (1) { - int recv_rc = avcodec_receive_frame(dec_ctx, frame); - if (recv_rc == AVERROR(EAGAIN) || recv_rc == AVERROR_EOF) break; - if (recv_rc < 0) { loop_break = 1; break; } - - void *out_buf = NULL; - size_t out_size = 0; - int emit_rc = ff_emit_frame_thumbnail( - frame, dec_ctx, in_fmt, in_stream, ".jpg[Q=65,strip]", - &out_buf, &out_size); - av_frame_unref(frame); - if (emit_rc != 0 || out_buf == NULL || out_size == 0) { - loop_break = 1; - break; - } - out_frames[i].data = out_buf; - out_frames[i].len = out_size; - produced++; - got_frame = 1; - loop_break = 1; - break; - } - if (got_frame || loop_break) break; - } - - if (!got_frame) { - (void)avcodec_send_packet(dec_ctx, NULL); - int recv_rc = avcodec_receive_frame(dec_ctx, frame); - if (recv_rc == 0) { - void *out_buf = NULL; - size_t out_size = 0; - int emit_rc = ff_emit_frame_thumbnail( - frame, dec_ctx, in_fmt, in_stream, ".jpg[Q=65,strip]", - &out_buf, &out_size); - av_frame_unref(frame); - if (emit_rc == 0 && out_buf != NULL && out_size > 0) { - out_frames[i].data = out_buf; - out_frames[i].len = out_size; - produced++; - } - } - (void)avformat_seek_file(in_fmt, stream_index, INT64_MIN, 0, INT64_MAX, - AVSEEK_FLAG_BACKWARD); - avcodec_flush_buffers(dec_ctx); - } - } - -cleanup: - if (frame != NULL) av_frame_free(&frame); - if (packet != NULL) av_packet_free(&packet); - if (dec_ctx != NULL) avcodec_free_context(&dec_ctx); - if (in_fmt != NULL) avformat_close_input(&in_fmt); - if (avio != NULL) { - if (avio->buffer != NULL) { - av_free(avio->buffer); - avio->buffer = NULL; - } - avio_context_free(&avio); - } else if (avio_buffer != NULL) { - av_free(avio_buffer); - } - return produced; -} diff --git a/fluxer_media_proxy/src/vips_shim.h b/fluxer_media_proxy/src/vips_shim.h index d28a9021a..6fa7a6e5f 100644 --- a/fluxer_media_proxy/src/vips_shim.h +++ b/fluxer_media_proxy/src/vips_shim.h @@ -12,99 +12,203 @@ extern "C" { typedef struct _VipsImage VipsImage; struct fluxer_webp_anim_limits; -int fluxer_vips_image_is_hdr(VipsImage *image); +enum fluxer_deadline_status { + FLUXER_DEADLINE_PENDING = 0, + FLUXER_DEADLINE_REACHED = 1, + FLUXER_DEADLINE_CLOCK_FAILED = -1 +}; -int fluxer_vips_tone_map_hdr_to_sdr(VipsImage *in, VipsImage **out); +enum fluxer_native_status { + FLUXER_NATIVE_STATUS_OK = 0, + FLUXER_NATIVE_STATUS_UNSUPPORTED = 1, + FLUXER_NATIVE_STATUS_CODEC_FAILURE = -1, + FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED = -2, + FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED = -3, + FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS = -4, + FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED = -5, + FLUXER_NATIVE_STATUS_ALLOCATION_FAILED = -6 +}; -int fluxer_heif_decode_animated(const void *buf, size_t len, VipsImage **out, - int n_max_pages, size_t max_total_pixels); - -int fluxer_heif_decode_animated_ex(const void *buf, size_t len, VipsImage **out, - int n_max_pages, size_t max_total_pixels, - int *was_hdr); - -int fluxer_heif_decode_animated_ex2(const void *buf, size_t len, VipsImage **out, - int n_max_pages, size_t max_total_pixels, - int *was_hdr, int *had_hdr_gain_map); - -int fluxer_heif_aux_type_is_hdr_gain_map_for_test(const char *type); - -int fluxer_heif_has_tmap_item_for_test(const void *buf, size_t len); - -int fluxer_avif_parse_track_delays_for_test(const void *buf, size_t len, - int **out_delays_ms, int *out_n_samples); -void fluxer_avif_free_delays(int *delays); - -void fluxer_vips_set_anim_metadata_for_test(VipsImage *image, int page_height, - int n_pages, const int *delays_ms); - -int fluxer_hdr_to_sdr_test(uint16_t r, uint16_t g, uint16_t b, - int bit_depth, int transfer, - uint8_t out_rgb[3]); +int fluxer_monotonic_deadline_status(long long deadline_monotonic_ms); extern const int fluxer_vips_format_uchar; +extern const int fluxer_vips_format_ushort; +extern const int fluxer_vips_format_float; int fluxer_vips_init(const char *argv0); void fluxer_vips_error_clear(void); +const char *fluxer_vips_error_buffer(void); void fluxer_vips_tune_for_server(int per_pipeline_threads); int fluxer_vips_probe_animated(const void *buf, size_t len, int *width, int *height, int *pages); +int fluxer_apng_probe(const void *buf, size_t len, int max_frames, + size_t max_total_pixels, int *width, int *height, int *frames); VipsImage *fluxer_vips_image_new_from_buffer(const void *buf, size_t len, const char *option_string); +VipsImage *fluxer_vips_image_new_from_memory(const void *data, size_t size, int width, int height, int bands, int format); VipsImage *fluxer_vips_image_new_from_memory_copy(const void *data, size_t size, int width, int height, int bands, int format); int fluxer_vips_image_write_to_buffer(VipsImage *image, const char *suffix, void **buf, size_t *size); int fluxer_vips_image_get_width(VipsImage *image); int fluxer_vips_image_get_height(VipsImage *image); +int fluxer_vips_image_get_orientation_swap(VipsImage *image); int fluxer_vips_image_get_bands(VipsImage *image); +int fluxer_vips_image_get_format(VipsImage *image); +int fluxer_vips_image_has_field(VipsImage *image, const char *field); int fluxer_vips_image_get_int(VipsImage *image, const char *field, int *out); void fluxer_vips_set_page_height(VipsImage *image, int page_height); +int fluxer_vips_set_animation_loop_count(VipsImage *image, int loop_count); int fluxer_vips_read_delays_ms(VipsImage *image, int n_pages, int **out_delays, int *out_len); -int fluxer_vips_autorot(VipsImage *in, VipsImage **out); +int fluxer_vips_autorot( + VipsImage *in, long long deadline_monotonic_ms, VipsImage **out); int fluxer_vips_extract_area(VipsImage *in, VipsImage **out, int left, int top, int width, int height); int fluxer_vips_resize(VipsImage *in, VipsImage **out, double scale); -int fluxer_vips_thumbnail_buffer(const void *buf, size_t len, VipsImage **out, int width, int height, int n); - +int fluxer_vips_join_animation_pages( + VipsImage *source, VipsImage **pages, int n_pages, int max_pages, + size_t max_total_pixels, VipsImage **out); +int fluxer_vips_image_copy_memory( + VipsImage *in, long long deadline_monotonic_ms, VipsImage **out); #define FLUXER_THUMB_CROP_NONE 0 #define FLUXER_THUMB_CROP_CENTRE 1 -int fluxer_vips_thumbnail_buffer_ex(const void *buf, size_t len, VipsImage **out, int width, int height, int n, int crop_mode); -int fluxer_vips_extract_rgba(VipsImage *in, void **out_buf, size_t *out_size); +int fluxer_vips_thumbnail_buffer_ex( + const void *buf, size_t len, long long deadline_monotonic_ms, + VipsImage **out, int width, int height, + int n, int crop_mode, int max_pages, size_t max_total_pixels); +int fluxer_vips_image_to_rgba(VipsImage *in, VipsImage **out); +int fluxer_vips_extract_rgba( + VipsImage *in, long long deadline_monotonic_ms, + void **out_buf, size_t *out_size); typedef int (*fluxer_vips_write_cb)(void *user_data, const void *bytes, size_t len); -int fluxer_vips_image_write_to_callback(VipsImage *image, const char *suffix, fluxer_vips_write_cb cb, void *user_data); +int fluxer_vips_image_write_to_callback( + VipsImage *image, const char *suffix, long long deadline_monotonic_ms, + fluxer_vips_write_cb cb, void *user_data); void fluxer_vips_unref(VipsImage *image); void fluxer_vips_free(void *mem); +void fluxer_av_free(void *mem); void fluxer_free_int_array(int *values); +int fluxer_heif_validate( + const void *buf, size_t len, long long deadline_monotonic_ms); + +struct fluxer_heif_primary_still_decode_facts { + int hdr_tone_mapped; + int hdr_gain_map_detected; +}; + +int fluxer_heif_decode_primary_still( + const void *buf, size_t len, long long deadline_monotonic_ms, + VipsImage **out, size_t max_pixels, int max_dimension, + struct fluxer_heif_primary_still_decode_facts *facts); + +int fluxer_ffmpeg_count_heif_sequence_frames( + const void *heif_data, + size_t heif_len, + int decoder_threads, + long long deadline_monotonic_ms, + int max_frames, + size_t max_total_pixels, + int *out_frame_count +); + +int fluxer_ffmpeg_decode_heif_sequence( + const void *heif_data, + size_t heif_len, + int decoder_threads, + long long deadline_monotonic_ms, + VipsImage **out, + int max_frames, + size_t max_total_pixels, + int *out_frame_count +); + int fluxer_ffmpeg_resize_gif( const void *gif_data, size_t gif_len, + int decoder_threads, int target_width, int target_height, - long long deadline_unix_ms, - long long max_frames, - long long max_total_pixels, + long long deadline_monotonic_ms, + int max_source_frames, + int max_encode_frames, + int max_encode_duration_ms, + size_t max_total_pixels, + size_t max_output_size, void **out_buf, - size_t *out_size + size_t *out_size, + size_t *out_capacity ); -int fluxer_ffmpeg_video_thumbnail( +int fluxer_validate_gif_animation( + const void *gif_data, + size_t gif_len, + int max_frames, + int max_duration_ms, + size_t max_total_pixels +); + +int fluxer_ffmpeg_video_thumbnail_ex( const void *media_data, size_t media_len, + int decoder_threads, + long long deadline_monotonic_ms, const char *suffix, int max_packets, + int max_width, + int max_height, + size_t max_output_size, + int *out_display_width, + int *out_display_height, void **out_buf, - size_t *out_size + size_t *out_size, + size_t *out_capacity +); + +struct fluxer_av_metadata_out { + int has_video; + int has_audio; + int frame_count; + double duration_seconds; + int display_width; + int display_height; + int rgba_width; + int rgba_height; + void *rgba; + size_t rgba_size; +}; + +int fluxer_av_metadata( + const void *media_data, + size_t media_len, + int decoder_threads, + long long deadline_monotonic_ms, + int max_packets, + int max_width, + int max_height, + struct fluxer_av_metadata_out *out ); int fluxer_ffmpeg_decode_apng( const void *apng_data, size_t apng_len, + int decoder_threads, + long long deadline_monotonic_ms, VipsImage **out, int max_frames, + size_t max_total_pixels, + int require_complete, + uint32_t *out_num_plays +); + +int fluxer_ffmpeg_decode_bmp( + const void *bmp_data, + size_t bmp_len, + int decoder_threads, + long long deadline_monotonic_ms, + VipsImage **out, size_t max_total_pixels ); struct fluxer_webp_anim_limits { int max_frames; int max_duration_ms; - long long deadline_unix_ms; + long long deadline_monotonic_ms; }; int fluxer_webp_encode_animated( @@ -114,35 +218,88 @@ int fluxer_webp_encode_animated( int effort, int alpha_q, int smart_subsample, + int thread_level, int loop_count, int full_canvas_frames, const struct fluxer_webp_anim_limits *limits, - unsigned char *scratch, - size_t scratch_cap, + size_t max_output_size, + void **out_buf, + size_t *out_size +); + +int fluxer_webp_transform_animated( + const void *webp_data, + size_t webp_len, + int max_width, + int max_height, + int quality, + int lossless, + int effort, + int alpha_q, + int smart_subsample, + int thread_level, + int max_source_frames, + size_t max_total_pixels, + const struct fluxer_webp_anim_limits *limits, + size_t max_output_size, void **out_buf, size_t *out_size ); void fluxer_webp_free(void *mem); -int fluxer_av_probe( - const void *media_data, - size_t media_len, - int *out_has_video, - int *out_has_audio, - double *out_duration_seconds -); - struct fluxer_nsfw_frame_out { void *data; size_t len; }; +int fluxer_ffmpeg_extract_apng_frames_for_nsfw( + const void *apng_data, + size_t apng_len, + int decoder_threads, + long long deadline_monotonic_ms, + const int *frame_indices, + size_t n_indices, + int max_frames, + size_t max_total_pixels, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out_frames +); + +int fluxer_ffmpeg_extract_gif_frames_for_nsfw( + const void *gif_data, + size_t gif_len, + int decoder_threads, + long long deadline_monotonic_ms, + const int *frame_indices, + size_t n_indices, + int max_frames, + size_t max_total_pixels, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out_frames +); + +int fluxer_webp_extract_frames_for_nsfw( + const void *webp_data, + size_t webp_len, + int thread_level, + long long deadline_monotonic_ms, + const int *frame_indices, + size_t n_indices, + int max_frames, + size_t max_total_pixels, + size_t max_frame_output_size, + struct fluxer_nsfw_frame_out *out_frames +); + int fluxer_av_extract_frames_for_nsfw( const void *media_data, size_t media_len, + int decoder_threads, + long long deadline_monotonic_ms, const double *timestamps_secs, size_t n_timestamps, + size_t max_frame_output_size, struct fluxer_nsfw_frame_out *out_frames ); diff --git a/fluxer_media_proxy/src/webp_animation.c b/fluxer_media_proxy/src/webp_animation.c new file mode 100644 index 000000000..ee7212b65 --- /dev/null +++ b/fluxer_media_proxy/src/webp_animation.c @@ -0,0 +1,1097 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#define _POSIX_C_SOURCE 200809L + +#include "vips_shim.h" +#include "webp_animation.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#define FLUXER_WEBP_MAX_FRAME_DURATION_MS ((1 << 24) - 1) +#define FLUXER_WEBP_MAX_LOOP_COUNT (1 << 16) +#define FLUXER_WEBP_ANIMATION_HEADER_BOUND ((size_t)44) +#define FLUXER_WEBP_ANIMATION_FRAME_OVERHEAD_BOUND ((size_t)24) +#define FLUXER_WEBP_WRITER_INITIAL_CAPACITY ((size_t)4096) +#define FLUXER_WEBP_WRITER_MAX_GROWTH_STEPS (sizeof(size_t) * CHAR_BIT) +#define FLUXER_WEBP_KEYFRAME_INTERVAL ((size_t)4) +#define FLUXER_WEBP_FRAME_SCAN_DEADLINE_ROWS 64 +#define FLUXER_WEBP_SCALE_DEADLINE_ROWS 64 + +enum fluxer_webp_writer_failure { + FLUXER_WEBP_WRITER_OK = 0, + FLUXER_WEBP_WRITER_LIMIT = 1, + FLUXER_WEBP_WRITER_ALLOC = 2, + FLUXER_WEBP_WRITER_INTERNAL = 3 +}; + +enum fluxer_webp_animation_encoder_state { + FLUXER_WEBP_ANIMATION_ENCODER_OPEN = 0, + FLUXER_WEBP_ANIMATION_ENCODER_FINISHED = 1 +}; + +struct fluxer_webp_bounded_writer { + uint8_t *data; + size_t len; + size_t capacity; + size_t max_size; + size_t allocation_bound; + enum fluxer_webp_writer_failure failure; +}; + +struct fluxer_webp_frame_plan { + const uint32_t *argb; + int argb_stride; + int x_offset; + int y_offset; + int width; + int height; + WebPMuxAnimBlend blend_method; +}; + +struct fluxer_webp_animation_encoder { + WebPMux *mux; + WebPConfig config; + uint32_t *current_canvas; + uint32_t *previous_canvas; + uint32_t transparent_pixel; + size_t frame_count; + size_t container_size_bound; + size_t max_output_size; + int canvas_width; + int canvas_height; + int full_canvas_frames; + int encode_deadline_exceeded; + enum fluxer_webp_pixel_layout pixel_layout; + long long deadline_monotonic_ms; + enum fluxer_webp_animation_encoder_state state; + struct fluxer_webp_bounded_writer frame_writer; +}; + +static int fluxer_webp_animation_settings_status( + const struct fluxer_webp_animation_encoder_settings *settings +) { + if (settings == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (settings->config == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (!WebPValidateConfig(settings->config)) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (settings->canvas_width <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (settings->canvas_width > WEBP_MAX_DIMENSION) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (settings->canvas_height <= 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + if (settings->canvas_height > WEBP_MAX_DIMENSION) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (settings->loop_count < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (settings->loop_count >= FLUXER_WEBP_MAX_LOOP_COUNT) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (settings->full_canvas_frames < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (settings->full_canvas_frames > 1) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (settings->deadline_monotonic_ms < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (settings->pixel_layout != FLUXER_WEBP_PIXEL_LAYOUT_RGBA) { + if (settings->pixel_layout != FLUXER_WEBP_PIXEL_LAYOUT_BGRA) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + } + if (settings->max_output_size < FLUXER_WEBP_ANIMATION_HEADER_BOUND) { + return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_webp_canvas_allocation_size( + int width, int height, size_t *canvas_bytes +) { + if (width <= 0) return -1; + if (height <= 0) return -1; + if (canvas_bytes == NULL) return -1; + size_t width_size = (size_t)width; + size_t height_size = (size_t)height; + if (height_size > SIZE_MAX / width_size) return -1; + size_t pixels = width_size * height_size; + if (pixels > SIZE_MAX / sizeof(uint32_t)) return -1; + *canvas_bytes = pixels * sizeof(uint32_t); + return 0; +} + +static void fluxer_webp_bounded_writer_init( + struct fluxer_webp_bounded_writer *writer, size_t allocation_bound +) { + assert(writer != NULL); + assert(allocation_bound > 0); + memset(writer, 0, sizeof(*writer)); + writer->allocation_bound = allocation_bound; +} + +static void fluxer_webp_bounded_writer_reset( + struct fluxer_webp_bounded_writer *writer, size_t max_size +) { + assert(writer != NULL); + assert(max_size > 0); + assert(max_size <= writer->allocation_bound); + writer->len = 0; + writer->max_size = max_size; + writer->failure = FLUXER_WEBP_WRITER_OK; +} + +static void fluxer_webp_bounded_writer_clear( + struct fluxer_webp_bounded_writer *writer +) { + if (writer == NULL) return; + WebPFree(writer->data); + memset(writer, 0, sizeof(*writer)); +} + +static int fluxer_webp_bounded_writer_next_capacity( + const struct fluxer_webp_bounded_writer *writer, + size_t required, + size_t *next_capacity +) { + if (writer == NULL) return -1; + if (next_capacity == NULL) return -1; + if (required == 0) return -1; + if (required > writer->max_size) return -1; + size_t capacity = writer->capacity; + if (capacity == 0) { + capacity = FLUXER_WEBP_WRITER_INITIAL_CAPACITY; + if (capacity > writer->max_size) capacity = writer->max_size; + } + for (size_t step = 0; capacity < required; step++) { + if (step >= FLUXER_WEBP_WRITER_MAX_GROWTH_STEPS) return -1; + size_t remaining = writer->max_size - capacity; + size_t growth = capacity; + if (growth > remaining) growth = remaining; + if (growth == 0) return -1; + capacity += growth; + } + *next_capacity = capacity; + return 0; +} + +static int fluxer_webp_bounded_writer_reserve( + struct fluxer_webp_bounded_writer *writer, size_t required +) { + assert(writer != NULL); + assert(required > writer->capacity); + size_t next_capacity = 0; + if (fluxer_webp_bounded_writer_next_capacity( + writer, required, &next_capacity) != 0) { + writer->failure = FLUXER_WEBP_WRITER_INTERNAL; + return -1; + } + uint8_t *next = (uint8_t *)WebPMalloc(next_capacity); + if (next == NULL) { + writer->failure = FLUXER_WEBP_WRITER_ALLOC; + return -1; + } + if (writer->len > 0) memcpy(next, writer->data, writer->len); + WebPFree(writer->data); + writer->data = next; + writer->capacity = next_capacity; + assert(writer->capacity <= writer->allocation_bound); + return 0; +} + +static int fluxer_webp_bounded_write( + const uint8_t *data, size_t data_size, const WebPPicture *picture +) { + if (picture == NULL) return 0; + if (picture->custom_ptr == NULL) return 0; + struct fluxer_webp_bounded_writer *writer = + (struct fluxer_webp_bounded_writer *)picture->custom_ptr; + if (writer->failure != FLUXER_WEBP_WRITER_OK) return 0; + if (data_size == 0) return 1; + if (data == NULL) { + writer->failure = FLUXER_WEBP_WRITER_INTERNAL; + return 0; + } + if (writer->len > writer->max_size) { + writer->failure = FLUXER_WEBP_WRITER_INTERNAL; + return 0; + } + if (data_size > writer->max_size - writer->len) { + writer->failure = FLUXER_WEBP_WRITER_LIMIT; + return 0; + } + size_t required = writer->len + data_size; + if (required > writer->capacity) { + if (fluxer_webp_bounded_writer_reserve(writer, required) != 0) return 0; + } + memcpy(writer->data + writer->len, data, data_size); + writer->len = required; + assert(writer->len <= writer->capacity); + assert(writer->capacity <= writer->allocation_bound); + return 1; +} + +static int fluxer_webp_frame_output_budget( + const struct fluxer_webp_animation_encoder *encoder, + size_t *frame_budget +) { + if (encoder == NULL) return -1; + if (frame_budget == NULL) return -1; + if (encoder->container_size_bound > encoder->max_output_size) { + return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } + size_t remaining = encoder->max_output_size - encoder->container_size_bound; + if (remaining <= FLUXER_WEBP_ANIMATION_FRAME_OVERHEAD_BOUND) { + return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } + *frame_budget = remaining - FLUXER_WEBP_ANIMATION_FRAME_OVERHEAD_BOUND; + return 0; +} + +static int fluxer_webp_next_container_bound( + const struct fluxer_webp_animation_encoder *encoder, + size_t frame_size, + size_t *next_bound +) { + if (encoder == NULL) return -1; + if (next_bound == NULL) return -1; + if (encoder->container_size_bound > encoder->max_output_size) { + return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } + size_t remaining = encoder->max_output_size - encoder->container_size_bound; + if (FLUXER_WEBP_ANIMATION_FRAME_OVERHEAD_BOUND > remaining) { + return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } + remaining -= FLUXER_WEBP_ANIMATION_FRAME_OVERHEAD_BOUND; + if (frame_size > remaining) return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + *next_bound = encoder->container_size_bound; + *next_bound += FLUXER_WEBP_ANIMATION_FRAME_OVERHEAD_BOUND; + *next_bound += frame_size; + return 0; +} + +static int fluxer_webp_deadline_status_at(long long deadline_monotonic_ms) { + int status = fluxer_monotonic_deadline_status(deadline_monotonic_ms); + return status == FLUXER_DEADLINE_PENDING + ? FLUXER_NATIVE_STATUS_OK + : FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; +} + +static int fluxer_webp_deadline_status( + const struct fluxer_webp_animation_encoder *encoder +) { + assert(encoder != NULL); + return fluxer_webp_deadline_status_at(encoder->deadline_monotonic_ms); +} + +static int fluxer_webp_encode_progress( + int percent, + const WebPPicture *picture +) { + (void)percent; + if (picture == NULL || picture->user_data == NULL) return 0; + struct fluxer_webp_animation_encoder *encoder = + (struct fluxer_webp_animation_encoder *)picture->user_data; + if (fluxer_webp_deadline_status(encoder) == FLUXER_NATIVE_STATUS_OK) { + return 1; + } + encoder->encode_deadline_exceeded = 1; + return 0; +} + +static uint32_t fluxer_webp_argb_pixel( + const uint8_t *pixel, enum fluxer_webp_pixel_layout layout +) { + assert(pixel != NULL); + uint32_t red = pixel[0]; + uint32_t green = pixel[1]; + uint32_t blue = pixel[2]; + uint32_t alpha = pixel[3]; + if (layout == FLUXER_WEBP_PIXEL_LAYOUT_BGRA) { + red = pixel[2]; + blue = pixel[0]; + } + if (alpha == 0) return 0; + return (alpha << 24) | (red << 16) | (green << 8) | blue; +} + +static int fluxer_webp_frame_is_keyframe( + const struct fluxer_webp_animation_encoder *encoder +) { + assert(encoder != NULL); + if (encoder->frame_count == 0) return 1; + if (encoder->full_canvas_frames != 0) return 1; + return encoder->frame_count % FLUXER_WEBP_KEYFRAME_INTERVAL == 0; +} + +static int fluxer_webp_prepare_frame_plan( + struct fluxer_webp_animation_encoder *encoder, + const uint8_t *pixels, + size_t stride, + struct fluxer_webp_frame_plan *plan +) { + assert(encoder != NULL); + assert(pixels != NULL); + assert(plan != NULL); + memset(plan, 0, sizeof(*plan)); + int keyframe = fluxer_webp_frame_is_keyframe(encoder); + int left = encoder->canvas_width; + int top = encoder->canvas_height; + int right = -1; + int bottom = -1; + for (int y = 0; y < encoder->canvas_height; y++) { + if (y % FLUXER_WEBP_FRAME_SCAN_DEADLINE_ROWS == 0 && + fluxer_webp_deadline_status(encoder) != + FLUXER_NATIVE_STATUS_OK) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + const uint8_t *source_row = pixels + (size_t)y * stride; + uint32_t *target_row = encoder->current_canvas + + (size_t)y * (size_t)encoder->canvas_width; + const uint32_t *previous_row = NULL; + if (!keyframe) { + assert(encoder->previous_canvas != NULL); + previous_row = encoder->previous_canvas + + (size_t)y * (size_t)encoder->canvas_width; + } + for (int x = 0; x < encoder->canvas_width; x++) { + uint32_t pixel = fluxer_webp_argb_pixel( + source_row + (size_t)x * 4u, encoder->pixel_layout); + target_row[x] = pixel; + if (keyframe) continue; + if (pixel == previous_row[x]) continue; + if (x < left) left = x; + if (x > right) right = x; + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + plan->blend_method = WEBP_MUX_NO_BLEND; + plan->argb_stride = encoder->canvas_width; + if (keyframe) { + plan->width = encoder->canvas_width; + plan->height = encoder->canvas_height; + plan->argb = encoder->current_canvas; + return fluxer_webp_deadline_status(encoder); + } + if (right >= 0) { + left &= ~1; + top &= ~1; + plan->x_offset = left; + plan->y_offset = top; + plan->width = right - left + 1; + plan->height = bottom - top + 1; + size_t offset = (size_t)plan->y_offset * (size_t)encoder->canvas_width; + offset += (size_t)plan->x_offset; + plan->argb = encoder->current_canvas + offset; + return fluxer_webp_deadline_status(encoder); + } + plan->argb = &encoder->transparent_pixel; + plan->argb_stride = 1; + plan->width = 1; + plan->height = 1; + plan->blend_method = WEBP_MUX_BLEND; + return fluxer_webp_deadline_status(encoder); +} + +static int fluxer_webp_encode_frame_candidate( + struct fluxer_webp_animation_encoder *encoder, + const struct fluxer_webp_frame_plan *plan, + size_t frame_budget +) { + assert(encoder != NULL); + assert(plan != NULL); + struct fluxer_webp_bounded_writer *writer = &encoder->frame_writer; + WebPPicture picture; + if (!WebPPictureInit(&picture)) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + fluxer_webp_bounded_writer_reset(writer, frame_budget); + picture.width = plan->width; + picture.height = plan->height; + picture.use_argb = 1; + picture.argb = (uint32_t *)(uintptr_t)plan->argb; + picture.argb_stride = plan->argb_stride; + picture.writer = fluxer_webp_bounded_write; + picture.custom_ptr = writer; + picture.progress_hook = fluxer_webp_encode_progress; + picture.user_data = encoder; + encoder->encode_deadline_exceeded = 0; + int encoded = WebPEncode(&encoder->config, &picture); + WebPEncodingError encode_error = picture.error_code; + WebPPictureFree(&picture); + if (!encoded) { + int status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (writer->failure == FLUXER_WEBP_WRITER_LIMIT) { + status = FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } else if (writer->failure == FLUXER_WEBP_WRITER_ALLOC) { + status = FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } else if (encode_error == VP8_ENC_ERROR_OUT_OF_MEMORY) { + status = FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } else if (encode_error == VP8_ENC_ERROR_USER_ABORT && + encoder->encode_deadline_exceeded != 0) { + status = FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + return status; + } + if (writer->failure != FLUXER_WEBP_WRITER_OK) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (writer->data == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (writer->len == 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_webp_push_frame_candidate( + struct fluxer_webp_animation_encoder *encoder, + const struct fluxer_webp_frame_plan *plan, + int duration_ms, + const struct fluxer_webp_bounded_writer *writer +) { + assert(encoder != NULL); + assert(plan != NULL); + assert(writer != NULL); + size_t next_bound = 0; + int bound_status = fluxer_webp_next_container_bound( + encoder, writer->len, &next_bound); + if (bound_status != 0) return bound_status; + WebPMuxFrameInfo frame; + memset(&frame, 0, sizeof(frame)); + frame.bitstream.bytes = writer->data; + frame.bitstream.size = writer->len; + frame.x_offset = plan->x_offset; + frame.y_offset = plan->y_offset; + frame.duration = duration_ms; + frame.id = WEBP_CHUNK_ANMF; + frame.dispose_method = WEBP_MUX_DISPOSE_NONE; + frame.blend_method = plan->blend_method; + WebPMuxError push_status = WebPMuxPushFrame(encoder->mux, &frame, 1); + if (push_status == WEBP_MUX_MEMORY_ERROR) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (push_status != WEBP_MUX_OK) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + encoder->container_size_bound = next_bound; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_webp_animation_mux_create( + struct fluxer_webp_animation_encoder *encoder, int loop_count +) { + assert(encoder != NULL); + encoder->mux = WebPMuxNew(); + if (encoder->mux == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + WebPMuxError canvas_status = WebPMuxSetCanvasSize( + encoder->mux, encoder->canvas_width, encoder->canvas_height); + if (canvas_status == WEBP_MUX_MEMORY_ERROR) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (canvas_status != WEBP_MUX_OK) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + WebPMuxAnimParams animation = { + .bgcolor = 0x00000000, + .loop_count = loop_count, + }; + WebPMuxError animation_status = WebPMuxSetAnimationParams( + encoder->mux, &animation); + if (animation_status == WEBP_MUX_MEMORY_ERROR) { + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (animation_status != WEBP_MUX_OK) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_webp_animation_encoder_create( + const struct fluxer_webp_animation_encoder_settings *settings, + struct fluxer_webp_animation_encoder **out_encoder +) { + if (out_encoder == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + *out_encoder = NULL; + int settings_status = fluxer_webp_animation_settings_status(settings); + if (settings_status != FLUXER_NATIVE_STATUS_OK) return settings_status; + if (fluxer_monotonic_deadline_status(settings->deadline_monotonic_ms) != + FLUXER_DEADLINE_PENDING) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + size_t canvas_bytes = 0; + if (fluxer_webp_canvas_allocation_size( + settings->canvas_width, settings->canvas_height, + &canvas_bytes) != 0) return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + struct fluxer_webp_animation_encoder *encoder = + (struct fluxer_webp_animation_encoder *)calloc(1, sizeof(*encoder)); + if (encoder == NULL) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + encoder->current_canvas = (uint32_t *)malloc(canvas_bytes); + if (settings->full_canvas_frames == 0) { + encoder->previous_canvas = (uint32_t *)malloc(canvas_bytes); + } + encoder->config = *settings->config; + encoder->canvas_width = settings->canvas_width; + encoder->canvas_height = settings->canvas_height; + encoder->full_canvas_frames = settings->full_canvas_frames; + encoder->pixel_layout = settings->pixel_layout; + encoder->deadline_monotonic_ms = settings->deadline_monotonic_ms; + encoder->max_output_size = settings->max_output_size; + encoder->container_size_bound = FLUXER_WEBP_ANIMATION_HEADER_BOUND; + fluxer_webp_bounded_writer_init( + &encoder->frame_writer, settings->max_output_size); + if (encoder->current_canvas == NULL || + (settings->full_canvas_frames == 0 && + encoder->previous_canvas == NULL)) { + fluxer_webp_animation_encoder_delete(encoder); + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + int mux_status = fluxer_webp_animation_mux_create( + encoder, settings->loop_count); + if (mux_status != FLUXER_NATIVE_STATUS_OK) { + fluxer_webp_animation_encoder_delete(encoder); + return mux_status; + } + if (fluxer_webp_deadline_status(encoder) != FLUXER_NATIVE_STATUS_OK) { + fluxer_webp_animation_encoder_delete(encoder); + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + *out_encoder = encoder; + return FLUXER_NATIVE_STATUS_OK; +} + +static int fluxer_webp_input_stride_valid( + const struct fluxer_webp_animation_encoder *encoder, size_t stride +) { + assert(encoder != NULL); + size_t row_bytes = (size_t)encoder->canvas_width * 4u; + if (stride < row_bytes) return 0; + if (encoder->canvas_height == 1) return 1; + size_t row_count = (size_t)encoder->canvas_height - 1u; + if (stride > (SIZE_MAX - row_bytes) / row_count) return 0; + return 1; +} + +int fluxer_webp_animation_encoder_add( + struct fluxer_webp_animation_encoder *encoder, + const uint8_t *pixels, + size_t stride, + int duration_ms +) { + if (encoder == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (pixels == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (encoder->state != FLUXER_WEBP_ANIMATION_ENCODER_OPEN) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!fluxer_webp_input_stride_valid(encoder, stride)) { + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + if (duration_ms <= 0) return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + if (duration_ms > FLUXER_WEBP_MAX_FRAME_DURATION_MS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (fluxer_webp_deadline_status(encoder) != FLUXER_NATIVE_STATUS_OK) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + struct fluxer_webp_frame_plan plan; + int plan_status = fluxer_webp_prepare_frame_plan( + encoder, pixels, stride, &plan); + if (plan_status != FLUXER_NATIVE_STATUS_OK) return plan_status; + size_t frame_budget = 0; + int budget_status = fluxer_webp_frame_output_budget(encoder, &frame_budget); + if (budget_status != 0) return budget_status; + int encode_status = fluxer_webp_encode_frame_candidate( + encoder, &plan, frame_budget); + if (encode_status != 0) return encode_status; + if (fluxer_webp_deadline_status(encoder) != FLUXER_NATIVE_STATUS_OK) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + int push_status = fluxer_webp_push_frame_candidate( + encoder, &plan, duration_ms, &encoder->frame_writer); + if (push_status != 0) return push_status; + if (encoder->full_canvas_frames == 0) { + assert(encoder->previous_canvas != NULL); + uint32_t *previous = encoder->previous_canvas; + encoder->previous_canvas = encoder->current_canvas; + encoder->current_canvas = previous; + } + encoder->frame_count++; + return FLUXER_NATIVE_STATUS_OK; +} + +int fluxer_webp_animation_encoder_finish( + struct fluxer_webp_animation_encoder *encoder, + void **out_buf, + size_t *out_size +) { + if (out_buf != NULL) *out_buf = NULL; + if (out_size != NULL) *out_size = 0; + if (encoder == NULL || out_buf == NULL || out_size == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (encoder->state != FLUXER_WEBP_ANIMATION_ENCODER_OPEN) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (encoder->frame_count == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (encoder->container_size_bound > encoder->max_output_size) { + return FLUXER_NATIVE_STATUS_OUTPUT_LIMIT_EXCEEDED; + } + if (fluxer_webp_deadline_status(encoder) != FLUXER_NATIVE_STATUS_OK) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + WebPData output; + WebPDataInit(&output); + WebPMuxError assemble_status = WebPMuxAssemble(encoder->mux, &output); + if (assemble_status == WEBP_MUX_MEMORY_ERROR) { + WebPDataClear(&output); + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + if (assemble_status != WEBP_MUX_OK) { + WebPDataClear(&output); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (output.bytes == NULL) { + WebPDataClear(&output); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (output.size == 0 || output.size > encoder->container_size_bound) { + WebPDataClear(&output); + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (fluxer_webp_deadline_status(encoder) != FLUXER_NATIVE_STATUS_OK) { + WebPDataClear(&output); + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + *out_buf = (void *)output.bytes; + *out_size = output.size; + encoder->state = FLUXER_WEBP_ANIMATION_ENCODER_FINISHED; + return FLUXER_NATIVE_STATUS_OK; +} + +void fluxer_webp_animation_encoder_delete( + struct fluxer_webp_animation_encoder *encoder +) { + if (encoder == NULL) return; + WebPMuxDelete(encoder->mux); + fluxer_webp_bounded_writer_clear(&encoder->frame_writer); + free(encoder->current_canvas); + free(encoder->previous_canvas); + memset(encoder, 0, sizeof(*encoder)); + free(encoder); +} + +struct fluxer_webp_direct_request { + const void *data; + size_t len; + int max_width; + int max_height; + int quality; + int lossless; + int effort; + int alpha_q; + int smart_subsample; + int thread_level; + int max_source_frames; + size_t max_total_pixels; + const struct fluxer_webp_anim_limits *limits; + size_t max_output_size; + void **out_buf; + size_t *out_size; +}; + +struct fluxer_webp_direct_state { + WebPAnimDecoder *decoder; + struct fluxer_webp_animation_encoder *encoder; + uint8_t *scaled_bgra; + int source_width; + int source_height; + int target_width; + int target_height; + int source_frame_count; + int max_frames; + int max_duration_ms; + long long deadline_monotonic_ms; +}; + +static int webp_direct_request_valid(const struct fluxer_webp_direct_request *request) { + if (request == NULL) return 0; + if (request->data == NULL) return 0; + if (request->len == 0) return 0; + if (request->max_width < 0) return 0; + if (request->max_height < 0) return 0; + if (request->max_source_frames <= 0) return 0; + if (request->max_total_pixels == 0) return 0; + if (request->limits == NULL) return 0; + if (request->limits->max_frames <= 0) return 0; + if (request->limits->max_duration_ms <= 0) return 0; + if (request->limits->deadline_monotonic_ms < 0) return 0; + if (request->max_output_size == 0) return 0; + if (request->out_buf == NULL) return 0; + if (request->out_size == NULL) return 0; + if (request->thread_level < 0) return 0; + if (request->thread_level > 1) return 0; + return 1; +} + +static int fit_webp_direct_dimensions( + const WebPAnimInfo *info, + const struct fluxer_webp_direct_request *request, + int *target_width, + int *target_height +) { + uint64_t numerator = 1; + uint64_t denominator = 1; + uint64_t source_width = info->canvas_width; + uint64_t source_height = info->canvas_height; + if (request->max_width > 0 && (uint64_t)request->max_width < source_width) { + numerator = (uint64_t)request->max_width; + denominator = source_width; + } + if (request->max_height > 0 && (uint64_t)request->max_height < source_height) { + uint64_t height_limit = (uint64_t)request->max_height; + if (height_limit * denominator < numerator * source_height) { + numerator = height_limit; + denominator = source_height; + } + } + uint64_t width = (source_width * numerator + denominator / 2u) / denominator; + uint64_t height = (source_height * numerator + denominator / 2u) / denominator; + if (width == 0) width = 1; + if (height == 0) height = 1; + if (width > INT_MAX) return 0; + if (height > INT_MAX) return 0; + *target_width = (int)width; + *target_height = (int)height; + return 1; +} + +static int webp_direct_metadata_supported(const WebPAnimDecoder *decoder) { + const WebPDemuxer *demuxer = WebPAnimDecoderGetDemuxer(decoder); + if (demuxer == NULL) return 0; + uint32_t flags = WebPDemuxGetI(demuxer, WEBP_FF_FORMAT_FLAGS); + if ((flags & ICCP_FLAG) != 0) return 0; + if ((flags & EXIF_FLAG) != 0) return 0; + if ((flags & XMP_FLAG) != 0) return 0; + return 1; +} + +static int scale_webp_bgra_frame( + const struct fluxer_webp_direct_state *state, + uint8_t *source_bgra, + uint8_t **out_bgra +) { + assert(state != NULL); + assert(source_bgra != NULL); + assert(out_bgra != NULL); + *out_bgra = NULL; + if (state->source_width == state->target_width) { + if (state->source_height == state->target_height) { + *out_bgra = source_bgra; + return fluxer_webp_deadline_status_at(state->deadline_monotonic_ms); + } + } + if (state->scaled_bgra == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + for (int clip_y = 0; clip_y < state->target_height; + clip_y += FLUXER_WEBP_SCALE_DEADLINE_ROWS) { + int deadline_status = fluxer_webp_deadline_status_at( + state->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + int clip_height = state->target_height - clip_y; + if (clip_height > FLUXER_WEBP_SCALE_DEADLINE_ROWS) { + clip_height = FLUXER_WEBP_SCALE_DEADLINE_ROWS; + } + int rc = ARGBScaleClip( + source_bgra, + state->source_width * 4, + state->source_width, + state->source_height, + state->scaled_bgra, + state->target_width * 4, + state->target_width, + state->target_height, + 0, + clip_y, + state->target_width, + clip_height, + kFilterBox + ); + if (rc > 0) return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + if (rc < 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int deadline_status = fluxer_webp_deadline_status_at( + state->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + *out_bgra = state->scaled_bgra; + return FLUXER_NATIVE_STATUS_OK; +} + +static int transform_webp_animation_frame( + struct fluxer_webp_direct_state *state, + int *source_timestamp, + int output_timestamp +) { + uint8_t *source_bgra = NULL; + int next_source_timestamp = 0; + if (!WebPAnimDecoderGetNext(state->decoder, &source_bgra, &next_source_timestamp)) { + int deadline_status = fluxer_webp_deadline_status_at( + state->deadline_monotonic_ms); + if (deadline_status != FLUXER_NATIVE_STATUS_OK) return deadline_status; + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (source_bgra == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (next_source_timestamp <= *source_timestamp) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + int delay = next_source_timestamp - *source_timestamp; + if (delay > FLUXER_WEBP_MAX_FRAME_DURATION_MS) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + if (output_timestamp > INT_MAX - delay) { + return FLUXER_NATIVE_STATUS_WORK_LIMIT_EXCEEDED; + } + uint8_t *output_bgra = NULL; + int scale_status = scale_webp_bgra_frame( + state, source_bgra, &output_bgra); + if (scale_status != FLUXER_NATIVE_STATUS_OK) return scale_status; + assert(output_bgra != NULL); + int add_status = fluxer_webp_animation_encoder_add( + state->encoder, + output_bgra, + (size_t)state->target_width * 4u, + delay + ); + if (add_status != 0) return add_status; + *source_timestamp = next_source_timestamp; + return delay; +} + +static int transform_webp_animation_frames(struct fluxer_webp_direct_state *state) { + if (state->source_frame_count <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (state->max_frames <= 0 || state->max_duration_ms <= 0) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + int source_timestamp = 0; + int output_timestamp = 0; + int frames_added = 0; + int truncated = 0; + while (WebPAnimDecoderHasMoreFrames(state->decoder)) { + if (frames_added >= state->source_frame_count) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (fluxer_monotonic_deadline_status(state->deadline_monotonic_ms) != FLUXER_DEADLINE_PENDING) { + return FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + } + if (frames_added >= state->max_frames) { + truncated = 1; + break; + } + if (output_timestamp >= state->max_duration_ms) { + truncated = 1; + break; + } + int delay = transform_webp_animation_frame(state, &source_timestamp, output_timestamp); + if (delay < 0) return delay; + output_timestamp += delay; + frames_added++; + } + if (frames_added == 0) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (frames_added > state->source_frame_count) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!truncated && frames_added != state->source_frame_count) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + return FLUXER_NATIVE_STATUS_OK; +} + +static WebPAnimDecoder *new_webp_direct_decoder( + const struct fluxer_webp_direct_request *request, + WebPAnimInfo *info, + int *status +) { + WebPData input = { .bytes = request->data, .size = request->len }; + struct fluxer_webp_animation_facts facts; + int preflight_status = fluxer_webp_animation_preflight( + request->data, request->len, request->max_source_frames, + request->max_total_pixels, + request->limits->deadline_monotonic_ms, &facts); + if (preflight_status != FLUXER_NATIVE_STATUS_OK) { + *status = preflight_status; + return NULL; + } + WebPAnimDecoderOptions options; + if (!WebPAnimDecoderOptionsInit(&options)) return NULL; + options.color_mode = MODE_BGRA; + options.use_threads = request->thread_level; + if (fluxer_monotonic_deadline_status( + request->limits->deadline_monotonic_ms) != + FLUXER_DEADLINE_PENDING) { + *status = FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + return NULL; + } + WebPAnimDecoder *decoder = WebPAnimDecoderNew(&input, &options); + if (fluxer_monotonic_deadline_status( + request->limits->deadline_monotonic_ms) != + FLUXER_DEADLINE_PENDING) { + if (decoder != NULL) WebPAnimDecoderDelete(decoder); + *status = FLUXER_NATIVE_STATUS_DEADLINE_EXCEEDED; + return NULL; + } + if (decoder == NULL) return NULL; + if (!WebPAnimDecoderGetInfo(decoder, info)) { + WebPAnimDecoderDelete(decoder); + return NULL; + } + if (info->canvas_width != facts.canvas_width || + info->canvas_height != facts.canvas_height || + info->frame_count != facts.frame_count) { + WebPAnimDecoderDelete(decoder); + return NULL; + } + if (info->loop_count >= (uint32_t)FLUXER_WEBP_MAX_LOOP_COUNT) { + WebPAnimDecoderDelete(decoder); + return NULL; + } + if (!webp_direct_metadata_supported(decoder)) { + WebPAnimDecoderDelete(decoder); + *status = FLUXER_NATIVE_STATUS_UNSUPPORTED; + return NULL; + } + return decoder; +} + +static int new_webp_direct_encoder( + const struct fluxer_webp_direct_request *request, + int target_width, + int target_height, + int loop_count, + struct fluxer_webp_animation_encoder **out_encoder +) { + WebPConfig config; + int config_rc = fluxer_configure_webp_encoder( + &config, + request->quality, + request->lossless, + request->effort, + request->alpha_q, + request->smart_subsample + ); + if (config_rc != FLUXER_NATIVE_STATUS_OK) return config_rc; + config.thread_level = request->thread_level; + if (!WebPValidateConfig(&config)) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + struct fluxer_webp_animation_encoder_settings settings = { + .config = &config, + .canvas_width = target_width, + .canvas_height = target_height, + .loop_count = loop_count, + .full_canvas_frames = 0, + .pixel_layout = FLUXER_WEBP_PIXEL_LAYOUT_BGRA, + .deadline_monotonic_ms = request->limits->deadline_monotonic_ms, + .max_output_size = request->max_output_size, + }; + return fluxer_webp_animation_encoder_create(&settings, out_encoder); +} + +static int run_webp_direct_transform(struct fluxer_webp_direct_request *request) { + if (request == NULL) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + if (request->out_buf != NULL) *request->out_buf = NULL; + if (request->out_size != NULL) *request->out_size = 0; + if (request->out_buf == NULL || request->out_size == NULL) { + return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + } + if (!webp_direct_request_valid(request)) return FLUXER_NATIVE_STATUS_CODEC_FAILURE; + WebPAnimInfo info; + int status = FLUXER_NATIVE_STATUS_CODEC_FAILURE; + WebPAnimDecoder *decoder = new_webp_direct_decoder(request, &info, &status); + if (decoder == NULL) return status; + int target_width = 0; + int target_height = 0; + if (!fit_webp_direct_dimensions(&info, request, &target_width, &target_height)) { + WebPAnimDecoderDelete(decoder); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + struct fluxer_webp_animation_encoder *encoder = NULL; + int encoder_status = new_webp_direct_encoder( + request, target_width, target_height, (int)info.loop_count, &encoder); + if (encoder_status != 0) { + WebPAnimDecoderDelete(decoder); + return encoder_status; + } + uint8_t *scaled_bgra = NULL; + if ((int)info.canvas_width != target_width || (int)info.canvas_height != target_height) { + size_t target_bytes = 0; + if (fluxer_webp_canvas_allocation_size( + target_width, target_height, &target_bytes) != 0) { + fluxer_webp_animation_encoder_delete(encoder); + WebPAnimDecoderDelete(decoder); + return FLUXER_NATIVE_STATUS_INVALID_DIMENSIONS; + } + scaled_bgra = (uint8_t *)malloc(target_bytes); + if (scaled_bgra == NULL) { + fluxer_webp_animation_encoder_delete(encoder); + WebPAnimDecoderDelete(decoder); + return FLUXER_NATIVE_STATUS_ALLOCATION_FAILED; + } + } + struct fluxer_webp_direct_state state = { + .decoder = decoder, + .encoder = encoder, + .scaled_bgra = scaled_bgra, + .source_width = (int)info.canvas_width, + .source_height = (int)info.canvas_height, + .target_width = target_width, + .target_height = target_height, + .source_frame_count = (int)info.frame_count, + .max_frames = request->limits->max_frames, + .max_duration_ms = request->limits->max_duration_ms, + .deadline_monotonic_ms = request->limits->deadline_monotonic_ms, + }; + int rc = transform_webp_animation_frames(&state); + free(scaled_bgra); + WebPAnimDecoderDelete(decoder); + if (rc == 0) { + rc = fluxer_webp_animation_encoder_finish( + encoder, request->out_buf, request->out_size); + } + fluxer_webp_animation_encoder_delete(encoder); + return rc; +} + +int fluxer_webp_transform_animated( + const void *webp_data, + size_t webp_len, + int max_width, + int max_height, + int quality, + int lossless, + int effort, + int alpha_q, + int smart_subsample, + int thread_level, + int max_source_frames, + size_t max_total_pixels, + const struct fluxer_webp_anim_limits *limits, + size_t max_output_size, + void **out_buf, + size_t *out_size +) { + struct fluxer_webp_direct_request request = { + .data = webp_data, + .len = webp_len, + .max_width = max_width, + .max_height = max_height, + .quality = quality, + .lossless = lossless, + .effort = effort, + .alpha_q = alpha_q, + .smart_subsample = smart_subsample, + .thread_level = thread_level, + .max_source_frames = max_source_frames, + .max_total_pixels = max_total_pixels, + .limits = limits, + .max_output_size = max_output_size, + .out_buf = out_buf, + .out_size = out_size, + }; + return run_webp_direct_transform(&request); +} diff --git a/fluxer_media_proxy/src/webp_animation.h b/fluxer_media_proxy/src/webp_animation.h new file mode 100644 index 000000000..8b6f42576 --- /dev/null +++ b/fluxer_media_proxy/src/webp_animation.h @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#pragma once + +#include +#include +#include + +struct fluxer_webp_animation_facts { + uint32_t canvas_width; + uint32_t canvas_height; + uint32_t frame_count; +}; + +int fluxer_webp_animation_preflight( + const void *webp_data, + size_t webp_len, + int max_frames, + size_t max_total_pixels, + long long deadline_monotonic_ms, + struct fluxer_webp_animation_facts *facts +); + +int fluxer_configure_webp_encoder( + WebPConfig *config, + int quality, + int lossless, + int effort, + int alpha_q, + int smart_subsample +); + +enum fluxer_webp_pixel_layout { + FLUXER_WEBP_PIXEL_LAYOUT_RGBA = 0, + FLUXER_WEBP_PIXEL_LAYOUT_BGRA = 1 +}; + +struct fluxer_webp_animation_encoder; + +struct fluxer_webp_animation_encoder_settings { + const WebPConfig *config; + int canvas_width; + int canvas_height; + int loop_count; + int full_canvas_frames; + enum fluxer_webp_pixel_layout pixel_layout; + long long deadline_monotonic_ms; + size_t max_output_size; +}; + +int fluxer_webp_animation_encoder_create( + const struct fluxer_webp_animation_encoder_settings *settings, + struct fluxer_webp_animation_encoder **out_encoder +); + +int fluxer_webp_animation_encoder_add( + struct fluxer_webp_animation_encoder *encoder, + const uint8_t *pixels, + size_t stride, + int duration_ms +); + +int fluxer_webp_animation_encoder_finish( + struct fluxer_webp_animation_encoder *encoder, + void **out_buf, + size_t *out_size +); + +void fluxer_webp_animation_encoder_delete( + struct fluxer_webp_animation_encoder *encoder +); diff --git a/fluxer_media_proxy/tools/install-native-deps.sh b/fluxer_media_proxy/tools/install-native-deps.sh new file mode 100755 index 000000000..9d7088494 --- /dev/null +++ b/fluxer_media_proxy/tools/install-native-deps.sh @@ -0,0 +1,272 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-or-later +set -euo pipefail + +PREFIX="${1:-/usr/local}" + +FFMPEG_VERSION=9.0 +FFMPEG_SHA256=7f607a00dd0d28a729d5a4811205812eef01cf6ef6155025febb6f36a9062d52 +FFMPEG_URL="https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.xz" + +LIBHEIF_VERSION=1.23.1 +LIBHEIF_SHA256=0de0327f60fcd47de90d5654c6fe152232738d60d84fe084ec3e0f35e03b166a +LIBHEIF_URL="https://github.com/strukturag/libheif/releases/download/v${LIBHEIF_VERSION}/libheif-${LIBHEIF_VERSION}.tar.gz" + +LIBAVFILTER_FLOOR=11.0.100 +LIBHEIF_FLOOR=1.23.0 +VIPS_FLOOR=8.13.0 +LIBWEBP_FLOOR=1.1.0 +LCMS2_FLOOR=2.8 + +LIBHEIF_FLOOR_REASON="src/native_shim/heif_pixels.c compiles a different HEIF decode path below 1.21 and below 1.23, so every environment must be at least ${LIBHEIF_FLOOR}" + +FFMPEG_BUILD_MODULES="zlib dav1d aom" +LIBHEIF_BUILD_MODULES="libde265 dav1d aom" +REQUIRED_DECODERS="apng gif h264 hevc vp8 vp9 libdav1d libaom-av1 mjpeg png webp bmp" +REQUIRED_ENCODERS="gif" +REQUIRED_DEMUXERS="apng bmp_pipe gif" + +log() { printf '==> %s\n' "$*" >&2; } + +die() { + printf 'install-native-deps: %s\n' "$*" >&2 + exit 1 +} + +pc() { + PKG_CONFIG_PATH="${PREFIX}/lib/pkgconfig:${PREFIX}/lib64/pkgconfig:${PKG_CONFIG_PATH:-}" pkg-config "$@" +} + +need_command() { + command -v "$1" >/dev/null 2>&1 || die "$1 is required but is not on PATH" +} + +atleast() { + pc --atleast-version="$2" "$1" || die "$1 >= $2 is required: $3" +} + +installed_here() { + local reported + reported="$(pc --variable=prefix "$1" 2>/dev/null)" || return 1 + [ "$reported" = "$PREFIX" ] +} + +verify_sha256() { + local file="$1" expected="$2" actual + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$file" | cut -d' ' -f1)" + else + actual="$(shasum -a 256 "$file" | cut -d' ' -f1)" + fi + [ "$actual" = "$expected" ] || die "sha256 mismatch for ${file}: expected ${expected}, got ${actual}" +} + +homebrew_prefix() { + if [ -n "${HOMEBREW_PREFIX:-}" ]; then + printf '%s\n' "$HOMEBREW_PREFIX" + return 0 + fi + need_command brew + brew --prefix +} + +verify_image_floors() { + local vips_hint="$1" webp_hint="$2" lcms2_hint="$3" + atleast vips "$VIPS_FLOOR" "$vips_hint" + atleast libwebp "$LIBWEBP_FLOOR" "$webp_hint" + atleast lcms2 "$LCMS2_FLOOR" "$lcms2_hint" +} + +verify_libyuv_toolchain() { + need_command cc + local probe_dir probe status=0 + probe_dir="$(mktemp -d)" + probe="${probe_dir}/libyuv_probe.c" + printf '%s\n' \ + '#include ' \ + 'int main(void) { return I420ToABGR(0, 0, 0, 0, 0, 0, 0, 0, 0, 0); }' \ + >"$probe" + cc -std=c11 -I"${PREFIX}/include" "$probe" -L"${PREFIX}/lib" -lyuv -o "${probe_dir}/libyuv_probe" \ + >/dev/null 2>&1 || status=$? + rm -rf "$probe_dir" + [ "$status" = 0 ] || die "libyuv.h and -lyuv must be usable from C11: $1" +} + +verify_macos_floors() { + need_command pkg-config + atleast libavfilter "$LIBAVFILTER_FLOOR" "FFmpeg >= 8.0, run 'brew install ffmpeg'" + atleast libheif "$LIBHEIF_FLOOR" "${LIBHEIF_FLOOR_REASON}, run 'brew install libheif'" + verify_image_floors \ + "run 'brew install vips'" \ + "run 'brew install webp'" \ + "run 'brew install little-cms2'" + local prefix + prefix="$(homebrew_prefix)" + [ -f "${prefix}/include/libyuv.h" ] \ + || die "libyuv header missing at ${prefix}/include/libyuv.h, run 'brew install libyuv'" + [ -f "${prefix}/lib/libyuv.dylib" ] || [ -f "${prefix}/lib/libyuv.a" ] \ + || die "libyuv library missing under ${prefix}/lib, run 'brew install libyuv'" +} + +verify_linux_floors() { + need_command pkg-config + verify_image_floors \ + "install libvips-dev" \ + "install libwebp-dev" \ + "install liblcms2-dev" + verify_libyuv_toolchain "install libyuv-dev" +} + +require_build_modules() { + local module + for module in $1; do + pc --exists "$module" \ + || die "build dependency ${module} is missing: install zlib1g-dev libdav1d-dev libaom-dev libde265-dev" + done +} + +fetch_source() { + local url="$1" sha256="$2" archive="$3" destination="$4" + log "downloading ${url}" + curl -fsSL --retry 3 --retry-delay 2 "$url" -o "$archive" + verify_sha256 "$archive" "$sha256" + mkdir -p "$destination" + tar -xf "$archive" -C "$destination" --strip-components=1 +} + +build_ffmpeg() { + need_command curl + need_command tar + need_command make + require_build_modules "$FFMPEG_BUILD_MODULES" + fetch_source "$FFMPEG_URL" "$FFMPEG_SHA256" "${WORKDIR}/ffmpeg.tar.xz" "${WORKDIR}/ffmpeg" + ( + cd "${WORKDIR}/ffmpeg" + ./configure \ + --prefix="$PREFIX" \ + --disable-debug --disable-doc --disable-static --enable-shared --enable-pic \ + --disable-programs --enable-ffmpeg \ + --enable-libaom --enable-libdav1d + make -j"$JOBS" + make install + ) +} + +build_libheif() { + need_command curl + need_command tar + need_command cmake + require_build_modules "$LIBHEIF_BUILD_MODULES" + fetch_source "$LIBHEIF_URL" "$LIBHEIF_SHA256" "${WORKDIR}/libheif.tar.gz" "${WORKDIR}/libheif" + cmake -S "${WORKDIR}/libheif" -B "${WORKDIR}/libheif/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$PREFIX" \ + -DBUILD_SHARED_LIBS=ON \ + -DBUILD_TESTING=OFF \ + -DBUILD_DOCUMENTATION=OFF \ + -DBUILD_DEVELOPMENT_TOOLS=OFF \ + -DENABLE_COVERAGE=OFF \ + -DENABLE_PLUGIN_LOADING=OFF \ + -DENABLE_MULTITHREADING_SUPPORT=ON \ + -DENABLE_PARALLEL_TILE_DECODING=ON \ + -DWITH_EXAMPLES=OFF \ + -DWITH_EXAMPLE_HEIF_THUMB=OFF \ + -DWITH_EXAMPLE_HEIF_VIEW=OFF \ + -DWITH_FUZZERS=OFF \ + -DWITH_GDK_PIXBUF=OFF \ + -DWITH_REDUCED_VISIBILITY=ON \ + -DWITH_HEADER_COMPRESSION=OFF \ + -DWITH_UNCOMPRESSED_CODEC=OFF \ + -DWITH_WEBCODECS=OFF \ + -DWITH_LIBSHARPYUV=OFF \ + -DWITH_LIBSHARPYUV_INTERNAL=OFF \ + -DWITH_LIBDE265=ON \ + -DWITH_DAV1D=ON \ + -DWITH_AOM_DECODER=ON \ + -DWITH_AOM_ENCODER=OFF \ + -DWITH_X265=OFF \ + -DWITH_X264=OFF \ + -DWITH_OpenH264_DECODER=OFF \ + -DWITH_KVAZAAR=OFF \ + -DWITH_UVG266=OFF \ + -DWITH_VVDEC=OFF \ + -DWITH_VVENC=OFF \ + -DWITH_SvtEnc=OFF \ + -DWITH_RAV1E=OFF \ + -DWITH_JPEG_DECODER=OFF \ + -DWITH_JPEG_ENCODER=OFF \ + -DWITH_OpenJPEG_DECODER=OFF \ + -DWITH_OpenJPEG_ENCODER=OFF \ + -DWITH_OPENJPH_ENCODER=OFF \ + -DWITH_FFMPEG_DECODER=OFF + cmake --build "${WORKDIR}/libheif/build" -j "$JOBS" + cmake --install "${WORKDIR}/libheif/build" +} + +register_library_path() { + [ "$(id -u)" = "0" ] || return 0 + [ -d /etc/ld.so.conf.d ] || return 0 + printf '%s\n' "${PREFIX}/lib" >/etc/ld.so.conf.d/fluxer-media-native.conf + ldconfig +} + +ffmpeg_listing() { + LD_LIBRARY_PATH="${PREFIX}/lib:${PREFIX}/lib64:${LD_LIBRARY_PATH:-}" "$1" -hide_banner "$2" +} + +require_listed() { + local listing="$1" kind="$2" names="$3" name + for name in $names; do + printf '%s\n' "$listing" | grep -qE "^[[:space:]]*[A-Z.]+[[:space:]]+${name}([[:space:]]|\$)" \ + || die "FFmpeg ${FFMPEG_VERSION} under ${PREFIX} is missing the ${name} ${kind}" + done +} + +verify_ffmpeg_codecs() { + local binary="${PREFIX}/bin/ffmpeg" listing + [ -x "$binary" ] || die "expected an ffmpeg binary at ${binary}" + listing="$(ffmpeg_listing "$binary" -decoders)" + require_listed "$listing" decoder "$REQUIRED_DECODERS" + listing="$(ffmpeg_listing "$binary" -encoders)" + require_listed "$listing" encoder "$REQUIRED_ENCODERS" + listing="$(ffmpeg_listing "$binary" -demuxers)" + require_listed "$listing" demuxer "$REQUIRED_DEMUXERS" +} + +if [ "$(uname -s)" = "Darwin" ]; then + log "macOS: verifying Homebrew native floors instead of source-building" + verify_macos_floors + log "Homebrew native floors satisfied" + exit 0 +fi + +verify_linux_floors + +JOBS="$(nproc 2>/dev/null || echo 4)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +if installed_here libavfilter && pc --atleast-version="$LIBAVFILTER_FLOOR" libavfilter && [ -x "${PREFIX}/bin/ffmpeg" ]; then + log "libavfilter $(pc --modversion libavfilter) already installed under ${PREFIX}" +else + log "building FFmpeg ${FFMPEG_VERSION} into ${PREFIX}" + build_ffmpeg +fi + +if installed_here libheif && pc --exact-version="$LIBHEIF_VERSION" libheif; then + log "libheif ${LIBHEIF_VERSION} already installed under ${PREFIX}" +else + log "building libheif ${LIBHEIF_VERSION} into ${PREFIX}" + build_libheif +fi + +register_library_path + +installed_here libavfilter || die "libavfilter must resolve from ${PREFIX}" +atleast libavfilter "$LIBAVFILTER_FLOOR" "FFmpeg >= 8.0 must resolve from ${PREFIX}" +installed_here libheif || die "libheif must resolve from ${PREFIX}" +pc --exact-version="$LIBHEIF_VERSION" libheif \ + || die "libheif ${LIBHEIF_VERSION} must resolve from ${PREFIX}, got $(pc --modversion libheif): ${LIBHEIF_FLOOR_REASON}" +verify_ffmpeg_codecs + +log "native media dependencies ready under ${PREFIX}"