diff --git a/lib/components/string_list_field.dart b/lib/components/string_list_field.dart new file mode 100644 index 0000000..338cd99 --- /dev/null +++ b/lib/components/string_list_field.dart @@ -0,0 +1,196 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; + +class StringListFormField extends StatefulWidget { + const StringListFormField( + {Key? key, + this.initialValue, + this.onChanged, + this.decoration, + this.padding, + this.validator, + this.autovalidateMode, + this.label, + this.helper, + this.constraints}) + : super(key: key); + final List? initialValue; + final ValueChanged>? onChanged; + final InputDecoration? decoration; + final EdgeInsetsGeometry? padding; + final FormFieldValidator? validator; + final AutovalidateMode? autovalidateMode; + final Widget? label; + final Widget? helper; + final BoxConstraints? constraints; + + @override + State createState() => _StringListFormField(); +} + +class _StringListFormField extends State { + late List value; + late String parentKey; + late Key lastKey; + late List keys; + late int rebuildKeys; + + @override + void initState() { + super.initState(); + value = widget.initialValue ?? []; + parentKey = widget.key?.toString() ?? ""; + lastKey = ValueKey("${parentKey}_new"); + final len = value.length; + keys = List.generate(len, (index) => ValueKey("${parentKey}_$index")); + rebuildKeys = 0; + } + + Widget _buildItem(BuildContext context, int index) { + final isLast = index == value.length; + if (isLast) { + return IconButton( + key: lastKey, + onPressed: () { + setState(() { + value.add(""); + keys.add(ValueKey("${parentKey}_${value.length}")); + }); + widget.onChanged?.call(value); + }, + icon: const Icon(Icons.add)); + } + return Row( + key: keys[index], + children: [ + Expanded( + child: TextFormField( + initialValue: value[index], + decoration: widget.decoration, + onChanged: (String? value) { + if (widget.validator != null) { + final re = widget.validator?.call(value); + if (re != null) { + return; + } + } + this.value[index] = value ?? ""; + widget.onChanged?.call(this.value); + }, + validator: widget.validator, + autovalidateMode: widget.autovalidateMode, + ), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + value.removeAt(index); + }); + widget.onChanged?.call(value); + }, + ), + ReorderableDragStartListener( + index: index, child: const Icon(Icons.reorder)), + ], + ); + } + + void onReorder(int oldIndex, int newIndex) { + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final String item = value.removeAt(oldIndex); + value.insert(newIndex, item); + final key = keys.removeAt(oldIndex); + keys.insert(newIndex, key); + }); + widget.onChanged?.call(value); + } + + Widget _buildList(BuildContext context) { + Widget list = ReorderableList( + itemBuilder: (context, index) { + final item = _buildItem(context, index); + if (widget.padding == null) { + return item; + } + return Padding( + key: item.key, + padding: widget.padding!, + child: item, + ); + }, + itemCount: value.length + 1, + onReorder: onReorder, + proxyDecorator: proxyDecorator, + shrinkWrap: true); + if (widget.constraints != null) { + list = ConstrainedBox( + constraints: widget.constraints!, + child: list, + ); + } + return list; + } + + Widget proxyDecorator(Widget child, int index, Animation animation) { + return AnimatedBuilder( + animation: animation, + builder: (BuildContext context, Widget? child) { + final double animValue = Curves.easeInOut.transform(animation.value); + final double elevation = lerpDouble(0, 6, animValue)!; + return Material( + elevation: elevation, + child: child, + ); + }, + child: child, + ); + } + + Widget _buildLabel(BuildContext context) { + if (widget.label == null) { + return Container(); + } + if (widget.padding == null) { + return widget.label!; + } + return Padding( + padding: widget.padding!, + child: widget.label!, + ); + } + + Widget _buildHelper(BuildContext context) { + if (widget.helper == null) { + return Container(); + } + if (widget.padding == null) { + return widget.helper!; + } + return Padding( + padding: widget.padding!, + child: widget.helper!, + ); + } + + @override + Widget build(BuildContext context) { + if (value.length != keys.length) { + final len = value.length; + keys = List.generate( + len, (index) => ValueKey("${parentKey}_${rebuildKeys}_$index")); + rebuildKeys++; + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabel(context), + _buildList(context), + _buildHelper(context), + ]); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3a2aa09..952de24 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -126,5 +126,11 @@ "downloadTimeout": "Download timeout", "downloadTimeoutHelp": "The download will be terminated when nothing is received within the specified time period.", "millisecond": "millisecond", - "ffprobePath": "The path to the ffprobe binary" + "ffprobePath": "The path to the ffprobe binary", + "corsCredentialsHostsHint": "eg. https://ehf.lifegpc.com", + "invalidURL": "Invalid URL.", + "invalidURLOrigin": "Invalid URL origin.", + "httpHttpsNeeded": "Unsupported scheme. Only https or http are supported.", + "corsCredentialsHosts": "The URL origins which are allowed to send CORS requests with credentials", + "corsCredentialsHostsHelp": "Add websites to this list can lead to security risks, make sure you trust these websites." } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index f09b4fc..cfe299d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -126,5 +126,11 @@ "downloadTimeout": "下载超时时间", "downloadTimeoutHelp": "当指定时间段内没有任何内容收到时,将会停止下载。", "millisecond": "毫秒", - "ffprobePath": "FFPROBE 二进制的位置" + "ffprobePath": "FFPROBE 二进制的位置", + "corsCredentialsHostsHint": "例如: https://ehf.lifegpc.com", + "invalidURL": "非法的 URL。", + "invalidURLOrigin": "非法的 URL 源。", + "httpHttpsNeeded": "不支持的协议。仅支持 http 或 https。", + "corsCredentialsHosts": "被允许发送带有凭证的 CORS 请求的 URL 源", + "corsCredentialsHostsHelp": "将网站加入这个列表会导致安全风险,请确保你信任这些网站。" } diff --git a/lib/server_settings.dart b/lib/server_settings.dart index be3298a..023d182 100644 --- a/lib/server_settings.dart +++ b/lib/server_settings.dart @@ -7,6 +7,7 @@ import 'package:logging/logging.dart'; import 'api/config.dart'; import 'components/labeled_checkbox.dart'; import 'components/number_field.dart'; +import 'components/string_list_field.dart'; import 'globals.dart'; import 'platform/ua.dart'; @@ -57,6 +58,7 @@ class _ServerSettingsPage extends State Future _saveConfig() async { if (_isSaving) return; try { + _now.corsCredentialsHosts?.removeWhere((e) => e.isEmpty); _saveCancel = CancelToken(); setState(() { _isSaving = true; @@ -171,7 +173,8 @@ class _ServerSettingsPage extends State actions: [ buildThemeModeIcon(context), buildMoreVertSettingsButon(context), - ]), + ], + floating: true), SliverList( delegate: SliverChildListDelegate([ _buildCheckBox(context), @@ -514,6 +517,49 @@ class _ServerSettingsPage extends State }); }, )), + StringListFormField( + key: const ValueKey("corsCredentialsHosts"), + initialValue: + _now.corsCredentialsHosts ?? _config!.corsCredentialsHosts, + decoration: InputDecoration( + border: const OutlineInputBorder(), + hintText: i18n.corsCredentialsHostsHint, + ), + padding: const EdgeInsets.symmetric(vertical: 8), + onChanged: (s) { + setState(() { + _now.corsCredentialsHosts = s; + _changed = true; + }); + }, + validator: (s) { + if (s == null || s.isEmpty) return null; + try { + final u = Uri.parse(s); + if (u.hasQuery || + u.userInfo.isNotEmpty || + u.hasFragment || + !u.hasEmptyPath || + !u.hasScheme) return i18n.invalidURLOrigin; + if (u.scheme != "http" && u.scheme != "https") { + return i18n.httpHttpsNeeded; + } + return null; + } catch (e) { + return i18n.invalidURL; + } + }, + autovalidateMode: AutovalidateMode.onUserInteraction, + label: Text(i18n.corsCredentialsHosts), + constraints: const BoxConstraints( + maxHeight: 300, + ), + helper: Text(i18n.corsCredentialsHostsHelp, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Colors.red)), + ), _buildWithVecticalPadding(TextFormField( initialValue: _now.flutterFrontend ?? _config!.flutterFrontend, decoration: InputDecoration(