From 1c3b56a3765f67492cb09c46fcd0803256a2579a Mon Sep 17 00:00:00 2001
From: loks0n <22452787+loks0n@users.noreply.github.com>
Date: Mon, 17 Jul 2023 09:45:00 +0100
Subject: [PATCH 1/2] feat: add db, refactor

---
 .node-version                |   1 +
 env.d.ts                     |  12 ++-
 package-lock.json            | 118 +++++++++++++++++++-
 package.json                 |   7 +-
 src/detectors.js             |  19 ----
 src/handlers/dynamic-link.js |  39 +++++++
 src/main.js                  |  89 ++++-----------
 src/services/appwrite.js     | 204 +++++++++++++++++++++++++++++++++++
 src/services/dynamic-link.js | 104 ++++++++++++++++++
 src/services/environment.js  |  25 +++++
 src/services/views.js        |  24 +++++
 src/setup.js                 |  18 ++++
 src/utils.js                 |  11 ++
 static/admin.html            |  80 ++++++++++++++
 14 files changed, 654 insertions(+), 97 deletions(-)
 create mode 100644 .node-version
 delete mode 100644 src/detectors.js
 create mode 100644 src/handlers/dynamic-link.js
 create mode 100644 src/services/appwrite.js
 create mode 100644 src/services/dynamic-link.js
 create mode 100644 src/services/environment.js
 create mode 100644 src/services/views.js
 create mode 100644 src/setup.js
 create mode 100644 src/utils.js
 create mode 100644 static/admin.html

diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000..95d62e4
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+v18.16.1
\ No newline at end of file
diff --git a/env.d.ts b/env.d.ts
index 0a83277..1acf2e6 100644
--- a/env.d.ts
+++ b/env.d.ts
@@ -1,9 +1,17 @@
 declare global {
   namespace NodeJS {
     interface ProcessEnv {
-      CONFIG?: string;
+      APPWRITE_ENDPOINT?: string
+      APPWRITE_PROJECT_ID?: string
+      APPWRITE_API_KEY?: string
+      DATABASE_ID?: string
+      DATABASE_NAME?: string
+      PATH_COLLECTION_ID?: string
+      PATH_COLLECTION_NAME?: string
+      RULE_COLLECTION_ID?: string
+      RULE_COLLECTION_NAME?: string
     }
   }
 }
 
-export {};
+export {}
diff --git a/package-lock.json b/package-lock.json
index 4657f8a..d05e212 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,8 +8,11 @@
       "name": "dynamic-link",
       "version": "1.0.0",
       "license": "MIT",
+      "dependencies": {
+        "node-appwrite": "^9.0.0"
+      },
       "devDependencies": {
-        "appwrite-function-utils": "^0.1.0",
+        "appwrite-function-utils": "^0.4.0",
         "dotenv-cli": "^7.2.1",
         "nodemon": "^3.0.1",
         "prettier": "^3.0.0"
@@ -59,12 +62,14 @@
       }
     },
     "node_modules/appwrite-function-utils": {
-      "version": "0.1.0",
-      "resolved": "https://registry.npmjs.org/appwrite-function-utils/-/appwrite-function-utils-0.1.0.tgz",
-      "integrity": "sha512-2fvfMM0IOi7c/5hUDTw1TyMsUqSjD2FM53xgPJvAikFt1v/yqB/lnk7zIV5VHppJjVX6A9YqNzIrkhpkm9vH7g==",
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/appwrite-function-utils/-/appwrite-function-utils-0.4.0.tgz",
+      "integrity": "sha512-vkEs0qI3GCeZY3CUp/oiNPM2STUNxbLPzS0Y2XlBb+qJjk+A54afWM6QyapqZexnuVkqLy7Mnz1I2RR4s5nHxA==",
       "dev": true,
       "dependencies": {
+        "dotenv": "^16.3.1",
         "micro": "^9.3.4",
+        "path-to-regexp": "^6.2.1",
         "yargs": "^17.7.2"
       },
       "bin": {
@@ -77,6 +82,21 @@
       "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==",
       "dev": true
     },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "node_modules/axios": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
+      "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
+      "dependencies": {
+        "follow-redirects": "^1.15.0",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
     "node_modules/balanced-match": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -182,6 +202,17 @@
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
       "dev": true
     },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/concat-map": {
       "version": "0.0.1",
       "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -220,6 +251,14 @@
         "ms": "^2.1.1"
       }
     },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
     "node_modules/depd": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
