feat(app): Implement deep linking for repository details#246
feat(app): Implement deep linking for repository details#246rainxchzed wants to merge 5 commits intomainfrom
Conversation
This commit introduces support for deep linking, allowing users to open repository detail screens directly from external URLs. The app now handles custom schemes and standard GitHub repository URLs.
To support opening repositories by owner and name (from a deep link) instead of just by ID, the data and presentation layers have been updated to fetch repository data using this new information.
- **feat(deeplink)**: Added `DeepLinkParser` to handle various URI formats:
- `githubstore://repo/{owner}/{repo}`
- `https://github.com/{owner}/{repo}`
- `https://github-store.org/app/{owner}/{repo}`
- **feat(android)**: Configured `AndroidManifest.xml` with intent filters for the supported deep link schemes and hosts, including `autoVerify` for app links.
- **feat(android, desktop)**: Updated `MainActivity` and `DesktopApp` to receive and process incoming deep link URIs.
- **feat(details)**: Added `getRepositoryByOwnerAndName` to the `DetailsRepository` and `DetailsViewModel` to fetch repository data via owner/name, complementing the existing `getRepositoryById` method.
- **refactor(navigation)**: Modified the `DetailsScreen` navigation destination to accept `owner` and `repo` parameters alongside `repositoryId`.
… default This commit removes the "All" filter option from the developer profile screen and establishes "With Releases" as the new default filter for repositories. - **refactor(domain)**: Removed `ALL` from the `RepoFilterType` enum. - **refactor(presentation)**: Updated the initial state in `DeveloperProfileState` to use `WITH_RELEASES` as the `currentFilter`. - **refactor(presentation)**: Removed logic and UI elements related to the "All" filter from the ViewModel, composables, and string resources.
This commit introduces deep linking functionality for the macOS desktop application. The app now registers and handles the custom `githubstore://` URI scheme. - **feat(desktop)**: Added a URI scheme handler (`setOpenURIHandler`) in `DesktopApp.kt` to process `githubstore://` links on macOS. - **feat(desktop)**: Configured the macOS app bundle (`build.gradle.kts`) by adding `CFBundleURLTypes` to the `Info.plist` to register the custom `githubstore` URL scheme. - **refactor(deeplink)**: Updated the `DeepLinkParser` to support a new web URL format (`https://github-store.org/app/?repo={owner}/{repo}`) using a query parameter, replacing the previous path-based structure. - **fix(android)**: Changed the Android intent filter for the app's website to be a standard deep link, as `autoVerify` is not applicable for this URL structure.
This commit enhances the null safety of the `RateLimitDialog`. The `rateLimitInfo` parameter is now non-nullable, preventing potential null pointer exceptions within the composable. The call site in `Main.kt` has been updated to handle the nullable `state.rateLimitInfo` with a `let` block, ensuring the dialog is only composed when the rate limit data is available. - **refactor(components)**: Changed `RateLimitDialog`'s `rateLimitInfo` parameter from nullable (`RateLimitInfo?`) to non-nullable (`RateLimitInfo`). - **fix(app)**: Wrapped the `RateLimitDialog` call in `Main.kt` within a null check (`state.rateLimitInfo?.let`) to ensure it's only shown when `rateLimitInfo` is not null. - **chore(preview)**: Updated the `RateLimitDialogPreview` to provide a non-null `RateLimitInfo` instance.
WalkthroughAdds cross-platform deep-link support (Android intent-filters, macOS URL scheme, desktop URI handler) and a DeepLinkParser, routes deep links to DetailsScreen via owner/repo lookups, updates navigation and viewmodels, changes RateLimitDialog API, and removes the RepoFilterType.ALL enum entry. Changes
Sequence Diagram(s)sequenceDiagram
participant User as User / OS
participant Platform as Platform Handler<br/>(Android / macOS / Desktop)
participant App as App Composable
participant Parser as DeepLinkParser
participant Nav as Navigation
participant VM as DetailsViewModel
participant Repo as DetailsRepository
User->>Platform: Open deep link (e.g. githubstore://repo/owner/name)
Platform->>App: Deliver deepLinkUri (intent / open-uri / CLI arg)
App->>Parser: parse(deepLinkUri)
Parser-->>App: DeepLinkDestination.Repository(owner, repo)
App->>Nav: navigate to DetailsScreen(owner, repo)
Nav->>VM: instantiate with ownerParam, repoParam
VM->>Repo: getRepositoryByOwnerAndName(owner, repo)
Repo-->>VM: GithubRepoSummary
VM-->>Nav: update state
Nav->>App: render DetailsScreen with repo data
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~35 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
No actionable comments were generated in the recent review. 🎉 Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Fix all issues with AI agents
In `@composeApp/src/androidMain/AndroidManifest.xml`:
- Around line 62-73: Remove the overly-broad intent filter that claims
github.com (the <intent-filter> with action android.intent.action.VIEW and <data
android:host="github.com" android:pathPattern="/.*/..*" android:scheme="https"
/>) from AndroidManifest.xml; instead rely on your custom scheme
(githubstore://) and your verified domain (github-store.org), or implement an
explicit user-initiated mechanism like ACTION_SEND/share-target for GitHub links
so you don’t intercept third-party github.com URLs without autoVerify and
assetlinks control.
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt`:
- Around line 31-53: The parser applies excluded-path validation only for GitHub
URLs via isValidOwnerRepo but not for the custom scheme or github-store.org
query; update DeepLinkParser so that both the githubstore:// branch (where you
call parseOwnerRepo on the removed prefix) and the storeQueryPattern branch
(where you call parseOwnerRepo on repoParam) enforce the same excluded-path
checks—either by having parseOwnerRepo perform isValidOwnerRepo(owner, repo) and
return null/throw for invalid paths, or by calling isValidOwnerRepo on the
owner/repo returned by parseOwnerRepo before returning
DeepLinkDestination.Repository; adjust parseOwnerRepo, isValidOwnerRepo and the
two call sites accordingly to ensure uniform validation.
In `@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt`:
- Around line 29-45: The effect using LaunchedEffect(deepLinkUri) won't re-run
for identical URIs; wrap the URI in an event that always changes (e.g., create
DeepLinkEvent(val uri: String, val timestamp: Long =
System.currentTimeMillis())) and switch the LaunchedEffect key to the
DeepLinkEvent, then call DeepLinkParser.parse(event.uri) and navigate with
navBackStack.navigate(GithubStoreGraph.DetailsScreen(owner = destination.owner,
repo = destination.repo)) as before; alternatively, if you prefer clearing
state, reset deepLinkUri to null after handling inside the same block so
subsequent identical intents update the state and re-trigger the effect.
In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt`:
- Around line 18-31: deepLinkUri is created with mutableStateOf(...) inside the
composable so it resets on recomposition and the Desktop.setOpenURIHandler
registration runs repeatedly; fix by making the state stable with remember {
mutableStateOf(args.firstOrNull()) } (replace mutableStateOf(...) with remember
{ mutableStateOf(...) } for deepLinkUri) and move the
Desktop.getDesktop()/setOpenURIHandler(...) logic into a side-effect that runs
once (e.g., DisposableEffect(Unit) or LaunchedEffect(Unit)) where you register
the handler (using setOpenURIHandler { event -> deepLinkUri =
event.uri.toString() }) and restore the previous handler or clear it onDispose;
reference deepLinkUri, mutableStateOf, remember, setOpenURIHandler,
Desktop.getDesktop, and DisposableEffect/LaunchedEffect when making the change.
In
`@feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt`:
- Around line 68-74: Sanitize and validate owner/name from all deep-link sources
before calling getRepositoryByOwnerAndName: URL-decode values returned by
DeepLinkParser.extractQueryParam, ensure DeepLinkParser.parseOwnerRepo performs
strict validation (reject empty, any '/' characters, path-traversal patterns
like '..', and characters '?', '#' and other special chars), and apply
DeepLinkParser.isValidOwnerRepo uniformly for github.com, githubstore://repo/,
and github-store.org paths; then only pass validated owner/name into
DetailsRepositoryImpl.getRepositoryByOwnerAndName so Ktor never receives
unvalidated path segments.
🧹 Nitpick comments (4)
composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/components/RateLimitDialog.kt (1)
131-135: Preview data hasremaining > limit, which is unrealistic.The preview passes
remaining = 2000withlimit = 1000andresetTimestamp = 0L. While this doesn't affect functionality, more realistic preview data (e.g.,remaining = 0,resetTimestamp = System.currentTimeMillis() + 60_000) would make the preview more useful for visual verification.composeApp/src/androidMain/AndroidManifest.xml (1)
75-86: Addandroid:autoVerify="true"to thegithub-store.orgintent filter and host/.well-known/assetlinks.json.Since
github-store.orgis your project's domain, implement Android App Links verification by addingandroid:autoVerify="true"to this intent filter and hosting anassetlinks.jsonfile on the domain. This allows Android to verify your app's ownership and bypass user disambiguation dialogs.The
DeepLinkParsercorrectly handles the query parameter parsing for this URL format—no changes needed there.composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/GithubStoreGraph.kt (1)
17-21: Consider documenting theDetailsScreenconstruction modes with KDoc.While
DetailsScreencan technically be constructed with all defaults, theDetailsViewModelcorrectly disambiguates between the two modes at lines 98–102:val repo = if (ownerParam.isNotEmpty() && repoParam.isNotEmpty()) { detailsRepository.getRepositoryByOwnerAndName(ownerParam, repoParam) } else { detailsRepository.getRepositoryById(repositoryId) }All call sites consistently follow the intended pattern (either
repositoryIdorowner+repo, never both). For improved maintainability, add a KDoc comment toDetailsScreenclarifying: "UserepositoryIdfor direct repository lookup (ownerParam/repoParam remain empty), orowner+repofor deep link navigation (repositoryId remains -1L)."composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt (1)
58-64: RedundantisNotEmpty()checks after filtering.
segmentsis already filtered to non-empty strings on Line 59, sosegments[0].isNotEmpty() && segments[1].isNotEmpty()on Line 60 are always true. You can simplify to justsegments.size >= 2.
| <!-- GitHub repository links: https://github.com/{owner}/{repo} --> | ||
| <intent-filter> | ||
| <action android:name="android.intent.action.VIEW" /> | ||
|
|
||
| <category android:name="android.intent.category.DEFAULT" /> | ||
| <category android:name="android.intent.category.BROWSABLE" /> | ||
|
|
||
| <data | ||
| android:host="github.com" | ||
| android:pathPattern="/.*/..*" | ||
| android:scheme="https" /> | ||
| </intent-filter> |
There was a problem hiding this comment.
Claiming github.com URLs without App Links verification is problematic.
This intent filter intercepts https://github.com URLs without android:autoVerify="true" and a corresponding /.well-known/assetlinks.json on github.com (which you don't control). This causes:
- Disambiguation dialogs: Android will prompt users to choose between your app and the browser/GitHub app for every matching URL, degrading UX.
- Overly broad
pathPattern:"/.*/..*"matches not just/{owner}/{repo}but also/{owner}/{repo}/issues,/{owner}/{repo}/tree/main/src/..., profile URLs like/{user}/stars, etc. Android'spathPatternregex is limited and can't constrain to exactly two segments. - User trust: Intercepting a major third-party domain may appear suspicious and could cause issues with Play Store review.
Consider removing this filter and instead relying solely on the custom githubstore:// scheme and your own domain (github-store.org). If you want to support GitHub URLs, implement a share-target or use ACTION_SEND with text filtering, which is opt-in by the user.
🤖 Prompt for AI Agents
In `@composeApp/src/androidMain/AndroidManifest.xml` around lines 62 - 73, Remove
the overly-broad intent filter that claims github.com (the <intent-filter> with
action android.intent.action.VIEW and <data android:host="github.com"
android:pathPattern="/.*/..*" android:scheme="https" />) from
AndroidManifest.xml; instead rely on your custom scheme (githubstore://) and
your verified domain (github-store.org), or implement an explicit user-initiated
mechanism like ACTION_SEND/share-target for GitHub links so you don’t intercept
third-party github.com URLs without autoVerify and assetlinks control.
| if (trimmed.startsWith("githubstore://repo/")) { | ||
| val path = trimmed.removePrefix("githubstore://repo/") | ||
| return parseOwnerRepo(path) | ||
| } | ||
|
|
||
| // Handle https://github.com/{owner}/{repo} | ||
| val githubPattern = Regex("^https?://github\\.com/([^/]+)/([^/?#]+)") | ||
| githubPattern.find(trimmed)?.let { match -> | ||
| val owner = match.groupValues[1] | ||
| val repo = match.groupValues[2] | ||
| if (isValidOwnerRepo(owner, repo)) { | ||
| return DeepLinkDestination.Repository(owner, repo) | ||
| } | ||
| } | ||
|
|
||
| // Handle https://github-store.org/app/?repo={owner}/{repo} | ||
| val storeQueryPattern = Regex("^https?://github-store\\.org/app/?(\\?|#)") | ||
| if (storeQueryPattern.containsMatchIn(trimmed)) { | ||
| val repoParam = extractQueryParam(trimmed, "repo") | ||
| if (repoParam != null) { | ||
| return parseOwnerRepo(repoParam) | ||
| } | ||
| } |
There was a problem hiding this comment.
Inconsistent excluded-path validation across URL schemes.
The githubExcludedPaths check is applied only for github.com URLs (via isValidOwnerRepo), but not for githubstore:// or github-store.org URLs. If a github-store.org link contains ?repo=settings/something, it would be accepted as a valid repository.
This may be intentional (app-owned schemes are trusted), but worth confirming. If these schemes could be triggered by external actors, consider applying validation uniformly.
🤖 Prompt for AI Agents
In
`@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.kt`
around lines 31 - 53, The parser applies excluded-path validation only for
GitHub URLs via isValidOwnerRepo but not for the custom scheme or
github-store.org query; update DeepLinkParser so that both the githubstore://
branch (where you call parseOwnerRepo on the removed prefix) and the
storeQueryPattern branch (where you call parseOwnerRepo on repoParam) enforce
the same excluded-path checks—either by having parseOwnerRepo perform
isValidOwnerRepo(owner, repo) and return null/throw for invalid paths, or by
calling isValidOwnerRepo on the owner/repo returned by parseOwnerRepo before
returning DeepLinkDestination.Repository; adjust parseOwnerRepo,
isValidOwnerRepo and the two call sites accordingly to ensure uniform
validation.
| LaunchedEffect(deepLinkUri) { | ||
| deepLinkUri?.let { uri -> | ||
| when (val destination = DeepLinkParser.parse(uri)) { | ||
| is DeepLinkDestination.Repository -> { | ||
| navBackStack.navigate( | ||
| GithubStoreGraph.DetailsScreen( | ||
| owner = destination.owner, | ||
| repo = destination.repo | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| DeepLinkDestination.None -> { /* ignore unrecognized deep links */ | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Repeated identical deep link URIs won't re-trigger navigation.
LaunchedEffect(deepLinkUri) only re-runs when the key value changes. If the user opens the same githubstore://repo/owner/repo link twice in a row, deepLinkUri stays the same string and the effect won't fire again. On Android with singleTask, successive intents carrying the same URI will update deepLinkUri via onNewIntent, but the state value won't actually change if it's the same string.
Consider using a wrapper that includes a timestamp or incrementing counter to guarantee re-trigger:
data class DeepLinkEvent(val uri: String, val timestamp: Long = System.currentTimeMillis())Alternatively, reset deepLinkUri to null after processing, though that requires a callback from App to the platform layer.
🤖 Prompt for AI Agents
In `@composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/Main.kt` around
lines 29 - 45, The effect using LaunchedEffect(deepLinkUri) won't re-run for
identical URIs; wrap the URI in an event that always changes (e.g., create
DeepLinkEvent(val uri: String, val timestamp: Long =
System.currentTimeMillis())) and switch the LaunchedEffect key to the
DeepLinkEvent, then call DeepLinkParser.parse(event.uri) and navigate with
navBackStack.navigate(GithubStoreGraph.DetailsScreen(owner = destination.owner,
repo = destination.repo)) as before; alternatively, if you prefer clearing
state, reset deepLinkUri to null after handling inside the same block so
subsequent identical intents update the state and re-trigger the effect.
| // Deep link state — can come from CLI args or macOS open-url event | ||
| var deepLinkUri by mutableStateOf(args.firstOrNull()) | ||
|
|
||
| // Register macOS URI scheme handler (githubstore://) | ||
| // When the packaged .app is opened via a URL, macOS delivers it here | ||
| if (Desktop.isDesktopSupported()) { | ||
| Desktop.getDesktop().let { desktop -> | ||
| if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) { | ||
| desktop.setOpenURIHandler { event -> | ||
| deepLinkUri = event.uri.toString() | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing remember — deep link state is reset on every recomposition.
mutableStateOf(args.firstOrNull()) on line 19 is inside the application {} composable scope but is not wrapped in remember. Every recomposition of the application block will re-create this state, discarding any URI set by setOpenURIHandler. The same applies to the handler registration — it runs on every recomposition.
Proposed fix
+ import androidx.compose.runtime.remember
+ import androidx.compose.runtime.LaunchedEffect
...
- var deepLinkUri by mutableStateOf(args.firstOrNull())
+ var deepLinkUri by remember { mutableStateOf(args.firstOrNull()) }
- if (Desktop.isDesktopSupported()) {
- Desktop.getDesktop().let { desktop ->
- if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) {
- desktop.setOpenURIHandler { event ->
- deepLinkUri = event.uri.toString()
- }
- }
- }
- }
+ LaunchedEffect(Unit) {
+ if (Desktop.isDesktopSupported()) {
+ Desktop.getDesktop().let { desktop ->
+ if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) {
+ desktop.setOpenURIHandler { event ->
+ deepLinkUri = event.uri.toString()
+ }
+ }
+ }
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Deep link state — can come from CLI args or macOS open-url event | |
| var deepLinkUri by mutableStateOf(args.firstOrNull()) | |
| // Register macOS URI scheme handler (githubstore://) | |
| // When the packaged .app is opened via a URL, macOS delivers it here | |
| if (Desktop.isDesktopSupported()) { | |
| Desktop.getDesktop().let { desktop -> | |
| if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) { | |
| desktop.setOpenURIHandler { event -> | |
| deepLinkUri = event.uri.toString() | |
| } | |
| } | |
| } | |
| } | |
| // Deep link state — can come from CLI args or macOS open-url event | |
| var deepLinkUri by remember { mutableStateOf(args.firstOrNull()) } | |
| // Register macOS URI scheme handler (githubstore://) | |
| // When the packaged .app is opened via a URL, macOS delivers it here | |
| LaunchedEffect(Unit) { | |
| if (Desktop.isDesktopSupported()) { | |
| Desktop.getDesktop().let { desktop -> | |
| if (desktop.isSupported(Desktop.Action.APP_OPEN_URI)) { | |
| desktop.setOpenURIHandler { event -> | |
| deepLinkUri = event.uri.toString() | |
| } | |
| } | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopApp.kt` around
lines 18 - 31, deepLinkUri is created with mutableStateOf(...) inside the
composable so it resets on recomposition and the Desktop.setOpenURIHandler
registration runs repeatedly; fix by making the state stable with remember {
mutableStateOf(args.firstOrNull()) } (replace mutableStateOf(...) with remember
{ mutableStateOf(...) } for deepLinkUri) and move the
Desktop.getDesktop()/setOpenURIHandler(...) logic into a side-effect that runs
once (e.g., DisposableEffect(Unit) or LaunchedEffect(Unit)) where you register
the handler (using setOpenURIHandler { event -> deepLinkUri =
event.uri.toString() }) and restore the previous handler or clear it onDispose;
reference deepLinkUri, mutableStateOf, remember, setOpenURIHandler,
Desktop.getDesktop, and DisposableEffect/LaunchedEffect when making the change.
| override suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary { | ||
| return httpClient.executeRequest<RepoByIdNetwork> { | ||
| get("/repos/$owner/$name") { | ||
| header(HttpHeaders.Accept, "application/vnd.github+json") | ||
| } | ||
| }.getOrThrow().toGithubRepoSummary() | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n "DeepLinkParser\|deeplink\|deep_link" --type=kt -C5Repository: rainxchzed/Github-Store
Length of output: 93
🏁 Script executed:
rg -n "DeepLinkParser|deeplink|deep_link" -C5Repository: rainxchzed/Github-Store
Length of output: 4363
🏁 Script executed:
cat -n composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/deeplink/DeepLinkParser.ktRepository: rainxchzed/Github-Store
Length of output: 3649
🏁 Script executed:
cat -n feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt | head -100Repository: rainxchzed/Github-Store
Length of output: 4676
🏁 Script executed:
fd -n "executeRequest" --type fRepository: rainxchzed/Github-Store
Length of output: 297
🏁 Script executed:
rg -n "fun executeRequest" -A 10Repository: rainxchzed/Github-Store
Length of output: 49
🏁 Script executed:
rg -n "executeRequest" -B 2 -A 8 | head -50Repository: rainxchzed/Github-Store
Length of output: 6868
🏁 Script executed:
fd "executeRequest" --extension kt | xargs cat | head -100Repository: rainxchzed/Github-Store
Length of output: 49
🏁 Script executed:
rg -n "inline.*executeRequest\|fun executeRequest" -B 2 -A 15Repository: rainxchzed/Github-Store
Length of output: 49
🏁 Script executed:
find . -name "*.kt" -type f | xargs grep -l "fun executeRequest" | head -5Repository: rainxchzed/Github-Store
Length of output: 49
🏁 Script executed:
rg -n "executeRequest" core/ -B 2 -A 12 | head -80Repository: rainxchzed/Github-Store
Length of output: 8059
🏁 Script executed:
cat -n core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/HttpClientFactory.kt | head -110Repository: rainxchzed/Github-Store
Length of output: 4029
Sanitize owner and name parameters from all deep-link paths before use in HTTP requests.
The DeepLinkParser validates these parameters inconsistently:
isValidOwnerRepo()is only called forgithub.compaths (line 41), not forgithubstore://repo/paths (line 33) orgithub-store.orgquery params (line 51)parseOwnerRepo()checks only for non-empty strings, missing validation against special characters, path traversal patterns (../), and excluded pathsextractQueryParam()does not URL-decode extracted values, allowing encoded payloads like%2e%2eto bypass filters- Ktor does not automatically URL-encode string-interpolated path segments (Ktor 3.2.3), so malicious input reaches the API as-is
Apply consistent validation across all deep-link formats: reject empty strings, special characters (/, .., ?, #), excluded paths, and URL-decode query parameters before validation.
🤖 Prompt for AI Agents
In
`@feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt`
around lines 68 - 74, Sanitize and validate owner/name from all deep-link
sources before calling getRepositoryByOwnerAndName: URL-decode values returned
by DeepLinkParser.extractQueryParam, ensure DeepLinkParser.parseOwnerRepo
performs strict validation (reject empty, any '/' characters, path-traversal
patterns like '..', and characters '?', '#' and other special chars), and apply
DeepLinkParser.isValidOwnerRepo uniformly for github.com, githubstore://repo/,
and github-store.org paths; then only pass validated owner/name into
DetailsRepositoryImpl.getRepositoryByOwnerAndName so Ktor never receives
unvalidated path segments.
There was a problem hiding this comment.
@coderabbitai Could you provide implementation?
There was a problem hiding this comment.
Task: Implement deep-link parameter sanitization and validation
- Enhance DeepLinkParser validation to reject malicious input
- Add URL-decoding for query parameters
- Apply consistent validation across all deep-link formats
- Ensure special characters and path traversal patterns are blocked
💡 For code changes across multiple files, use @coderabbitai plan in an issue comment
There was a problem hiding this comment.
@coderabbitai i meant code implementation, maybe proper fix for this issue
There was a problem hiding this comment.
@rainxchzed, I'll implement the proper security fix for the deep-link parameter sanitization issue.
Task: Implement deep-link parameter sanitization and validation
- Add URL decoding functionality to handle percent-encoded characters
- Implement strict validation for owner and repo parameters
- Apply consistent validation across all deep-link formats (githubstore://, github.com, github-store.org)
- Block special characters, path traversal patterns, and malicious input
💡 For code changes across multiple files, use @coderabbitai plan in an issue comment
There was a problem hiding this comment.
@coderabbitai could you write down the solution right here?
There was a problem hiding this comment.
@rainxchzed, here's the complete updated code for DeepLinkParser.kt with all security fixes:
package zed.rainxch.githubstore.app.deeplink
sealed interface DeepLinkDestination {
data class Repository(val owner: String, val repo: String) : DeepLinkDestination
data object None : DeepLinkDestination
}
object DeepLinkParser {
// Special characters that should not appear in valid GitHub usernames or repo names
private val INVALID_CHARS = setOf('/', '\\', '?', '#', '@', ':', '*', '"', '<', '>', '|', '%', '&', '=')
// Path traversal patterns to reject
private val FORBIDDEN_PATTERNS = listOf("..", "~", "\u0000")
// GitHub reserved paths that cannot be usernames
private val EXCLUDED_PATHS = setOf(
"about", "account", "admin", "api", "apps", "articles",
"blog", "business", "collections", "contact", "dashboard",
"enterprises", "events", "explore", "features", "home",
"issues", "marketplace", "new", "notifications", "orgs",
"pricing", "pulls", "search", "security", "settings",
"showcases", "site", "sponsors", "topics", "trending", "team"
)
fun parse(uri: String): DeepLinkDestination {
return when {
uri.startsWith("githubstore://repo/") -> {
val path = uri.removePrefix("githubstore://repo/")
val decoded = urlDecode(path)
parseOwnerRepo(decoded)
}
uri.startsWith("https://github.com/") -> {
val path = uri.removePrefix("https://github.com/")
.substringBefore('?')
.substringBefore('#')
val decoded = urlDecode(path)
val parts = decoded.split("/").filter { it.isNotEmpty() }
if (parts.size >= 2) {
val owner = parts[0]
val repo = parts[1]
if (isStrictlyValidOwnerRepo(owner, repo)) {
return DeepLinkDestination.Repository(owner, repo)
}
}
DeepLinkDestination.None
}
uri.startsWith("https://github-store.org/app/") -> {
extractQueryParam(uri, "repo")?.let { encodedRepoParam ->
val decoded = urlDecode(encodedRepoParam)
parseOwnerRepo(decoded)
} ?: DeepLinkDestination.None
}
else -> DeepLinkDestination.None
}
}
/**
* URL-decode a string, handling percent-encoded characters.
* Returns the original string if decoding fails.
*/
private fun urlDecode(value: String): String {
return try {
val result = StringBuilder()
var i = 0
while (i < value.length) {
when (val c = value[i]) {
'%' -> {
if (i + 2 < value.length) {
val hex = value.substring(i + 1, i + 3)
val code = hex.toIntOrNull(16)
if (code != null) {
result.append(code.toChar())
i += 3
continue
}
}
result.append(c)
i++
}
'+' -> {
result.append(' ')
i++
}
else -> {
result.append(c)
i++
}
}
}
result.toString()
} catch (e: Exception) {
value
}
}
private fun parseOwnerRepo(path: String): DeepLinkDestination {
val parts = path.split("/").filter { it.isNotEmpty() }
return if (parts.size >= 2) {
val owner = parts[0]
val repo = parts[1]
// Apply strict validation to all parsed owner/repo pairs
if (isStrictlyValidOwnerRepo(owner, repo)) {
DeepLinkDestination.Repository(owner, repo)
} else {
DeepLinkDestination.None
}
} else {
DeepLinkDestination.None
}
}
/**
* Strictly validate owner and repo names to prevent injection attacks.
* Rejects:
* - Empty strings
* - Special characters that could be used for injection
* - Path traversal patterns
* - Control characters and whitespace
* - Excluded GitHub paths (like 'about', 'settings', etc.)
* - Names that exceed GitHub's length limits
* - Names that don't start with alphanumeric characters
*/
private fun isStrictlyValidOwnerRepo(owner: String, repo: String): Boolean {
// Check for empty strings
if (owner.isEmpty() || repo.isEmpty()) {
return false
}
// Check for invalid characters
if (owner.any { it in INVALID_CHARS } || repo.any { it in INVALID_CHARS }) {
return false
}
// Check for forbidden patterns (path traversal, etc.)
if (FORBIDDEN_PATTERNS.any { pattern ->
owner.contains(pattern, ignoreCase = true) ||
repo.contains(pattern, ignoreCase = true)
}) {
return false
}
// Check for control characters
if (owner.any { it.isISOControl() } || repo.any { it.isISOControl() }) {
return false
}
// Check for whitespace (GitHub usernames/repos don't contain spaces)
if (owner.contains(' ') || repo.contains(' ')) {
return false
}
// Check against excluded paths
if (EXCLUDED_PATHS.contains(owner.lowercase())) {
return false
}
// Additional length validation (GitHub has limits)
// GitHub usernames: 1-39 characters, repo names: 1-100 characters
if (owner.length > 39 || repo.length > 100) {
return false
}
// GitHub usernames and repos must start with alphanumeric
if (!owner.first().isLetterOrDigit() || !repo.first().isLetterOrDigit()) {
return false
}
return true
}
private fun extractQueryParam(uri: String, key: String): String? {
val queryStart = uri.indexOf('?')
if (queryStart == -1) return null
val queryString = uri.substring(queryStart + 1)
val params = queryString.split('&')
for (param in params) {
val keyValue = param.split('=')
if (keyValue.size == 2 && keyValue[0] == key) {
// Return the encoded value; it will be decoded by the caller
return keyValue[1]
}
}
return null
}
}Key changes made:
- Added security constants (lines 10-23): Define invalid characters, forbidden patterns, and excluded GitHub paths
- Added
urlDecode()function (lines 68-99): Handles percent-encoded characters like%2e%2e→.. - Updated
parse()to decode all inputs (lines 27, 37, 48): URLs are decoded before validation - Enhanced validation with
isStrictlyValidOwnerRepo()(lines 118-167):- Blocks special characters (
/,..,?,#, etc.) - Prevents path traversal attacks
- Validates against GitHub's actual constraints (length limits, character requirements)
- Rejects reserved GitHub paths
- Blocks special characters (
- Updated
parseOwnerRepo()(lines 101-116): Now uses strict validation for all parsed parameters
This implementation ensures that malicious inputs like ../admin, %2e%2e%2fadmin, or user/../other are rejected before reaching the HTTP client.
This commit introduces localized strings for relative release timestamps and bottom navigation titles across multiple languages. - **feat(i18n)**: Added translations for relative time expressions like "just now," "yesterday," and "x days ago." - **feat(i18n)**: Added translations for the bottom navigation bar titles: Home, Search, Apps, and Profile. - **chore(i18n)**: Provided translations for the following languages: Bengali (`bn`), Spanish (`es`), French (`fr`), Italian (`it`), Japanese (`ja`), Korean (`kr`), Polish (`pl`), Russian (`ru`), Turkish (`tr`), and Simplified Chinese (`zh-rCN`).
Summary by CodeRabbit
New Features
Improvements