From 46ed9f9b9aa1d044a0635d7f33a0dd2c8ba3599c Mon Sep 17 00:00:00 2001 From: Ngoc Leek Date: Sun, 12 Oct 2025 16:53:28 -0700 Subject: [PATCH 1/2] feat: implement mobile deep linking support --- .env.example | 2 + README.md | 1 + docs/configuration.md | 12 + nuxt.config.ts | 2 + server/middleware/1.redirect.ts | 14 +- server/utils/deep-link-configs.ts | 895 ++++++++++++++++++++++++++++++ server/utils/mobile-deep-links.ts | 247 +++++++++ 7 files changed, 1171 insertions(+), 2 deletions(-) create mode 100644 server/utils/deep-link-configs.ts create mode 100644 server/utils/mobile-deep-links.ts diff --git a/.env.example b/.env.example index ce2fc13a7..de2c257f6 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,5 @@ NUXT_CF_API_TOKEN=CloudflareAPIToken NUXT_DATASET=sink NUXT_AI_MODEL="@cf/meta/llama-3-8b-instruct" NUXT_AI_PROMPT="You are a URL shortening assistant......" +NUXT_ENABLE_MOBILE_DEEP_LINKS=false +NUXT_DEEP_LINK_TIMEOUT=3000 diff --git a/README.md b/README.md index e4b10e30b..ab087d719 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ - **Customizable Slug:** Support for personalized slugs and case sensitivity. - **🪄 AI Slug:** Leverage AI to generate slugs. - **Link Expiration:** Set expiration dates for your links. +- **Mobile Deep Links:** Automatically redirect mobile users to native apps when available. ## 🪧 Demo diff --git a/docs/configuration.md b/docs/configuration.md index 87e9e81a5..bd04bf515 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,3 +65,15 @@ Access statistics do not count bot traffic. ## `NUXT_API_CORS` Set the environment variable `NUXT_API_CORS=true` during build to enable CORS support for the API. + +## `NUXT_ENABLE_MOBILE_DEEP_LINKS` + +Enables mobile deep link functionality. When enabled, mobile users accessing shortened links will be automatically redirected to the corresponding native app if available (e.g., Instagram app for Instagram links, Amazon app for Amazon product links). + +Default: `false` + +## `NUXT_DEEP_LINK_TIMEOUT` + +Sets the timeout in milliseconds for deep link attempts before falling back to the web browser. This gives the system time to attempt opening the native app before redirecting to the web version. + +Default: `3000` (3 seconds) diff --git a/nuxt.config.ts b/nuxt.config.ts index 487e438d7..c8ac58d8c 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -33,6 +33,8 @@ export default defineNuxtConfig({ caseSensitive: false, listQueryLimit: 500, disableBotAccessLog: false, + enableMobileDeepLinks: false, + deepLinkTimeout: 3000, public: { previewMode: '', slugDefaultLength: '6', diff --git a/server/middleware/1.redirect.ts b/server/middleware/1.redirect.ts index 760b9d414..a41ead102 100644 --- a/server/middleware/1.redirect.ts +++ b/server/middleware/1.redirect.ts @@ -1,11 +1,12 @@ import type { LinkSchema } from '@@/schemas/link' import type { z } from 'zod' import { parsePath, withQuery } from 'ufo' +import { handleMobileDeepLink } from '../utils/mobile-deep-links' export default eventHandler(async (event) => { const { pathname: slug } = parsePath(event.path.replace(/^\/|\/$/g, '')) // remove leading and trailing slashes const { slugRegex, reserveSlug } = useAppConfig(event) - const { homeURL, linkCacheTtl, redirectWithQuery, caseSensitive } = useRuntimeConfig(event) + const { homeURL, linkCacheTtl, redirectWithQuery, caseSensitive, redirectStatusCode } = useRuntimeConfig(event) const { cloudflare } = event.context if (event.path === '/' && homeURL) @@ -36,8 +37,17 @@ export default eventHandler(async (event) => { catch (error) { console.error('Failed write access log:', error) } + const target = redirectWithQuery ? withQuery(link.url, getQuery(event)) : link.url - return sendRedirect(event, target, +useRuntimeConfig(event).redirectStatusCode) + + // Check for mobile deep linking + const { shouldInterceptRedirect, htmlResponse } = await handleMobileDeepLink(event, target) + if (shouldInterceptRedirect && htmlResponse) { + return htmlResponse + } + + // Default redirect + return sendRedirect(event, target, +redirectStatusCode) } } }) diff --git a/server/utils/deep-link-configs.ts b/server/utils/deep-link-configs.ts new file mode 100644 index 000000000..10990a420 --- /dev/null +++ b/server/utils/deep-link-configs.ts @@ -0,0 +1,895 @@ +/** + * Deep Link Configuration for Mobile Apps + * + * This file contains configurations for different mobile apps and their deep link schemes. + * Add new app configurations here to support additional mobile apps. + */ + +export interface DeepLinkConfig { + hostname: string + appScheme: string + transformPath?: (url: URL) => string + description?: string +} + +/** + * Configuration for Amazon Shopping App + * Supports multiple Amazon domains + */ +const amazonDomains = [ + { hostname: 'amazon.com', country: 'US' }, + { hostname: 'amazon.co.uk', country: 'UK' }, + { hostname: 'amazon.de', country: 'Germany' }, + { hostname: 'amazon.fr', country: 'France' }, + { hostname: 'amazon.ca', country: 'Canada' }, + { hostname: 'amazon.com.au', country: 'Australia' }, + { hostname: 'amazon.co.jp', country: 'Japan' }, + { hostname: 'amazon.in', country: 'India' }, +] + +const amazonConfigs: DeepLinkConfig[] = amazonDomains.map(({ hostname, country }) => ({ + hostname, + appScheme: 'com.amazon.mobile.shopping.web://', + transformPath: (url: URL) => url.hostname + url.pathname + url.search, + description: `Amazon ${country} Shopping App`, +})) + +/** + * Social Media Apps - Most Popular + */ +const socialMediaConfigs: DeepLinkConfig[] = [ + { + hostname: 'instagram.com', + appScheme: 'instagram://app/', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + if (!parts.length) + return 'camera' + + // Handle posts: /p/POST_ID/ + if (parts[0] === 'p' && parts[1]) { + return `media?id=${parts[1]}` + } + + // Handle reels: /reel/REEL_ID/ or /reels/REEL_ID/ + if ((parts[0] === 'reel' || parts[0] === 'reels') && parts[1]) { + return `reel?id=${parts[1]}` + } + + // Handle stories: /stories/USERNAME/STORY_ID/ + if (parts[0] === 'stories' && parts[1] && parts[2]) { + return `story?story_id=${parts[2]}&username=${parts[1]}` + } + + // Handle IGTV: /tv/VIDEO_ID/ + if (parts[0] === 'tv' && parts[1]) { + return `tv?id=${parts[1]}` + } + + // Handle user profiles: /USERNAME/ + if (parts[0] && !['p', 'reel', 'reels', 'stories', 'tv'].includes(parts[0])) { + return `user?username=${parts[0]}` + } + + return 'camera' + } + catch { + return 'camera' + } + }, + description: 'Instagram App', + }, + { + hostname: 'tiktok.com', + appScheme: 'snssdk1233://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle user profiles: /@username + if (parts[0]?.startsWith('@')) { + return `user/profile?username=${parts[0].substring(1)}` + } + + // Handle videos: /@username/video/VIDEO_ID + if (parts[0]?.startsWith('@') && parts[1] === 'video' && parts[2]) { + return `video?id=${parts[2]}` + } + + // Handle direct video links: /video/VIDEO_ID + if (parts[0] === 'video' && parts[1]) { + return `video?id=${parts[1]}` + } + + return 'home' + } + catch { + return 'home' + } + }, + description: 'TikTok App', + }, + { + hostname: 'vm.tiktok.com', + appScheme: 'snssdk1233://', + transformPath: (_url: URL) => { + // Short URLs - just open the app, it will handle the redirect + return 'home' + }, + description: 'TikTok App (Short URL)', + }, + { + hostname: 'vt.tiktok.com', + appScheme: 'snssdk1233://', + transformPath: (_url: URL) => { + return 'home' + }, + description: 'TikTok App (Short URL)', + }, + { + hostname: 'x.com', + appScheme: 'twitter://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + if (!parts.length) + return 'timeline' + + // Handle tweets: /username/status/TWEET_ID + if (parts.length >= 3 && parts[1] === 'status') { + return `status?id=${parts[2]}` + } + + // Handle user profiles: /username + if (parts.length === 1) { + return `user?screen_name=${parts[0]}` + } + + return 'timeline' + } + catch { + return 'timeline' + } + }, + description: 'X (Twitter) App', + }, + { + hostname: 'twitter.com', + appScheme: 'twitter://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + if (!parts.length) + return 'timeline' + + // Handle tweets: /username/status/TWEET_ID + if (parts.length >= 3 && parts[1] === 'status') { + return `status?id=${parts[2]}` + } + + // Handle user profiles: /username + if (parts.length === 1) { + return `user?screen_name=${parts[0]}` + } + + return 'timeline' + } + catch { + return 'timeline' + } + }, + description: 'Twitter App', + }, + { + hostname: 'facebook.com', + appScheme: 'fb://facewebmodal/f', + transformPath: (url: URL) => { + try { + // Facebook deep links are complex - fallback to opening in app + const fullUrl = url.href + return `?href=${encodeURIComponent(fullUrl)}` + } + catch { + return '' + } + }, + description: 'Facebook App', + }, + { + hostname: 'm.facebook.com', + appScheme: 'fb://facewebmodal/f', + transformPath: (url: URL) => { + try { + const fullUrl = url.href + return `?href=${encodeURIComponent(fullUrl)}` + } + catch { + return '' + } + }, + description: 'Facebook App', + }, + { + hostname: 'linkedin.com', + appScheme: 'linkedin://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle profiles: /in/username + if (parts[0] === 'in' && parts[1]) { + return `profile/${parts[1]}` + } + + // Handle company pages: /company/company-name + if (parts[0] === 'company' && parts[1]) { + return `company/${parts[1]}` + } + + // Handle posts: /feed/update/urn:li:activity:ID + if (parts[0] === 'feed' || parts[0] === 'posts') { + return `feed${url.search}` + } + + return 'feed' + } + catch { + return 'feed' + } + }, + description: 'LinkedIn App', + }, + { + hostname: 'reddit.com', + appScheme: 'reddit://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle subreddits: /r/subreddit + if (parts[0] === 'r' && parts[1]) { + // Handle posts: /r/subreddit/comments/POST_ID/title + if (parts[2] === 'comments' && parts[3]) { + return `${parts[1]}/comments/${parts[3]}` + } + return parts[1] + } + + // Handle user profiles: /u/username or /user/username + if ((parts[0] === 'u' || parts[0] === 'user') && parts[1]) { + return `user/${parts[1]}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Reddit App', + }, +] + +/** + * Messaging Apps + */ +const messagingConfigs: DeepLinkConfig[] = [ + { + hostname: 'wa.me', + appScheme: 'whatsapp://', + transformPath: (url: URL) => { + try { + const phoneNumber = url.pathname.substring(1).split('?')[0] + const text = url.searchParams.get('text') + + if (!phoneNumber) + return 'send' + + if (text) { + return `send?phone=${phoneNumber}&text=${encodeURIComponent(text)}` + } + return `send?phone=${phoneNumber}` + } + catch { + return 'send' + } + }, + description: 'WhatsApp App', + }, + { + hostname: 'web.whatsapp.com', + appScheme: 'whatsapp://', + transformPath: (url: URL) => { + try { + if (url.searchParams.has('phone')) { + const phone = url.searchParams.get('phone') + const text = url.searchParams.get('text') + + if (text) { + return `send?phone=${phone}&text=${encodeURIComponent(text)}` + } + return `send?phone=${phone}` + } + return 'send' + } + catch { + return 'send' + } + }, + description: 'WhatsApp App', + }, + { + hostname: 'api.whatsapp.com', + appScheme: 'whatsapp://', + transformPath: (url: URL) => { + try { + if (url.searchParams.has('phone')) { + const phone = url.searchParams.get('phone') + const text = url.searchParams.get('text') + + if (text) { + return `send?phone=${phone}&text=${encodeURIComponent(text)}` + } + return `send?phone=${phone}` + } + return 'send' + } + catch { + return 'send' + } + }, + description: 'WhatsApp App', + }, + { + hostname: 't.me', + appScheme: 'tg://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + if (!parts.length) + return 'resolve' + + // Handle join links: /joinchat/INVITE_CODE or /+INVITE_CODE + if (parts[0] === 'joinchat' && parts[1]) { + return `join?invite=${parts[1]}` + } + + if (parts[0].startsWith('+')) { + return `join?invite=${parts[0].substring(1)}` + } + + // Handle sticker sets: /addstickers/SET_NAME + if (parts[0] === 'addstickers' && parts[1]) { + return `addstickers?set=${parts[1]}` + } + + // Handle username/channel: /username + return `resolve?domain=${parts[0]}` + } + catch { + return 'resolve' + } + }, + description: 'Telegram App', + }, + { + hostname: 'telegram.me', + appScheme: 'tg://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + if (!parts.length) + return 'resolve' + return `resolve?domain=${parts[0]}` + } + catch { + return 'resolve' + } + }, + description: 'Telegram App', + }, + { + hostname: 'discord.com', + appScheme: 'discord://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle channels: /channels/GUILD_ID/CHANNEL_ID + if (parts[0] === 'channels' && parts[1] && parts[2]) { + return `channels/${parts[1]}/${parts[2]}` + } + + // Handle invites: /invite/CODE + if (parts[0] === 'invite' && parts[1]) { + return `invite/${parts[1]}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Discord App', + }, + { + hostname: 'discord.gg', + appScheme: 'discord://', + transformPath: (url: URL) => { + try { + const inviteCode = url.pathname.substring(1).split('?')[0] + return inviteCode ? `invite/${inviteCode}` : '' + } + catch { + return '' + } + }, + description: 'Discord App (Invite)', + }, +] + +/** + * Shopping Apps + */ +const shoppingConfigs: DeepLinkConfig[] = [ + { + hostname: 'ebay.com', + appScheme: 'ebay://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle items: /itm/ITEM_ID or /itm/title/ITEM_ID + if (parts[0] === 'itm') { + const itemId = parts[parts.length - 1] + return `item/view?id=${itemId}` + } + + // Handle user profiles: /usr/USERNAME + if (parts[0] === 'usr' && parts[1]) { + return `user/${parts[1]}` + } + + return '' + } + catch { + return '' + } + }, + description: 'eBay App', + }, + { + hostname: 'etsy.com', + appScheme: 'etsy://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle listings: /listing/LISTING_ID + if (parts[0] === 'listing' && parts[1]) { + return `listing/${parts[1]}` + } + + // Handle shops: /shop/SHOP_NAME + if (parts[0] === 'shop' && parts[1]) { + return `shop/${parts[1]}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Etsy App', + }, + { + hostname: 'walmart.com', + appScheme: 'walmart://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle products: /ip/PRODUCT_NAME/PRODUCT_ID + if (parts[0] === 'ip' && parts.length >= 2) { + const productId = parts[parts.length - 1] + return `product/${productId}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Walmart App', + }, +] + +/** + * Entertainment Apps + */ +const entertainmentConfigs: DeepLinkConfig[] = [ + { + hostname: 'youtube.com', + appScheme: 'youtube://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle video: /watch?v=VIDEO_ID + if (url.pathname === '/watch' || parts[0] === 'watch') { + const videoId = url.searchParams.get('v') + return videoId ? `watch?v=${videoId}` : '' + } + + // Handle shorts: /shorts/VIDEO_ID + if (parts[0] === 'shorts' && parts[1]) { + return `shorts/${parts[1]}` + } + + // Handle channels: /channel/CHANNEL_ID or /c/CHANNEL_NAME or /@HANDLE + if (parts[0] === 'channel' && parts[1]) { + return `channel/${parts[1]}` + } + + if (parts[0] === 'c' && parts[1]) { + return `channel/${parts[1]}` + } + + if (parts[0] === 'user' && parts[1]) { + return `user/${parts[1]}` + } + + if (parts[0]?.startsWith('@')) { + return `channel/${parts[0].substring(1)}` + } + + return '' + } + catch { + return '' + } + }, + description: 'YouTube App', + }, + { + hostname: 'youtu.be', + appScheme: 'youtube://', + transformPath: (url: URL) => { + try { + const videoId = url.pathname.substring(1).split('?')[0] + return videoId ? `watch?v=${videoId}` : '' + } + catch { + return '' + } + }, + description: 'YouTube App (Short URL)', + }, + { + hostname: 'music.youtube.com', + appScheme: 'youtubemusic://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + if (parts[0] === 'watch' || url.pathname === '/watch') { + const videoId = url.searchParams.get('v') + return videoId ? `watch?v=${videoId}` : '' + } + + if (parts[0] === 'playlist' && url.searchParams.get('list')) { + return `playlist?list=${url.searchParams.get('list')}` + } + + return '' + } + catch { + return '' + } + }, + description: 'YouTube Music App', + }, + { + hostname: 'netflix.com', + appScheme: 'nflx://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle titles: /title/TITLE_ID or /watch/TITLE_ID + if (parts[0] === 'title' && parts[1]) { + return `title/${parts[1]}` + } + + if (parts[0] === 'watch' && parts[1]) { + return `watch/${parts[1]}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Netflix App', + }, + { + hostname: 'open.spotify.com', + appScheme: 'spotify://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + if (parts.length < 2) + return '' + + const [type, id] = parts + + // Handle: /track/ID, /album/ID, /playlist/ID, /artist/ID, /show/ID, /episode/ID + if (['track', 'album', 'playlist', 'artist', 'show', 'episode'].includes(type)) { + return `${type}/${id}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Spotify App', + }, + { + hostname: 'twitch.tv', + appScheme: 'twitch://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + if (!parts.length) + return 'stream' + + // Handle videos: /videos/VIDEO_ID or /USERNAME/videos/VIDEO_ID + const videoIndex = parts.indexOf('videos') + if (videoIndex !== -1 && parts[videoIndex + 1]) { + return `video/v${parts[videoIndex + 1]}` + } + + // Handle clips: /USERNAME/clip/CLIP_SLUG or /clip/CLIP_SLUG + const clipIndex = parts.indexOf('clip') + if (clipIndex !== -1 && parts[clipIndex + 1]) { + return `clip/${parts[clipIndex + 1]}` + } + + // Handle streams: /USERNAME + return `stream/${parts[0]}` + } + catch { + return 'stream' + } + }, + description: 'Twitch App', + }, +] + +/** + * Productivity Apps + */ +const productivityConfigs: DeepLinkConfig[] = [ + { + hostname: 'drive.google.com', + appScheme: 'googledrive://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle files: /file/d/FILE_ID/view + if (parts[0] === 'file' && parts[1] === 'd' && parts[2]) { + return `file?id=${parts[2]}` + } + + // Handle folders: /drive/folders/FOLDER_ID + if (parts.includes('folders')) { + const folderIndex = parts.indexOf('folders') + if (parts[folderIndex + 1]) { + return `folder?id=${parts[folderIndex + 1]}` + } + } + + return '' + } + catch { + return '' + } + }, + description: 'Google Drive App', + }, + { + hostname: 'docs.google.com', + appScheme: 'googledocs://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle: /document/d/DOC_ID, /spreadsheets/d/SHEET_ID, /presentation/d/PRESENTATION_ID + if (parts[0] === 'document' && parts[1] === 'd' && parts[2]) { + return `document?id=${parts[2]}` + } + + if (parts[0] === 'spreadsheets' && parts[1] === 'd' && parts[2]) { + return `spreadsheets?id=${parts[2]}` + } + + if (parts[0] === 'presentation' && parts[1] === 'd' && parts[2]) { + return `presentation?id=${parts[2]}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Google Docs/Sheets/Slides App', + }, + { + hostname: 'dropbox.com', + appScheme: 'dbapi-2://', + transformPath: (url: URL) => { + try { + // Dropbox deep links work better with the web URL + return `1/view${url.pathname}${url.search}` + } + catch { + return '' + } + }, + description: 'Dropbox App', + }, + { + hostname: 'notion.so', + appScheme: 'notion://', + transformPath: (url: URL) => { + try { + const pathname = url.pathname.substring(1) + return pathname ? `${pathname}${url.search}` : '' + } + catch { + return '' + } + }, + description: 'Notion App', + }, + { + hostname: 'slack.com', + appScheme: 'slack://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle archives: /archives/CHANNEL_ID or /archives/CHANNEL_ID/pMESSAGE_TS + if (parts[0] === 'archives' && parts[1]) { + const channelId = parts[1] + + // Extract team ID from URL if present + const match = url.hostname.match(/^([^.]+)\.slack\.com$/) + const teamId = match ? match[1] : '' + + if (parts[2] && parts[2].startsWith('p')) { + // Has message timestamp + const messageTs = parts[2].substring(1) + return `channel?team=${teamId}&id=${channelId}&message=${messageTs}` + } + + return `channel?team=${teamId}&id=${channelId}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Slack App', + }, + { + hostname: 'trello.com', + appScheme: 'trello://', + transformPath: (url: URL) => { + try { + const parts = url.pathname.split('/').filter(p => p) + + // Handle boards: /b/BOARD_ID/board-name + if (parts[0] === 'b' && parts[1]) { + return `x-callback-url/showBoard?boardId=${parts[1]}` + } + + // Handle cards: /c/CARD_ID/card-name + if (parts[0] === 'c' && parts[1]) { + return `x-callback-url/showCard?cardId=${parts[1]}` + } + + return '' + } + catch { + return '' + } + }, + description: 'Trello App', + }, +] + +/** + * All deep link configurations + * Add new configurations here or import from other modules + */ +export const DEEP_LINK_CONFIGS: DeepLinkConfig[] = [ + ...amazonConfigs, + ...socialMediaConfigs, + ...messagingConfigs, + ...shoppingConfigs, + ...entertainmentConfigs, + ...productivityConfigs, +] + +/** + * Helper function to get all supported hostnames + */ +export function getSupportedHostnames(): string[] { + return DEEP_LINK_CONFIGS.map(config => config.hostname) +} + +/** + * Helper function to normalize hostname (remove www.) + */ +function normalizeHostname(hostname: string): string { + return hostname.replace(/^www\./, '') +} + +/** + * Helper function to get configuration for a specific hostname + * Handles www. variants automatically + */ +export function getConfigForHostname(hostname: string): DeepLinkConfig | undefined { + const normalized = normalizeHostname(hostname) + + return DEEP_LINK_CONFIGS.find((config) => { + const configNormalized = normalizeHostname(config.hostname) + return normalized === configNormalized + || normalized.endsWith(`.${configNormalized}`) + || configNormalized.endsWith(`.${normalized}`) + }) +} + +/** + * Helper function to check if a hostname is supported + */ +export function isHostnameSupported(hostname: string): boolean { + return getConfigForHostname(hostname) !== undefined +} + +/** + * Helper function to generate deep link from URL + */ +export function generateDeepLink(url: string): string | null { + try { + const urlObj = new URL(url) + const config = getConfigForHostname(urlObj.hostname) + + if (!config) + return null + + const path = config.transformPath ? config.transformPath(urlObj) : '' + return `${config.appScheme}${path}` + } + catch { + return null + } +} diff --git a/server/utils/mobile-deep-links.ts b/server/utils/mobile-deep-links.ts new file mode 100644 index 000000000..92f78e5a2 --- /dev/null +++ b/server/utils/mobile-deep-links.ts @@ -0,0 +1,247 @@ +import type { H3Event } from 'h3' +import { UAParser } from 'ua-parser-js' +import { DEEP_LINK_CONFIGS } from './deep-link-configs' + +// Simple HTML escaping function to prevent XSS +function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +export interface MobileDeepLinkResult { + shouldInterceptRedirect: boolean + htmlResponse: Response | null +} + +export async function handleMobileDeepLink(event: H3Event, targetUrl: string): Promise { + const config = useRuntimeConfig(event) + + // Check if mobile deep linking is enabled in environment + if (!config.enableMobileDeepLinks) { + return { + shouldInterceptRedirect: false, + htmlResponse: null, + } + } + + // Get the user agent + const userAgent = getRequestHeader(event, 'user-agent') || '' + const parser = new UAParser(userAgent) + const device = parser.getDevice() + const isMobile = device.type === 'mobile' || device.type === 'tablet' + + if (!isMobile) { + return { + shouldInterceptRedirect: false, + htmlResponse: null, + } + } + + // Parse the target URL + const parsedUrl = new URL(targetUrl) + + // Find matching deep link configuration + const matchingConfig = DEEP_LINK_CONFIGS.find(config => + parsedUrl.hostname.includes(config.hostname), + ) + + if (!matchingConfig) { + return { + shouldInterceptRedirect: false, + htmlResponse: null, + } + } + + // Generate the deep link URL + const appPath = matchingConfig.transformPath + ? matchingConfig.transformPath(parsedUrl) + : parsedUrl.pathname + parsedUrl.search + + const appUrl = matchingConfig.appScheme + appPath.replace(/^\//, '') + + // Escape URLs to prevent XSS + const safeAppUrl = escapeHtml(appUrl) + const safeTargetUrl = escapeHtml(targetUrl) + + // Create HTML response with deep linking + const html = ` + + + + + + Opening App... + + + +
+
⌛
+
Opening app...
+
If the app doesn't open, you'll be redirected to the website
+ + + + +
+ + + + + + + ` + + return { + shouldInterceptRedirect: true, + htmlResponse: new Response(html, { + headers: { + 'Content-Type': 'text/html', + }, + }), + } +} From 4f43b2df7df70ceed98771a67fd2c786015e1fb1 Mon Sep 17 00:00:00 2001 From: Ngoc Leek Date: Sun, 12 Oct 2025 21:02:21 -0700 Subject: [PATCH 2/2] fix: reorder user profile handling in deep link configuration and normalize mobile deep link timeout handling --- server/utils/deep-link-configs.ts | 11 +++++------ server/utils/mobile-deep-links.ts | 16 +++++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/server/utils/deep-link-configs.ts b/server/utils/deep-link-configs.ts index 10990a420..952559437 100644 --- a/server/utils/deep-link-configs.ts +++ b/server/utils/deep-link-configs.ts @@ -88,16 +88,16 @@ const socialMediaConfigs: DeepLinkConfig[] = [ try { const parts = url.pathname.split('/').filter(p => p) - // Handle user profiles: /@username - if (parts[0]?.startsWith('@')) { - return `user/profile?username=${parts[0].substring(1)}` - } - // Handle videos: /@username/video/VIDEO_ID if (parts[0]?.startsWith('@') && parts[1] === 'video' && parts[2]) { return `video?id=${parts[2]}` } + // Handle user profiles: /@username + if (parts[0]?.startsWith('@')) { + return `user/profile?username=${parts[0].substring(1)}` + } + // Handle direct video links: /video/VIDEO_ID if (parts[0] === 'video' && parts[1]) { return `video?id=${parts[1]}` @@ -864,7 +864,6 @@ export function getConfigForHostname(hostname: string): DeepLinkConfig | undefin const configNormalized = normalizeHostname(config.hostname) return normalized === configNormalized || normalized.endsWith(`.${configNormalized}`) - || configNormalized.endsWith(`.${normalized}`) }) } diff --git a/server/utils/mobile-deep-links.ts b/server/utils/mobile-deep-links.ts index 92f78e5a2..a6e6d184e 100644 --- a/server/utils/mobile-deep-links.ts +++ b/server/utils/mobile-deep-links.ts @@ -20,8 +20,12 @@ export interface MobileDeepLinkResult { export async function handleMobileDeepLink(event: H3Event, targetUrl: string): Promise { const config = useRuntimeConfig(event) + // Normalize runtime config values (they arrive as strings) + const enableMobileDeepLinks = String(config.enableMobileDeepLinks) === 'true' + const deepLinkTimeout = Number(config.deepLinkTimeout) || 3000 + // Check if mobile deep linking is enabled in environment - if (!config.enableMobileDeepLinks) { + if (!enableMobileDeepLinks) { return { shouldInterceptRedirect: false, htmlResponse: null, @@ -45,9 +49,11 @@ export async function handleMobileDeepLink(event: H3Event, targetUrl: string): P const parsedUrl = new URL(targetUrl) // Find matching deep link configuration - const matchingConfig = DEEP_LINK_CONFIGS.find(config => - parsedUrl.hostname.includes(config.hostname), - ) + const hostname = parsedUrl.hostname.toLowerCase() + const matchingConfig = DEEP_LINK_CONFIGS.find((config) => { + const candidate = config.hostname.toLowerCase() + return hostname === candidate || hostname.endsWith(`.${candidate}`) + }) if (!matchingConfig) { return { @@ -230,7 +236,7 @@ export async function handleMobileDeepLink(event: H3Event, targetUrl: string): P // Fallback to browser after timeout setTimeout(function() { window.location.replace("${safeTargetUrl}"); - }, ${config.deepLinkTimeout || 3000}); + }, ${deepLinkTimeout});