Files
Obtainium/lib/app_sources/gitlab.dart
Imran Remtulla 6e2c2c592c fix: resolve bugs, dead code, unsafe patterns, and infrastructure issues
HIGH severity:
- Fix Completer double-completion masking CheckUpdatesException as StateError
- Delete orphaned APK files on install-already-pending status code
- Stop transient source-load errors from permanently deleting user app entries
- Await saveApps in setCategories to avoid data inconsistency
- Guard packageManager.openApp double-tap behind installed check
- Fix hashCode.abs() overflow on min int value in DownloadNotification

MEDIUM severity:
- Wrap checkETagHeader client.send in try/finally to prevent resource leak
- Remove unused loadingCompleter dead code in loadApps
- Pass additionalSettings (not {}) in apkcombo sourceRequest call
- Remove dead getRequestHeaders call in coolapk
- Add missing == true on boolean setting checks in fdroidrepo
- Zero-pad month/day in itchio date version string
- Deep-copy form items by value in izzyondroid and codeberg constructors
- Remove redundant double-reverse for sort=none in github release sorting
- Clamp negative gradient stop to valid range in app_list_tile
- Show WebView error state when main frame resource fails to load
- Defer WebView appId changes until page load completes
- Defer category pruning side effect out of build into post-frame callback
- Expand URL regex in import_export to match whitespace-delimited URLs
- Remove future-update-in-build side effect for source notes in add_app
- Add items hash to form reinit check alongside widget key

LOW severity:
- Add 'name' field to all 11 AppSource subclasses that lacked it
- Remove unused source_provider imports from app_detail_widgets, category_editor
- Rename copy-pasted bool show param to bool value on non-show setter pairs
- Delete dead proguard-rules.pro (Gson is not a dependency)
- Remove npm package files from git tracking in assets/translations
- Fix DEVELOPER_GUIDE test directory reference
2026-07-02 14:38:17 +01:00