@@ -292,6 +331,38 @@
         "node": ">=8"
       }
     },
+    "node_modules/follow-redirects": {
+      "version": "1.15.2",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
+      "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
     "node_modules/fsevents": {
       "version": "2.3.2",
       "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -462,6 +533,25 @@
         "node": ">= 8.0.0"
       }
     },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
     "node_modules/minimatch": {
       "version": "3.1.2",
       "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -489,6 +579,15 @@
       "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
       "dev": true
     },
+    "node_modules/node-appwrite": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/node-appwrite/-/node-appwrite-9.0.0.tgz",
+      "integrity": "sha512-iTcHbuaJfr6bP/HFkRVV+FcaumKkbINqZyypQdl+tYxv6Dx0bkB/YKUXGYfTkgP18TLPWQQB++OGQhi98dlo2w==",
+      "dependencies": {
+        "axios": "^1.3.6",
+        "form-data": "^4.0.0"
+      }
+    },
     "node_modules/nodemon": {
       "version": "3.0.1",
       "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz",
@@ -550,6 +649,12 @@
         "node": ">=8"
       }
     },
+    "node_modules/path-to-regexp": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
+      "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==",
+      "dev": true
+    },
     "node_modules/picomatch": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -577,6 +682,11 @@
         "url": "https://github.com/prettier/prettier?sponsor=1"
       }
     },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
     "node_modules/pstree.remy": {
       "version": "1.1.8",
       "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
diff --git a/package.json b/package.json
index 7f9f692..4c77ee9 100644
--- a/package.json
+++ b/package.json
@@ -6,14 +6,17 @@
   "type": "module",
   "scripts": {
     "format": "prettier --write src/**/*.js",
-    "dev": "nodemon --watch src --watch static --exec \"dotenv appwrite-function-utils dev src/main.js\""
+    "dev": "nodemon --watch src --watch static --exec \"appwrite-function-utils dev src/main.js\""
   },
   "author": "",
   "license": "MIT",
   "devDependencies": {
-    "appwrite-function-utils": "^0.1.0",
+    "appwrite-function-utils": "^0.4.0",
     "dotenv-cli": "^7.2.1",
     "nodemon": "^3.0.1",
     "prettier": "^3.0.0"
+  },
+  "dependencies": {
+    "node-appwrite": "^9.0.0"
   }
 }
diff --git a/src/detectors.js b/src/detectors.js
deleted file mode 100644
index adbb62a..0000000
--- a/src/detectors.js
+++ /dev/null
@@ -1,19 +0,0 @@
-const detectors = {
-  android: (ua) => /Android/i.test(ua),
-  ios: (ua) => /iPhone|iPad|iPod/i.test(ua),
-  mobile: (ua) =>
-    /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua),
-  desktop: (ua) =>
-    !/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
-      ua
-    ) && /Windows|Macintosh|Linux/i.test(ua),
-  default: () => true,
-}
-
-/**
- * @param {string} ua
- * @returns {string[]}
- */
-export default function detectPlatforms(ua) {
-  return Object.keys(detectors).filter((key) => detectors[key](ua))
-}
diff --git a/src/handlers/dynamic-link.js b/src/handlers/dynamic-link.js
new file mode 100644
index 0000000..b9a3c8b
--- /dev/null
+++ b/src/handlers/dynamic-link.js
@@ -0,0 +1,39 @@
+import AppwriteService from '../services/appwrite.js'
+import environment from '../services/environment.js'
+import views from '../services/views.js'
+import DynamicLinkService from '../services/dynamic-link.js'
+import { parseUrl } from '../utils.js'
+
+const appwrite = AppwriteService(environment)
+
+/** @type {import('appwrite-function-utils').Function} */
+export default async ({ req, res, error }) => {
+  const dynamicLink = DynamicLinkService(req)
+
+  const paths = await appwrite.getPaths()
+  const matchedPath = dynamicLink.findPath(paths)
+  if (!matchedPath) {
+    error(`Path not found for path: ${req.url}`)
+    return res.send('Not found', 404)
+  }
+
+  const rule = dynamicLink.findRule(matchedPath)
+  if (!rule) {
+    error(`Rule not found for path: ${matchedPath.$id}`)
+    return res.redirect(matchedPath.fallbackTarget, 302)
+  }
+
+  const target = parseUrl(rule.target)
+  if (!target) {
+    error(`Invalid target for rule: ${rule.$id}`)
+    return res.redirect(matchedPath.fallbackTarget, 302)
+  }
+
+  if (target.protocol === 'intent://') {
+    return res.send(views.deepLink(target.toString()), 200, {
+      'Content-Type': 'text/html; charset=utf-8',
+    })
+  }
+
+  return res.redirect(target.toString(), 302)
+}
diff --git a/src/main.js b/src/main.js
index 2403360..8a95ef5 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,70 +1,19 @@
-import { readFileSync } from 'fs'
-import path from 'path'
-import { fileURLToPath } from 'url'
-import detectPlatforms from './detectors'
-
-const __filename = fileURLToPath(import.meta.url)
-const __dirname = path.dirname(__filename)
-
-const staticFolder = path.join(__dirname, '../static')
-
-export default async ({ req, res, log }) => {
-  const config = JSON.parse(process.env.CONFIG ?? '[]')
-
-  if (config.length === 0) {
-    throw new Error('CONFIG environment variable must be set')
-  }
-
-  const targets = config.find(({ path }) => path === req.path)?.targets
-  if (!targets) {
-    log(`No targets for path ${req.path}`)
-    return res.empty()
-  }
-  log(`Found targets for path ${req.path}`)
-
-  const platforms = detectPlatforms(req.headers['user-agent'])
-  log(`Detected platforms: ${platforms.join(', ')}`)
-
-  for (const platform of platforms) {
-    const target = targets[platform]
-    if (!target) {
-      log(`No redirect for platform ${platform}`)
-      continue
-    }
-
-    if (platform === 'default') {
-      log(`Default for platform ${platform}`)
-      return res.redirect(targets.default)
-    }
-
-    if (typeof target === 'string') {
-      log(`Simple redirect to ${target}`)
-      return res.redirect(target)
-    }
-
-    if (typeof target === 'object' && target.appName) {
-      log(`Deep link to app=${target.appName} path=${target.appPath}`)
-
-      const template = readFileSync(
-        path.join(staticFolder, 'deeplink.html')
-      ).toString()
-
-      const html = template
-        .split('{{APP_NAME}}')
-        .join(target.appName)
-        .split('{{APP_PATH}}')
-        .join(target.appPath)
-        .split('{{APP_PACKAGE}}')
-        .join(target.appPackage ?? '')
-        .split('{{FALLBACK}}')
-        .join(target.fallback ?? target.default ?? '')
-
-      return res.send(html, 200, {
-        'Content-Type': 'text/html; charset=utf-8',
-      })
-    }
-  }
-
-  log(`Out of ideas, returning empty response`)
-  return res.empty()
-}
+import { HttpMethod, router } from 'appwrite-function-utils'
+import dynamicLinkHandler from './handlers/dynamic-link.js'
+
+export default router({
+  routes: [
+    {
+      path: '/admin',
+      methods: [HttpMethod.GET],
+      handler: async ({ res }) => {
+        return res.send('Hello World')
+      },
+    },
+    {
+      path: '/(.*)',
+      methods: [HttpMethod.GET],
+      handler: dynamicLinkHandler,
+    },
+  ],
+})
diff --git a/src/services/appwrite.js b/src/services/appwrite.js
new file mode 100644
index 0000000..b56d720
--- /dev/null
+++ b/src/services/appwrite.js
@@ -0,0 +1,204 @@
+import { Client, Databases } from 'node-appwrite'
+
+export const PathType = {
+  EXACT: 'exact',
+  PREFIX: 'prefix',
+  SUFFIX: 'suffix',
+  REGEX: 'regex',
+}
+
+export const RuleType = {
+  PLATFORM: 'platform',
+  OS: 'os',
+  BROWSER: 'browser',
+  LANGUAGE: 'language',
+  COUNTRY: 'country',
+}
+
+/**
+ * @typedef {Object} DynamicLinkRule
+ * @property {boolean} enabled
+ * @property {("platform" | "os" | "browser" | "language" | "country")} ruleType
+ * @property {string} ruleValue
+ * @property {string} target
+ *
+ * @typedef {import('node-appwrite').Models.Document & DynamicLinkRule} DynamicLinkRuleDocument
+ */
+
+/**
+ * @typedef {Object} DynamicLinkPath
+ * @property {boolean} enabled
+ * @property {("exact" | "prefix" | "suffix"| "regex")} pathType
+ * @property {string} pathValue
+ * @property {string} fallbackTarget
+ * @property {DynamicLinkRuleDocument[]} rules
+ *
+ * @typedef {import('node-appwrite').Models.Document & DynamicLinkPath} DynamicLinkPathDocument
+ */
+
+/**
+ * @typedef {Object} AppwriteService
+ * @property {() => Promise<DynamicLinkPathDocument[]>} getPaths
+ * @property {() => Promise<void>} setupDynamicLinksDatabase
+ * @property {() => Promise<boolean>} doesDynamicLinksDatabaseExist
+ */
+
+/**
+ * @param {typeof import('./environment').default} environment
+ * @returns {AppwriteService}
+ */
+export default function AppwriteService(environment) {
+  const {
+    APPWRITE_ENDPOINT,
+    APPWRITE_PROJECT_ID,
+    APPWRITE_API_KEY,
+    DATABASE_ID,
+    DATABASE_NAME,
+    PATH_COLLECTION_ID,
+    PATH_COLLECTION_NAME,
+    RULE_COLLECTION_ID,
+    RULE_COLLECTION_NAME,
+  } = environment
+
+  const client = new Client()
+  client
+    .setEndpoint(APPWRITE_ENDPOINT)
+    .setProject(APPWRITE_PROJECT_ID)
+    .setKey(APPWRITE_API_KEY)
+
+  const databases = new Databases(client)
+
+  async function setupPathsCollection() {
+    try {
+      await databases.createCollection(
+        DATABASE_ID,
+        PATH_COLLECTION_ID,
+        PATH_COLLECTION_NAME
+      )
+      await databases.createBooleanAttribute(
+        DATABASE_ID,
+        PATH_COLLECTION_ID,
+        'enabled',
+        true
+      )
+      await databases.createEnumAttribute(
+        DATABASE_ID,
+        PATH_COLLECTION_ID,
+        'pathType',
+        ['exact', 'prefix', 'suffix', 'regex'],
+        true
+      )
+      await databases.createStringAttribute(
+        DATABASE_ID,
+        PATH_COLLECTION_ID,
+        'pathValue',
+        65536,
+        true
+      )
+      await databases.createUrlAttribute(
+        DATABASE_ID,
+        PATH_COLLECTION_ID,
+        'fallbackTarget',
+        true
+      )
+    } catch (err) {
+      // If resource already exists, we can ignore the error
+      if (err.code !== 409) throw err
+    }
+  }
+
+  async function setupRulesCollection() {
+    try {
+      await databases.createCollection(
+        DATABASE_ID,
+        RULE_COLLECTION_ID,
+        RULE_COLLECTION_NAME
+      )
+      await databases.createBooleanAttribute(
+        DATABASE_ID,
+        RULE_COLLECTION_ID,
+        'enabled',
+        true
+      )
+      await databases.createEnumAttribute(
+        DATABASE_ID,
+        RULE_COLLECTION_ID,
+        'ruleType',
+        ['platform', 'os', 'browser', 'language', 'country'],
+        true
+      )
+      await databases.createStringAttribute(
+        DATABASE_ID,
+        RULE_COLLECTION_ID,
+        'ruleValue',
+        255,
+        true
+      )
+      await databases.createUrlAttribute(
+        DATABASE_ID,
+        RULE_COLLECTION_ID,
+        'target',
+        true
+      )
+    } catch (err) {
+      // If resource already exists, we can ignore the error
+      if (err.code !== 409) throw err
+    }
+  }
+
+  async function setupRelationships() {
+    try {
+      await databases.createRelationshipAttribute(
+        DATABASE_ID,
+        PATH_COLLECTION_ID,
+        RULE_COLLECTION_ID,
+        'oneToMany',
+        false,
+        'rules',
+        undefined,
+        'cascade'
+      )
+    } catch (err) {
+      // If resource already exists, we can ignore the error
+      if (err.code !== 409) throw err
+    }
+  }
+
+  return {
+    /**
+     * @returns {Promise<boolean>}
+     */
+    doesDynamicLinksDatabaseExist: async function () {
+      try {
+        await databases.get(DATABASE_ID)
+        return true
+      } catch (err) {
+        if (err.code !== 404) throw err
+        return false
+      }
+    },
+
+    setupDynamicLinksDatabase: async function () {
+      try {
+        await databases.create(DATABASE_ID, DATABASE_NAME)
+        await setupPathsCollection()
+        await setupRulesCollection()
+        await setupRelationships()
+      } catch (err) {
+        // If resource already exists, we can ignore the error
+        if (err.code !== 409) throw err
+      }
+    },
+
+    /**
+     * @returns {Promise<DynamicLinkPathDocument[]>}
+     */
+    getPaths: async function () {
+      const { documents } = await databases.listDocuments(
+        DATABASE_ID,
+        PATH_COLLECTION_ID
+      )
+      return /** @type {DynamicLinkPathDocument[]} */ (documents)
+    },
+  }
+}
diff --git a/src/services/dynamic-link.js b/src/services/dynamic-link.js
new file mode 100644
index 0000000..9b07e99
--- /dev/null
+++ b/src/services/dynamic-link.js
@@ -0,0 +1,104 @@
+import { PathType, RuleType } from './appwrite.js'
+
+const detector = {
+  /**
+   * @param {string} userAgent
+   */
+  platform: (userAgent) => {
+    if (/Android|webOS/i.test(userAgent)) return 'mobile'
+    if (/iPhone|iPad|iPod/i.test(userAgent)) return 'mobile'
+    if (/BlackBerry|IEMobile|Opera Mini/i.test(userAgent)) return 'mobile'
+    if (/Windows|Macintosh|Linux/i.test(userAgent)) return 'desktop'
+    return 'unknown'
+  },
+  /**
+   * @param {string} userAgent
+   */
+  os: (userAgent) => {
+    if (/Android/i.test(userAgent)) return 'android'
+    if (/iPhone|iPad|iPod/i.test(userAgent)) return 'ios'
+    if (/Windows/i.test(userAgent)) return 'windows'
+    if (/Macintosh/i.test(userAgent)) return 'macos'
+    if (/Linux/i.test(userAgent)) return 'linux'
+    return 'unknown'
+  },
+  /**
+   * @param {string} userAgent
+   */
+  browser: (userAgent) => {
+    if (/Edg/i.test(userAgent)) return 'edge'
+    if (/Chrome/i.test(userAgent)) return 'chrome'
+    if (/Safari/i.test(userAgent)) return 'safari'
+    if (/Firefox/i.test(userAgent)) return 'firefox'
+    if (/Opera/i.test(userAgent)) return 'opera'
+    if (/MSIE|Trident/i.test(userAgent)) return 'ie'
+    return 'unknown'
+  },
+}
+
+/**
+ * @param {string} path
+ * @param {import('./appwrite').DynamicLinkPathDocument} pathDocument
+ * @returns {boolean}
+ */
+function matchPath(path, pathDocument) {
+  switch (pathDocument.pathType) {
+    case PathType.EXACT:
+      return pathDocument.pathValue === path
+    case PathType.PREFIX:
+      return path.startsWith(pathDocument.pathValue)
+    case PathType.SUFFIX:
+      return path.endsWith(pathDocument.pathValue)
+    case PathType.REGEX:
+      return new RegExp(pathDocument.pathValue).test(path)
+    default:
+      return false
+  }
+}
+
+/**
+ * @param {import('./appwrite').DynamicLinkRuleDocument} rule
+ * @returns {boolean}
+ */
+function matchRule(rule, req) {
+  const userAgent = req.headers['user-agent'] ?? ''
+  switch (rule.ruleType) {
+    case RuleType.PLATFORM:
+      return detector.platform(userAgent) === rule.ruleValue
+    case RuleType.OS:
+      return detector.os(userAgent) === rule.ruleValue
+    case RuleType.BROWSER:
+      return detector.browser(userAgent) === rule.ruleValue
+    default:
+      return false
+  }
+}
+
+/**
+ * @typedef {Object} DynamicLinkService
+ * @property {(paths: import('./appwrite').DynamicLinkPathDocument[]) => import('./appwrite').DynamicLinkPathDocument | undefined} findPath
+ * @property {(path: import('./appwrite').DynamicLinkPathDocument) => import('./appwrite').DynamicLinkRuleDocument | undefined} findRule
+ */
+
+/**
+ * @param {import('appwrite-function-utils').HttpRequest} req
+ * @returns {DynamicLinkService}
+ */
+export default function DynamicLinkService(req) {
+  return {
+    /**
+     * @param {import('./appwrite').DynamicLinkPathDocument[]} paths
+     * @returns {import('./appwrite').DynamicLinkPathDocument | undefined}
+     */
+    findPath: function (paths) {
+      return paths.find((path) => path.enabled && matchPath(req.path, path))
+    },
+    /**
+     * @param {import('./appwrite').DynamicLinkPathDocument} path
+     * @returns {import('./appwrite').DynamicLinkRuleDocument | undefined}
+     */
+    findRule: function (path) {
+      return path.rules.find((rule) => rule.enabled && matchRule(rule, req))
+    },
+  }
+}
diff --git a/src/services/environment.js b/src/services/environment.js
new file mode 100644
index 0000000..1f52c1a
--- /dev/null
+++ b/src/services/environment.js
@@ -0,0 +1,25 @@
+/**
+ * @param {string} key
+ * @return {string}
+ */
+function getRequiredEnv(key) {
+  const value = process.env[key]
+  if (value === undefined) {
+    throw new Error(`Environment variable ${key} is not set`)
+  }
+  return value
+}
+
+const environment = {
+  APPWRITE_API_KEY: getRequiredEnv('APPWRITE_API_KEY'),
+  APPWRITE_ENDPOINT: getRequiredEnv('APPWRITE_ENDPOINT'),
+  APPWRITE_PROJECT_ID: getRequiredEnv('APPWRITE_PROJECT_ID'),
+  DATABASE_ID: process.env.DATABASE_ID ?? 'dynamic-links',
+  DATABASE_NAME: 'Dynamic Links',
+  PATH_COLLECTION_ID: process.env.COLLECTION_ID ?? 'paths',
+  PATH_COLLECTION_NAME: 'Paths',
+  RULE_COLLECTION_ID: process.env.COLLECTION_ID ?? 'rules',
+  RULE_COLLECTION_NAME: 'Rules',
+}
+
+export default environment
diff --git a/src/services/views.js b/src/services/views.js
new file mode 100644
index 0000000..35afaa2
--- /dev/null
+++ b/src/services/views.js
@@ -0,0 +1,24 @@
+import { readFileSync } from 'node:fs'
+import path from 'node:path'
+
+function interpolate(template, data) {
+  return template.replaceAll(/{{\s*([^}]+)\s*}}/g, (_, key) => data[key.trim()])
+}
+
+const views = {
+  /**
+   * @param {string} link
+   * @returns {string} HTML response
+   */
+  deepLink: (link) => {
+    const template = readFileSync(
+      path.join(__dirname, 'deep-link.html')
+    ).toString()
+
+    return interpolate(template, {
+      LINK: link,
+    })
+  },
+}
+
+export default views
diff --git a/src/setup.js b/src/setup.js
new file mode 100644
index 0000000..3067727
--- /dev/null
+++ b/src/setup.js
@@ -0,0 +1,18 @@
+import AppwriteService from './services/appwrite.js'
+import environment from './services/environment.js'
+
+const appwrite = AppwriteService(environment)
+
+async function setup() {
+  console.log('Executing setup script...')
+
+  if (await appwrite.doesDynamicLinksDatabaseExist()) {
+    console.log(`Database exists.`)
+    return
+  }
+
+  await appwrite.setupDynamicLinksDatabase()
+  console.log(`Database created.`)
+}
+
+setup()
diff --git a/src/utils.js b/src/utils.js
new file mode 100644
index 0000000..88246e1
--- /dev/null
+++ b/src/utils.js
@@ -0,0 +1,11 @@
+/**
+ * @param {string} url
+ * @returns {URL | null}
+ */
+export function parseUrl(url) {
+  try {
+    return new URL(url)
+  } catch {
+    return null
+  }
+}
diff --git a/static/admin.html b/static/admin.html
new file mode 100644
index 0000000..297fa80
--- /dev/null
+++ b/static/admin.html
@@ -0,0 +1,80 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+    <title>Dynamic Links</title>
+
+    <link rel="stylesheet" href="https://unpkg.com/@appwrite.io/pink" />
+    <link rel="stylesheet" href="https://unpkg.com/@appwrite.io/pink-icons" />
+  </head>
+  <body>
+    <main class="main-content">
+      <div class="top-cover u-padding-block-end-56">
+        <div
+          class="container u-flex-vertical u-cross-center"
+          style="max-width: 40rem"
+        >
+          <div class="u-flex u-main-center u-margin-block-start-16">
+            <h1 class="heading-level-1">Manage</h1>
+            <code class="u-un-break-text"></code>
+          </div>
+        </div>
+      </div>
+    </main>
+
+    <script>
+      const appName = '{{APP_NAME}}'
+      const appPath = '{{APP_PATH}}'
+      const appPackage = '{{APP_PACKAGE}}'
+      const fallback = '{{FALLBACK}}'
+
+      function navigateFallback() {
+        setTimeout(() => {
+          window.location.href = fallback
+        }, 500)
+      }
+
+      window.addEventListener('DOMContentLoaded', function () {
+        const manualAnchor = document.querySelector('#manual')
+
+        if (/Android/i.test(window.navigator.userAgent)) {
+          let intentURL = `intent://${appPath}#Intent;scheme=${appName};package=${appPackage};action=android.intent.action.VIEW;category=android.intent.category.DEFAULT;category=android.intent.category.BROWSABLE;`
+
+          if (fallback) {
+            intentURL += `S.browser_fallback_url=${encodeURIComponent(
+              fallback
+            )}`
+          }
+
+          intentURL += ';end'
+
+          manualAnchor.setAttribute('href', intentURL)
+          manualAnchor.textContent = intentURL
+
+          window.location.href = intentURL
+          navigateFallback()
+
+          return
+        }
+
+        if (/iPhone|iPad|iPod/i.test(window.navigator.userAgent)) {
+          const deepLink = `${appName}://${appPath}`
+
+          manualAnchor.setAttribute('href', deepLink)
+          manualAnchor.textContent = deepLink
+
+          window.location.href = deepLink
+          navigateFallback()
+
+          return
+        }
+
+        return
+        window.location.href = fallback
+      })
+    </script>
+  </body>
+</html>

