/** * CatchFolder upload app, v1.0.0 (2026-10-17) * Runs inside YOUR Google account. Files go straight into a folder in YOUR Google Drive. * CatchFolder never receives your files, your password or a token to your Drive. * The only call to CatchFolder is a license check (license key + this script's ID + version). * * Setup (about 5 minutes): https://catchfolder.vercel.app/docs/setup * 1. Paste your license key below. * 2. Optional: edit the other settings. * 3. Run the function `setup` once (Run button) and approve the permissions. * 4. Deploy > New deployment > Web app: Execute as "Me", Who has access "Anyone". Share the /exec URL. */ var CONFIG = { LICENSE_KEY: 'PASTE-YOUR-LICENSE-KEY-HERE', // What uploaders see PAGE_TITLE: 'Upload your files', PAGE_INSTRUCTIONS: 'Choose your files and press Upload. You do not need a Google account.', SUCCESS_MESSAGE: 'Thank you! Your files were received.', // Where files go (created in your My Drive on first setup) FOLDER_NAME: 'CatchFolder uploads', // Limits. 50 MB per file is the tested-for launch limit (see docs). MAX_FILE_MB: 50, MAX_FILES_PER_SUBMISSION: 10, ALLOWED_EXTENSIONS: '', // e.g. 'pdf,jpg,jpeg,png,docx'. Empty = any file type. // Optional fields shown to uploaders ASK_NAME: true, REQUIRE_NAME: false, ASK_EMAIL: true, REQUIRE_EMAIL: false, ASK_NOTE: true, // Email me a summary after each submission (uses your Gmail; Google allows 100 recipients/day on personal accounts) NOTIFY_OWNER_EMAIL: true, // Optional: stop accepting uploads after this date/time, e.g. '2026-12-31T23:59:00'. Empty = no end date. CLOSE_AFTER: '' }; // --------------------------------------------------------------------------- // Internals. You don't need to change anything below this line. // --------------------------------------------------------------------------- var CF_VERSION = '1.0.0'; var CF_API = 'https://catchfolder.vercel.app/api/license'; var CF_CHUNK_BYTES = 4 * 1024 * 1024; // must be a multiple of 256 KiB (Drive resumable upload rule) var CF_GRACE_HOURS = 72; // keep working this long if the license server is unreachable var CF_LICENSE_CACHE_SECONDS = 6 * 60 * 60; function setup() { var props = PropertiesService.getScriptProperties(); var folder = getOrCreateFolder_(); var sheet = getOrCreateLog_(folder); var lic = checkLicense_(true); // Touch the services once so the authorization prompt covers everything the app needs. var me = Session.getEffectiveUser().getEmail(); ScriptApp.getOAuthToken(); if (CONFIG.NOTIFY_OWNER_EMAIL) MailApp.getRemainingDailyQuota(); var lines = [ 'CatchFolder setup', 'Owner account: ' + me, 'Upload folder: ' + folder.getUrl(), 'Upload log (Google Sheet): ' + sheet.getUrl(), 'License: ' + (lic.ok ? ('ACTIVE, plan ' + lic.plan + ', valid until ' + lic.expires_at + (lic.max_uploads ? (', max ' + lic.max_uploads + ' uploads') : '')) : ('NOT ACTIVE: ' + lic.message)), 'Uploads so far: ' + Number(props.getProperty('UPLOAD_COUNT') || 0), lic.ok ? 'Next: Deploy > New deployment > Web app > Execute as: Me, Who has access: Anyone > Deploy. Share the URL ending in /exec.' : 'Fix the license problem above, then run setup again.' ]; Logger.log(lines.join('\n')); return lines.join('\n'); } function doGet(e) { var lic = checkLicense_(false); var closed = isClosed_(); if (!lic.ok || closed) { var msg = closed ? 'This upload link is closed and no longer accepts files.' : 'This upload link is not active right now. ' + (lic.public_message || ''); return HtmlService.createHtmlOutput('

Upload link unavailable

' + escape_(msg) + '

If you own this link, open the script and run setup to see details.

') .setTitle('Upload link unavailable'); } var t = HtmlService.createTemplateFromFile('Upload'); t.cfgJson = JSON.stringify(publicConfig_(lic)); return t.evaluate() .setTitle(CONFIG.PAGE_TITLE) .addMetaTag('viewport', 'width=device-width, initial-scale=1') .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); // lets you embed the page on your own site } function publicConfig_(lic) { return { title: CONFIG.PAGE_TITLE, instructions: CONFIG.PAGE_INSTRUCTIONS, success: CONFIG.SUCCESS_MESSAGE, maxFileMb: Number(CONFIG.MAX_FILE_MB), maxFiles: Number(CONFIG.MAX_FILES_PER_SUBMISSION), allowed: parseExts_(CONFIG.ALLOWED_EXTENSIONS), askName: !!CONFIG.ASK_NAME, requireName: !!CONFIG.REQUIRE_NAME, askEmail: !!CONFIG.ASK_EMAIL, requireEmail: !!CONFIG.REQUIRE_EMAIL, askNote: !!CONFIG.ASK_NOTE, chunkBytes: CF_CHUNK_BYTES, version: CF_VERSION }; } /** Called by the upload page for each file. Validates, then opens a Drive resumable upload session. */ function startUpload(meta) { meta = meta || {}; var lic = checkLicense_(false); if (!lic.ok) throw new Error('This upload link is not active right now.'); if (isClosed_()) throw new Error('This upload link is closed.'); var name = cleanName_(meta.name); var size = Number(meta.size); if (!name) throw new Error('The file has no name.'); if (!(size > 0)) throw new Error('"' + name + '" is empty (0 bytes).'); if (size > CONFIG.MAX_FILE_MB * 1024 * 1024) throw new Error('"' + name + '" is larger than ' + CONFIG.MAX_FILE_MB + ' MB.'); var allowed = parseExts_(CONFIG.ALLOWED_EXTENSIONS); if (allowed.length && allowed.indexOf(extOf_(name)) < 0) throw new Error('"' + name + '": this file type is not accepted. Allowed: ' + allowed.join(', ') + '.'); var up = meta.uploader || {}; if (CONFIG.REQUIRE_NAME && !String(up.name || '').trim()) throw new Error('Please enter your name.'); if (CONFIG.REQUIRE_EMAIL && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(up.email || '').trim())) throw new Error('Please enter a valid email address.'); var count = Number(PropertiesService.getScriptProperties().getProperty('UPLOAD_COUNT') || 0); if (lic.max_uploads && count >= lic.max_uploads) throw new Error('This upload link has reached its upload limit.'); var folderId = getOrCreateFolder_().getId(); var who = String(up.name || up.email || 'anonymous').trim().slice(0, 60); var stamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HHmm'); var driveName = cleanName_(stamp + ' - ' + who + ' - ' + name); var description = 'Uploaded via CatchFolder' + (up.name ? '\nName: ' + up.name : '') + (up.email ? '\nEmail: ' + up.email : '') + (up.note ? '\nNote: ' + up.note : ''); var type = String(meta.type || '') || 'application/octet-stream'; var res = UrlFetchApp.fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&supportsAllDrives=true&fields=id,name,size,webViewLink', { method: 'post', contentType: 'application/json; charset=UTF-8', headers: { Authorization: 'Bearer ' + ScriptApp.getOAuthToken(), 'X-Upload-Content-Type': type, 'X-Upload-Content-Length': String(size) }, payload: JSON.stringify({ name: driveName, parents: [folderId], description: description.slice(0, 4000) }), muteHttpExceptions: true }); var code = res.getResponseCode(); var headers = res.getAllHeaders(); var location = headers.Location || headers.location; if (code !== 200 || !location) throw new Error('Google Drive could not start the upload (HTTP ' + code + '). Please try again in a minute.'); var token = Utilities.getUuid(); CacheService.getScriptCache().put('u:' + token, JSON.stringify({ loc: location, size: size, type: type, name: name, driveName: driveName, up: { name: up.name || '', email: up.email || '', note: up.note || '' }, batch: String(meta.batchId || '').slice(0, 64) }), 21600); return { token: token, chunkBytes: CF_CHUNK_BYTES }; } /** Called by the upload page for each chunk (base64). Returns {done:false,next} or {done:true,name}. */ function uploadChunk(token, b64, offset) { var cache = CacheService.getScriptCache(); var raw = cache.get('u:' + token); if (!raw) throw new Error('This upload session expired. Please upload the file again.'); var s = JSON.parse(raw); offset = Number(offset); var bytes = Utilities.base64Decode(String(b64 || '')); if (!bytes.length) throw new Error('Empty chunk received.'); var end = offset + bytes.length - 1; if (end >= s.size) throw new Error('Chunk goes past the end of the file.'); if (end < s.size - 1 && bytes.length % (256 * 1024) !== 0) throw new Error('Bad chunk size.'); var res = UrlFetchApp.fetch(s.loc, { method: 'put', contentType: s.type, payload: bytes, headers: { 'Content-Range': 'bytes ' + offset + '-' + end + '/' + s.size }, muteHttpExceptions: true }); var code = res.getResponseCode(); if (code === 308) { var range = res.getAllHeaders().Range || res.getAllHeaders().range || ''; var m = /bytes=0-(\d+)/.exec(range); return { done: false, next: m ? Number(m[1]) + 1 : end + 1 }; } if (code === 200 || code === 201) { var file = {}; try { file = JSON.parse(res.getContentText()); } catch (err) {} finishFile_(s, file); cache.remove('u:' + token); return { done: true, name: s.name }; } if (code === 403 && /storageQuotaExceeded|quota/i.test(res.getContentText())) throw new Error('The owner\'s Google Drive is full, so this file could not be saved.'); throw new Error('Google Drive rejected part of the upload (HTTP ' + code + '). Please try again.'); } /** Called once after all files of a submission finished. Sends one summary email to the owner. */ function finishSubmission(batchId) { batchId = String(batchId || '').slice(0, 64); var cache = CacheService.getScriptCache(); var raw = cache.get('b:' + batchId); if (!raw) return { ok: true, files: 0 }; var b = JSON.parse(raw); cache.remove('b:' + batchId); if (CONFIG.NOTIFY_OWNER_EMAIL && MailApp.getRemainingDailyQuota() > 0) { var body = b.files.length + ' file(s) uploaded via your CatchFolder link.\n\n' + (b.up.name ? 'Name: ' + b.up.name + '\n' : '') + (b.up.email ? 'Email: ' + b.up.email + '\n' : '') + (b.up.note ? 'Note: ' + b.up.note + '\n' : '') + '\nFiles:\n' + b.files.map(function (f) { return '- ' + f.name + ' (' + f.mb + ' MB) ' + (f.link || ''); }).join('\n') + '\n\nFolder: ' + getOrCreateFolder_().getUrl(); MailApp.sendEmail(Session.getEffectiveUser().getEmail(), 'CatchFolder: ' + b.files.length + ' new file(s)' + (b.up.name ? ' from ' + b.up.name : ''), body); } return { ok: true, files: b.files.length }; } function finishFile_(s, file) { var lock = LockService.getScriptLock(); lock.waitLock(20000); try { var props = PropertiesService.getScriptProperties(); props.setProperty('UPLOAD_COUNT', String(Number(props.getProperty('UPLOAD_COUNT') || 0) + 1)); var mb = Math.round(s.size / 1048576 * 100) / 100; var link = file.webViewLink || (file.id ? 'https://drive.google.com/file/d/' + file.id + '/view' : ''); getOrCreateLog_(getOrCreateFolder_()).getSheets()[0].appendRow([new Date(), s.up.name, s.up.email, s.up.note, s.name, mb, link, s.batch]); if (s.batch) { var cache = CacheService.getScriptCache(); var b = JSON.parse(cache.get('b:' + s.batch) || 'null') || { up: s.up, files: [] }; b.files.push({ name: s.name, mb: mb, link: link }); cache.put('b:' + s.batch, JSON.stringify(b), 21600); } } finally { lock.releaseLock(); } } /** License check. activate=true is used by setup() to start the pass clock and bind this script. */ function checkLicense_(activate) { var cache = CacheService.getScriptCache(); var props = PropertiesService.getScriptProperties(); if (!activate) { var c = cache.get('lic'); if (c) { var cl = JSON.parse(c); if (cl.ok && cl.expires_at && new Date(cl.expires_at) < new Date()) cl = expired_(cl); return cl; } } var key = String(CONFIG.LICENSE_KEY || '').trim(); if (!key || key.indexOf('PASTE') === 0) return { ok: false, message: 'Paste your license key into CONFIG.LICENSE_KEY.', public_message: '' }; var lic; try { var url = CF_API + '?key=' + encodeURIComponent(key) + '&install=' + encodeURIComponent(ScriptApp.getScriptId()) + '&v=' + CF_VERSION + (activate ? '&activate=1' : ''); var res = UrlFetchApp.fetch(url, { muteHttpExceptions: true, headers: { 'User-Agent': 'CatchFolder-AppsScript/' + CF_VERSION } }); var code = res.getResponseCode(); if (code >= 500) throw new Error('license server ' + code); lic = JSON.parse(res.getContentText()); if (lic.ok) props.setProperty('LAST_GOOD', JSON.stringify({ lic: lic, at: Date.now() })); } catch (err) { var last = JSON.parse(props.getProperty('LAST_GOOD') || 'null'); if (last && Date.now() - last.at < CF_GRACE_HOURS * 3600 * 1000) lic = last.lic; else lic = { ok: false, message: 'Could not reach the CatchFolder license server (' + err + '). Try again later.', public_message: 'Please try again later.' }; } if (lic.ok && lic.expires_at && new Date(lic.expires_at) < new Date()) lic = expired_(lic); cache.put('lic', JSON.stringify(lic), lic.ok ? CF_LICENSE_CACHE_SECONDS : 300); return lic; } function expired_(lic) { return { ok: false, status: 'expired', plan: lic.plan, expires_at: lic.expires_at, message: 'Your ' + lic.plan + ' expired on ' + lic.expires_at + '.', public_message: '' }; } function getOrCreateFolder_() { var props = PropertiesService.getScriptProperties(); var id = props.getProperty('FOLDER_ID'); if (id) { try { var f = DriveApp.getFolderById(id); if (!f.isTrashed()) return f; } catch (err) {} } var folder = DriveApp.createFolder(CONFIG.FOLDER_NAME); props.setProperty('FOLDER_ID', folder.getId()); return folder; } function getOrCreateLog_(folder) { var props = PropertiesService.getScriptProperties(); var id = props.getProperty('SHEET_ID'); if (id) { try { var ss = SpreadsheetApp.openById(id); return ss; } catch (err) {} } var ss2 = SpreadsheetApp.create('CatchFolder upload log'); ss2.getSheets()[0].appendRow(['Time', 'Name', 'Email', 'Note', 'File', 'Size (MB)', 'Drive link', 'Submission ID']); ss2.getSheets()[0].setFrozenRows(1); DriveApp.getFileById(ss2.getId()).moveTo(folder); props.setProperty('SHEET_ID', ss2.getId()); return ss2; } function isClosed_() { return !!(CONFIG.CLOSE_AFTER && new Date(CONFIG.CLOSE_AFTER) < new Date()); } function parseExts_(s) { return String(s || '').toLowerCase().split(/[\s,;]+/).map(function (x) { return x.replace(/^\./, ''); }).filter(String); } function extOf_(n) { var m = /\.([a-z0-9]+)$/i.exec(n || ''); return m ? m[1].toLowerCase() : ''; } function cleanName_(n) { return String(n || '').replace(/[\u0000-\u001f\\\/:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim().slice(0, 200); } function escape_(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); }