277 lines
9.1 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart';
import 'package:obtainium/app_sources/github.dart';
import 'package:obtainium/custom_errors.dart';
import 'package:obtainium/providers/settings_provider.dart';
import 'package:obtainium/providers/source_provider.dart';
import 'package:obtainium/components/generated_form_model.dart';
import 'package:easy_localization/easy_localization.dart';
class GitLab extends AppSource {
// Reused for getAppNames, API URL building, search, and getRequestHeaders
// so a single GitHub instance handles all delegated behaviour.
final GitHub _gh = GitHub(hostChanged: true);
GitLab({bool hostChanged = false}) {
name = 'GitLab';
hosts = ['gitlab.com'];
canSearch = true;
showReleaseDateAsVersionToggle = true;
this.hostChanged = hostChanged;
sourceConfigSettingFormItems = [
GeneratedFormTextField(
'gitlab-creds',
label: tr('gitlabPATLabel'),
password: true,
required: false,
helpUrl:
'https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#create-a-personal-access-token',
),
];
additionalSourceAppSpecificSettingFormItems = [
AppSource.fallbackToOlderReleasesFormItem,
];
}
@override
String sourceSpecificStandardizeURL(String url, {bool forSelection = false}) {
final urlSegments = url.split('/');
final cutOffIndex = urlSegments.indexWhere((s) => s == '-');
url = urlSegments
.sublist(0, cutOffIndex <= 0 ? null : cutOffIndex)
.join('/');
final RegExp standardUrlRegEx = RegExp(
'^https?://(www\\.)?${getSourceRegex(hosts)}/[^/]+(/[^/]+){1,20}',
caseSensitive: false,
);
final RegExpMatch? match = standardUrlRegEx.firstMatch(url);
if (match == null) {
throw InvalidURLError(name);
}
return match.group(0)!;
}
Future<String?> getPATIfAny(Map<String, dynamic> additionalSettings) async {
final SettingsProvider settingsProvider = SettingsProvider();
await settingsProvider.initializeSettings();
final sourceConfig = await getSourceConfigValues(
additionalSettings,
settingsProvider,
);
final String? creds = sourceConfig['gitlab-creds'];
return creds != null && creds.isNotEmpty ? creds : null;
}
@override
Future<Map<String, List<String>>> search(
String query, {
Map<String, dynamic> querySettings = const {},
}) async {
final url =
'https://${hosts[0]}/api/v4/projects?search=${Uri.encodeQueryComponent(query)}';
final res = await sourceRequest(url, {});
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
}
final json = jsonDecode(res.body) as List<dynamic>;
final Map<String, List<String>> results = {};
for (var element in json) {
results['https://${hosts[0]}/${element['path_with_namespace']}'] = [
element['name_with_namespace'],
element['description'] ?? tr('noDescription'),
];
}
return results;
}
@override
String? changeLogPageFromStandardUrl(String standardUrl) =>
'$standardUrl/-/releases';
@override
Future<Map<String, String>?> getRequestHeaders(
Map<String, dynamic> additionalSettings,
String url, {
bool forAPKDownload = false,
}) async {
// Provide headers acceptable to, e.g. Cloudflare protection
final headers = <String, String>{};
headers[HttpHeaders.refererHeader] = 'https://${hosts[0]}';
return headers;
}
@override
Future<String> assetUrlPrefetchModifier(
String assetUrl,
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
final String? pat = await getPATIfAny(
hostChanged ? additionalSettings : {},
);
final String optionalAuth = (pat != null) ? 'private_token=$pat' : '';
return optionalAuth.isEmpty
? assetUrl
: '$assetUrl${Uri.parse(assetUrl).query.isEmpty ? '?' : '&'}$optionalAuth';
}
@override
Future<APKDetails> getLatestAPKDetails(
String standardUrl,
Map<String, dynamic> additionalSettings,
) async {
try {
final names = _gh.getAppNames(standardUrl);
final String projectUriComponent =
'${Uri.encodeComponent(names.author)}%2F${Uri.encodeComponent(names.name)}';
final String? pat = await getPATIfAny(
hostChanged ? additionalSettings : {},
);
final String optionalAuth = (pat != null) ? 'private_token=$pat' : '';
final bool trackOnly = additionalSettings['trackOnly'] == true;
// Get project ID
final Response res0 = await sourceRequest(
'https://${hosts[0]}/api/v4/projects/$projectUriComponent?$optionalAuth',
additionalSettings,
);
if (res0.statusCode != 200) {
throw getObtainiumHttpError(res0);
}
final int? projectId = jsonDecode(res0.body)['id'];
if (projectId == null) {
throw NoReleasesError();
}
// Request data from REST API
final String releasesPath = trackOnly ? 'repository/tags' : 'releases';
final String query = [
if (optionalAuth.isNotEmpty) optionalAuth,
'per_page=100',
].join('&');
final Response res = await sourceRequest(
'https://${hosts[0]}/api/v4/projects/$projectUriComponent/$releasesPath?$query',
additionalSettings,
);
if (res.statusCode != 200) {
throw getObtainiumHttpError(res);
}
// Extract .apk details from received data
Iterable<APKDetails> apkDetailsList = [];
final decoded = jsonDecode(res.body);
if (decoded is! List) {
throw NoReleasesError();
}
final json = decoded;
apkDetailsList = json.map((e) {
final apkUrlsFromAssets =
(e['assets']?['links'] as List<dynamic>? ?? [])
.map((e) {
final url =
(e['direct_asset_url'] ?? e['url'] ?? '') as String;
final parsedUrl = url.isNotEmpty ? Uri.parse(url) : null;
return MapEntry(
(e['name'] ??
(parsedUrl != null &&
parsedUrl.pathSegments.isNotEmpty
? parsedUrl.pathSegments.last
: 'unknown'))
as String,
(e['direct_asset_url'] ?? e['url'] ?? '') as String,
);
})
.where(
(s) =>
s.key.isNotEmpty &&
(AppSource.isApkOrContainerFile(s.key) ||
AppSource.isApkOrContainerFile(s.value)),
)
.toList();
final uploadedAPKsFromDescription = ((e['description'] ?? '') as String)
.split('](')
.join('\n')
.split('.apk)')
.join('.apk\n')
.split('.xapk)')
.join('.xapk\n')
.split('.apkm)')
.join('.apkm\n')
.split('.apks)')
.join('.apks\n')
.split('\n')
.where(
(s) =>
s.startsWith('/uploads/') &&
AppSource.isApkOrContainerFile(s),
)
.map((s) => 'https://${hosts[0]}/-/project/$projectId$s')
.map((l) => MapEntry(Uri.parse(l).pathSegments.last, l))
.toList();
final Map<String, String> apkUrls = {};
for (var entry in apkUrlsFromAssets) {
apkUrls[entry.key] = entry.value;
}
for (var entry in uploadedAPKsFromDescription) {
apkUrls[entry.key] = entry.value;
}
final releaseDateString =
e['released_at'] ?? e['created_at'] ?? e['commit']?['created_at'];
final DateTime? releaseDate = releaseDateString != null
? DateTime.tryParse(releaseDateString.toString())
: null;
return APKDetails(
e['tag_name'] ?? e['name'],
apkUrls.entries.toList(),
AppNames(names.author, names.name.split('/').last),
releaseDate: releaseDate,
);
});
if (apkDetailsList.isEmpty) {
throw NoReleasesError();
}
var finalResult = apkDetailsList.first;
final bool fallbackToOlderReleases =
additionalSettings['fallbackToOlderReleases'] == true;
if (finalResult.apkUrls.isEmpty &&
fallbackToOlderReleases &&
!trackOnly) {
apkDetailsList = apkDetailsList
.where((e) => e.apkUrls.isNotEmpty)
.toList();
if (apkDetailsList.isEmpty) {
throw NoReleasesError();
}
finalResult = apkDetailsList.first;
}
if (finalResult.apkUrls.isEmpty && !trackOnly) {
throw NoAPKError();
}
finalResult.apkUrls = finalResult.apkUrls.map((apkUrl) {
if (RegExp(
'^${RegExp.escape(standardUrl)}/-/jobs/[0-9]+/artifacts/file/[^/]+',
).hasMatch(apkUrl.value)) {
return MapEntry(
apkUrl.key,
apkUrl.value.replaceFirst('/file/', '/raw/'),
);
} else {
return apkUrl;
}
}).toList();
return finalResult;
} catch (e) {
rethrowOrWrapError(e);
}
}
}