From a1f5075de3e91bb5bd5338b45dbedea87159a616 Mon Sep 17 00:00:00 2001
From: loks0n <22452787+loks0n@users.noreply.github.com>
Date: Mon, 17 Jul 2023 09:49:36 +0100
Subject: [PATCH 2/2] chore: remove dotenv-cli

---
 package-lock.json | 99 -----------------------------------------------
 package.json      |  1 -
 2 files changed, 100 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index d05e212..36208fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,7 +13,6 @@
       },
       "devDependencies": {
         "appwrite-function-utils": "^0.4.0",
-        "dotenv-cli": "^7.2.1",
         "nodemon": "^3.0.1",
         "prettier": "^3.0.0"
       }
@@ -228,20 +227,6 @@
         "node": ">= 0.6"
       }
     },
-    "node_modules/cross-spawn": {
-      "version": "7.0.3",
-      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
-      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
-      "dev": true,
-      "dependencies": {
-        "path-key": "^3.1.0",
-        "shebang-command": "^2.0.0",
-        "which": "^2.0.1"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
     "node_modules/debug": {
       "version": "3.2.7",
       "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
@@ -280,30 +265,6 @@
         "url": "https://github.com/motdotla/dotenv?sponsor=1"
       }
     },
-    "node_modules/dotenv-cli": {
-      "version": "7.2.1",
-      "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-7.2.1.tgz",
-      "integrity": "sha512-ODHbGTskqRtXAzZapDPvgNuDVQApu4oKX8lZW7Y0+9hKA6le1ZJlyRS687oU9FXjOVEDU/VFV6zI125HzhM1UQ==",
-      "dev": true,
-      "dependencies": {
-        "cross-spawn": "^7.0.3",
-        "dotenv": "^16.0.0",
-        "dotenv-expand": "^10.0.0",
-        "minimist": "^1.2.6"
-      },
-      "bin": {
-        "dotenv": "cli.js"
-      }
-    },
-    "node_modules/dotenv-expand": {
-      "version": "10.0.0",
-      "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz",
-      "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==",
-      "dev": true,
-      "engines": {
-        "node": ">=12"
-      }
-    },
     "node_modules/emoji-regex": {
       "version": "8.0.0",
       "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -498,12 +459,6 @@
         "node": ">=0.12.0"
       }
     },
