Skip to content

Instantly share code, notes, and snippets.

@FaserF
Last active February 21, 2025 19:02
Show Gist options
  • Save FaserF/77948f1a70f4631946dad1c5d631b15d to your computer and use it in GitHub Desktop.
Save FaserF/77948f1a70f4631946dad1c5d631b15d to your computer and use it in GitHub Desktop.
Cloudflare Worker to send Mail from contact form via Microsoft Graph Exchange Online, Update SharePoint Lists and add Outlook Contacts
// IMPORTANT!!! CHANGE THE FOLLOWING VARIABLES BEFORE USING:
// mainDomain, clientId, clientSecret, tenantId, siteId, listId, cloudflare_TurnstileTSecret, companyName, allowedDomains
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
})
async function validateTurnstileToken(tokenCf, ip, cloudflare_TurnstileTSecret) {
const formDataT = new FormData();
formDataT.append("secret", cloudflare_TurnstileTSecret);
formDataT.append("response", tokenCf);
formDataT.append("remoteip", ip);
const url = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
const response = await fetch(url, {
method: "POST",
body: formDataT,
});
const data = await response.json();
return data.success;
}
async function handleRequest(request) {
const referer = request.headers.get('Referer');
if (!referer) {
return new Response('Missing Referer header. Calling this domain directly or from localhost is not allowed.', { status: 403 });
}
const domain = new URL(referer).hostname;
// Main Variables
const mainDomain = 'mydomain.de';
const cloudflare_TurnstileTSecret = "1x0000000000000000000000000000000AA";
const clientId = '00000000-0000-0000-c000-000000000000';
const clientSecret = 'clientSecret';
const tenantId = '00000000-0000-0000-c000-000000000000';
const siteId = 'tenantname.sharepoint.com,00000000-0000-0000-c000-000000000000,00000000-0000-0000-c000-000000000000';
const listId = '00000000-0000-0000-c000-000000000000';
const listIdRegistrations = '00000000-0000-0000-c000-000000000000';
const companyName = 'MyCompanyName';
const maxFileSizeMB = 149
// Zulässige Domains (Hauptdomains und Subdomains)
const allowedDomains = [
mainDomain,
`*.${mainDomain}`,
'dash.cloudflare.com'
];
const isAllowed = allowedDomains.some(allowedDomain => domain === allowedDomain || domain.endsWith(`.${allowedDomain}`));
if (!isAllowed) {
return new Response(`Forbidden domain ${domain}`, { status: 403 });
}
// Ermitteln der bevorzugten Sprache
const userLanguage = request.headers.get('Accept-Language')?.split(',')[0];
// Standardtext, falls keine spezifische Sprache gefunden wird
const responseMessage = userLanguage && userLanguage.startsWith('de') ? {
generalTitle: "Datenübermittlung",
successMessage: `Vielen Dank für Ihre Anfrage. Die Formulardaten wurde erfolgreich übermittelt. Wir werden uns zeitnah bei Ihnen melden. Ihr ${companyName} Team`,
errorTitleToken: "Fehler beim Abrufen des Tokens",
errorTitleMailSend: "Fehler bei der E-Mail Übermittlung",
errorTitleGraphPermission: "Fehlende Backend Graph Berechtigung",
errorMaxAttachmentSize: `Die Gesamtgröße der Anhänge überschreitet das erlaubte Limit von ${maxFileSizeMB} MB. Bitte erneut versuchen mit einem kleineren Anhang.`,
errorMessageServerAuthentification: "Leider gab es ein Problem bei der Authentifizierung des Servers. Bitte versuchen Sie es später erneut.",
errorMessageServerFields: "Es wurden nicht alle notwendigen Formulardaten gefunden.",
errorMessageContactIT: `Der Fehler ist nicht durch Ihre Eingabe entstanden. Bitte versuchen Sie es später erneut. Um dieses Problem zu beheben, wenden Sie sich bitte an die ${companyName} IT-Abteilung (it@${mainDomain}).`,
technicalInfoTitle: "Technische Informationen:",
errorDetails: "Fehlermeldung:",
redirectFromDomain: "Ursprüngliche Übermittlung von Domain:",
redirectInformation1: "Sie werden in Kürze zur ",
redirectInformation2: "weitergeleitet."
} : {
generalTitle: "data transmission",
successMessage: `Thank you for your inquiry. The form data was sent successfully. We will get back to you shortly. Your ${companyName} team.`,
errorTitleToken: "Error Fetching Token",
errorTitleMailSend: "Error Sending Mail",
errorTitleGraphPermission: "Missing backend graph permission",
errorMaxAttachmentSize: `The total size of attachments exceeds the allowed limit of ${maxFileSizeMB} MB. Please try again with a smaller attachment.`,
errorMessageServerAuthentification: "There was an issue with authenticating the server. Please try again later.",
errorMessageServerFields: "Not all necessary form data was found.",
errorMessageContactIT: `The error was not caused by your input. Please try again later. To resolve this issue, please contact the ${companyName} IT department (it@${mainDomain}).`,
technicalInfoTitle: "Technical Information:",
errorDetails: "Error Message:",
redirectFromDomain: "Original submission of domain:",
redirectInformation1: "You will be redirected to the ",
redirectInformation2: "shortly."
};
if (request.method === 'GET' || request.method === 'POST') {
let token = "";
try {
token = await getMicrosoftGraphToken(clientId, clientSecret, tenantId);
} catch (error) {
console.error("Error while receiving Graph authentication token:", error);
// Technische Details für die Fehlermeldung einfügen
const errorMessage = error.message || "Unbekannter Fehler";
const errorDetails = error.stack || "Keine weiteren Details verfügbar.";
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorTitleToken}</h1>
<p>${responseMessage.errorMessageServerAuthentification}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}:</strong> ${errorMessage}</p>
<p><strong>${responseMessage.redirectFromDomain}</strong> ${domain}</p>
<p>Please check the authentification secret at <a href="https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Credentials/appId/${clientId}">Entra</a>. You may need to replace the app id in the url.</p>
<h3>Stacktrace:</h3>
<pre>${errorDetails}</pre>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 500,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
// Berechtigungen prüfen
let permissions = await getPermissionsFromToken(token);
let errorBody = '';
try {
if (request.method === 'POST') {
const formData = await request.formData();
const tokenCf = formData.get("cf-turnstile-response");
const ip = request.headers.get("CF-Connecting-IP");
if (!tokenCf) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>Inavlid Anti-Bot Challenge</h1>
<p>Unfortunatly the Anti-Bot Challenge failed. Please try it again.</p>
<p>Missing Turnstile token</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 400,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
const isValid = await validateTurnstileToken(tokenCf, ip, cloudflare_TurnstileTSecret);
if (!isValid) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>Inavlid Anti-Bot Challenge</h1>
<p>Unfortunatly the Anti-Bot Challenge failed. Please try it again.</p>
<p>Invalid Turnstile token</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 403,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
if (!permissions.includes('Mail.Send')) {
return new Response("Missing Graph permission: Mail.Send", { status: 403 });
}
// Alle Datei-Felder automatisch erkennen und leere Dateien ignorieren
const files = [];
formData.forEach((value, key) => {
if (value instanceof File && value.name && value.size > 0) {
files.push({ key, file: value });
}
});
// Graph allows max 150MB as file size. Cloudflare Workers allows 100? MB in the Free plan
const maxSize = maxFileSizeMB * 1024 * 1024; // calculate MB to bytes
if (files.length > 0) {
let totalSize = 0;
for (const { file } of files) {
const buffer = await file.arrayBuffer();
totalSize += buffer.byteLength;
}
if (totalSize > maxSize) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${companyName} ${responseMessage.errorMessageServerFields}</h1>
<p>${responseMessage.errorMaxAttachmentSize}</p>
<p>${responseMessage.redirectInformation1} <a href="https://meldung.${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 400,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
}
const visitorCountry = request.headers.get('CF-IPCountry') || 'Unknown';
const requiredFields = ['firstname', 'lastname', 'mail', 'description'];
// Arrays für erkannte und fehlende Felder
let missingFields = [];
let recognizedFields = [];
const newAddress = formData.get('newaddress');
// Alle Formulardaten durchlaufen und überprüfen
formData.forEach((value, key) => {
if (requiredFields.includes(key)) {
if (value) {
recognizedFields.push(key);
} else {
missingFields.push(key);
}
} else {
if (key === 'address' && value === 'Andere Immobilie') {
const newAddress = formData.get('newaddress');
if (newAddress) {
// "address" mit "newaddress" überschreiben und "newaddress" löschen
formData.set('address', newAddress);
formData.delete('newaddress');
}
} else if (key === 'cf-turnstile-response') {
formData.delete('cf-turnstile-response');
} else if (value) {
recognizedFields.push(key);
}
}
});
if (newAddress) {
let permissionGranted = permissions.includes('Sites.ReadWrite.All') ||
permissions.includes('Sites.FullControl.All');
if (!permissionGranted) {
errorBody = `
<br><br><br>
<h1>${responseMessage.errorTitleGraphPermission}</h1>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}</strong> Missing Graph API permission "Sites.ReadWrite.All"</p>
<p><strong>Detected Graph API permissions:</strong> ${permissions.join(', ')}</p>
<p>Please check the permission at <a href="https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/${clientId}">Entra</a>. You may need to replace the app id in the url.</p>
`;
} else {
// Aufruf der Funktion, um den Wert in SharePoint zu speichern
const itemData = {
Title: newAddress
};
try {
await addItemToSharePointList(token, itemData, siteId, listId);
} catch (error) {
errorBody = `
<br><br><br>
<h1>${responseMessage.technicalInfoTitle}</h1>
<p><strong>${responseMessage.errorDetails}</strong> Issue while adding estate to form list on SharePoint</p>
<p>${error.message}</p>
<pre>${error.stack}</pre>
`;
console.error("Error during SharePoint item addition:", error);
console.error("Stack trace:", error.stack);
}
}
}
// Falls Pflichtfelder fehlen, Fehlerantwort zurückgeben
if (missingFields.length > 0) {
const missingFieldsMessage = `Missing required fields: ${missingFields.join(', ')}`;
const recognizedFieldsMessage = `Recognized fields: ${recognizedFields.join(', ')}`;
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorMessageServerFields}</h1>
<p><strong>${responseMessage.errorDetails}</strong> ${missingFieldsMessage}</p>
<p>${recognizedFieldsMessage}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 400,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
let emailData;
// Icons für bekannte Felder
const fieldIcons = {
address: "📍",
firstname: "👤",
lastname: "👤",
mail: "📧",
phone: "📞",
description: "📝"
};
// Mapping von internen Feldnamen auf verständlichere Bezeichnungen
const fieldLabels = {
firstname: "Vorname",
lastname: "Nachname",
mail: "E-Mail",
phone: "Telefon",
address: "Adresse",
description: "Nachricht"
};
if (new URL(request.url).pathname === '/register') {
try {
let permissionGranted = permissions.includes('Contacts.ReadWrite')
if (!permissionGranted) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorTitleMailSend}</h1>
<p>${responseMessage.errorMessageServerAuthentification}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h1>${responseMessage.errorTitleGraphPermission}</h1>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}</strong> Missing Graph API permission "Contacts.ReadWrite"</p>
<p><strong>Detected Graph API permissions:</strong> ${permissions.join(', ')}</p>
<p>Please check the permission at <a href="https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/${clientId}">Entra</a>. You may need to replace the app id in the url.</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 500,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
} else {
function parseAddress(address) {
if (!address) return { street: "", houseNumber: "", zipCode: "", city: "" };
// Regex zum Extrahieren: Straße, Hausnummer, PLZ, Stadt
const addressRegex = /^(.+?)\s(\d+[a-zA-Z]?),\s(\d{5})\s(.+)$/;
const match = address.match(addressRegex);
if (!match) {
console.error("Invalid address format:", address);
return { street: address || "", houseNumber: "", zipCode: "", city: "" };
}
return {
street: match[1] || "",
houseNumber: match[2] || "",
zipCode: match[3] || "",
city: match[4] || ""
};
}
// Sicherstellen, dass formData existiert und die Werte nicht undefined sind
const addressData = parseAddress(formData.get('address') || "");
const contact = {
firstName: formData.get('firstname') || "",
lastName: formData.get('lastname') || "",
email: formData.get('mail') || "",
phone: formData.get('phone') || "",
street: addressData.street,
houseNumber: addressData.houseNumber,
zipCode: addressData.zipCode,
city: addressData.city,
country: "Germany"
};
const contactlist = "Benutzerregistrierung"
try {
await addContactToOutlook(token, contact, mainDomain, contactlist);
} catch (error) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorTitleMailSend}</h1>
<p>${responseMessage.errorMessageServerAuthentification}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h1>${responseMessage.errorTitleGraphPermission}</h1>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}</strong> Issue while adding contact Exchange Online</p>
<p>${error.message}</p>
<pre>${error.stack}</pre>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 500,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
contact.Title = new Date().toLocaleString();
await addItemToSharePointList(token, contact, siteId, listIdRegistrations);
}
// E-Mail-Body zusammenstellen
let emailBody = `Neue Registrierung von ${formData.get('mail')} via https://${domain}\n`;
emailBody += `Dies dient nur zur Information. Die Daten wurden automatisch als Kontakt angelegt und die Immobilie in einer SharePoint Liste eingetragen.\n\n`;
emailBody += `━━━━━━━━━━━━━━━━━━━\n`;
recognizedFields.forEach(field => {
const value = formData.get(field);
const icon = fieldIcons[field] || "🔹";
const label = fieldLabels[field] || field;
emailBody += `${icon} ${label}: ${value}\n`;
});
emailBody += `━━━━━━━━━━━━━━━━━━━\n`;
if (visitorCountry !== "DE") {
emailBody += `\n\n⚠️ Der Aufrufer hat seine Anfrage nicht aus Deutschland gestellt, bitte aufpassen! Erkanntes Land: ${visitorCountry}\n`;
}
// Falls es Anhänge gibt, diese Base64-kodieren
let attachments = [];
if (files.length > 0) {
attachments = await Promise.all(files.map(async ({ file }) => {
const fileContent = await file.arrayBuffer();
return {
"@odata.type": "#microsoft.graph.fileAttachment",
contentBytes: bufferToBase64(fileContent),
name: file.name,
contentType: file.type
};
}));
}
// E-Mail-Daten setzen
const address = formData.get('address');
emailData = {
subject: `Neue Registierung von ${formData.get('mail')}`,
body: emailBody,
from: `info@${mainDomain}`,
to: `register@${mainDomain}`,
replyTo: formData.get('mail')?.trim() || `info@${mainDomain}`,
attachments: attachments.length > 0 ? attachments : null
};
} catch (error) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorDetails} Register</h1>
<p>${responseMessage.errorMessageServerAuthentification}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}:</strong> ${error.message}</p>
<p><strong>${responseMessage.redirectFromDomain}</strong> ${domain}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 500,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
} else {
try {
// E-Mail-Body zusammenstellen
let emailBody = `Neue Kontaktaufnahme von ${formData.get('mail')} via https://${domain}\n\n`;
emailBody += `━━━━━━━━━━━━━━━━━━━\n`;
recognizedFields.forEach(field => {
const value = formData.get(field);
const icon = fieldIcons[field] || "🔹";
const label = fieldLabels[field] || field;
emailBody += `${icon} ${label}: ${value}\n`;
});
emailBody += `━━━━━━━━━━━━━━━━━━━\n`;
if (visitorCountry !== "DE") {
emailBody += `\n\n⚠️ Der Aufrufer hat seine Anfrage nicht aus Deutschland gestellt, bitte aufpassen! Erkanntes Land: ${visitorCountry}\n`;
}
// Falls es Anhänge gibt, diese Base64-kodieren
let attachments = [];
if (files.length > 0) {
attachments = await Promise.all(files.map(async ({ file }) => {
const fileContent = await file.arrayBuffer();
return {
"@odata.type": "#microsoft.graph.fileAttachment",
contentBytes: bufferToBase64(fileContent),
name: file.name,
contentType: file.type
};
}));
}
// E-Mail-Daten setzen
const address = formData.get('address');
emailData = {
subject: address ? `Neue Meldung für ${address}` : `Neue Kontaktaufnahme von ${formData.get('mail')}`,
body: emailBody,
from: `info@${mainDomain}`,
to: address ? `meldung@${mainDomain}` : `kontakt@${mainDomain}`,
replyTo: formData.get('mail')?.trim() || `info@${mainDomain}`,
attachments: attachments.length > 0 ? attachments : null
};
} catch (error) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorDetails} Mailbody</h1>
<p>${responseMessage.errorMessageServerAuthentification}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}:</strong> ${error.message}</p>
<p><strong>${responseMessage.redirectFromDomain}</strong> ${domain}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 500,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
}
try {
const response = await sendEmailAndRespond(emailData, mainDomain, token, companyName, responseMessage, errorBody);
return response;
} catch (error) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorDetails} Mailsend</h1>
<p>${responseMessage.errorMessageServerAuthentification}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}:</strong> ${error.message}</p>
<p><strong>${responseMessage.redirectFromDomain}</strong> ${domain}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 500,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
} else if (request.method === 'GET' && new URL(request.url).pathname === '/get-properties') {
// Überprüfen, ob die Berechtigung "Sites.Read.All" im Token enthalten ist
let permissionGranted = permissions.includes('Sites.Read.All') ||
permissions.includes('Sites.ReadWrite.All') ||
permissions.includes('Sites.FullControl.All');
if (!permissionGranted) {
// Fehlerseite mit Titel, Icon und technischem Abschnitt bei fehlender Berechtigung
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorTitleGraphPermission}</h1>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}</strong> Missing Graph API permission "Sites.Read.All"</p>
<p><strong>Detected Graph API permissions:</strong> ${permissions.join(', ')}</p>
<p>Please check the permission at <a href="https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/${clientId}">Entra</a>. You may need to replace the app id in the url.</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 403,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
try {
const properties = await getSharePointList(token, siteId, listId);
return new Response(JSON.stringify(properties), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
} catch (error) {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorDetails} POST</h1>
<p>${responseMessage.errorMessageServerAuthentification}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}:</strong> ${error.message}</p>
<p><strong>${responseMessage.redirectFromDomain}</strong> ${domain}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 500,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
}
if (domain === 'dash.cloudflare.com') {
return new Response(`Cannot progress data from ${domain}, you have to call this worker from a website with a POST message.`, { status: 405 });
} else {
const htmlResponse = `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorMessageServerFields}</h1>
<p><strong>${responseMessage.errorDetails}</strong> Invalid request method</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p><strong>${responseMessage.redirectFromDomain}</strong> ${domain}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
</body>
</html>
`;
return new Response(htmlResponse, {
status: 405,
headers: {
'Content-Type': 'text/html; charset=utf-8',
}
});
}
}
async function getMicrosoftGraphToken(clientId, clientSecret, tenantId) {
const tokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`;
const body = new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials',
scope: 'https://graph.microsoft.com/.default',
});
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body,
});
const tokenData = await tokenResponse.json();
console.log("Token Response:", tokenData);
if (!tokenData.access_token) {
throw new Error(`Error while recieving Graph authentification token: ${tokenData.error_description || JSON.stringify(tokenData)}`);
}
return tokenData.access_token;
}
async function sendEmailAndRespond(emailData, mainDomain, token, companyName, responseMessage, errorBody = "") {
const maxRetries = 1;
let attempt = 0;
let emailResponse;
while (attempt <= maxRetries) {
try {
emailResponse = await fetch(`https://graph.microsoft.com/v1.0/users/info@${mainDomain}/sendMail`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: {
subject: emailData.subject,
body: { contentType: 'Text', content: emailData.body },
from: { emailAddress: { address: emailData.from } },
toRecipients: [{ emailAddress: { address: emailData.to } }],
replyTo: emailData.replyTo ? [{ emailAddress: { address: emailData.replyTo } }] : undefined,
attachments: emailData.attachments || []
}
})
});
if (emailResponse.ok) {
// Erfolgreiche E-Mail-Übertragung
return new Response(generateHtmlResponse(
200, mainDomain, companyName, responseMessage.generalTitle,
responseMessage.successMessage, responseMessage.redirectInformation1,
responseMessage.redirectInformation2, errorBody
), {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
if (attempt < maxRetries) {
console.warn(`Email attempt ${attempt + 1} failed. Retrying in 15 seconds...`);
await new Promise(resolve => setTimeout(resolve, 15000)); // 15 Sekunden warten
}
attempt++;
} catch (error) {
console.error("Error sending email:", error);
return new Response(generateErrorHtml(
500, mainDomain, companyName, responseMessage, { status: 500, statusText: error.message }, errorBody
), {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
}
// Falls alle Versuche fehlschlagen, endgültige Fehlerseite zurückgeben
return new Response(generateErrorHtml(
500, mainDomain, companyName, responseMessage, emailResponse, errorBody
), {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
function generateHtmlResponse(status, mainDomain, companyName, title, message, redirectInfo1, redirectInfo2, errorBody) {
return `
<html>
<head>
<meta http-equiv="refresh" content="15;url=https://${mainDomain}">
<title>${companyName} ${title}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${companyName} ${title}</h1>
<p>${message}</p>
<p>${redirectInfo1} <a href="https://${mainDomain}">Homepage</a> ${redirectInfo2}</p>
${errorBody}
</body>
</html>
`;
}
function generateErrorHtml(status, mainDomain, companyName, responseMessage, emailResponse, errorBody) {
return `
<html>
<head>
<meta http-equiv="refresh" content="60;url=https://${mainDomain}">
<title>${companyName} ${responseMessage.generalTitle}</title>
<link rel="icon" type="image/png" href="https://${mainDomain}/assets/img/favicon/favicon-48x48.png" sizes="48x48" />
<link rel="icon" type="image/svg+xml" href="https://${mainDomain}/assets/img/favicon/favicon.svg" />
</head>
<body>
<h1>${responseMessage.errorTitleMailSend}</h1>
<p>${responseMessage.errorMessageServerAuthentification}</p>
<p>${responseMessage.errorMessageContactIT}</p>
<p>${responseMessage.redirectInformation1} <a href="https://${mainDomain}">Homepage</a> ${responseMessage.redirectInformation2}</p>
<h2>${responseMessage.technicalInfoTitle}</h2>
<p><strong>${responseMessage.errorDetails}</strong> Error during sending mail.</p>
<h3>Response from Graph API:</h3>
<p><strong>Status Code:</strong> ${emailResponse.status}</p>
<p><strong>Status Text:</strong> ${emailResponse.statusText}</p>
<h3>Error Message:</h3>
<pre>${emailResponse.status === 500 ? "Internal Server Error" : emailResponse.statusText}</pre>
<h3>Request Details:</h3>
<p><strong>Method:</strong> POST</p>
<p><strong>URL:</strong> https://graph.microsoft.com/v1.0/users/info@${mainDomain}/sendMail</p>
${errorBody}
</body>
</html>
`;
}
async function getSharePointList(token, siteId, listId) {
const response = await fetch(`https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listId}/items?expand=fields`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`Failed to fetch SharePoint list: ${await response.text()}`);
}
const data = await response.json();
return data.value.map(item => item.fields.Title);
}
async function addItemToSharePointList(token, itemData, siteId, listId) {
const url = `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listId}/columns`;
const headers = {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"Accept": "application/json"
};
// Schritt 1: Liste der Spalten abrufen
const getColumns = async () => {
const response = await fetch(url, {
method: 'GET',
headers: headers
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(`Error fetching columns: ${response.statusText} - Details: ${JSON.stringify(responseData, null, 2)}`);
}
return responseData.value; // Gibt die Liste der existierenden Spalten zurück
};
// Schritt 2: Hinzufügen von neuen Feldern, falls notwendig
const addMissingFields = async (columns, itemData) => {
for (const [key, value] of Object.entries(itemData)) {
const fieldExists = columns.some(column => column.name === key);
if (!fieldExists) {
console.log(`Field "${key}" does not exist, adding...`);
// Füge das Feld hinzu (hier muss ein passender API-Aufruf gemacht werden, um das Feld hinzuzufügen)
const addFieldUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listId}/columns`;
const addFieldBody = {
"displayName": key,
"name": key,
"text": {} // Hier könntest du je nach Feldtyp spezifische Konfigurationen machen
};
const addFieldResponse = await fetch(addFieldUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify(addFieldBody)
});
const addFieldResponseData = await addFieldResponse.json();
if (!addFieldResponse.ok) {
throw new Error(`Error adding field "${key}": ${addFieldResponse.statusText} - Details: ${JSON.stringify(addFieldResponseData, null, 2)}`);
}
console.log(`Field "${key}" added successfully.`);
}
}
};
// Schritt 3: Überprüfen, ob der Eintrag bereits existiert
const checkIfItemExists = async (itemData) => {
const getItemsUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listId}/items?$expand=fields&$select=fields`;
const response = await fetch(getItemsUrl, {
method: 'GET',
headers: headers
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(`Error fetching items: ${response.statusText} - Details: ${JSON.stringify(responseData, null, 2)}`);
}
// Funktion, um zu prüfen, ob der Wert ein Datum oder eine Uhrzeit ist
const isDateOrTime = (value) => {
// RegEx zum Erkennen von Datums-/Uhrzeitformaten
const dateTimePattern = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)|(\d{2}\/\d{2}\/\d{4})|(\d{2}:\d{2}(:\d{2})?)/;
return dateTimePattern.test(value);
};
// Vergleich der Felder mit Normalisierung
const normalizeValue = (value) => {
if (typeof value === 'string') {
return value.trim().toLowerCase();
}
return value;
};
// Überprüfen, ob ein Eintrag mit denselben Werten bereits existiert
const existingItem = responseData.value.find(item => {
let isDuplicate = true; // Defaultmäßig als Duplikat ansehen
// Durch alle Felder in itemData iterieren und vergleichen
for (const [key, value] of Object.entries(itemData)) {
// Wenn das Feld Datum oder Uhrzeit enthält, überspringen
if (isDateOrTime(value)) {
continue; // Ignoriere den Vergleich für dieses Feld
}
// Check ob der Wert im bestehenden Item existiert
if (item.fields && item.fields[key] !== undefined) {
const itemValue = item.fields[key];
// Normalisierung sowohl des existierenden Wertes als auch des Eingabewertes
const normalizedItemValue = normalizeValue(itemValue);
const normalizedInputValue = normalizeValue(value);
// Wenn Werte nicht übereinstimmen, markiere es als nicht Duplikat
if (normalizedItemValue !== normalizedInputValue) {
isDuplicate = false;
break; // Keine weiteren Felder mehr überprüfen
}
}
}
return isDuplicate;
});
return existingItem;
};
try {
// Hole die existierenden Spalten der Liste
const columns = await getColumns();
// Überprüfen und Felder hinzufügen, wenn sie fehlen
await addMissingFields(columns, itemData);
// Überprüfen, ob das Item bereits existiert
const existingItem = await checkIfItemExists(itemData);
if (existingItem) {
console.log("Item already exists in the list, skipping addition.");
return existingItem; // Item existiert bereits, zurückgeben
}
// Schritt 4: Item zur Liste hinzufügen
const addItemUrl = `https://graph.microsoft.com/v1.0/sites/${siteId}/lists/${listId}/items`;
const requestBody = {
fields: itemData
};
console.log("Request Body:", JSON.stringify(requestBody, null, 2));
const response = await fetch(addItemUrl, {
method: 'POST',
headers: headers,
body: JSON.stringify(requestBody)
});
const responseData = await response.json();
if (!response.ok) {
throw new Error(`Error adding item to SharePoint list: ${response.statusText} - Details: ${JSON.stringify(responseData, null, 2)}`);
}
console.log("Item successfully added to SharePoint List:", responseData);
return responseData;
} catch (error) {
console.error("Error during the API call:", error);
console.error("Stack trace:", error.stack);
throw new Error(`Error adding item to SharePoint list: ${error.message}`);
}
}
async function addContactToOutlook(token, contact, mainDomain, contactlist) {
// URL für die Benutzer-Kontakte
const userContactsUrl = `https://graph.microsoft.com/v1.0/users/info@${mainDomain}/contacts`;
// URL für Benutzer-Kontaktlisten
const contactListsUrl = `https://graph.microsoft.com/v1.0/users/info@${mainDomain}/contactFolders`;
// 1. Hole alle Kontaktlisten des Benutzers
const existingListsResponse = await fetch(contactListsUrl, {
method: 'GET',
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
});
if (!existingListsResponse.ok) {
throw new Error(`Failed to fetch existing contact lists: ${await existingListsResponse.text()}`);
}
const existingListsData = await existingListsResponse.json();
let contactListId = null;
// 2. Prüfe, ob eine benutzerdefinierte Kontaktliste existiert (wird aber nicht mehr verwendet, nur noch die Existenzprüfung für den Kontakt)
const existingList = existingListsData.value.find(list => list.displayName === contactlist);
// Falls die benutzerdefinierte Liste nicht existiert, erstelle eine neue
if (!existingList) {
const createListResponse = await fetch(contactListsUrl, {
method: 'POST',
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
displayName: contactlist
})
});
if (!createListResponse.ok) {
throw new Error(`Failed to create contact list: ${await createListResponse.text()}`);
}
const createListData = await createListResponse.json();
contactListId = createListData.id;
console.log(`Created new contact list: ${createListData.displayName}`);
} else {
contactListId = existingList.id;
console.log(`Using existing contact list: ${existingList.displayName}`);
}
// 3. Durchsuche **alle** Kontaktlisten (einschließlich Standardlisten) nach dem Kontakt
let contactExists = false;
let existingContactId = null;
let contactExistsDefault = false;
let existingContactDefaultId = null;
for (const list of existingListsData.value) {
const existingContactsResponse = await fetch(`${contactListsUrl}/${list.id}/contacts`, {
method: 'GET',
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
});
if (!existingContactsResponse.ok) {
throw new Error(`Failed to fetch existing contacts from list ${list.displayName}: ${await existingContactsResponse.text()}`);
}
const existingContactsData = await existingContactsResponse.json();
const existingContact = existingContactsData.value.find(c =>
c.emailAddresses && c.emailAddresses.some(e => e.address.toLowerCase() === contact.email.toLowerCase())
);
if (existingContact) {
contactExists = true;
existingContactId = existingContact.id;
console.log(`Contact with email ${contact.email} already exists in list ${list.displayName}. Updating fields.`);
break;
}
}
if (!contactExists) {
const existingContactsResponse = await fetch(`${userContactsUrl}`, {
method: 'GET',
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
}
});
if (!existingContactsResponse.ok) {
throw new Error(`Failed to fetch existing contacts from default list: ${await existingContactsResponse.text()}`);
}
const existingContactsData = await existingContactsResponse.json();
const existingContact = existingContactsData.value.find(c =>
c.emailAddresses && c.emailAddresses.some(e => e.address.toLowerCase() === contact.email.toLowerCase())
);
if (existingContact) {
contactExistsDefault = true;
existingContactDefaultId = existingContact.id;
console.log(`Contact with email ${contact.email} already exists in default contact list. Updating fields.`);
}
}
// Falls der Kontakt existiert, aktualisiere ihn
if (contactExists && existingContactId) {
const updatedContactData = {
givenName: contact.firstName || "",
surname: contact.lastName || "",
emailAddresses: [{ address: contact.email, name: `${contact.firstName} ${contact.lastName}` }],
businessPhones: contact.phone ? [contact.phone] : [],
homeAddress: {
street: `${contact.street} ${contact.houseNumber}`.trim(),
city: contact.city || "",
postalCode: contact.zipCode || "",
countryOrRegion: contact.country || ""
}
};
// Aktualisiere den Kontakt
const updateContactResponse = await fetch(`${contactListsUrl}/${existingContactId}/contacts/${existingContactId}`, {
method: 'PATCH',
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify(updatedContactData)
});
if (!updateContactResponse.ok) {
throw new Error(`Failed to update contact: ${await updateContactResponse.text()}`);
}
const updatedContact = await updateContactResponse.json();
console.log("Contact updated successfully:", updatedContact);
return updatedContact;
}
// Falls der Kontakt existiert in der Standardkontaktliste, aktualisiere ihn
if (contactExistsDefault && existingContactDefaultId) {
const updatedContactData = {
givenName: contact.firstName || "",
surname: contact.lastName || "",
emailAddresses: [{ address: contact.email, name: `${contact.firstName} ${contact.lastName}` }],
businessPhones: contact.phone ? [contact.phone] : [],
homeAddress: {
street: `${contact.street} ${contact.houseNumber}`.trim(),
city: contact.city || "",
postalCode: contact.zipCode || "",
countryOrRegion: contact.country || ""
}
};
const updateContactResponse = await fetch(`${userContactsUrl}/${existingContactDefaultId}`, {
method: 'PATCH',
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify(updatedContactData)
});
if (!updateContactResponse.ok) {
throw new Error(`Failed to update contact in default list: ${await updateContactResponse.text()}`);
}
const updatedContact = await updateContactResponse.json();
console.log("Contact updated in default list successfully:", updatedContact);
return updatedContact;
}
// Falls der Kontakt nicht existiert, einen neuen Kontakt erstellen
const contactData = {
givenName: contact.firstName || "",
surname: contact.lastName || "",
emailAddresses: [{ address: contact.email, name: `${contact.firstName} ${contact.lastName}` }],
businessPhones: contact.phone ? [contact.phone] : [],
homeAddress: {
street: `${contact.street} ${contact.houseNumber}`.trim(),
city: contact.city || "",
postalCode: contact.zipCode || "",
countryOrRegion: contact.country || ""
}
};
// 4. Kontakt in der gewünschten Kontaktliste erstellen
const createContactResponse = await fetch(`${contactListsUrl}/${contactListId}/contacts`, {
method: 'POST',
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json"
},
body: JSON.stringify(contactData)
});
if (!createContactResponse.ok) {
throw new Error(`Failed to add contact: ${await createContactResponse.text()}`);
}
const responseData = await createContactResponse.json();
console.log("Contact added successfully to the contact list:", responseData);
return responseData;
}
function decodeJWT(token) {
// Teile des JWT-Tokens (Header, Payload, Signature) durch den Punkt trennen
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT token');
}
// Der Payload-Teil des JWT-Tokens (zweiter Teil) decodiert von Base64Url
const payload = parts[1];
const decodedPayload = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
return decodedPayload;
}
async function getPermissionsFromToken(token) {
try {
const decodedToken = decodeJWT(token);
// Die "roles" und "permissions" befinden sich im Payload des Tokens
console.log("Decoded Token:", decodedToken);
const permissions = decodedToken.roles || [];
return permissions;
} catch (error) {
console.error('Error decoding JWT token:', error);
return [];
}
}
// Funktion zur Umwandlung eines ArrayBuffer in Base64
function bufferToBase64(buffer) {
let binary = '';
let bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment