seoActionFormMiddleware function

Middleware seoActionFormMiddleware({
  1. required String publicOrigin,
  2. required List<SeoActionFormRegistration> registrations,
  3. List<SeoRoute> routes = const [],
  4. int maxBodyBytes = 32768,
  5. int maxUploadBodyBytes = seoActionFormMaxImageBytes + 65536,
  6. SeoActionFormErrorHandler? onError,
})

Handles the package-owned POST endpoint for registered action forms.

Place this middleware before seoBotMiddleware. Requests outside the fixed action-form namespace are passed through unchanged. The application handler receives only bounded, decoded and schema-validated values. When a registration declares a success redirect, routes must be the same route table used for DOM-first delivery. Text-only plans accept only URL-encoded bodies; plans with the single image field accept only multipart bodies and retain the package's fixed per-image ceiling. maxUploadBodyBytes may lower the total multipart body ceiling.

Implementation

Middleware seoActionFormMiddleware({
  required String publicOrigin,
  required List<SeoActionFormRegistration> registrations,
  List<SeoRoute> routes = const [],
  int maxBodyBytes = 32768,
  int maxUploadBodyBytes = seoActionFormMaxImageBytes + 65536,
  SeoActionFormErrorHandler? onError,
}) {
  final siteBase = _canonicalSiteBase(publicOrigin);
  if (siteBase == null) {
    throw ArgumentError.value(
      publicOrigin,
      'publicOrigin',
      'must be an absolute HTTP(S) origin or site URL',
    );
  }
  final expectedOrigin = siteBase.origin;
  if (maxBodyBytes < 1024 || maxBodyBytes > 1024 * 1024) {
    throw RangeError.range(maxBodyBytes, 1024, 1024 * 1024, 'maxBodyBytes');
  }
  if (maxUploadBodyBytes < 1024 ||
      maxUploadBodyBytes > seoActionFormMaxImageBytes + 65536) {
    throw RangeError.range(
      maxUploadBodyBytes,
      1024,
      seoActionFormMaxImageBytes + 65536,
      'maxUploadBodyBytes',
    );
  }
  final byPath = <String, SeoActionFormRegistration>{};
  final successLocations = Map<SeoActionFormRegistration, String>.identity();
  for (final registration in registrations) {
    if (byPath.containsKey(registration.plan.endpointPath)) {
      throw ArgumentError.value(
        registration.plan.actionId,
        'registrations',
        'contains a duplicate action id',
      );
    }
    byPath[registration.plan.endpointPath] = registration;
    final redirect = registration.successRedirect;
    if (redirect != null) {
      successLocations[registration] = _requireSuccessLocation(
        registration,
        redirect,
        routes,
        siteBase.path,
      );
    }
  }
  final frozen = Map<String, SeoActionFormRegistration>.unmodifiable(byPath);

  return (innerHandler) {
    return (request) async {
      final path = '/${request.url.path}';
      if (!path.startsWith(internalSeoActionFormEndpointPrefix)) {
        return innerHandler(request);
      }
      final registration = frozen[path];
      if (registration == null) {
        return _plainResponse(404, 'Not found.');
      }
      if (request.method != 'POST') {
        return _plainResponse(405, 'Method not allowed.', extraHeaders: {
          'allow': 'POST',
        });
      }
      if (_canonicalOrigin(request.headers['origin']) != expectedOrigin) {
        return _resultResponse(
          request,
          registration,
          403,
          registration._preparedMessages.forbiddenRequest,
        );
      }
      final bodyFormat = _requestBodyFormat(
        request.headers['content-type'],
        expectsImage: registration.plan.hasImage,
      );
      if (bodyFormat == null) {
        return _resultResponse(
          request,
          registration,
          415,
          registration._preparedMessages.malformedRequest,
        );
      }
      final contentEncoding = request.headers['content-encoding'];
      if (contentEncoding != null &&
          contentEncoding.trim().toLowerCase() != 'identity') {
        return _resultResponse(
          request,
          registration,
          415,
          registration._preparedMessages.malformedRequest,
        );
      }
      final declared = request.headers['content-length'];
      final declaredLength = declared == null ? null : int.tryParse(declared);
      if (declared != null && (declaredLength == null || declaredLength < 0)) {
        return _resultResponse(
          request,
          registration,
          400,
          registration._preparedMessages.malformedRequest,
        );
      }
      final bodyLimit =
          registration.plan.hasImage ? maxUploadBodyBytes : maxBodyBytes;
      if (declaredLength != null && declaredLength > bodyLimit) {
        return _resultResponse(
          request,
          registration,
          413,
          registration._preparedMessages.malformedRequest,
        );
      }

      Map<String, String>? rawValues;
      Map<String, SeoActionFormImageSelection> images = const {};
      try {
        if (bodyFormat.boundary case final boundary?) {
          final decoded = await _decodeMultipart(
            request.read(),
            bodyLimit,
            boundary,
            registration.plan,
            allowContext: registration.contextCodec != null,
          );
          rawValues = decoded?.values;
          images = decoded?.images ?? const {};
        } else {
          final bytes = await _readBounded(request.read(), bodyLimit);
          rawValues = _decodeForm(bytes);
        }
      } on _BodyTooLarge {
        return _resultResponse(
          request,
          registration,
          413,
          registration._preparedMessages.malformedRequest,
        );
      } on MimeMultipartException {
        rawValues = null;
      } on FormatException {
        rawValues = null;
      } on ArgumentError {
        rawValues = null;
      }
      if (rawValues == null) {
        return _resultResponse(
          request,
          registration,
          400,
          registration._preparedMessages.malformedRequest,
        );
      }

      final submittedValues = Map<String, String>.of(rawValues);
      final contextToken =
          submittedValues.remove(internalSeoActionFormContextFieldName);
      SeoActionFormContext? context;
      if (registration.contextCodec case final codec?) {
        context = contextToken == null
            ? null
            : codec.verify(
                actionId: registration.plan.actionId,
                token: contextToken,
              );
        if (context == null) {
          return _resultResponse(
            request,
            registration,
            403,
            registration._preparedMessages.forbiddenRequest,
          );
        }
      } else if (contextToken != null) {
        return _resultResponse(
          request,
          registration,
          400,
          registration._preparedMessages.malformedRequest,
        );
      }

      final validation = registration.flowPlan == null
          ? validateSeoActionFormValues(
              registration.plan,
              submittedValues,
              images: images,
            )
          : validateSeoActionFlowValues(
              registration.flowPlan!,
              submittedValues,
              images: images,
            );
      if (validation.malformed) {
        return _resultResponse(
          request,
          registration,
          400,
          registration._preparedMessages.malformedRequest,
        );
      }
      if (!validation.isValid) {
        return _resultResponse(
          request,
          registration,
          422,
          registration._preparedMessages.invalidSubmission,
          fieldErrors: validation.errors,
        );
      }

      try {
        final rawResult = context == null
            ? await registration.handler!(validation.values!)
            : await registration.contextHandler!(
                validation.values!,
                context,
              );
        final result = canonicalizeSeoActionFormResult(
          registration.plan,
          rawResult,
          allowedFieldNames: registration.flowPlan == null
              ? null
              : validation.values!.values.keys.toSet(),
        );
        if (result == null) {
          throw const FormatException('Invalid action-form handler result');
        }
        return _resultResponse(
          request,
          registration,
          result.outcome == SeoActionFormOutcome.success ? 200 : 422,
          result.message,
          fieldErrors: result.fieldErrors,
          success: result.outcome == SeoActionFormOutcome.success,
          successLocation: result.outcome == SeoActionFormOutcome.success
              ? successLocations[registration]
              : null,
        );
      } catch (error, stackTrace) {
        try {
          onError?.call(error, stackTrace);
        } catch (_) {
          // Diagnostics must not turn a contained application failure into an
          // unhandled request failure.
        }
        return _resultResponse(
          request,
          registration,
          500,
          registration._preparedMessages.internalError,
        );
      }
    };
  };
}