Files
reelbox/lib/data/home_banners.dart

133 lines
3.8 KiB
Dart

import 'api_service.dart';
import 'content_policy.dart';
class HomeBanner {
const HomeBanner._({
required this.titleEn,
required this.titleFa,
required this.subtitleEn,
required this.subtitleFa,
required this.target,
required this.isAdvertisement,
this.image,
});
final String titleEn, titleFa, subtitleEn, subtitleFa;
final Uri target;
final Uri? image;
final bool isAdvertisement;
String title(bool persian) => persian ? titleFa : titleEn;
String subtitle(bool persian) => persian ? subtitleFa : subtitleEn;
static final telegram = HomeBanner._(
titleEn: 'ReelBox updates',
titleFa: 'تازه‌های ریل‌باکس',
subtitleEn: 'Updates and announcements',
subtitleFa: 'به‌روزرسانی‌ها و اطلاعیه‌ها',
target: Uri.parse('https://t.me/ReelBoxApp'),
isAdvertisement: false,
);
}
/// Only configured advertisements are parsed. The official Telegram entry is
/// fixed in the client and can never be replaced or relabelled by server data.
List<HomeBanner> parseHomeBanners(dynamic raw) {
final result = <HomeBanner>[HomeBanner.telegram];
if (raw is! Map || raw['banners'] is! List) return result;
for (final row in (raw['banners'] as List).take(20)) {
if (result.length == 6) break;
if (row is! Map) continue;
final en = _label(row['title_en'], 80);
final fa = _label(row['title_fa'] ?? row['title_en'], 80);
final subtitleEn = _label(row['subtitle_en'] ?? '', 180);
final subtitleFa = _label(
row['subtitle_fa'] ?? row['subtitle_en'] ?? '',
180,
);
final target = _https(row['target_url']);
final imageValue = row['image_url'];
final image = imageValue == null || imageValue == ''
? null
: _https(imageValue);
if (en == null ||
en.isEmpty ||
fa == null ||
fa.isEmpty ||
subtitleEn == null ||
subtitleFa == null ||
target == null ||
(imageValue != null && imageValue != '' && image == null)) {
continue;
}
result.add(
HomeBanner._(
titleEn: en,
titleFa: fa,
subtitleEn: subtitleEn,
subtitleFa: subtitleFa,
target: target,
image: image,
isAdvertisement: true,
),
);
}
return List.unmodifiable(result);
}
String? _label(dynamic value, int limit) {
if (value is! String ||
value.length > limit ||
RegExp(r'[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]').hasMatch(value) ||
!ContentPolicy.allowsText(value)) {
return null;
}
return value.trim();
}
Uri? _https(dynamic value) {
if (value is! String ||
value.length > 2048 ||
RegExp(r'[\x00-\x20\x7f\\]').hasMatch(value) ||
RegExp(
r'%(?![0-9a-fA-F]{2})|%(?:0[0-9a-f]|1[0-9a-f]|7f)',
caseSensitive: false,
).hasMatch(value) ||
!ContentPolicy.allowsUrl(value)) {
return null;
}
try {
final uri = Uri.parse(value);
if (uri.scheme != 'https' ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.port != 443 ||
uri.host.length > 253 ||
!uri.host
.split('.')
.every(
(part) =>
RegExp(r'^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$')
.hasMatch(part),
)) {
return null;
}
return uri;
} on FormatException {
return null;
}
}
/// Uses only the existing trusted metadata transport, never a config URL.
class HomeBannerService {
const HomeBannerService(this.api);
final ApiService api;
Future<List<HomeBanner>> load() async {
try {
return parseHomeBanners(await api.get('banners/'));
} catch (_) {
// Announcements remain reachable when the metadata service is offline.
return [HomeBanner.telegram];
}
}
}