-    "node_modules/isexe": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
-      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
-      "dev": true
-    },
     "node_modules/lru-cache": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -564,15 +519,6 @@
         "node": "*"
       }
     },
-    "node_modules/minimist": {
-      "version": "1.2.8",
-      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
-      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
-      "dev": true,
-      "funding": {
-        "url": "https://github.com/sponsors/ljharb"
-      }
-    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -640,15 +586,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/path-key": {
-      "version": "3.1.1",
-      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
-      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
-      "dev": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/path-to-regexp": {
       "version": "6.2.1",
       "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz",
@@ -756,27 +693,6 @@
       "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==",
       "dev": true
     },
-    "node_modules/shebang-command": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
-      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
-      "dev": true,
-      "dependencies": {
-        "shebang-regex": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/shebang-regex": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
-      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
-      "dev": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/simple-update-notifier": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
@@ -884,21 +800,6 @@
         "node": ">= 0.8"
       }
     },
-    "node_modules/which": {
-      "version": "2.0.2",
-      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
-      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
-      "dev": true,
-      "dependencies": {
-        "isexe": "^2.0.0"
-      },
-      "bin": {
-        "node-which": "bin/node-which"
-      },
-      "engines": {
-        "node": ">= 8"
-      }
-    },
     "node_modules/wrap-ansi": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
diff --git a/package.json b/package.json
index 4c77ee9..b43acf1 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,6 @@
   "license": "MIT",
   "devDependencies": {
     "appwrite-function-utils": "^0.4.0",
-    "dotenv-cli": "^7.2.1",
     "nodemon": "^3.0.1",
     "prettier": "^3.0.0"
   },
