|
| 1 | +/* |
| 2 | + * Copyright 2025 Adobe. All rights reserved. |
| 3 | + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); |
| 4 | + * you may not use this file except in compliance with the License. You may obtain a copy |
| 5 | + * of the License at https://www.apache.org/licenses/LICENSE-2.0 |
| 6 | + * |
| 7 | + * Unless required by applicable law or agreed to in writing, software distributed under |
| 8 | + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS |
| 9 | + * OF ANY KIND, either express or implied. See the License for the specific language |
| 10 | + * governing permissions and limitations under the License. |
| 11 | + */ |
| 12 | +import { getCachePlugin } from '@adobe/helix-shared-tokencache'; |
| 13 | +import processQueue from '@adobe/helix-shared-process-queue'; |
| 14 | + |
| 15 | +/** |
| 16 | + * Build an object containing all gdrive root ids as keys. |
| 17 | + * |
| 18 | + * @param {array} inventory inventory of repositories |
| 19 | + * @returns object with gdrive root ids |
| 20 | + */ |
| 21 | +function buildRoots(inventory) { |
| 22 | + return inventory.filter(({ gdriveId }) => !!gdriveId) |
| 23 | + .reduce((roots, { gdriveId }) => { |
| 24 | + // eslint-disable-next-line no-param-reassign |
| 25 | + roots[gdriveId] = '/'; |
| 26 | + return roots; |
| 27 | + }, {}); |
| 28 | +} |
| 29 | + |
| 30 | +/** |
| 31 | + * A custom user consists of a project (org/site) and a content bus id. |
| 32 | + * |
| 33 | + * @typedef CustomUser |
| 34 | + * @property {string} project |
| 35 | + * @property {string} contentBusId |
| 36 | + */ |
| 37 | + |
| 38 | +/** |
| 39 | + * Matcher that filters inventory entries against known google drives. |
| 40 | + */ |
| 41 | +export default class GoogleMatcher { |
| 42 | + constructor(context) { |
| 43 | + this.context = context; |
| 44 | + } |
| 45 | + |
| 46 | + /** |
| 47 | + * Return all custom users that we should use to lookup Google items. |
| 48 | + * |
| 49 | + * @param {import('../inventory.js').InventoryEntry[]} entries entries |
| 50 | + * @returns {CustomUser[]} |
| 51 | + */ |
| 52 | + #getCustomUsers(entries) { |
| 53 | + const { env } = this.context; |
| 54 | + |
| 55 | + return (env.HLX_CUSTOM_GOOGLE_USERS ?? '').split(',') |
| 56 | + .map((project) => { |
| 57 | + const [org, site] = project.trim().split('/'); |
| 58 | + return { org, site }; |
| 59 | + }) |
| 60 | + .reduce((users, { org, site }) => { |
| 61 | + // for orgs (i.e. site = '*'), return just the first custom user |
| 62 | + // adorned project in that org. this avoids doing a lookup with |
| 63 | + // the same registered user multiple times |
| 64 | + const entry = entries.find((e) => !!e.customUser |
| 65 | + && e.org === org && (site === '*' || e.site === site)); |
| 66 | + if (entry) { |
| 67 | + const { contentBusId } = entry; |
| 68 | + users.push({ project: `${org}/${entry.site}`, contentBusId }); |
| 69 | + } |
| 70 | + return users; |
| 71 | + }, []); |
| 72 | + } |
| 73 | + |
| 74 | + /** |
| 75 | + * Find the inventory entries that have the given google document, spreadsheet |
| 76 | + * or folder in their tree. |
| 77 | + * |
| 78 | + * @param {URL} url google document or spreadsheet |
| 79 | + * @param {Inventory} inventory inventory of entries |
| 80 | + */ |
| 81 | + async filter(url, inventory) { |
| 82 | + const { log } = this.context; |
| 83 | + |
| 84 | + const segs = url.pathname.split('/'); |
| 85 | + let id = segs.pop(); |
| 86 | + if (id.startsWith('edit')) { |
| 87 | + id = segs.pop(); |
| 88 | + } |
| 89 | + if (id === '') { |
| 90 | + log.info(`Google URL contains no id: ${url}`); |
| 91 | + return []; |
| 92 | + } |
| 93 | + |
| 94 | + // finding the inventory items for google is a bit more tricky, as we can't match the url with |
| 95 | + // the mountpoint, because everything is just an ID. we need to lookup the hierarchy of the |
| 96 | + // item in the url; but for that we need to use the correct connected user. fortunately, |
| 97 | + // 99% of the projects use the default google user, so we try to resolve with that first. |
| 98 | + // if the item specified in the url id is not found, we need to traverse all google entries |
| 99 | + // with the `customUser` flag and try to load it using the entry user. |
| 100 | + |
| 101 | + try { |
| 102 | + const entries = inventory.entries(); |
| 103 | + |
| 104 | + // trivial case, id == mountpoint |
| 105 | + let ret = entries.filter(({ gdriveId }) => gdriveId === id); |
| 106 | + if (ret.length) { |
| 107 | + // we don't want to support overlapping projects, so we return the once found here |
| 108 | + log.info('%j', { |
| 109 | + discover: { |
| 110 | + id, |
| 111 | + count: ret.length, |
| 112 | + client: false, |
| 113 | + }, |
| 114 | + }); |
| 115 | + return ret; |
| 116 | + } |
| 117 | + |
| 118 | + // resolve using the default user |
| 119 | + const roots = buildRoots(entries); |
| 120 | + let client = await this.context.getGoogleClient(); |
| 121 | + let hierarchy = await client.getItemsFromId(id, roots); |
| 122 | + if (hierarchy.length) { |
| 123 | + const { id: rootId } = hierarchy[hierarchy.length - 1]; |
| 124 | + ret = entries.filter(({ gdriveId }) => gdriveId === rootId); |
| 125 | + log.info('%j', { |
| 126 | + discover: { |
| 127 | + id, |
| 128 | + count: ret.length, |
| 129 | + client: true, |
| 130 | + }, |
| 131 | + }); |
| 132 | + return ret; |
| 133 | + } |
| 134 | + |
| 135 | + // if still nothing found. find using the entries with a custom user |
| 136 | + ret = null; |
| 137 | + const customUsers = this.#getCustomUsers(entries); |
| 138 | + await processQueue(customUsers, async ({ project, contentBusId }) => { |
| 139 | + if (!ret) { |
| 140 | + try { |
| 141 | + // eslint-disable-next-line no-await-in-loop |
| 142 | + client = await this.context.getGoogleClient(contentBusId); |
| 143 | + // eslint-disable-next-line no-await-in-loop |
| 144 | + hierarchy = await client.getItemsFromId(id, roots); |
| 145 | + if (hierarchy.length) { |
| 146 | + const { id: rootId } = hierarchy[hierarchy.length - 1]; |
| 147 | + ret = entries.filter(({ gdriveId }) => gdriveId === rootId); |
| 148 | + log.info('%j', { |
| 149 | + discover: { |
| 150 | + id, |
| 151 | + count: ret.length, |
| 152 | + client: true, |
| 153 | + project, |
| 154 | + }, |
| 155 | + }); |
| 156 | + } |
| 157 | + } catch (e) { |
| 158 | + log.info(`Unable to get items from id: ${url} in ${project}: ${e.message}`); |
| 159 | + } |
| 160 | + } |
| 161 | + }, 3); |
| 162 | + return ret ?? []; |
| 163 | + } catch (e) { |
| 164 | + log.info(`Unable to get items from id: ${url}: ${e.message}`); |
| 165 | + return []; |
| 166 | + } |
| 167 | + } |
| 168 | + |
| 169 | + /** |
| 170 | + * Test whether this class can handle an URL |
| 171 | + * |
| 172 | + * @param {URL} url url to match |
| 173 | + * @param {Inventory} inventory |
| 174 | + * @returns true if this class can handle the URL |
| 175 | + */ |
| 176 | + static match(url, inventory) { |
| 177 | + return inventory.getHostType(url.hostname) === 'google' || url.hostname.match(/^.*\.google\.com$/); |
| 178 | + } |
| 179 | + |
| 180 | + /** |
| 181 | + * Extract some data from a URL to store in the inventory. |
| 182 | + * |
| 183 | + * @param {import('../../index.js').AdminContext} context context |
| 184 | + * @param {URL} url url to extract data from |
| 185 | + * @returns object that contains additional entries to store in inventory |
| 186 | + */ |
| 187 | + static async extract(context, url, entry) { |
| 188 | + const match = url.pathname.match(/\/.*\/folders\/([^?/]+)$/); |
| 189 | + if (match) { |
| 190 | + // eslint-disable-next-line no-param-reassign |
| 191 | + [, entry.gdriveId] = match; |
| 192 | + // check for custom user |
| 193 | + if (entry.contentBusId) { |
| 194 | + const { code: codeBucket, content: contentBucket } = context.attributes.bucketMap; |
| 195 | + const plugin = await getCachePlugin({ |
| 196 | + log: context.log, |
| 197 | + contentBusId: entry.contentBusId, |
| 198 | + readOnly: true, |
| 199 | + codeBucket, |
| 200 | + contentBucket, |
| 201 | + }, 'google'); |
| 202 | + if (!plugin.key.startsWith('default/.helix-auth/')) { |
| 203 | + // eslint-disable-next-line no-param-reassign |
| 204 | + entry.customUser = true; |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + } |
| 209 | +} |
0 commit comments