Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/config/dartlang-pub-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,5 @@ rateLimits:
scope: package
burst: 10
daily: 24
imageProxyHmacKeyVersion: projects/dartlang-pub-dev/locations/us-central1/keyRings/image-proxy-key-ring/cryptoKeys/image-proxy-mac-key/cryptoKeyVersions/1
imageProxyServiceBaseUrl: https://external-images-staging.pub.dev
98 changes: 98 additions & 0 deletions app/lib/service/image_proxy/backend.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:convert';

import 'package:crypto/crypto.dart';
import 'package:gcloud/service_scope.dart' as ss;
import 'package:googleapis/cloudkms/v1.dart' as kms;
import 'package:googleapis_auth/auth_io.dart';
import 'package:pub_dev/shared/configuration.dart';
import 'package:retry/retry.dart';

import '../../shared/cached_value.dart';

/// Sets the Image proxy backend service.
void registerImageProxyBackend(ImageProxyBackend backend) =>
ss.register(#_imageProxyBackend, backend);

/// The active Youtube backend service.
ImageProxyBackend get imageProxyBackend =>
ss.lookup(#_imageProxyBackend) as ImageProxyBackend;

/// Represents the backend for the Youtube handling and related utilities.
class ImageProxyBackend {
ImageProxyBackend._();

static Future<ImageProxyBackend> create() async {
final instance = ImageProxyBackend._();
await instance._dailySecret.update();
return instance;
}

static Future<List<int>> _getDailySecret(
DateTime day,
AuthClient client,
) async {
return await retry(() async {
final api = kms.CloudKMSApi(client);
final response = await api
.projects
.locations
.keyRings
.cryptoKeys
.cryptoKeyVersions
.macSign(
kms.MacSignRequest()
..dataAsBytes = utf8.encode(
DateTime(
day.year,
day.month,
day.day,
).toUtc().toIso8601String(),
),
activeConfiguration.imageProxyHmacKeyVersion!,
);
return response.macAsBytes;
});
}

final _dailySecret = CachedValue(
name: 'image-proxy-daily-secret',
interval: Duration(minutes: 15),
maxAge: Duration(hours: 12),
timeout: Duration(hours: 12),
updateFn: () async {
final now = DateTime.now().toUtc();
final today = DateTime(now.year, now.month, now.day);
return (
today,
await _getDailySecret(
today,
await clientViaApplicationDefaultCredentials(
scopes: [kms.CloudKMSApi.cloudPlatformScope],
),
),
);
},
);

String imageProxyUrl(Uri originalUrl) {
final dailySecret = _dailySecret.value;
// TODO handle the null case more gracefully.
if (dailySecret == null) {
throw StateError('Image proxy HMAC secret is not available.');
}
final (today, secret) = dailySecret;

final hmac = Hmac(
sha256,
secret,
).convert(originalUrl.toString().codeUnits).bytes;
activeConfiguration.imageProxyServiceBaseUrl;

return '${activeConfiguration.imageProxyServiceBaseUrl}/${Uri.encodeComponent(base64Encode(hmac))}/${today.millisecondsSinceEpoch}/${Uri.encodeComponent(originalUrl.toString())}';
}
}
3 changes: 3 additions & 0 deletions app/lib/service/services.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'package:pub_dev/package/api_export/api_exporter.dart';
import 'package:pub_dev/search/handlers.dart';
import 'package:pub_dev/service/async_queue/async_queue.dart';
import 'package:pub_dev/service/download_counts/backend.dart';
import 'package:pub_dev/service/image_proxy/backend.dart';
import 'package:pub_dev/service/security_advisories/backend.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart';
Expand Down Expand Up @@ -319,6 +320,8 @@ Future<R> _withPubServices<R>(FutureOr<R> Function() fn) async {
registerYoutubeBackend(YoutubeBackend());
registerScopeExitCallback(youtubeBackend.close);

registerImageProxyBackend(await ImageProxyBackend.create());

// depends on previously registered services
registerPackageBackend(
PackageBackend(
Expand Down
10 changes: 10 additions & 0 deletions app/lib/shared/configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,10 @@ final class Configuration {
/// The rate limits for auditable operations.
final List<RateLimit>? rateLimits;

/// The Cloud KMS HMAC key version used to sign image proxy URLs.
final String? imageProxyHmacKeyVersion;
final String? imageProxyServiceBaseUrl;

/// Load [Configuration] from YAML file at [path] substituting `{{ENV}}` for
/// the value of environment variable `ENV`.
factory Configuration.fromYamlFile(final String path) {
Expand Down Expand Up @@ -285,6 +289,8 @@ final class Configuration {
required this.defaultServiceBaseUrl,
required this.tools,
required this.rateLimits,
required this.imageProxyHmacKeyVersion,
required this.imageProxyServiceBaseUrl,
});

/// Load configuration from `app/config/<projectId>.yaml` where `projectId`
Expand Down Expand Up @@ -356,6 +362,8 @@ final class Configuration {
],
tools: null,
rateLimits: null,
imageProxyHmacKeyVersion: null,
imageProxyServiceBaseUrl: null,
);
}

Expand Down Expand Up @@ -406,6 +414,8 @@ final class Configuration {
],
tools: null,
rateLimits: null,
imageProxyHmacKeyVersion: null,
imageProxyServiceBaseUrl: null,
);
}

Expand Down
12 changes: 12 additions & 0 deletions app/lib/shared/configuration.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions app/lib/shared/markdown.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:convert';

import 'package:googleapis/cloudkms/v1.dart' as kms;
import 'package:html/dom.dart' as html;
import 'package:html/dom_parsing.dart' as html_parsing;
import 'package:html/parser.dart' as html_parser;
import 'package:logging/logging.dart';
import 'package:markdown/markdown.dart' as m;
import 'package:pub_dev/frontend/static_files.dart';
import 'package:pub_dev/service/image_proxy/backend.dart';
import 'package:pub_dev/shared/changelog.dart';
import 'package:sanitize_html/sanitize_html.dart';

Expand Down Expand Up @@ -124,6 +128,7 @@ String _postProcessHtml(
var root = html_parser.parseFragment(rawHtml);

_RelativeUrlRewriter(urlResolverFn, relativeFrom).visit(root);
_ImageProxyRewriter().visit(root);

if (isChangelog) {
final oldNodes = [...root.nodes];
Expand Down Expand Up @@ -203,6 +208,27 @@ class _UnsafeUrlFilter extends html_parsing.TreeVisitor {
}
}

class _ImageProxyRewriter extends html_parsing.TreeVisitor {
@override
void visitElement(html.Element element) {
super.visitElement(element);

if (element.localName == 'img') {
final src = element.attributes['src'];
final uri = Uri.tryParse(src ?? '');
if (uri == null || uri.isInvalid) {
// TODO: consider removing the element entirely
return;
}
if ((uri.isScheme('http') || uri.isScheme('https')) &&
!uri.isTrustedHost) {
final proxiedUrl = imageProxyBackend.imageProxyUrl(uri);
element.attributes['src'] = proxiedUrl;
}
}
}
}

/// Rewrites relative URLs with the provided [urlResolverFn].
class _RelativeUrlRewriter extends html_parsing.TreeVisitor {
final UrlResolverFn? urlResolverFn;
Expand Down
Loading