feat(projects): migrate haumdaucherde website into monorepo and document in AGENTS.md
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
const CONFIG = {
|
||||
SPREADSHEET_ID: "1q4r08nBA_ClWv3ypPCQ6GVCfMVkQwSKzDSRiokkQQ8Q", // ID from mail_forwarding
|
||||
SHEET_NAME: "Form Responses 1",
|
||||
COL_FORWARD_TO_ADDRESS: 4, // 1-indexed (Column D from mail_forwarding)
|
||||
ADMIN_EMAIL: "info@haumdaucher.de",
|
||||
PROJECT_ID: "haumdaucher", // Used in the Identity Toolkit REST API
|
||||
DRY_RUN: false,
|
||||
SEND_EMAIL_ON_CREATION: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Run this function from the Apps Script IDE after pushing code
|
||||
* to establish the required background triggers.
|
||||
*/
|
||||
function setup() {
|
||||
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
|
||||
|
||||
// Clear old triggers
|
||||
const existingTriggers = ScriptApp.getProjectTriggers();
|
||||
for (const trigger of existingTriggers) {
|
||||
if (trigger.getHandlerFunction() === 'syncUsers') {
|
||||
ScriptApp.deleteTrigger(trigger);
|
||||
}
|
||||
}
|
||||
|
||||
// Install triggers
|
||||
ScriptApp.newTrigger('syncUsers')
|
||||
.forSpreadsheet(ss)
|
||||
.onFormSubmit()
|
||||
.create();
|
||||
|
||||
ScriptApp.newTrigger('syncUsers')
|
||||
.forSpreadsheet(ss)
|
||||
.onChange()
|
||||
.create();
|
||||
|
||||
const triggers = ScriptApp.getProjectTriggers();
|
||||
console.log(`Setup complete. ${triggers.length} trigger(s) are now active for 'syncUsers'.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Entry Point
|
||||
*/
|
||||
function syncUsers() {
|
||||
console.log(`Starting syncUsers... (DRY_RUN: ${CONFIG.DRY_RUN})`);
|
||||
|
||||
try {
|
||||
const desiredEmails = getDesiredState();
|
||||
console.log(`Desired state: ${desiredEmails.length} valid email(s) found in sheet.`);
|
||||
|
||||
const currentState = getCurrentState();
|
||||
console.log(`Current state: ${Object.keys(currentState).length} user(s) found in Firebase.`);
|
||||
|
||||
const actions = reconcileUsers(desiredEmails, currentState);
|
||||
|
||||
if (actions.length > 0) {
|
||||
console.log(`Found ${actions.length} action(s) to execute.`);
|
||||
sendAdminReport(actions);
|
||||
} else {
|
||||
console.log("States are synchronized. No actions required.");
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error during syncUsers:", err);
|
||||
MailApp.sendEmail({
|
||||
to: CONFIG.ADMIN_EMAIL,
|
||||
subject: "Haumdaucher Firebase Sync Failed",
|
||||
body: `The Firebase User sync script failed with the following error:\n\n${err.toString()}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts valid emails from the Google Sheet.
|
||||
*/
|
||||
function getDesiredState() {
|
||||
const ss = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
|
||||
const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
|
||||
const data = sheet.getDataRange().getValues();
|
||||
|
||||
const emails = new Set();
|
||||
|
||||
// Start from row 1 (skipping header row 0)
|
||||
for (let i = 1; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
const email = row[CONFIG.COL_FORWARD_TO_ADDRESS - 1];
|
||||
|
||||
if (email && typeof email === 'string' && email.trim() !== "") {
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
emails.add(cleanEmail);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(emails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all users from Firebase Identity Platform.
|
||||
* Returns an object map: { "email@example.com": { localId: "...", disabled: false } }
|
||||
*/
|
||||
function getCurrentState() {
|
||||
const token = ScriptApp.getOAuthToken();
|
||||
const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:query`;
|
||||
|
||||
// We use query with an empty payload to list users.
|
||||
// Note: For very large datasets, pagination (nextPageToken) would be required.
|
||||
const payload = {
|
||||
returnUserInfo: true,
|
||||
maxResults: 1000 // Safely assuming < 1000 users as per requirements
|
||||
};
|
||||
|
||||
const options = {
|
||||
method: 'post',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
"Authorization": "Bearer " + token
|
||||
},
|
||||
payload: JSON.stringify(payload),
|
||||
muteHttpExceptions: true
|
||||
};
|
||||
|
||||
console.log("Calling Identity Toolkit accounts:query...");
|
||||
const response = UrlFetchApp.fetch(url, options);
|
||||
const json = JSON.parse(response.getContentText());
|
||||
|
||||
if (response.getResponseCode() !== 200) {
|
||||
throw new Error(`Firebase API Error: ${response.getContentText()}`);
|
||||
}
|
||||
|
||||
const userMap = {};
|
||||
if (json.userInfo) {
|
||||
json.userInfo.forEach(user => {
|
||||
if (user.email) {
|
||||
userMap[user.email.toLowerCase()] = {
|
||||
localId: user.localId,
|
||||
disabled: user.disabled || false
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
return userMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates differences and executes mutations against Firebase.
|
||||
*/
|
||||
function reconcileUsers(desiredEmails, currentState) {
|
||||
const token = ScriptApp.getOAuthToken();
|
||||
const executedActions = [];
|
||||
|
||||
// Build a Set for O(1) lookup performance (avoids O(n²) with .includes())
|
||||
const desiredSet = new Set(desiredEmails);
|
||||
|
||||
// 1. Check for Creations and Re-enables
|
||||
for (const email of desiredEmails) {
|
||||
const fbUser = currentState[email];
|
||||
|
||||
if (!fbUser) {
|
||||
// Missing in Firebase -> CREATE
|
||||
console.log(`[ACTION: CREATE] User missing in Firebase: ${email}`);
|
||||
if (!CONFIG.DRY_RUN) {
|
||||
const password = generateSecurePassword();
|
||||
const success = createUserInFirebase(email, password, token);
|
||||
if (success) {
|
||||
executedActions.push(`Created user: ${email}`);
|
||||
if (CONFIG.SEND_EMAIL_ON_CREATION) {
|
||||
sendPasswordReset(email, token);
|
||||
executedActions.push(`Sent password reset email to: ${email}`);
|
||||
}
|
||||
} else {
|
||||
executedActions.push(`[ERROR] Failed to create user: ${email}`);
|
||||
}
|
||||
} else {
|
||||
executedActions.push(`[DRY RUN] Would create user: ${email}`);
|
||||
}
|
||||
} else if (fbUser.disabled) {
|
||||
// Exists but disabled -> RE-ENABLE
|
||||
console.log(`[ACTION: RE-ENABLE] User is disabled in Firebase: ${email}`);
|
||||
if (!CONFIG.DRY_RUN) {
|
||||
const success = updateUserStatus(fbUser.localId, false, token);
|
||||
if (success) {
|
||||
executedActions.push(`Re-enabled user: ${email}`);
|
||||
} else {
|
||||
executedActions.push(`[ERROR] Failed to re-enable user: ${email}`);
|
||||
}
|
||||
} else {
|
||||
executedActions.push(`[DRY RUN] Would re-enable user: ${email}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for Disablements
|
||||
for (const [email, fbUser] of Object.entries(currentState)) {
|
||||
if (!desiredSet.has(email) && !fbUser.disabled) {
|
||||
// Exists in Firebase, but NOT in sheet -> DISABLE (Soft Delete)
|
||||
console.log(`[ACTION: DISABLE] User not in sheet: ${email}`);
|
||||
if (!CONFIG.DRY_RUN) {
|
||||
const success = updateUserStatus(fbUser.localId, true, token);
|
||||
if (success) {
|
||||
executedActions.push(`Disabled user: ${email}`);
|
||||
} else {
|
||||
executedActions.push(`[ERROR] Failed to disable user: ${email}`);
|
||||
}
|
||||
} else {
|
||||
executedActions.push(`[DRY RUN] Would disable user: ${email}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return executedActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls Identity Toolkit to create a user.
|
||||
*/
|
||||
function createUserInFirebase(email, password, token) {
|
||||
const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts`;
|
||||
|
||||
const payload = {
|
||||
email: email,
|
||||
password: password,
|
||||
emailVerified: true // Pre-verified since it comes from our trusted sheet
|
||||
};
|
||||
|
||||
const options = {
|
||||
method: 'post',
|
||||
contentType: 'application/json',
|
||||
headers: { "Authorization": "Bearer " + token },
|
||||
payload: JSON.stringify(payload),
|
||||
muteHttpExceptions: true
|
||||
};
|
||||
|
||||
const res = UrlFetchApp.fetch(url, options);
|
||||
if (res.getResponseCode() !== 200) {
|
||||
console.error(`Failed to create ${email}: ${res.getContentText()}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls Identity Toolkit to update user status (enable/disable).
|
||||
*/
|
||||
function updateUserStatus(localId, disabled, token) {
|
||||
const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:update`;
|
||||
|
||||
const payload = {
|
||||
localId: localId,
|
||||
disableUser: disabled
|
||||
};
|
||||
|
||||
const options = {
|
||||
method: 'post',
|
||||
contentType: 'application/json',
|
||||
headers: { "Authorization": "Bearer " + token },
|
||||
payload: JSON.stringify(payload),
|
||||
muteHttpExceptions: true
|
||||
};
|
||||
|
||||
const res = UrlFetchApp.fetch(url, options);
|
||||
if (res.getResponseCode() !== 200) {
|
||||
console.error(`Failed to update ${localId}: ${res.getContentText()}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls Identity Toolkit to send a password reset email.
|
||||
*/
|
||||
function sendPasswordReset(email, token) {
|
||||
const url = `https://identitytoolkit.googleapis.com/v1/projects/${CONFIG.PROJECT_ID}/accounts:sendOobCode`;
|
||||
|
||||
const payload = {
|
||||
requestType: "PASSWORD_RESET",
|
||||
email: email
|
||||
};
|
||||
|
||||
const options = {
|
||||
method: 'post',
|
||||
contentType: 'application/json',
|
||||
headers: { "Authorization": "Bearer " + token },
|
||||
payload: JSON.stringify(payload),
|
||||
muteHttpExceptions: true
|
||||
};
|
||||
|
||||
const res = UrlFetchApp.fetch(url, options);
|
||||
if (res.getResponseCode() !== 200) {
|
||||
console.error(`Failed to send reset email to ${email}: ${res.getContentText()}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a 24-character cryptographically secure random password.
|
||||
* Uses Utilities.getSecureRandomBytes() instead of Math.random() for security.
|
||||
*/
|
||||
function generateSecurePassword() {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?";
|
||||
const bytes = Utilities.getSecureRandomBytes(24);
|
||||
let pwd = "";
|
||||
for (let i = 0; i < 24; i++) {
|
||||
// Mask to avoid modulo bias: chars.length (88) fits safely in a byte (0-255)
|
||||
pwd += chars.charAt(bytes[i] % chars.length);
|
||||
}
|
||||
return pwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the final admin report.
|
||||
*/
|
||||
function sendAdminReport(actions) {
|
||||
const subject = `Firebase User Sync Report ${CONFIG.DRY_RUN ? '[DRY RUN]' : ''}`;
|
||||
const body = `The synchronization process has completed.\n\nActions taken:\n` +
|
||||
actions.map(a => `- ${a}`).join("\n");
|
||||
|
||||
MailApp.sendEmail({
|
||||
to: CONFIG.ADMIN_EMAIL,
|
||||
subject: subject,
|
||||
body: body
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user