seoActionFormMiddleware function
Middleware
seoActionFormMiddleware({
- required String publicOrigin,
- required List<
SeoActionFormRegistration> registrations, - List<
SeoRoute> routes = const [], - int maxBodyBytes = 32768,
- int maxUploadBodyBytes = seoActionFormMaxImageBytes + 65536,
- 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,
);
}
};
};
}