Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
#include <tunables/global>
|
||||
|
||||
profile stalwart flags=(attach_disconnected) {
|
||||
#include <abstractions/base>
|
||||
#include <abstractions/nameservice>
|
||||
#include <abstractions/openssl>
|
||||
|
||||
# Allow network access
|
||||
network inet stream,
|
||||
network inet6 stream,
|
||||
network inet dgram,
|
||||
network inet6 dgram,
|
||||
|
||||
# Outgoing access to port 25 and 443
|
||||
network tcp,
|
||||
network udp,
|
||||
owner /proc/*/net/if_inet6 r,
|
||||
owner /proc/*/net/ipv6_route r,
|
||||
|
||||
# Full write access to /opt/stalwart
|
||||
/opt/stalwart/** rwk,
|
||||
|
||||
# Allow creating directories under /tmp
|
||||
/tmp/ r,
|
||||
/tmp/** rwk,
|
||||
|
||||
# Allow binding to specific ports
|
||||
network inet stream bind port 25,
|
||||
network inet stream bind port 587,
|
||||
network inet stream bind port 465,
|
||||
network inet stream bind port 143,
|
||||
network inet stream bind port 993,
|
||||
network inet stream bind port 110,
|
||||
network inet stream bind port 995,
|
||||
network inet stream bind port 4190,
|
||||
network inet stream bind port 443,
|
||||
network inet stream bind port 8080,
|
||||
network inet6 stream bind port 25,
|
||||
network inet6 stream bind port 587,
|
||||
network inet6 stream bind port 465,
|
||||
network inet6 stream bind port 143,
|
||||
network inet6 stream bind port 993,
|
||||
network inet6 stream bind port 110,
|
||||
network inet6 stream bind port 995,
|
||||
network inet6 stream bind port 4190,
|
||||
network inet6 stream bind port 443,
|
||||
network inet6 stream bind port 8080,
|
||||
|
||||
# Allow UDP port 7911
|
||||
network inet dgram bind port 7911,
|
||||
network inet6 dgram bind port 7911,
|
||||
|
||||
# Basic system access
|
||||
/usr/bin/stalwart rix,
|
||||
/etc/stalwart/** r,
|
||||
/var/log/stalwart/** w,
|
||||
|
||||
# Additional permissions might be needed depending on specific requirements
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{lang}}" dir="{{dir}}" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="x-apple-disable-message-reformatting" content="">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>{{page_title}}</title>
|
||||
<!--[if mso]>
|
||||
<noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
||||
<![endif]-->
|
||||
<!--[if mso]>
|
||||
<style type="text/css">body,table,td,div,a,span{font-family:Arial,Helvetica,sans-serif !important}</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
:root {
|
||||
color-scheme: light only;
|
||||
}
|
||||
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
background-color: #f2f2f0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #185fa5;
|
||||
}
|
||||
|
||||
@media only screen and (max-width:600px) {
|
||||
.shell {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.pad {
|
||||
padding-left: 22px !important;
|
||||
padding-right: 22px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body dir="{{dir}}" style="margin:0;padding:0;background-color:#f2f2f0;">
|
||||
<div
|
||||
style="display:none;font-size:1px;color:#f2f2f0;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
|
||||
{{page_title}}</div>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="background-color:#f2f2f0;">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 12px;">
|
||||
|
||||
<table role="presentation" class="shell" border="0" cellpadding="0" cellspacing="0" width="600"
|
||||
style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid #e5e5e3;border-radius:12px;overflow:hidden;">
|
||||
|
||||
<tr>
|
||||
<td height="3" style="height:3px;line-height:3px;font-size:0;background-color:#1a1a18;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="pad" style="padding:30px 40px 8px 40px;">
|
||||
<img src="{{logo_cid}}" width="180" alt=""
|
||||
style="display:block;width:180px;max-width:180px;height:auto;border:0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{#if header}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:14px 40px 0 40px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding:11px 16px;border-radius:8px;background-color:#f4f4f2;color:#1a1a18;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;line-height:1.4;">
|
||||
{{header}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if header}}
|
||||
|
||||
{{#if event_title}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:22px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:23px;font-weight:600;line-height:1.28;letter-spacing:-0.3px;color:#1a1a18;">
|
||||
{{event_title}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if event_title}}
|
||||
|
||||
{{#if event_description}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:12px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.6;color:#4a4a46;">
|
||||
{{event_description}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if event_description}}
|
||||
|
||||
{{#if event_details}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:22px 40px 0 40px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="background-color:#f9f9f7;border:1px solid #ececea;border-radius:9px;">
|
||||
<tr>
|
||||
<td style="padding:6px 18px 10px 18px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
|
||||
width="100%">
|
||||
{{#each event_details}}
|
||||
<tr>
|
||||
<td style="padding:10px 0 0 0;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.7px;text-transform:uppercase;color:#87877f;">
|
||||
{{key}}</div>
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;color:#1a1a18;padding-top:3px;word-break:break-word;">
|
||||
{{#if link}}<a href="{{link}}" target="_blank" style="color:#185fa5;text-decoration:underline;">{{/if link}}{{value}}{{#if link}}</a>{{/if link}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each event_details}}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if event_details}}
|
||||
|
||||
{{#if attendees}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:24px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.7px;text-transform:uppercase;color:#87877f;padding-bottom:8px;">
|
||||
{{attendees_title}}</div>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
{{#each attendees}}
|
||||
<tr>
|
||||
<td
|
||||
style="padding:4px 0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.45;color:#1a1a18;">
|
||||
{{key}} <span style="color:#87877f;">{{value}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each attendees}}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if attendees}}
|
||||
|
||||
<tr>
|
||||
<td class="pad" style="padding:26px 40px 0 40px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td align="center" valign="middle"
|
||||
style="border-radius:8px;background-color:#1a1a18;mso-padding-alt:13px 24px;">
|
||||
<a href="{{action_url}}" target="_blank"
|
||||
style="display:block;padding:13px 24px;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:15px;font-weight:600;line-height:1;color:#ffffff;text-decoration:none;border-radius:8px;">{{action_name}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 0 30px 0;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{#if footer}}
|
||||
<table role="presentation" class="shell" border="0" cellpadding="0" cellspacing="0" width="600"
|
||||
style="width:600px;max-width:600px;">
|
||||
<tr>
|
||||
<td align="center" class="pad" style="padding:6px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:12px;line-height:1.55;color:#87877f;">
|
||||
{{footer}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 0 18px 0;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/if footer}}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,299 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{lang}}" dir="{{dir}}" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="x-apple-disable-message-reformatting" content="">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<title>{{page_title}}</title>
|
||||
<!--[if mso]>
|
||||
<noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
|
||||
<![endif]-->
|
||||
<!--[if mso]>
|
||||
<style type="text/css">body,table,td,div,a,span{font-family:Arial,Helvetica,sans-serif !important}</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
:root {
|
||||
color-scheme: light only;
|
||||
}
|
||||
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
background-color: #f2f2f0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #185fa5;
|
||||
}
|
||||
|
||||
.bar-info {
|
||||
background-color: #1f7a4d;
|
||||
}
|
||||
|
||||
.bar-warning {
|
||||
background-color: #b26a00;
|
||||
}
|
||||
|
||||
.bar-danger {
|
||||
background-color: #c02b3f;
|
||||
}
|
||||
|
||||
.banner-info {
|
||||
background-color: #eaf6ef;
|
||||
color: #14603b;
|
||||
}
|
||||
|
||||
.banner-warning {
|
||||
background-color: #fdf3e3;
|
||||
color: #8a4b00;
|
||||
}
|
||||
|
||||
.banner-danger {
|
||||
background-color: #fdecee;
|
||||
color: #96202f;
|
||||
}
|
||||
|
||||
.btn-info {
|
||||
background-color: #1f7a4d;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #b26a00;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #c02b3f;
|
||||
}
|
||||
|
||||
@media only screen and (max-width:600px) {
|
||||
.shell {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.pad {
|
||||
padding-left: 22px !important;
|
||||
padding-right: 22px !important;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
padding: 0 0 10px 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body dir="{{dir}}" style="margin:0;padding:0;background-color:#f2f2f0;">
|
||||
<div style="display:none;font-size:1px;color:#f2f2f0;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">
|
||||
{{page_title}}</div>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="background-color:#f2f2f0;">
|
||||
<tr>
|
||||
<td align="center" style="padding:32px 12px;">
|
||||
|
||||
<table role="presentation" class="shell" border="0" cellpadding="0" cellspacing="0" width="600"
|
||||
style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid #e5e5e3;border-radius:12px;overflow:hidden;">
|
||||
|
||||
<tr>
|
||||
<td class="bar-{{color}}" height="3"
|
||||
style="height:3px;line-height:3px;font-size:0;background-color:#1a1a18;"> </td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td align="center" class="pad" style="padding:30px 40px 8px 40px;">
|
||||
<img src="{{logo_cid}}" width="180" alt=""
|
||||
style="display:block;width:180px;max-width:180px;height:auto;border:0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{#if header}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:14px 40px 0 40px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td class="banner-{{color}}" align="center"
|
||||
style="padding:11px 16px;border-radius:8px;background-color:#f4f4f2;color:#1a1a18;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;line-height:1.4;">
|
||||
{{header}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if header}}
|
||||
|
||||
{{#if event_title}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:22px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:23px;font-weight:600;line-height:1.28;letter-spacing:-0.3px;color:#1a1a18;">
|
||||
{{event_title}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if event_title}}
|
||||
|
||||
{{#if event_description}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:12px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.6;color:#4a4a46;">
|
||||
{{event_description}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if event_description}}
|
||||
|
||||
{{#if event_details}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:22px 40px 0 40px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||
style="background-color:#f9f9f7;border:1px solid #ececea;border-radius:9px;">
|
||||
<tr>
|
||||
<td style="padding:6px 18px 10px 18px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
|
||||
width="100%">
|
||||
{{#each event_details}}
|
||||
<tr>
|
||||
<td style="padding:10px 0 0 0;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.7px;text-transform:uppercase;color:#87877f;">
|
||||
{{key}}{{#if changed}}<span
|
||||
style="display:inline-block;margin-left:7px;padding:2px 7px;border-radius:999px;background-color:#1a1a18;color:#ffffff;font-size:9px;font-weight:700;letter-spacing:0.4px;text-transform:uppercase;">{{changed}}</span>{{/if changed}}
|
||||
</div>
|
||||
{{#if old_value}}<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:#a3a39c;text-decoration:line-through;padding-top:3px;">
|
||||
{{old_value}}</div>{{/if old_value}}
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.5;color:#1a1a18;padding-top:3px;word-break:break-word;">
|
||||
{{#if link}}<a href="{{link}}" target="_blank" style="color:#185fa5;text-decoration:underline;">{{/if link}}{{value}}{{#if link}}</a>{{/if link}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each event_details}}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if event_details}}
|
||||
|
||||
{{#if attendees}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:24px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:11px;font-weight:600;letter-spacing:0.7px;text-transform:uppercase;color:#87877f;padding-bottom:8px;">
|
||||
{{attendees_title}}</div>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
{{#each attendees}}
|
||||
<tr>
|
||||
<td
|
||||
style="padding:4px 0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.45;color:#1a1a18;">
|
||||
{{key}} <span style="color:#87877f;">{{value}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each attendees}}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if attendees}}
|
||||
|
||||
{{#if rsvp}}
|
||||
<tr>
|
||||
<td class="pad" style="padding:26px 40px 0 40px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td height="1"
|
||||
style="height:1px;line-height:1px;font-size:0;background-color:#ececea;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" align="center" style="padding:20px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;line-height:1.5;color:#4a4a46;">
|
||||
{{rsvp}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="pad" style="padding:14px 34px 4px 34px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
{{#each actions}}
|
||||
<td class="stack" width="33.33%" align="center" valign="middle"
|
||||
style="padding:0 6px;">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0"
|
||||
width="100%">
|
||||
<tr>
|
||||
<td class="btn-{{color}}" align="center" valign="middle"
|
||||
style="border-radius:8px;background-color:#1a1a18;mso-padding-alt:12px 8px;">
|
||||
<a href="{{action_url}}" target="_blank"
|
||||
style="display:block;padding:12px 8px;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;line-height:1;color:#ffffff;text-decoration:none;border-radius:8px;">{{action_name}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
{{/each actions}}
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{{/if rsvp}}
|
||||
|
||||
<tr>
|
||||
<td style="padding:0 0 30px 0;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{#if footer}}
|
||||
<table role="presentation" class="shell" border="0" cellpadding="0" cellspacing="0" width="600"
|
||||
style="width:600px;max-width:600px;">
|
||||
{{#each footer}}
|
||||
<tr>
|
||||
<td align="center" class="pad" style="padding:6px 40px 0 40px;">
|
||||
<div
|
||||
style="font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;font-size:12px;line-height:1.55;color:#87877f;">
|
||||
{{key}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each footer}}
|
||||
<tr>
|
||||
<td style="padding:0 0 18px 0;font-size:0;line-height:0;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
{{/if footer}}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1,606 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>Sign in</title>
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f4;
|
||||
--card: #fff;
|
||||
--border: #e5e5e3;
|
||||
--text: #1a1a18;
|
||||
--muted: #6b6b67;
|
||||
--accent: #1a1a18;
|
||||
--accent-fg: #fff;
|
||||
--info: #185fa5;
|
||||
--success: #0f7a3c;
|
||||
--radius: 10px;
|
||||
--input-bg: #fafaf9;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme:dark) {
|
||||
:root {
|
||||
--bg: #18181b;
|
||||
--card: #1f1f23;
|
||||
--border: #2e2e33;
|
||||
--text: #f4f4f2;
|
||||
--muted: #9b9b95;
|
||||
--accent: #f4f4f2;
|
||||
--accent-fg: #18181b;
|
||||
--info: #5faee3;
|
||||
--success: #58c98a;
|
||||
--input-bg: #2a2a2e;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 2.25rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 384px;
|
||||
}
|
||||
|
||||
.logo-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
margin: 0.25rem auto 0.5rem;
|
||||
}
|
||||
|
||||
.logo-wrap svg.default-logo {
|
||||
display: block;
|
||||
width: calc(100% + 1rem);
|
||||
height: auto;
|
||||
margin: -0.25rem auto 0.1rem;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.logo-wrap[data-loading] .default-logo {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.logo-wrap img.custom-logo {
|
||||
display: block;
|
||||
max-width: 220px;
|
||||
max-height: 96px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.default-logo .wordmark {
|
||||
fill: var(--text)
|
||||
}
|
||||
|
||||
.default-logo .symbol {
|
||||
fill: #db2d54
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.015em;
|
||||
margin-bottom: .25rem
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: .8125rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 1.75rem
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: .625rem;
|
||||
padding: .625rem .75rem;
|
||||
background: color-mix(in srgb, #db2d54 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #db2d54 30%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: .8125rem;
|
||||
color: #a0192f;
|
||||
margin-bottom: .875rem;
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
background: color-mix(in srgb, #0f7a3c 10%, transparent);
|
||||
border-color: color-mix(in srgb, #0f7a3c 30%, transparent);
|
||||
color: #0f7a3c;
|
||||
}
|
||||
|
||||
@media(prefers-color-scheme:dark) {
|
||||
.alert {
|
||||
color: #f4839a
|
||||
}
|
||||
|
||||
.alert.success {
|
||||
color: #58c98a;
|
||||
}
|
||||
}
|
||||
|
||||
.alert svg {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .875rem
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: .75rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
letter-spacing: .03em;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: .3125rem;
|
||||
}
|
||||
|
||||
input[type=text],
|
||||
input[type=password],
|
||||
input[type=email] {
|
||||
width: 100%;
|
||||
padding: .5625rem .75rem;
|
||||
font-size: .9375rem;
|
||||
font-family: inherit;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input[type=text]:focus,
|
||||
input[type=password]:focus,
|
||||
input[type=email]:focus {
|
||||
border-color: color-mix(in srgb, var(--info) 60%, transparent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--info) 12%, transparent);
|
||||
}
|
||||
|
||||
input[type=text]::placeholder,
|
||||
input[type=password]::placeholder,
|
||||
input[type=email]::placeholder {
|
||||
color: var(--muted);
|
||||
opacity: .7
|
||||
}
|
||||
|
||||
#otp {
|
||||
letter-spacing: .2em;
|
||||
font-size: 1.0625rem;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums
|
||||
}
|
||||
|
||||
#device-code {
|
||||
letter-spacing: .15em;
|
||||
font-size: 1.0625rem;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
margin-top: .375rem;
|
||||
padding: .625rem 0;
|
||||
font-size: .9375rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
letter-spacing: -.01em;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: .88
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
opacity: .75;
|
||||
transform: scale(.99)
|
||||
}
|
||||
|
||||
.btn[disabled] {
|
||||
opacity: .55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo-wrap" id="logo-wrap" data-loading>
|
||||
<svg class="default-logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 680.5 252.1" aria-label="Logo">
|
||||
<path class="wordmark"
|
||||
d="M227.8 143.6c.3 4.2 2.1 7.6 5.1 10.1 3.1 2.5 7.1 3.8 12.1 3.8 4.3 0 7.9-.9 10.5-2.8 2.7-1.9 4-4.5 4-7.8 0-2.4-.7-4.3-2.2-5.7-1.5-1.4-3.4-2.5-6-3.2-2.5-.7-6-1.5-10.6-2.3-4.6-.8-8.6-1.9-11.9-3.2-3.3-1.3-6-3.3-8.1-6.1-2.1-2.7-3.1-6.3-3.1-10.7 0-4.1 1.1-7.7 3.2-10.9s5.1-5.7 9-7.4c3.8-1.8 8.2-2.6 13.2-2.6 5.1 0 9.6 1 13.7 2.9 4 1.9 7.2 4.5 9.5 7.8s3.6 7.1 3.8 11.4h-11.5c-.4-3.7-2-6.6-4.8-8.9-2.8-2.2-6.3-3.4-10.6-3.4-4.1 0-7.5.9-9.9 2.7-2.5 1.8-3.7 4.3-3.7 7.6 0 2.3.7 4.1 2.2 5.5 1.5 1.4 3.4 2.4 5.9 3.1 2.4.7 5.9 1.4 10.5 2.2 4.6.8 8.6 1.9 11.9 3.3 3.3 1.4 6 3.4 8.2 6 2.1 2.6 3.2 6.1 3.2 10.5 0 4.2-1.1 8-3.4 11.3-2.2 3.3-5.4 5.9-9.4 7.8-4 1.9-8.6 2.8-13.7 2.8-5.6 0-10.6-1-14.9-3.1-4.3-2-7.6-4.9-10-8.5-2.4-3.6-3.7-7.8-3.7-12.5l11.5.3zM278.5 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5-2.2-2.3-3.4-5.9-3.4-10.7v-50.5zM356.8 114.6v52.2h-9.7l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8s8.2-1.6 11-4.8zM365.5 97.5l11-2.1v71.3h-11V97.5zM380.3 114.6h11.6l11.9 39.9 11.9-39.9h10.1l11.4 39.9 12.3-39.9h11.2l-17.3 52.2h-11.8l-11-35.5-11.4 35.5-11.9.1-17-52.3zM513.7 114.6v52.2H504l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8 4.6 0 8.2-1.6 11-4.8zM551.3 114.6v10.3h-4.9c-4.6 0-7.8 1.5-9.9 4.4-2 3-3.1 6.7-3.1 11.3v26.2h-11v-52.2h9.8l1.2 7.8c1.5-2.4 3.4-4.4 5.8-5.8 2.4-1.4 5.6-2.1 9.6-2.1h2.5zM556.3 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7v-50.5z" />
|
||||
<path class="symbol"
|
||||
d="M149.1 84.7h-4.8l-44.8 25.9v8.3l44.8 25.9h4.8l44.8-25.9v-8.3l-44.8-25.9zm32.9 30h-35.3V94.4l35.3 20.3zm-35.3 20.4-35.3-20.4 27-15.6v20.2l6.3 3.6h22.9l-20.9 12.2zM99.5 129.9v11l44.8 25.9h4.8l44.8-25.9v-11l-47.2 27.3zM187.3 166.8l6.6-3.8v-11l-25.7 14.8zM99.5 163l6.6 3.8h19.1L99.5 152z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 id="title">Sign in</h1>
|
||||
<p class="sub" id="subtitle">Enter your credentials to continue</p>
|
||||
|
||||
<div class="alert" id="alert" hidden aria-live="polite">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<span id="alert-msg"></span>
|
||||
</div>
|
||||
|
||||
<form class="fields" id="login-form" novalidate>
|
||||
<div class="field" id="field-username">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" placeholder="[email protected]" autocomplete="username"
|
||||
autocapitalize="none" autocorrect="off" spellcheck="false" required>
|
||||
</div>
|
||||
<div class="field" id="field-password">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" placeholder="••••••••"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
<div class="field" id="field-device-code" hidden>
|
||||
<label for="device-code">Device code</label>
|
||||
<input id="device-code" name="device-code" type="text" placeholder="XXXX-XXXX" autocomplete="off"
|
||||
autocapitalize="characters" spellcheck="false">
|
||||
</div>
|
||||
<div class="field" id="field-otp" hidden>
|
||||
<label for="otp">One-time code</label>
|
||||
<input id="otp" name="otp" type="text" placeholder="000000" maxlength="6" inputmode="numeric"
|
||||
pattern="\d{6}" autocomplete="one-time-code">
|
||||
</div>
|
||||
<button type="submit" class="btn" id="submit-btn">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
function setText(el, text) {
|
||||
// textContent is XSS-safe: never parses HTML.
|
||||
el.textContent = text == null ? '' : String(text);
|
||||
}
|
||||
|
||||
function show(el) { if (el) el.hidden = false; }
|
||||
function hide(el) { if (el) el.hidden = true; }
|
||||
|
||||
function showError(msg) {
|
||||
var a = $('alert');
|
||||
a.classList.remove('success');
|
||||
setText($('alert-msg'), msg);
|
||||
show(a);
|
||||
}
|
||||
|
||||
function showSuccess(msg) {
|
||||
var a = $('alert');
|
||||
a.classList.add('success');
|
||||
setText($('alert-msg'), msg);
|
||||
show(a);
|
||||
}
|
||||
|
||||
function clearAlert() { hide($('alert')); }
|
||||
|
||||
var url = new URL(window.location.href);
|
||||
var params = url.searchParams;
|
||||
var isDevice = /\/device(\/|$)/.test(url.pathname);
|
||||
|
||||
// Collected OAuth params for the code flow
|
||||
var oauth = {
|
||||
client_id: params.get('client_id') || '',
|
||||
redirect_uri: params.get('redirect_uri'),
|
||||
scope: params.get('scope'),
|
||||
state: params.get('state'),
|
||||
nonce: params.get('nonce'),
|
||||
code_challenge: params.get('code_challenge'),
|
||||
code_challenge_method: params.get('code_challenge_method'),
|
||||
resource: params.getAll('resource')
|
||||
};
|
||||
|
||||
// This page is the OAuth authorization screen used by apps
|
||||
if (!isDevice && !oauth.redirect_uri) {
|
||||
hide($('login-form'));
|
||||
setText($('title'), 'Sign in with an app');
|
||||
setText($('subtitle'), 'This page is opened automatically by your mail, calendar or contacts app to authorize access to your account.');
|
||||
showError('This sign-in page cannot be used directly. Please start the sign-in from your app instead. (The required "redirect_uri" parameter is missing.)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefill username from login_hint
|
||||
var loginHint = params.get('login_hint');
|
||||
if (loginHint) $('username').value = loginHint;
|
||||
|
||||
// Device-flow specific UI
|
||||
if (isDevice) {
|
||||
setText($('title'), 'Authorize device');
|
||||
setText($('subtitle'), 'Sign in to approve this device.');
|
||||
show($('field-device-code'));
|
||||
var prefillCode = params.get('code');
|
||||
if (prefillCode) $('device-code').value = prefillCode;
|
||||
}
|
||||
|
||||
var inMfa = false;
|
||||
var lastCreds = null; // { account_name, account_secret } cached to resubmit with OTP
|
||||
|
||||
function enterMfaMode() {
|
||||
inMfa = true;
|
||||
hide($('field-username'));
|
||||
hide($('field-password'));
|
||||
if (isDevice) hide($('field-device-code'));
|
||||
show($('field-otp'));
|
||||
setText($('title'), 'Two-factor authentication');
|
||||
setText($('subtitle'), 'Enter the 6-digit code from your authenticator app.');
|
||||
clearAlert();
|
||||
setTimeout(function () { $('otp').focus(); }, 0);
|
||||
}
|
||||
|
||||
(function loadCustomLogo() {
|
||||
var wrap = $('logo-wrap');
|
||||
function showDefault() { wrap.removeAttribute('data-loading'); }
|
||||
var target = '/logo';
|
||||
if (loginHint) {
|
||||
var at = loginHint.lastIndexOf('@');
|
||||
if (at !== -1 && at < loginHint.length - 1) {
|
||||
target = '/logo?domain=' + encodeURIComponent(loginHint.slice(at + 1).toLowerCase());
|
||||
}
|
||||
}
|
||||
fetch(target, { method: 'GET', credentials: 'same-origin' })
|
||||
.then(function (res) {
|
||||
if (!res.ok) return null;
|
||||
var ct = (res.headers.get('content-type') || '').toLowerCase();
|
||||
if (ct.indexOf('image/') !== 0) return null;
|
||||
return res.blob();
|
||||
})
|
||||
.then(function (blob) {
|
||||
if (!blob) { showDefault(); return; }
|
||||
var objectUrl = URL.createObjectURL(blob);
|
||||
var img = new Image();
|
||||
img.className = 'custom-logo';
|
||||
img.alt = 'Logo';
|
||||
img.onload = function () {
|
||||
while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
|
||||
wrap.appendChild(img);
|
||||
wrap.removeAttribute('data-loading');
|
||||
};
|
||||
img.onerror = function () {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
showDefault();
|
||||
};
|
||||
img.src = objectUrl;
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.log('Custom logo unavailable:', err);
|
||||
showDefault();
|
||||
});
|
||||
})();
|
||||
|
||||
function buildRequest(creds, otpValue) {
|
||||
if (isDevice) {
|
||||
var req = {
|
||||
type: 'authDevice',
|
||||
accountName: creds.account_name,
|
||||
accountSecret: creds.account_secret,
|
||||
code: ($('device-code').value || '').trim()
|
||||
};
|
||||
if (otpValue) req.mfaToken = otpValue;
|
||||
return req;
|
||||
}
|
||||
var r = {
|
||||
type: 'authCode',
|
||||
accountName: creds.account_name,
|
||||
accountSecret: creds.account_secret,
|
||||
clientId: oauth.client_id || ''
|
||||
};
|
||||
if (oauth.redirect_uri) r.redirectUri = oauth.redirect_uri;
|
||||
if (oauth.scope) r.scope = oauth.scope;
|
||||
if (oauth.state) r.state = oauth.state;
|
||||
if (oauth.nonce) r.nonce = oauth.nonce;
|
||||
if (oauth.code_challenge) r.codeChallenge = oauth.code_challenge;
|
||||
if (oauth.code_challenge_method) r.codeChallengeMethod = oauth.code_challenge_method;
|
||||
if (oauth.resource && oauth.resource.length) r.resource = oauth.resource;
|
||||
if (otpValue) r.mfaToken = otpValue;
|
||||
return r;
|
||||
}
|
||||
|
||||
function performRedirect(clientCode, issuer) {
|
||||
// Build redirect_uri?code=<client_code>&state=<state>
|
||||
var target;
|
||||
try {
|
||||
if (!oauth.redirect_uri) throw new Error('missing redirect_uri');
|
||||
target = new URL(oauth.redirect_uri);
|
||||
} catch (e) {
|
||||
console.log('Invalid or missing redirect_uri:', oauth.redirect_uri, e);
|
||||
showError('Temporary server failure. If the problem persists, contact your administrator.');
|
||||
return;
|
||||
}
|
||||
target.searchParams.set('code', clientCode);
|
||||
if (oauth.state) target.searchParams.set('state', oauth.state);
|
||||
target.searchParams.set('iss', issuer || url.origin);
|
||||
window.location.assign(target.toString());
|
||||
}
|
||||
|
||||
function handleResponse(resp) {
|
||||
if (!resp || typeof resp !== 'object' || typeof resp.type !== 'string') {
|
||||
console.log('Malformed login response:', resp);
|
||||
showError('Temporary server failure. If the problem persists, contact your administrator.');
|
||||
return;
|
||||
}
|
||||
switch (resp.type) {
|
||||
case 'authenticated':
|
||||
if (typeof resp.client_code !== 'string') {
|
||||
console.log('Missing client_code in response:', resp);
|
||||
showError('Temporary server failure. If the problem persists, contact your administrator.');
|
||||
return;
|
||||
}
|
||||
performRedirect(resp.client_code, resp.iss);
|
||||
return;
|
||||
case 'verified':
|
||||
hide($('login-form'));
|
||||
setText($('title'), 'Device authorized');
|
||||
setText($('subtitle'), 'You have successfully authorized this device. You may now close this window.');
|
||||
clearAlert();
|
||||
showSuccess('Device verified.');
|
||||
return;
|
||||
case 'mfaRequired':
|
||||
enterMfaMode();
|
||||
return;
|
||||
case 'failure':
|
||||
// Reset MFA state on failure so the user can retry credentials.
|
||||
if (inMfa) {
|
||||
inMfa = false;
|
||||
show($('field-username'));
|
||||
show($('field-password'));
|
||||
if (isDevice) show($('field-device-code'));
|
||||
hide($('field-otp'));
|
||||
$('otp').value = '';
|
||||
setText($('title'), isDevice ? 'Authorize device' : 'Sign in');
|
||||
setText($('subtitle'), isDevice
|
||||
? 'Sign in to approve this device.'
|
||||
: 'Enter your credentials to continue');
|
||||
}
|
||||
showError('Invalid username or password. Please try again.');
|
||||
return;
|
||||
default:
|
||||
console.log('Unknown login response type:', resp);
|
||||
showError('Temporary server failure. If the problem persists, contact your administrator.');
|
||||
}
|
||||
}
|
||||
|
||||
function submitLogin(e) {
|
||||
if (e) e.preventDefault();
|
||||
clearAlert();
|
||||
|
||||
var creds;
|
||||
if (inMfa) {
|
||||
var otp = ($('otp').value || '').trim();
|
||||
if (!otp) { showError('Please enter your one-time code.'); return; }
|
||||
if (!lastCreds) {
|
||||
// Safety net: shouldn't happen.
|
||||
inMfa = false;
|
||||
show($('field-username'));
|
||||
show($('field-password'));
|
||||
hide($('field-otp'));
|
||||
showError('Session expired. Please sign in again.');
|
||||
return;
|
||||
}
|
||||
creds = lastCreds;
|
||||
var body = buildRequest(creds, otp);
|
||||
postLogin(body);
|
||||
return;
|
||||
}
|
||||
|
||||
var username = ($('username').value || '').trim();
|
||||
var password = $('password').value || '';
|
||||
if (!username || !password) {
|
||||
showError('Please enter your username and password.');
|
||||
return;
|
||||
}
|
||||
if (isDevice) {
|
||||
var devCode = ($('device-code').value || '').trim();
|
||||
if (!devCode) {
|
||||
showError('Please enter the device code.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
creds = { account_name: username, account_secret: password };
|
||||
lastCreds = creds;
|
||||
postLogin(buildRequest(creds, null));
|
||||
}
|
||||
|
||||
function postLogin(body) {
|
||||
var btn = $('submit-btn');
|
||||
btn.disabled = true;
|
||||
fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
}).then(function (res) {
|
||||
if (res.status !== 200) {
|
||||
return res.text().then(function (txt) {
|
||||
console.log('Login endpoint returned HTTP', res.status, txt);
|
||||
throw new Error('http ' + res.status);
|
||||
});
|
||||
}
|
||||
return res.json().catch(function (err) {
|
||||
console.log('Failed to parse login response JSON:', err);
|
||||
throw err;
|
||||
});
|
||||
}).then(function (json) {
|
||||
handleResponse(json);
|
||||
}).catch(function (err) {
|
||||
console.log('Login request failed:', err);
|
||||
showError('Temporary server failure. If the problem persists, contact your administrator.');
|
||||
}).then(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
$('login-form').addEventListener('submit', submitLogin);
|
||||
|
||||
// Digit-only filter on OTP and auto-submit on 6 digits.
|
||||
$('otp').addEventListener('input', function () {
|
||||
this.value = this.value.replace(/\D/g, '');
|
||||
if (this.value.length === 6) submitLogin();
|
||||
});
|
||||
|
||||
// Focus the first visible empty field.
|
||||
setTimeout(function () {
|
||||
if (!$('username').value) { $('username').focus(); return; }
|
||||
if (!$('password').value) { $('password').focus(); return; }
|
||||
if (isDevice && !$('device-code').value) { $('device-code').focus(); return; }
|
||||
}, 0);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1 @@
|
||||
zUWyYdvOBMVeP1H7DNb7OqUThdpIaKjph4RyqBSKZAA
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Decode a raw Stalwart blob object into its original bytes.
|
||||
|
||||
Stalwart appends a one-byte compression marker to every blob it writes to the
|
||||
blob store (see BlobStore::put_blob in crates/store/src/dispatch/blob.rs):
|
||||
|
||||
0x00 (NONE_MARKER) the preceding bytes are the verbatim payload
|
||||
0xa1 (LZ4_MARKER) the preceding bytes are an lz4_flex block prefixed with a
|
||||
little-endian u32 holding the uncompressed size
|
||||
other a legacy blob stored without a marker; emitted unchanged
|
||||
|
||||
This mirrors the read path, which inspects the last byte, strips the marker and,
|
||||
for LZ4, decompresses the size-prepended block.
|
||||
|
||||
Reads the object from a file (or stdin with "-") and writes the decoded payload
|
||||
to stdout (or to a file with -o).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
NONE_MARKER = 0x00
|
||||
LZ4_MARKER = 0xA1
|
||||
|
||||
|
||||
def decode(data):
|
||||
if not data:
|
||||
return data
|
||||
marker = data[-1]
|
||||
if marker == LZ4_MARKER:
|
||||
import lz4.block
|
||||
return lz4.block.decompress(data[:-1])
|
||||
if marker == NONE_MARKER:
|
||||
return data[:-1]
|
||||
print(f"warning: no known compression marker (last byte 0x{marker:02x}); "
|
||||
"emitting bytes unchanged", file=sys.stderr)
|
||||
return data
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("input", help='raw blob file, or "-" to read from stdin')
|
||||
ap.add_argument("-o", "--output",
|
||||
help="write the decoded payload here (default: stdout)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.input == "-":
|
||||
data = sys.stdin.buffer.read()
|
||||
else:
|
||||
with open(args.input, "rb") as fh:
|
||||
data = fh.read()
|
||||
|
||||
out = decode(data)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "wb") as fh:
|
||||
fh.write(out)
|
||||
else:
|
||||
sys.stdout.buffer.write(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
IMAP Log sanitizer - Extracts and groups IMAP transactions from log files
|
||||
"""
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
|
||||
import re
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
|
||||
def unescape_imap_content(content):
|
||||
|
||||
if content.startswith('"') and content.endswith('"'):
|
||||
content = content[1:-1]
|
||||
|
||||
replacements = {
|
||||
'\\r\\n': '\r\n',
|
||||
'\\n': '\n',
|
||||
'\\r': '\r',
|
||||
'\\t': '\t',
|
||||
'\\"': '"',
|
||||
'\\\\': '\\'
|
||||
}
|
||||
|
||||
for escaped, unescaped in replacements.items():
|
||||
content = content.replace(escaped, unescaped)
|
||||
|
||||
return content
|
||||
|
||||
def parse_imap_log_line(line):
|
||||
|
||||
pattern = r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)\s+TRACE\s+Raw IMAP\s+(input received|output sent)\s+.*?remoteIp\s*=\s*([^,]+),\s*remotePort\s*=\s*(\d+).*?contents\s*=\s*(.+)$'
|
||||
|
||||
match = re.search(pattern, line)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
timestamp, direction, remote_ip, remote_port, contents = match.groups()
|
||||
|
||||
return {
|
||||
'timestamp': timestamp,
|
||||
'direction': direction,
|
||||
'remote_ip': remote_ip.strip(),
|
||||
'remote_port': int(remote_port),
|
||||
'contents': unescape_imap_content(contents.strip()),
|
||||
'raw_line': line.strip()
|
||||
}
|
||||
|
||||
def group_by_connection(log_entries):
|
||||
|
||||
connections = defaultdict(list)
|
||||
|
||||
for entry in log_entries:
|
||||
if entry:
|
||||
key = f"{entry['remote_ip']}:{entry['remote_port']}"
|
||||
connections[key].append(entry)
|
||||
|
||||
for key in connections:
|
||||
connections[key].sort(key=lambda x: x['timestamp'])
|
||||
|
||||
return dict(connections)
|
||||
|
||||
def format_imap_transaction(entries):
|
||||
|
||||
transaction = []
|
||||
|
||||
for entry in entries:
|
||||
direction_symbol = "C: " if "input received" in entry['direction'] else "S: "
|
||||
timestamp = entry['timestamp']
|
||||
content = entry['contents']
|
||||
|
||||
if content.endswith('\\r\\n') or content.endswith('\r\n'):
|
||||
content = content.rstrip('\\r\\n\r\n')
|
||||
|
||||
transaction.append(f"[{timestamp}] {direction_symbol}{content}")
|
||||
|
||||
return transaction
|
||||
|
||||
def write_output_file(connections, output_file):
|
||||
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write("IMAP Transaction Log Analysis\n")
|
||||
f.write("=" * 50 + "\n\n")
|
||||
|
||||
for connection_key, entries in connections.items():
|
||||
f.write(f"Connection: {connection_key}\n")
|
||||
f.write("-" * 30 + "\n")
|
||||
f.write(f"Total messages: {len(entries)}\n")
|
||||
f.write(f"Duration: {entries[0]['timestamp']} to {entries[-1]['timestamp']}\n\n")
|
||||
|
||||
transaction = format_imap_transaction(entries)
|
||||
for line in transaction:
|
||||
f.write(line + "\n")
|
||||
|
||||
f.write("\n" + "=" * 50 + "\n\n")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Parse IMAP log files and group transactions by connection')
|
||||
parser.add_argument('input_file', help='Input log file path')
|
||||
parser.add_argument('-o', '--output', default='imap_transactions.txt',
|
||||
help='Output file path (default: imap_transactions.txt)')
|
||||
parser.add_argument('-j', '--json', action='store_true',
|
||||
help='Also output raw data as JSON')
|
||||
parser.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Enable verbose output')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
print(f"Reading log file: {args.input_file}")
|
||||
|
||||
log_entries = []
|
||||
imap_line_count = 0
|
||||
|
||||
try:
|
||||
with open(args.input_file, 'r', encoding='utf-8') as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
if 'Raw IMAP' in line:
|
||||
imap_line_count += 1
|
||||
parsed_entry = parse_imap_log_line(line)
|
||||
if parsed_entry:
|
||||
log_entries.append(parsed_entry)
|
||||
elif args.verbose:
|
||||
print(f"Warning: Could not parse line {line_num}: {line.strip()}")
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: File '{args.input_file}' not found")
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {e}")
|
||||
return 1
|
||||
|
||||
if args.verbose:
|
||||
print(f"Found {imap_line_count} Raw IMAP lines")
|
||||
print(f"Successfully parsed {len(log_entries)} entries")
|
||||
|
||||
connections = group_by_connection(log_entries)
|
||||
|
||||
if args.verbose:
|
||||
print(f"Found {len(connections)} unique connections:")
|
||||
for conn_key, entries in connections.items():
|
||||
print(f" {conn_key}: {len(entries)} messages")
|
||||
|
||||
try:
|
||||
write_output_file(connections, args.output)
|
||||
print(f"IMAP transactions written to: {args.output}")
|
||||
|
||||
if args.json:
|
||||
json_file = args.output.rsplit('.', 1)[0] + '.json'
|
||||
with open(json_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(connections, f, indent=2, ensure_ascii=False)
|
||||
print(f"Raw data written to: {json_file}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error writing output: {e}")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""List the S3 object keys of every blob that is still actively linked.
|
||||
|
||||
Stalwart reference-counts blobs through link entries kept in the data store
|
||||
under the SUBSPACE_BLOB_LINK subspace (the PostgreSQL table named "k"). A blob
|
||||
is garbage-collectable once it has no surviving link; this tool reports the
|
||||
opposite set: the S3 keys of blobs that are still referenced, so they can be
|
||||
diffed against the actual contents of an S3 bucket to find orphans.
|
||||
|
||||
Key layout in table "k" (the subspace byte is NOT stored, it only selects the
|
||||
table). All integers are big-endian:
|
||||
|
||||
32 bytes Commit : <hash:32> (blob exists marker)
|
||||
40 bytes Id link : <hash:32><id:8>
|
||||
41 bytes Doc link: <hash:32><account_id:4><collection:1><document_id:4>
|
||||
44 bytes Tmp link: <hash:32><account_id:4><until:8> (active iff until > now)
|
||||
|
||||
A blob is active when it has at least one Id/Doc link, or a Temporary link whose
|
||||
"until" (unix seconds) is still in the future. The 32-byte Commit marker alone
|
||||
does not keep a blob alive.
|
||||
|
||||
The S3 object key is the optional configured key prefix (literal string)
|
||||
followed by the custom-base32 encoding of the 32-byte blob hash, matching
|
||||
S3Store::build_key in crates/store/src/backend/s3/mod.rs.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
BLOB_HASH_LEN = 32
|
||||
ID_LINK = BLOB_HASH_LEN + 8 # 40
|
||||
DOC_LINK = BLOB_HASH_LEN + 8 + 1 # 41
|
||||
TEMP_LINK = BLOB_HASH_LEN + 4 + 8 # 44
|
||||
|
||||
BASE32_ALPHABET = b"abcdefghijklmnopqrstuvwxyz792013"
|
||||
|
||||
|
||||
class Base32Writer:
|
||||
"""Faithful port of utils::codec::base32_custom::Base32Writer."""
|
||||
|
||||
def __init__(self, prefix=""):
|
||||
self.last_byte = 0
|
||||
self.pos = 0
|
||||
self.out = [prefix] if prefix else []
|
||||
|
||||
def _push_byte(self, byte, is_remainder):
|
||||
p = self.pos % 5
|
||||
if p == 0:
|
||||
ch1 = (byte & 0xF8) >> 3
|
||||
ch2 = 0xFF
|
||||
elif p == 1:
|
||||
ch1 = ((self.last_byte & 0x07) << 2) | ((byte & 0xC0) >> 6)
|
||||
ch2 = (byte & 0x3E) >> 1
|
||||
elif p == 2:
|
||||
ch1 = ((self.last_byte & 0x01) << 4) | ((byte & 0xF0) >> 4)
|
||||
ch2 = 0xFF
|
||||
elif p == 3:
|
||||
ch1 = ((self.last_byte & 0x0F) << 1) | (byte >> 7)
|
||||
ch2 = (byte & 0x7C) >> 2
|
||||
else:
|
||||
ch1 = ((self.last_byte & 0x03) << 3) | ((byte & 0xE0) >> 5)
|
||||
ch2 = byte & 0x1F
|
||||
|
||||
self.out.append(chr(BASE32_ALPHABET[ch1]))
|
||||
if not is_remainder:
|
||||
if ch2 != 0xFF:
|
||||
self.out.append(chr(BASE32_ALPHABET[ch2]))
|
||||
self.last_byte = byte
|
||||
self.pos += 1
|
||||
|
||||
def write(self, data):
|
||||
for byte in data:
|
||||
self._push_byte(byte, False)
|
||||
return self
|
||||
|
||||
def finalize(self):
|
||||
if self.pos % 5 != 0:
|
||||
self._push_byte(0, True)
|
||||
return "".join(self.out)
|
||||
|
||||
|
||||
def s3_key(blob_hash, prefix=""):
|
||||
return Base32Writer(prefix).write(blob_hash).finalize()
|
||||
|
||||
|
||||
def be_u64(b):
|
||||
return int.from_bytes(b, "big")
|
||||
|
||||
|
||||
def collect_active_hashes(rows, now, include_expired_temporary=False):
|
||||
active = set()
|
||||
unknown = 0
|
||||
for (key,) in rows:
|
||||
key = bytes(key)
|
||||
n = len(key)
|
||||
if n == BLOB_HASH_LEN:
|
||||
continue
|
||||
if n in (ID_LINK, DOC_LINK):
|
||||
active.add(key[:BLOB_HASH_LEN])
|
||||
elif n == TEMP_LINK:
|
||||
until = be_u64(key[BLOB_HASH_LEN + 4:BLOB_HASH_LEN + 12])
|
||||
if include_expired_temporary or until > now:
|
||||
active.add(key[:BLOB_HASH_LEN])
|
||||
else:
|
||||
unknown += 1
|
||||
if unknown:
|
||||
print(f"warning: skipped {unknown} key(s) of unexpected length",
|
||||
file=sys.stderr)
|
||||
return active
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--host", default=os.environ.get("PGHOST", "localhost"))
|
||||
ap.add_argument("--port", type=int, default=int(os.environ.get("PGPORT", "5432")))
|
||||
ap.add_argument("--user", default=os.environ.get("PGUSER", "stalwart"))
|
||||
ap.add_argument("--password", default=os.environ.get("PGPASSWORD", "stalwart"))
|
||||
ap.add_argument("--dbname", default=os.environ.get("PGDATABASE", "stalwart"))
|
||||
ap.add_argument("--table", default="k",
|
||||
help="SUBSPACE_BLOB_LINK table name (default: k)")
|
||||
ap.add_argument("--prefix", default="",
|
||||
help="S3 key_prefix configured on the blob store (default: none)")
|
||||
ap.add_argument("--now", type=int, default=None,
|
||||
help="override unix-seconds used to expire temporary links")
|
||||
ap.add_argument("--include-expired-temporary", action="store_true",
|
||||
help="treat expired temporary links as active too")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
conn = psycopg2.connect(host=args.host, port=args.port, user=args.user,
|
||||
password=args.password, dbname=args.dbname)
|
||||
except ImportError:
|
||||
import psycopg
|
||||
conn = psycopg.connect(host=args.host, port=args.port, user=args.user,
|
||||
password=args.password, dbname=args.dbname)
|
||||
|
||||
now = args.now if args.now is not None else int(time.time())
|
||||
|
||||
with conn, conn.cursor() as cur:
|
||||
cur.execute(f'SELECT k FROM "{args.table}"')
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
active = collect_active_hashes(rows, now,
|
||||
include_expired_temporary=args.include_expired_temporary)
|
||||
|
||||
for h in sorted(active):
|
||||
print(s3_key(h, args.prefix))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Minify a self-contained HTML file (with inlined <style> and <script>).
|
||||
#
|
||||
# Uses html-minifier-terser via `npx`, which runs it from npx's cache without
|
||||
# touching the repo's package.json / node_modules. The first invocation will
|
||||
# download the package; subsequent runs are instant.
|
||||
#
|
||||
# Usage:
|
||||
# resources/scripts/minify_html.sh [--gzip] path/to/file.html
|
||||
#
|
||||
# Writes `path/to/file.ext.min` next to the source, and with --gzip also
|
||||
# `path/to/file.ext.min.gz` for pages that are served pre-compressed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
gzip_output=0
|
||||
if [[ "${1:-}" == "--gzip" ]]; then
|
||||
gzip_output=1
|
||||
shift
|
||||
fi
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "usage: $(basename "$0") [--gzip] <file.html>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
src="$1"
|
||||
|
||||
if [[ ! -f "$src" ]]; then
|
||||
echo "error: not a file: $src" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v npx >/dev/null 2>&1; then
|
||||
echo "error: npx not found in PATH (install Node.js)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# login.html -> login.min.html
|
||||
dir=$(dirname -- "$src")
|
||||
base=$(basename -- "$src")
|
||||
stem="${base%.*}"
|
||||
ext="${base##*.}"
|
||||
dst="$dir/$stem.$ext.min"
|
||||
|
||||
npx -y html-minifier-terser@latest \
|
||||
--collapse-whitespace \
|
||||
--conservative-collapse \
|
||||
--remove-comments \
|
||||
--minify-css true \
|
||||
--minify-js true \
|
||||
--decode-entities \
|
||||
-o "$dst" \
|
||||
"$src"
|
||||
|
||||
before=$(wc -c < "$src" | tr -d ' ')
|
||||
after=$(wc -c < "$dst" | tr -d ' ')
|
||||
saved=$((before - after))
|
||||
pct=$(awk "BEGIN { printf \"%.1f\", ($saved / $before) * 100 }")
|
||||
|
||||
echo "$src -> $dst"
|
||||
echo " $before B -> $after B ($saved B, $pct% smaller)"
|
||||
|
||||
if [ "$gzip_output" = "1" ]; then
|
||||
gzip -9 -n -c "$dst" > "$dst.gz"
|
||||
gz=$(wc -c < "$dst.gz" | tr -d ' ')
|
||||
gzpct=$(awk "BEGIN { printf \"%.1f\", (($before - $gz) / $before) * 100 }")
|
||||
echo " gzip: $dst.gz $gz B ($gzpct% smaller than source)"
|
||||
fi
|
||||
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stalwart SEL code remover
|
||||
|
||||
This script removes SEL code from the Stalwart codebase by:
|
||||
1. Removing entire .rs files that contain "SPDX-License-Identifier: LicenseRef-SEL" in their first comment
|
||||
2. Removing SEL snippets marked with SPDX-SnippetBegin/End from mixed files
|
||||
|
||||
Usage: python ossify.py <stalwart_repository>/crates
|
||||
"""
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
def find_first_comment_block(content: str) -> Optional[str]:
|
||||
|
||||
lines = content.strip().split('\n')
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
first_line = lines[0].strip()
|
||||
|
||||
if first_line.startswith('/*'):
|
||||
comment_lines = []
|
||||
in_comment = True
|
||||
|
||||
for line in lines:
|
||||
if in_comment:
|
||||
comment_lines.append(line)
|
||||
if '*/' in line:
|
||||
break
|
||||
|
||||
return '\n'.join(comment_lines)
|
||||
|
||||
elif first_line.startswith('//'):
|
||||
comment_lines = []
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('//'):
|
||||
comment_lines.append(line)
|
||||
elif stripped == '':
|
||||
comment_lines.append(line)
|
||||
else:
|
||||
break
|
||||
|
||||
return '\n'.join(comment_lines)
|
||||
|
||||
return None
|
||||
|
||||
def should_remove_file(file_path: str) -> bool:
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
first_comment = find_first_comment_block(content)
|
||||
if first_comment and 'SPDX-License-Identifier: LicenseRef-SEL' in first_comment:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading file {file_path}: {e}")
|
||||
|
||||
return False
|
||||
|
||||
CRATE_ROOT_STUB = """/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
"""
|
||||
|
||||
def is_crate_root(file_path: str) -> bool:
|
||||
|
||||
path = Path(file_path)
|
||||
|
||||
return (
|
||||
path.name == 'lib.rs'
|
||||
and path.parent.name == 'src'
|
||||
and (path.parent.parent / 'Cargo.toml').is_file()
|
||||
)
|
||||
|
||||
def remove_proprietary_snippets(content: str) -> Tuple[str, int]:
|
||||
|
||||
snippets_removed = 0
|
||||
|
||||
lines = content.split('\n')
|
||||
result_lines = []
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
if '// SPDX-SnippetBegin' in line:
|
||||
|
||||
snippet_start = i
|
||||
snippet_lines = []
|
||||
j = i
|
||||
|
||||
while j < len(lines):
|
||||
snippet_lines.append(lines[j])
|
||||
if '// SPDX-SnippetEnd' in lines[j]:
|
||||
break
|
||||
j += 1
|
||||
|
||||
snippet_content = '\n'.join(snippet_lines)
|
||||
if 'SPDX-License-Identifier: LicenseRef-SEL' in snippet_content:
|
||||
|
||||
snippets_removed += 1
|
||||
i = j + 1
|
||||
continue
|
||||
else:
|
||||
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
else:
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
|
||||
return '\n'.join(result_lines), snippets_removed
|
||||
|
||||
def process_rust_file(file_path: str, dry_run: bool = False) -> dict:
|
||||
|
||||
result = {
|
||||
'file': file_path,
|
||||
'action': 'none',
|
||||
'snippets_removed': 0,
|
||||
'error': None
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
if should_remove_file(file_path):
|
||||
if is_crate_root(file_path):
|
||||
result['action'] = 'file_emptied'
|
||||
if not dry_run:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(CRATE_ROOT_STUB)
|
||||
else:
|
||||
result['action'] = 'file_removed'
|
||||
if not dry_run:
|
||||
os.remove(file_path)
|
||||
return result
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
original_content = f.read()
|
||||
|
||||
modified_content, snippets_removed = remove_proprietary_snippets(original_content)
|
||||
|
||||
if snippets_removed > 0:
|
||||
result['action'] = 'snippets_removed'
|
||||
result['snippets_removed'] = snippets_removed
|
||||
|
||||
if not dry_run:
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.write(modified_content)
|
||||
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
def find_rust_files(directory: str) -> List[str]:
|
||||
|
||||
rust_files = []
|
||||
|
||||
for root, dirs, files in os.walk(directory):
|
||||
for file in files:
|
||||
if file.endswith('.rs'):
|
||||
rust_files.append(os.path.join(root, file))
|
||||
|
||||
return rust_files
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Remove Enterprise licensed code from Stalwart codebase'
|
||||
)
|
||||
parser.add_argument(
|
||||
'directory',
|
||||
help='Directory containing Stalwart code to process'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show what would be done without making changes'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
help='Show detailed output for each file'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isdir(args.directory):
|
||||
print(f"Error: {args.directory} is not a valid directory")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Processing Rust files in: {args.directory}")
|
||||
if args.dry_run:
|
||||
print("DRY RUN MODE - No changes will be made")
|
||||
print()
|
||||
|
||||
rust_files = find_rust_files(args.directory)
|
||||
|
||||
if not rust_files:
|
||||
print("No .rs files found in the specified directory")
|
||||
return
|
||||
|
||||
print(f"Found {len(rust_files)} Rust files")
|
||||
print()
|
||||
|
||||
files_removed = 0
|
||||
files_emptied = 0
|
||||
files_with_snippets_removed = 0
|
||||
total_snippets_removed = 0
|
||||
errors = []
|
||||
|
||||
for file_path in rust_files:
|
||||
result = process_rust_file(file_path, args.dry_run)
|
||||
|
||||
if result['error']:
|
||||
errors.append(f"{file_path}: {result['error']}")
|
||||
continue
|
||||
|
||||
if result['action'] == 'file_removed':
|
||||
files_removed += 1
|
||||
if args.verbose or args.dry_run:
|
||||
action_text = "Would remove" if args.dry_run else "Removed"
|
||||
print(f"{action_text} file: {file_path}")
|
||||
|
||||
elif result['action'] == 'file_emptied':
|
||||
files_emptied += 1
|
||||
if args.verbose or args.dry_run:
|
||||
action_text = "Would empty" if args.dry_run else "Emptied"
|
||||
print(f"{action_text} crate root: {file_path}")
|
||||
|
||||
elif result['action'] == 'snippets_removed':
|
||||
files_with_snippets_removed += 1
|
||||
total_snippets_removed += result['snippets_removed']
|
||||
if args.verbose or args.dry_run:
|
||||
action_text = "Would remove" if args.dry_run else "Removed"
|
||||
print(f"{action_text} {result['snippets_removed']} snippet(s) from: {file_path}")
|
||||
|
||||
print("\nSummary:")
|
||||
action_text = "Would be" if args.dry_run else "Were"
|
||||
print(f"- {files_removed} files {action_text.lower()} completely removed")
|
||||
print(f"- {files_emptied} crate roots {action_text.lower()} emptied")
|
||||
print(f"- {total_snippets_removed} proprietary snippets {action_text.lower()} removed from {files_with_snippets_removed} files")
|
||||
|
||||
if errors:
|
||||
print(f"- {len(errors)} errors occurred:")
|
||||
for error in errors:
|
||||
print(f" {error}")
|
||||
|
||||
if args.dry_run:
|
||||
print("\nRun without --dry-run to apply changes")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,413 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Repair orphaned value chunks in a Stalwart FoundationDB data store.
|
||||
|
||||
Values larger than 100000 bytes are split across a base key and one key per
|
||||
extra chunk, suffixed with a single byte: `key`, `key || 0x00`, `key || 0x01`,
|
||||
and so on. Releases up to and including v0.16.18 overwrote such a value without
|
||||
removing the chunk keys the previous, longer value had used. The leftovers are
|
||||
spliced onto later reads, which surfaces as:
|
||||
|
||||
ERROR Data corruption detected (store.data-corruption)
|
||||
details = 'Archive integrity compromised'
|
||||
|
||||
This script finds those leftovers and deletes them. It is read-only unless
|
||||
--commit is given.
|
||||
|
||||
Only subspaces whose keys are prefix-free are scanned, so that a key one byte
|
||||
longer than its predecessor is unambiguously a chunk and never a distinct
|
||||
record. This is a stricter condition than `is_chunked_subspace` in
|
||||
crates/store/src/backend/foundationdb/mod.rs, which answers "can chunking
|
||||
happen here" rather than "are keys here prefix-free". See CHUNKED_SUBSPACES
|
||||
below for what is excluded and why. Keys are additionally checked against the
|
||||
shapes a real record can have in their subspace, so a legal record is never
|
||||
mistaken for a chunk.
|
||||
|
||||
Two cases are handled:
|
||||
|
||||
* The base value is under 100000 bytes, so the record is no longer chunked
|
||||
and every chunk key that follows it is stale. Unambiguous.
|
||||
|
||||
* The base value is exactly 100000 bytes, so the record is still chunked and
|
||||
the live chunk count has to be worked out. Two independent methods must
|
||||
agree before anything is deleted: chunk sizes, since every chunk of a live
|
||||
value except the last is exactly 100000 bytes, and the trailing xxh3
|
||||
checksum that Stalwart archives carry. Records where they disagree, or
|
||||
where neither applies, are left untouched and reported.
|
||||
|
||||
Each repair re-reads the record and deletes inside a single transaction, so a
|
||||
record rewritten between the scan and the delete is judged on what is actually
|
||||
stored rather than on what the scan saw.
|
||||
|
||||
Requirements:
|
||||
pip install foundationdb xxhash
|
||||
|
||||
The `foundationdb` package version must match the cluster's API version.
|
||||
|
||||
Usage:
|
||||
python repair_fdb_chunks.py --cluster-file /etc/foundationdb/fdb.cluster
|
||||
python repair_fdb_chunks.py --cluster-file /etc/foundationdb/fdb.cluster --commit
|
||||
|
||||
Stop Stalwart before running with --commit.
|
||||
"""
|
||||
|
||||
# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
try:
|
||||
import fdb
|
||||
except ImportError:
|
||||
sys.exit("Missing dependency: pip install foundationdb")
|
||||
|
||||
try:
|
||||
import xxhash
|
||||
except ImportError:
|
||||
sys.exit("Missing dependency: pip install xxhash")
|
||||
|
||||
MAX_VALUE_SIZE = 100000
|
||||
|
||||
MAGIC_MARKER = 1 << 7
|
||||
VERSIONED = 1 << 6
|
||||
HASHED = 1 << 5
|
||||
|
||||
U32_LEN = 4
|
||||
U64_LEN = 8
|
||||
|
||||
CHUNKED_SUBSPACES = {
|
||||
b"p": "property",
|
||||
b"e": "queue message",
|
||||
b"f": "task queue",
|
||||
b"d": "directory",
|
||||
b"s": "registry",
|
||||
b"j": "deleted items",
|
||||
b"w": "spam samples",
|
||||
b"r": "inbound reports",
|
||||
b"h": "outbound reports",
|
||||
b"o": "telemetry spans",
|
||||
}
|
||||
|
||||
BASE_KEY_LENGTHS = {
|
||||
b"e": {9},
|
||||
b"f": {17},
|
||||
b"o": {9, 11},
|
||||
b"d": {11},
|
||||
b"s": {11},
|
||||
b"j": {11},
|
||||
b"w": {11},
|
||||
b"r": {11},
|
||||
b"h": {11},
|
||||
}
|
||||
|
||||
# EmailField::Threading, the only property indexed by hash
|
||||
THREADING_FIELD = 90
|
||||
|
||||
|
||||
def is_record_key(subspace, key):
|
||||
lengths = BASE_KEY_LENGTHS.get(subspace)
|
||||
if lengths is not None:
|
||||
return len(key) in lengths
|
||||
if subspace == b"p":
|
||||
# Property (11), IndexProperty::Integer (19), the 2-byte schema version written as
|
||||
# ValueClass::Any, and IndexProperty::Hash whose CheekyHash is 1 to 16 bytes
|
||||
if len(key) in (2, 11, 19):
|
||||
return True
|
||||
return 12 <= len(key) <= 27 and key[6] == THREADING_FIELD
|
||||
return False
|
||||
|
||||
READ_BATCH = 32
|
||||
|
||||
|
||||
def is_valid_archive(value):
|
||||
"""Mirror of validate_marker_and_contents in crates/store/src/write/serialize.rs."""
|
||||
if not value:
|
||||
return False
|
||||
|
||||
marker = value[-1]
|
||||
if marker & MAGIC_MARKER == 0:
|
||||
return False
|
||||
|
||||
contents = value[:-1]
|
||||
|
||||
if marker & VERSIONED != 0:
|
||||
if len(contents) < U64_LEN + U32_LEN:
|
||||
return False
|
||||
contents = contents[:-U64_LEN]
|
||||
elif marker & HASHED != 0:
|
||||
if len(contents) < U32_LEN:
|
||||
return False
|
||||
else:
|
||||
# Unversioned archives carry no checksum, so length cannot be verified
|
||||
return False
|
||||
|
||||
contents, archive_hash = contents[:-U32_LEN], contents[-U32_LEN:]
|
||||
hash32 = xxhash.xxh3_64_intdigest(contents) & 0xFFFFFFFF
|
||||
return hash32.to_bytes(U32_LEN, "big") == archive_hash
|
||||
|
||||
|
||||
def count_by_chunk_size(chunk_values):
|
||||
"""Live chunk count implied by chunk sizes, or None if it cannot be read off them.
|
||||
|
||||
`chunk_value` splits with value.chunks(MAX_VALUE_SIZE), so every chunk of the live value
|
||||
except its last is exactly MAX_VALUE_SIZE bytes. The first short chunk therefore ends the
|
||||
live value. This never undercounts: if the live value happens to be an exact multiple of
|
||||
MAX_VALUE_SIZE the first short chunk belongs to the stale tail, which yields a count that
|
||||
is too high and so only leaves orphans behind. It works for every value format.
|
||||
"""
|
||||
for index, chunk in enumerate(chunk_values):
|
||||
if len(chunk) < MAX_VALUE_SIZE:
|
||||
return index + 1
|
||||
return None
|
||||
|
||||
|
||||
def count_by_archive(base_value, chunk_values):
|
||||
"""Live chunk count implied by the archive checksum, or None if undecidable.
|
||||
|
||||
Refuses when more than one prefix validates rather than taking the shortest, so a value
|
||||
that embeds a complete inner archive cannot cause the live tail to be truncated.
|
||||
"""
|
||||
matches = []
|
||||
if is_valid_archive(base_value):
|
||||
matches.append(0)
|
||||
|
||||
accumulated = bytearray(base_value)
|
||||
for count, chunk in enumerate(chunk_values, start=1):
|
||||
accumulated.extend(chunk)
|
||||
if is_valid_archive(bytes(accumulated)):
|
||||
matches.append(count)
|
||||
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def live_chunk_count(base_value, chunk_values):
|
||||
"""Number of chunk keys that belong to the current value, or None if undecidable.
|
||||
|
||||
Both independent methods must agree. The size rule never undercounts and the checksum
|
||||
rule is format specific, so requiring agreement means a disagreement leaves the record
|
||||
untouched instead of guessing at an operator's data.
|
||||
"""
|
||||
if len(base_value) < MAX_VALUE_SIZE:
|
||||
return 0
|
||||
|
||||
by_size = count_by_chunk_size(chunk_values)
|
||||
by_archive = count_by_archive(base_value, chunk_values)
|
||||
|
||||
if by_size is not None and by_archive is not None:
|
||||
return by_size if by_size == by_archive else None
|
||||
|
||||
# Non-archive formats (pickled tasks and traces, raw thread indexes, untrusted archives)
|
||||
# carry no checksum, so the size rule is the only available signal
|
||||
return by_size if by_archive is None else None
|
||||
|
||||
|
||||
TRANSACTION_TOO_OLD = 1007
|
||||
TRANSACTION_TIMED_OUT = 1031
|
||||
|
||||
|
||||
def read_range(db, begin, end, limit):
|
||||
"""Read one batch starting at `begin`, returning the rows and the batch size that worked.
|
||||
|
||||
A chunked record holds 100000 bytes per key, so a batch can carry several megabytes and
|
||||
outlive FoundationDB's five second transaction window. Every attempt starts a fresh
|
||||
transaction from `begin`, so a retry resumes exactly where the failed one started, and the
|
||||
batch is halved on each timeout until it fits.
|
||||
"""
|
||||
while True:
|
||||
tr = db.create_transaction()
|
||||
try:
|
||||
rows = list(
|
||||
tr.get_range(begin, end, limit=limit, streaming_mode=fdb.StreamingMode.want_all)
|
||||
)
|
||||
return rows, limit
|
||||
except fdb.FDBError as err:
|
||||
if err.code in (TRANSACTION_TOO_OLD, TRANSACTION_TIMED_OUT) and limit > 1:
|
||||
limit = max(1, limit // 2)
|
||||
print(f" transaction exceeded its time budget, retrying with batch of {limit}")
|
||||
continue
|
||||
tr.on_error(err).wait()
|
||||
|
||||
|
||||
def repair_record(db, subspace, base_key):
|
||||
"""Re-read one record and delete its orphans in a single transaction.
|
||||
|
||||
The scan reads the store in many separate transactions, so a record's base value and its
|
||||
chunks are not a consistent snapshot. Recomputing inside the transaction that performs
|
||||
the delete removes that race: if the record was rewritten in the meantime the deletion is
|
||||
based on what is actually there, not on what the scan saw.
|
||||
"""
|
||||
while True:
|
||||
tr = db.create_transaction()
|
||||
try:
|
||||
rows = [
|
||||
(bytes(kv.key), bytes(kv.value))
|
||||
for kv in tr.get_range(
|
||||
base_key,
|
||||
base_key + bytes([0xFF]),
|
||||
streaming_mode=fdb.StreamingMode.want_all,
|
||||
)
|
||||
]
|
||||
if not rows or rows[0][0] != base_key:
|
||||
return 0
|
||||
|
||||
base_value = rows[0][1]
|
||||
chunks = [
|
||||
(key, value)
|
||||
for key, value in rows[1:]
|
||||
if len(key) == len(base_key) + 1
|
||||
and key.startswith(base_key)
|
||||
and not is_record_key(subspace, key)
|
||||
]
|
||||
|
||||
count = live_chunk_count(base_value, [value for _, value in chunks])
|
||||
if count is None:
|
||||
return 0
|
||||
|
||||
orphans = [key for key, _ in chunks[count:]]
|
||||
for key in orphans:
|
||||
tr.clear(key)
|
||||
tr.commit().wait()
|
||||
return len(orphans)
|
||||
except fdb.FDBError as err:
|
||||
tr.on_error(err).wait()
|
||||
|
||||
|
||||
class Stats:
|
||||
def __init__(self):
|
||||
self.records = 0
|
||||
self.orphans = 0
|
||||
self.deleted = 0
|
||||
self.undecidable = 0
|
||||
|
||||
|
||||
def scan_subspace(db, subspace, label, commit, verbose, stats):
|
||||
begin = subspace
|
||||
end = bytes([subspace[0] + 1])
|
||||
|
||||
base_key = None
|
||||
base_value = None
|
||||
chunk_keys = []
|
||||
chunk_values = []
|
||||
cursor = begin
|
||||
|
||||
def flush_record():
|
||||
if base_key is None:
|
||||
return
|
||||
stats.records += 1
|
||||
if not chunk_keys:
|
||||
return
|
||||
|
||||
count = live_chunk_count(base_value, chunk_values)
|
||||
if count is None:
|
||||
stats.undecidable += 1
|
||||
print(
|
||||
f" ! {label}: cannot determine chunk count for {base_key.hex()} "
|
||||
f"({len(chunk_keys)} chunk keys, {len(base_value)} byte base), left untouched"
|
||||
)
|
||||
return
|
||||
|
||||
orphans = chunk_keys[count:]
|
||||
if not orphans:
|
||||
return
|
||||
|
||||
stats.orphans += len(orphans)
|
||||
if verbose:
|
||||
print(
|
||||
f" - {label}: {base_key.hex()} has {len(orphans)} orphaned "
|
||||
f"chunk(s), keeping {count}"
|
||||
)
|
||||
if commit:
|
||||
stats.deleted += repair_record(db, subspace, base_key)
|
||||
|
||||
batch = READ_BATCH
|
||||
while True:
|
||||
rows, batch = read_range(db, cursor, end, batch)
|
||||
if not rows:
|
||||
break
|
||||
|
||||
for kv in rows:
|
||||
key = bytes(kv.key)
|
||||
value = bytes(kv.value)
|
||||
|
||||
if (
|
||||
base_key is not None
|
||||
and len(key) == len(base_key) + 1
|
||||
and key.startswith(base_key)
|
||||
and not is_record_key(subspace, key)
|
||||
):
|
||||
chunk_keys.append(key)
|
||||
chunk_values.append(value)
|
||||
continue
|
||||
|
||||
flush_record()
|
||||
base_key = key
|
||||
base_value = value
|
||||
chunk_keys = []
|
||||
chunk_values = []
|
||||
|
||||
cursor = bytes(rows[-1].key) + b"\x00"
|
||||
|
||||
flush_record()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Repair orphaned value chunks in a Stalwart FoundationDB store"
|
||||
)
|
||||
parser.add_argument("--cluster-file", help="Path to fdb.cluster")
|
||||
parser.add_argument(
|
||||
"--api-version", type=int, default=740, help="FoundationDB API version (default: 740)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Delete the orphaned chunks. Without this the script only reports.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--subspace",
|
||||
action="append",
|
||||
help="Limit the scan to this subspace letter. May be repeated.",
|
||||
)
|
||||
parser.add_argument("--verbose", action="store_true", help="Print every affected record")
|
||||
args = parser.parse_args()
|
||||
|
||||
fdb.api_version(args.api_version)
|
||||
db = fdb.open(args.cluster_file)
|
||||
|
||||
selected = CHUNKED_SUBSPACES
|
||||
if args.subspace:
|
||||
wanted = {s.encode() for s in args.subspace}
|
||||
unknown = wanted - set(CHUNKED_SUBSPACES)
|
||||
if unknown:
|
||||
sys.exit(
|
||||
"Not a chunked subspace: "
|
||||
+ ", ".join(sorted(u.decode() for u in unknown))
|
||||
+ "\nValid: "
|
||||
+ ", ".join(sorted(s.decode() for s in CHUNKED_SUBSPACES))
|
||||
)
|
||||
selected = {s: CHUNKED_SUBSPACES[s] for s in wanted}
|
||||
|
||||
if not args.commit:
|
||||
print("DRY RUN. No key will be deleted. Pass --commit to apply the repair.\n")
|
||||
else:
|
||||
print("COMMIT MODE. Orphaned chunks will be deleted. Stalwart must be stopped.\n")
|
||||
|
||||
stats = Stats()
|
||||
for subspace, label in sorted(selected.items()):
|
||||
print(f"Scanning subspace {subspace.decode()!r} ({label})...")
|
||||
scan_subspace(db, subspace, label, args.commit, args.verbose, stats)
|
||||
|
||||
print(f"\nRecords scanned: {stats.records}")
|
||||
print(f"Orphaned chunks: {stats.orphans}")
|
||||
if stats.undecidable:
|
||||
print(f"Undecidable: {stats.undecidable} (reported above, not modified)")
|
||||
if args.commit:
|
||||
print(f"Chunks deleted: {stats.deleted}")
|
||||
elif stats.orphans:
|
||||
print("\nRe-run with --commit to delete them.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=Stalwart Server
|
||||
Conflicts=postfix.service sendmail.service exim4.service
|
||||
ConditionPathExists=__PATH__/etc/config.json
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
LimitNOFILE=65536
|
||||
KillMode=process
|
||||
KillSignal=SIGINT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
ExecStart=__PATH__/bin/stalwart --config=__PATH__/etc/config.json
|
||||
SyslogIdentifier=stalwart
|
||||
User=stalwart
|
||||
Group=stalwart
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>stalwart.mail</string>
|
||||
<key>ServiceDescription</key>
|
||||
<string>Stalwart</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>__PATH__/bin/stalwart</string>
|
||||
<string>--config=__PATH__/etc/config.json</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user