diff --git a/lib/network/components/request_rewrite_manager.dart b/lib/network/components/request_rewrite_manager.dart index 69756e3..09bb6b2 100644 --- a/lib/network/components/request_rewrite_manager.dart +++ b/lib/network/components/request_rewrite_manager.dart @@ -388,12 +388,13 @@ class RequestRewrites { //修改消息 _updateMessage(HttpMessage message, RewriteItem item) { if (item.type == RewriteType.updateBody && message.body != null) { - message.body = utf8.encode(message.bodyAsString.replaceAllMapped(RegExp(item.key!), (match) { + String body = message.bodyAsString.replaceAllMapped(RegExp(item.key!), (match) { if (match.groupCount > 0 && item.value?.contains("\$1") == true) { return item.value!.replaceAll("\$1", match.group(1)!); } return item.value ?? ''; - })); + }); + message.body = message.charset == 'utf-8' ? utf8.encode(body) : body.codeUnits; message.headers.remove(HttpHeaders.CONTENT_ENCODING); message.headers.contentLength = message.body!.length; @@ -453,7 +454,7 @@ class RequestRewrites { } if (item.body != null) { - message.body = utf8.encode(item.body!); + message.body = message.charset == 'utf-8' ? utf8.encode(item.body!) : item.body?.codeUnits; message.headers.contentLength = message.body!.length; } return; diff --git a/lib/network/components/script_manager.dart b/lib/network/components/script_manager.dart index 26dee9a..b0389f5 100644 --- a/lib/network/components/script_manager.dart +++ b/lib/network/components/script_manager.dart @@ -310,7 +310,8 @@ async function onResponse(context, request, response) { 'queries': requestUri?.queryParameters, 'headers': request.headers.toMap(), 'method': request.method.name, - 'body': request.bodyAsString + 'body': request.bodyAsString, + 'rawBody': request.body }; } @@ -320,8 +321,12 @@ async function onResponse(context, request, response) { if (response.contentType.isBinary) { body = response.body; } - - return {'headers': response.headers.toMap(), 'statusCode': response.status.code, 'body': body}; + return { + 'headers': response.headers.toMap(), + 'statusCode': response.status.code, + 'body': body, + 'rawBody': response.body + }; } //http request @@ -348,8 +353,11 @@ async function onResponse(context, request, response) { } request.headers.add(key, value); }); - request.body = map['body'] == null ? null : utf8.encode(map['body'].toString()); + request.body = map['body']?.toString().codeUnits; + if (request.body != null && request.charset == 'utf-8') { + request.body = utf8.encode(map['body'].toString()); + } return request; } @@ -365,6 +373,7 @@ async function onResponse(context, request, response) { response.headers.add(key, value); }); + response.headers.remove(HttpHeaders.CONTENT_ENCODING); //判断是否是二进制 @@ -373,7 +382,11 @@ async function onResponse(context, request, response) { return response; } - response.body = map['body'] == null ? null : utf8.encode(map['body'].toString()); + response.body = map['body']?.toString().codeUnits; + if (response.body != null && response.charset == 'utf-8') { + response.body = utf8.encode(map['body'].toString()); + } + return response; } } diff --git a/lib/network/http/content_type.dart b/lib/network/http/content_type.dart new file mode 100644 index 0000000..56626be --- /dev/null +++ b/lib/network/http/content_type.dart @@ -0,0 +1,184 @@ +/* + * Copyright Copyright 2024 WangHongEn. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:network_proxy/network/util/cache.dart'; + +///content type +///@author WangHongEn +enum ContentType { + json, + formUrl, + formData, + js, + html, + text, + css, + font, + image, + video, + http; + + static ContentType valueOf(String name) { + return ContentType.values.firstWhere((element) => element.name == name.toLowerCase(), orElse: () => http); + } + + //是否是二进制 + bool get isBinary { + return this == image || this == font || this == video; + } +} + +class MediaType { + static const String wildcardType = "*/*"; + static LruCache cachedMediaTypes = LruCache(64); + + ///默认编码类型 + static List defaultCharsetMediaTypes = [ + MediaType("text", "plain", charset: "utf-8"), + MediaType("text", "html", charset: "utf-8"), + MediaType("application", "json", charset: "utf-8"), + MediaType("application", "problem+json", charset: "utf-8"), + MediaType("application", "xml", charset: "utf-8"), + MediaType("application", "xhtml+xml", charset: "utf-8"), + MediaType("application", "octet-stream", charset: "utf-16"), + MediaType("image", "*", charset: "utf-16"), + ]; + + final String type; + final String subtype; + final Map parameters; + + MediaType(this.type, this.subtype, {this.parameters = const {}, String? charset}) { + if (charset != null) { + parameters["charset"] = charset; + } + } + + factory MediaType.valueOf(String mediaType) { + if (mediaType.isEmpty) { + throw InvalidMediaTypeException(mediaType, "'mediaType' must not be empty"); + } + // do not cache multipart mime types with random boundaries + if (mediaType.startsWith("multipart")) { + return _parseMediaTypeInternal(mediaType); + } + + return cachedMediaTypes.pubIfAbsent(mediaType, () => _parseMediaTypeInternal(mediaType)); + } + + ///编码 + String? get charset { + return parameters["charset"]; + } + + ///获取默认编码 + static String? defaultCharset(MediaType mediaType) { + for (var defaultMediaType in defaultCharsetMediaTypes) { + if (defaultMediaType.equalsTypeAndSubtype(mediaType)) { + return defaultMediaType.charset; + } + } + return null; + } + + static MediaType _parseMediaTypeInternal(String mediaType) { + int index = mediaType.indexOf(';'); + String fullType = (index >= 0 ? mediaType.substring(0, index) : mediaType).trim(); + if (fullType.isEmpty) { + throw InvalidMediaTypeException(mediaType, "'mediaType' must not be empty"); + } + + if (MediaType.wildcardType == fullType) { + fullType = "*/*"; + } + int subIndex = fullType.indexOf('/'); + if (subIndex == -1) { + throw InvalidMediaTypeException(mediaType, "does not contain '/'"); + } + if (subIndex == fullType.length - 1) { + throw InvalidMediaTypeException(mediaType, "does not contain subtype after '/'"); + } + String type = fullType.substring(0, subIndex); + String subtype = fullType.substring(subIndex + 1); + if (MediaType.wildcardType == type && MediaType.wildcardType != subtype) { + throw InvalidMediaTypeException(mediaType, "wildcard type is legal only in '*/*' (all mime types)"); + } + + Map parameters = {}; + do { + int nextIndex = index + 1; + bool quoted = false; + while (nextIndex < mediaType.length) { + var ch = mediaType[0]; + if (ch == ';') { + if (!quoted) { + break; + } + } else if (ch == '"') { + quoted = !quoted; + } + nextIndex++; + } + String parameter = mediaType.substring(index + 1, nextIndex).trim(); + if (parameter.isNotEmpty) { + int eqIndex = parameter.indexOf('='); + if (eqIndex >= 0) { + String attribute = parameter.substring(0, eqIndex).trim(); + String value = parameter.substring(eqIndex + 1).trim(); + parameters[attribute] = value; + } + } + index = nextIndex; + } while (index < mediaType.length); + + try { + return MediaType(type, subtype, parameters: parameters); + } catch (e) { + throw InvalidMediaTypeException(mediaType, e.toString()); + } + } + + ///类似于equals(Object),但仅基于类型和子类型,即忽略参数。 + bool equalsTypeAndSubtype(MediaType other) { + return type.toLowerCase() == other.type.toLowerCase() && subtype.toLowerCase() == other.subtype.toLowerCase(); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other is MediaType) { + return type == other.type && subtype == other.subtype && parameters == other.parameters; + } + return false; + } + + @override + int get hashCode => type.hashCode ^ subtype.hashCode ^ parameters.hashCode; +} + +class InvalidMediaTypeException implements Exception { + final String mediaType; + final String message; + + InvalidMediaTypeException(this.mediaType, this.message); + + @override + String toString() { + return "InvalidMediaTypeException: $message (mediaType: $mediaType)"; + } +} diff --git a/lib/network/http/http.dart b/lib/network/http/http.dart index 6912ed3..9c3c38e 100644 --- a/lib/network/http/http.dart +++ b/lib/network/http/http.dart @@ -1,5 +1,5 @@ /* - * Copyright 2023 the original author or authors. + * Copyright Copyright 2023 WangHongEn. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ import 'dart:convert'; import 'dart:math'; import 'package:network_proxy/network/host_port.dart'; +import 'package:network_proxy/network/http/content_type.dart'; import 'package:network_proxy/network/http/websocket.dart'; import 'package:network_proxy/network/util/logger.dart'; import 'package:network_proxy/network/util/process_info.dart'; @@ -26,6 +27,7 @@ import 'package:network_proxy/utils/compress.dart'; import 'http_headers.dart'; ///定义HTTP消息的接口,为HttpRequest和HttpResponse提供公共属性。 +///@author WangHongEn abstract class HttpMessage { ///内容类型 static final Map contentTypes = { @@ -76,15 +78,31 @@ abstract class HttpMessage { orElse: () => const MapEntry("unknown", ContentType.http)) .value; + ///获取消息体编码 + String? get charset { + var contentType = headers.contentType; + if (contentType.isEmpty) { + return null; + } + + MediaType mediaType = MediaType.valueOf(contentType); + return mediaType.charset ?? MediaType.defaultCharset(mediaType); + } + String get bodyAsString { if (body == null || body?.isEmpty == true) { return ""; } try { + List rawBody = body!; if (headers.contentEncoding == 'br') { - return utf8.decode(brDecode(body!)); + rawBody = brDecode(body!); } - return utf8.decode(body!); + if (charset == 'utf-8') { + return utf8.decode(rawBody); + } + + return String.fromCharCodes(rawBody); } catch (e) { return String.fromCharCodes(body!); } @@ -148,6 +166,12 @@ class HttpRequest extends HttpMessage { } } + ///获取消息体编码 + @override + String? get charset { + return super.charset ?? 'utf-8'; + } + ///复制请求 HttpRequest copy({String? uri}) { var request = HttpRequest(method, uri ?? this.uri, protocolVersion: protocolVersion); @@ -189,29 +213,6 @@ class HttpRequest extends HttpMessage { } } -enum ContentType { - json, - formUrl, - formData, - js, - html, - text, - css, - font, - image, - video, - http; - - static ContentType valueOf(String name) { - return ContentType.values.firstWhere((element) => element.name == name.toLowerCase(), orElse: () => http); - } - - //是否是二进制 - bool get isBinary { - return this == image || this == font || this == video; - } -} - ///HTTP响应。 class HttpResponse extends HttpMessage { HttpStatus status; diff --git a/lib/network/util/cache.dart b/lib/network/util/cache.dart index 17541f7..2c32301 100644 --- a/lib/network/util/cache.dart +++ b/lib/network/util/cache.dart @@ -15,7 +15,11 @@ */ import 'dart:async'; +import 'dart:collection'; +/// A cache that expires entries after a given duration. +/// The cache uses a timer to remove entries after the specified duration. +/// @author WangHongEn class ExpiringCache { final Duration duration; final _cache = {}; @@ -48,4 +52,53 @@ class ExpiringCache { _cache.remove(key); } +} + +class LruCache { + final int capacity; + final _cache = LinkedHashMap(); + + LruCache(this.capacity); + + V? get(K key) { + if (!_cache.containsKey(key)) { + return null; + } + + // Move the accessed key to the end to show that it was recently used + final value = _cache.remove(key); + _cache[key] = value as V; + return value; + } + + V pubIfAbsent(K key, V Function() ifAbsent) { + if (_cache.containsKey(key)) { + return _cache[key]!; + } + + final value = ifAbsent(); + set(key, value); + return value; + + } + void set(K key, V value) { + if (_cache.containsKey(key)) { + // Remove the old value + _cache.remove(key); + } else if (_cache.length == capacity) { + // Remove the first key (least recently used) + _cache.remove(_cache.keys.first); + } + _cache[key] = value; + } + + void remove(K key) { + _cache.remove(key); + } + + int get length => _cache.length; + + void clear() { + _cache.clear(); + } } \ No newline at end of file diff --git a/lib/ui/component/utils.dart b/lib/ui/component/utils.dart index ccda916..fce2fda 100644 --- a/lib/ui/component/utils.dart +++ b/lib/ui/component/utils.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; +import 'package:network_proxy/network/http/content_type.dart'; import 'package:network_proxy/network/http/http.dart'; const contentMap = { diff --git a/lib/ui/content/body.dart b/lib/ui/content/body.dart index afd66b0..27dae90 100644 --- a/lib/ui/content/body.dart +++ b/lib/ui/content/body.dart @@ -22,6 +22,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:network_proxy/network/components/request_rewrite_manager.dart'; +import 'package:network_proxy/network/http/content_type.dart'; import 'package:network_proxy/network/http/http.dart'; import 'package:network_proxy/network/util/logger.dart'; import 'package:network_proxy/ui/component/encoder.dart'; diff --git a/lib/ui/desktop/request/model/search_model.dart b/lib/ui/desktop/request/model/search_model.dart index ae6c6c2..08dd0c2 100644 --- a/lib/ui/desktop/request/model/search_model.dart +++ b/lib/ui/desktop/request/model/search_model.dart @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import 'package:network_proxy/network/http/content_type.dart'; import 'package:network_proxy/network/http/http.dart'; /// @author wanghongen diff --git a/lib/ui/desktop/request/request_editor.dart b/lib/ui/desktop/request/request_editor.dart index 5beed48..6939931 100644 --- a/lib/ui/desktop/request/request_editor.dart +++ b/lib/ui/desktop/request/request_editor.dart @@ -256,7 +256,7 @@ class _HttpState extends State<_HttpWidget> { message = widget.message; body = TextEditingController(text: widget.message?.bodyAsString); if (widget.message?.headers == null && !widget.readOnly) { - initHeader["User-Agent"] = ["ProxyPin/1.1.1"]; + initHeader["User-Agent"] = ["ProxyPin/1.1.3"]; initHeader["Accept"] = ["*/*"]; return; } diff --git a/lib/ui/desktop/request/search.dart b/lib/ui/desktop/request/search.dart index d513bf4..0130d28 100644 --- a/lib/ui/desktop/request/search.dart +++ b/lib/ui/desktop/request/search.dart @@ -15,7 +15,7 @@ */ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:network_proxy/network/http/http.dart'; +import 'package:network_proxy/network/http/content_type.dart'; import 'package:network_proxy/ui/desktop/request/model/search_model.dart'; import 'package:network_proxy/ui/desktop/request/search_condition.dart'; diff --git a/lib/ui/desktop/request/search_condition.dart b/lib/ui/desktop/request/search_condition.dart index d9f4944..5e39740 100644 --- a/lib/ui/desktop/request/search_condition.dart +++ b/lib/ui/desktop/request/search_condition.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:network_proxy/network/http/content_type.dart'; import 'package:network_proxy/network/http/http.dart'; import 'package:network_proxy/ui/desktop/request/model/search_model.dart'; import 'package:network_proxy/utils/lang.dart'; diff --git a/lib/ui/desktop/toolbar/setting/script.dart b/lib/ui/desktop/toolbar/setting/script.dart index 7afc5d2..ecc6b91 100644 --- a/lib/ui/desktop/toolbar/setting/script.dart +++ b/lib/ui/desktop/toolbar/setting/script.dart @@ -104,15 +104,17 @@ class _ScriptWidgetState extends State { children: [ Row(children: [ SizedBox( - width: 300, - child: SwitchWidget( - title: localizations.enableScript, - subtitle: localizations.scriptUseDescribe, - value: data.enabled, - onChanged: (value) { - data.enabled = value; - _refreshScript(); - })), + width: 350, + child: ListTile( + title: Text(localizations.enableScript), + subtitle: Text(localizations.scriptUseDescribe), + trailing: SwitchWidget( + value: data.enabled, + scale: 0.9, + onChanged: (value) { + data.enabled = value; + _refreshScript(); + }))), Expanded( child: Row( mainAxisAlignment: MainAxisAlignment.end, diff --git a/lib/ui/mobile/request/request_editor.dart b/lib/ui/mobile/request/request_editor.dart index d679313..18c6488 100644 --- a/lib/ui/mobile/request/request_editor.dart +++ b/lib/ui/mobile/request/request_editor.dart @@ -244,7 +244,7 @@ class _HttpState extends State<_HttpWidget> with AutomaticKeepAliveClientMixin { message = widget.message; body = widget.message?.bodyAsString; if (widget.message?.headers == null && !widget.readOnly) { - initHeader["User-Agent"] = ["ProxyPin/1.1.1"]; + initHeader["User-Agent"] = ["ProxyPin/1.1.3"]; initHeader["Accept"] = ["*/*"]; return; } diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index f505a87..75db61c 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -72,19 +72,18 @@ class _MobileScriptState extends State { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ - Row(children: [ - SizedBox( - width: 300, - child: SwitchWidget( - title: localizations.enableScript, - subtitle: localizations.scriptUseDescribe, - value: data.enabled, - onChanged: (value) { - data.enabled = value; - _refreshScript(); - }, - )), - ]), + SizedBox( + child: ListTile( + title: Text(localizations.enableScript), + subtitle: Text(localizations.scriptUseDescribe), + trailing: SwitchWidget( + value: data.enabled, + onChanged: (value) { + data.enabled = value; + _refreshScript(); + }, + ))), + const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ diff --git a/lib/utils/har.dart b/lib/utils/har.dart index 5dccd26..9dafc91 100644 --- a/lib/utils/har.dart +++ b/lib/utils/har.dart @@ -17,6 +17,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:network_proxy/network/host_port.dart'; +import 'package:network_proxy/network/http/content_type.dart'; import 'package:network_proxy/network/http/http.dart'; import 'package:network_proxy/network/http/http_headers.dart'; import 'package:network_proxy/network/util/process_info.dart'; @@ -80,7 +81,7 @@ class Har { title = title.contains("ProxyPin") ? title : "[ProxyPin]$title"; har["log"] = { "version": "1.2", - "creator": {"name": "ProxyPin", "version": "1.1.1"}, + "creator": {"name": "ProxyPin", "version": "1.1.3"}, "pages": [ { "title": title, diff --git a/test/http_test.dart b/test/http_test.dart index e8ade12..4fb4c6f 100644 --- a/test/http_test.dart +++ b/test/http_test.dart @@ -1,6 +1,9 @@ import 'dart:io'; main() async { + var contentType = ContentType.parse("application/json"); + print(contentType); + print(contentType.charset); print(Uri.parse("https://www.v2ex.com").scheme); // await socketTest(); await webTest();