/**
* 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]; }); }