feat: security.txt (#38530)
* feat: `security.txt` * fix(security-settings): public_policy must start be https * feat(security-settings): preview `security.txt` * refactor(security-settings): security_txt logic * feat(security-settings): security_txt expires * refactor(security-txt): get content from security settings * fix(security-txt): serve only over https * fix(security-settings): change labels (plural) - contacts - languages * refactor(security-settings): move to website module * feat(security-settings): banner/alert on security.txt with link to RFC * feat(security-txt): expiry alert emails * fix(security-settings): banner gets duplicated on save * refactor(security-settings): move to `Core` module * test(security-settings): add unit tests * fix(security-settings): translatable strings on throw
This commit is contained in:
parent
4e52cbfb95
commit
ec9a60172f
15 changed files with 678 additions and 0 deletions
|
|
@ -126,6 +126,12 @@ def application(request: Request):
|
|||
elif request.path.startswith("/private/files/"):
|
||||
response = frappe.utils.response.download_private_file(request.path)
|
||||
|
||||
elif request.path == "/.well-known/security.txt" and request.method == "GET":
|
||||
if request.scheme != "https":
|
||||
raise NotFound
|
||||
security_settings = frappe.get_doc("Security Settings")
|
||||
response = Response(security_settings.security_txt, content_type="text/plain")
|
||||
|
||||
elif request.path.startswith("/.well-known/") and request.method == "GET":
|
||||
response = handle_wellknown(request.path)
|
||||
|
||||
|
|
|
|||
0
frappe/core/doctype/security_settings/__init__.py
Normal file
0
frappe/core/doctype/security_settings/__init__.py
Normal file
20
frappe/core/doctype/security_settings/security_settings.js
Normal file
20
frappe/core/doctype/security_settings/security_settings.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Copyright (c) 2026, Frappe Technologies and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Security Settings", {
|
||||
refresh(frm) {
|
||||
const wrapper = frm.fields_dict.securitytxt_section.wrapper;
|
||||
if ($(wrapper).find(".security-txt-banner").length) return;
|
||||
|
||||
$(wrapper)
|
||||
.find(".section-body")
|
||||
.prepend(
|
||||
`<div class="alert alert-warning border d-flex justify-content-between align-items-center security-txt-banner" style="flex: 0 0 100%; max-width: 100%; border-color: var(--border-color);">
|
||||
<span>${__("Security.txt will be served only under HTTPS.")}</span>
|
||||
<a href="https://tools.ietf.org/html/rfc9116#section-6.7" target="_blank" class="btn btn-xs btn-secondary">${__(
|
||||
"Learn more"
|
||||
)}</a>
|
||||
</div>`
|
||||
);
|
||||
},
|
||||
});
|
||||
81
frappe/core/doctype/security_settings/security_settings.json
Normal file
81
frappe/core/doctype/security_settings/security_settings.json
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"actions": [],
|
||||
"creation": "2026-04-10 16:14:40.343135",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"securitytxt_section",
|
||||
"public_expires",
|
||||
"public_contacts",
|
||||
"public_languages",
|
||||
"public_policy",
|
||||
"security_txt"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "securitytxt_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Security.txt"
|
||||
},
|
||||
{
|
||||
"description": "Date after which this security.txt should be considered stale.",
|
||||
"fieldname": "public_expires",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Expires"
|
||||
},
|
||||
{
|
||||
"description": "Website, email or phone where vulnerabilities can be reported. Defaults to `https://security.frappe.io`",
|
||||
"fieldname": "public_contacts",
|
||||
"fieldtype": "Table",
|
||||
"label": "Contact",
|
||||
"options": "Security Settings Contact"
|
||||
},
|
||||
{
|
||||
"description": "Defaults to `en`",
|
||||
"fieldname": "public_languages",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Preferred Language",
|
||||
"options": "Security Settings Language"
|
||||
},
|
||||
{
|
||||
"description": "Guidelines and policies on vulnerability reporting. Defaults to `https://frappe.io/security`",
|
||||
"fieldname": "public_policy",
|
||||
"fieldtype": "Data",
|
||||
"label": "Policy",
|
||||
"options": "URL"
|
||||
},
|
||||
{
|
||||
"fieldname": "security_txt",
|
||||
"fieldtype": "Small Text",
|
||||
"is_virtual": 1,
|
||||
"label": "Preview",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 12:50:57.138749",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Security Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
111
frappe/core/doctype/security_settings/security_settings.py
Normal file
111
frappe/core/doctype/security_settings/security_settings.py
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import validate_email_address, validate_phone_number, validate_url
|
||||
|
||||
|
||||
class SecuritySettings(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.core.doctype.security_settings_contact.security_settings_contact import (
|
||||
SecuritySettingsContact,
|
||||
)
|
||||
from frappe.core.doctype.security_settings_language.security_settings_language import (
|
||||
SecuritySettingsLanguage,
|
||||
)
|
||||
from frappe.types import DF
|
||||
|
||||
public_contacts: DF.Table[SecuritySettingsContact]
|
||||
public_expires: DF.Datetime | None
|
||||
public_languages: DF.TableMultiSelect[SecuritySettingsLanguage]
|
||||
public_policy: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
@property
|
||||
def security_txt(self):
|
||||
return "\n\n".join(
|
||||
[
|
||||
self.public_policy_section,
|
||||
self.public_contacts_section,
|
||||
self.public_languages_section,
|
||||
self.public_expires_section,
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def public_policy_section(self):
|
||||
value = self.public_policy or "https://frappe.io/security"
|
||||
return f"# Read our security policy before reporting an issue\nPolicy: {value}"
|
||||
|
||||
@property
|
||||
def public_contacts_section(self):
|
||||
contacts = [self.with_protocol(c.contact, c.type) for c in self.public_contacts] or [
|
||||
"https://security.frappe.io"
|
||||
]
|
||||
value = "\n".join(f"Contact: {c}" for c in contacts)
|
||||
return f"# Our security address\n{value}"
|
||||
|
||||
@property
|
||||
def public_languages_section(self):
|
||||
langs = [l.language for l in self.public_languages] or ["en"]
|
||||
value = ", ".join(langs)
|
||||
return f"# We prefer talking in\nPreferred-Languages: {value}"
|
||||
|
||||
@property
|
||||
def public_expires_section(self):
|
||||
expires = self.public_expires or frappe.utils.add_years(frappe.utils.now_datetime(), 1)
|
||||
expires = (isinstance(expires, str) and datetime.fromisoformat(expires)) or expires
|
||||
expires = expires.replace(microsecond=0)
|
||||
value = expires.isoformat()
|
||||
return f"Expires: {value}"
|
||||
|
||||
def with_protocol(self, url: str, type_: str) -> str:
|
||||
"""Prefix the URL with the appropriate protocol based on the contact type."""
|
||||
match type_:
|
||||
case "Email":
|
||||
if not url.startswith("mailto:"):
|
||||
return f"mailto:{url}"
|
||||
case "Phone":
|
||||
if not url.startswith("tel:"):
|
||||
return f"tel:{url}"
|
||||
return url
|
||||
|
||||
def validate(self):
|
||||
self.validate_public_policy()
|
||||
self.validate_public_contacts()
|
||||
self.validate_expires()
|
||||
|
||||
def validate_public_policy(self):
|
||||
if self.public_policy:
|
||||
if not self.public_policy.startswith("https://"):
|
||||
frappe.throw(_("Public Policy URL must start with https://"))
|
||||
|
||||
def validate_public_contacts(self):
|
||||
for contact in self.public_contacts:
|
||||
match contact.type:
|
||||
case "Email":
|
||||
validate_email_address(contact.contact, throw=True)
|
||||
case "Phone":
|
||||
validate_phone_number(contact.contact, throw=True)
|
||||
case "Website":
|
||||
validate_url(contact.contact, throw=True)
|
||||
if not contact.contact.startswith("https://"):
|
||||
frappe.throw(_("URL contact must start with https://"))
|
||||
|
||||
def validate_expires(self):
|
||||
if self.public_expires:
|
||||
expires = self.public_expires
|
||||
if isinstance(expires, str):
|
||||
expires = datetime.fromisoformat(expires)
|
||||
if expires <= datetime.now():
|
||||
frappe.throw(_("Expiration date must be in the future"))
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import get_datetime, now_datetime
|
||||
from frappe.utils.user import get_users_with_role
|
||||
|
||||
|
||||
def check_security_txt_expiry():
|
||||
security_settings = frappe.get_doc("Security Settings")
|
||||
if not security_settings.public_expires:
|
||||
return
|
||||
expires = security_settings.public_expires
|
||||
if isinstance(expires, str):
|
||||
expires = get_datetime(expires)
|
||||
now = now_datetime()
|
||||
days_until_expiry = (expires - now).days
|
||||
alert_days = [30, 15, 7, 1]
|
||||
if days_until_expiry in alert_days:
|
||||
send_expiry_alert(frappe.local.site, expires, days_until_expiry)
|
||||
|
||||
|
||||
def send_expiry_alert(site: str, expires, days_until_expiry: int):
|
||||
recipients = get_users_with_role("System Manager")
|
||||
if not recipients:
|
||||
return
|
||||
subject = get_email_subject(site, days_until_expiry)
|
||||
frappe.sendmail(
|
||||
recipients=recipients,
|
||||
subject=subject,
|
||||
template="security_txt_expiry_alert",
|
||||
args={
|
||||
"site": site,
|
||||
"expires": expires,
|
||||
"days_remaining": days_until_expiry,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_email_subject(site: str, days_until_expiry: int) -> str:
|
||||
if days_until_expiry == 1:
|
||||
return f"[URGENT] Security.txt expires in 1 day - {site}"
|
||||
return f"Security.txt expires in {days_until_expiry} days - {site}"
|
||||
268
frappe/core/doctype/security_settings/test_security_settings.py
Normal file
268
frappe/core/doctype/security_settings/test_security_settings.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
# Copyright (c) 2026, Frappe Technologies and Contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.tests import UnitTestCase
|
||||
|
||||
|
||||
class TestSecuritySettings(UnitTestCase):
|
||||
def test_public_policy_section_default(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_policy": None,
|
||||
}
|
||||
)
|
||||
section = doc.public_policy_section
|
||||
self.assertIn("Policy: https://frappe.io/security", section)
|
||||
|
||||
def test_public_policy_section_custom(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_policy": "https://example.com/security-policy",
|
||||
}
|
||||
)
|
||||
section = doc.public_policy_section
|
||||
self.assertIn("Policy: https://example.com/security-policy", section)
|
||||
|
||||
def test_public_languages_section_default(self):
|
||||
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||
section = doc.public_languages_section
|
||||
self.assertIn("Preferred-Languages: en", section)
|
||||
|
||||
def test_public_languages_section_custom(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_languages": [
|
||||
{"language": "en"},
|
||||
{"language": "fr"},
|
||||
],
|
||||
}
|
||||
)
|
||||
section = doc.public_languages_section
|
||||
self.assertIn("Preferred-Languages: en, fr", section)
|
||||
|
||||
def test_public_contacts_section_default(self):
|
||||
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||
section = doc.public_contacts_section
|
||||
self.assertIn("https://security.frappe.io", section)
|
||||
|
||||
def test_public_contacts_section_email(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Email", "contact": "security@example.com"},
|
||||
],
|
||||
}
|
||||
)
|
||||
section = doc.public_contacts_section
|
||||
self.assertIn("mailto:security@example.com", section)
|
||||
|
||||
def test_public_contacts_section_phone(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Phone", "contact": "+1234567890"},
|
||||
],
|
||||
}
|
||||
)
|
||||
section = doc.public_contacts_section
|
||||
self.assertIn("tel:+1234567890", section)
|
||||
|
||||
def test_public_contacts_section_website(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Website", "contact": "https://security.example.com"},
|
||||
],
|
||||
}
|
||||
)
|
||||
section = doc.public_contacts_section
|
||||
self.assertIn("https://security.example.com", section)
|
||||
|
||||
def test_with_protocol_email_without_protocol(self):
|
||||
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||
result = doc.with_protocol("security@example.com", "Email")
|
||||
self.assertEqual(result, "mailto:security@example.com")
|
||||
|
||||
def test_with_protocol_email_with_protocol(self):
|
||||
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||
result = doc.with_protocol("mailto:security@example.com", "Email")
|
||||
self.assertEqual(result, "mailto:security@example.com")
|
||||
|
||||
def test_with_protocol_phone_without_protocol(self):
|
||||
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||
result = doc.with_protocol("+1234567890", "Phone")
|
||||
self.assertEqual(result, "tel:+1234567890")
|
||||
|
||||
def test_with_protocol_phone_with_protocol(self):
|
||||
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||
result = doc.with_protocol("tel:+1234567890", "Phone")
|
||||
self.assertEqual(result, "tel:+1234567890")
|
||||
|
||||
def test_with_protocol_website(self):
|
||||
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||
result = doc.with_protocol("https://example.com", "Website")
|
||||
self.assertEqual(result, "https://example.com")
|
||||
|
||||
def test_security_txt_full(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_policy": "https://example.com/policy",
|
||||
"public_contacts": [
|
||||
{"type": "Email", "contact": "security@example.com"},
|
||||
],
|
||||
"public_languages": [
|
||||
{"language": "en"},
|
||||
],
|
||||
"public_expires": datetime.now() + timedelta(days=365),
|
||||
}
|
||||
)
|
||||
security_txt = doc.security_txt
|
||||
self.assertIn("Policy: https://example.com/policy", security_txt)
|
||||
self.assertIn("mailto:security@example.com", security_txt)
|
||||
self.assertIn("Preferred-Languages: en", security_txt)
|
||||
self.assertIn("Expires:", security_txt)
|
||||
|
||||
def test_validate_public_policy_with_http(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_policy": "http://example.com",
|
||||
}
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_public_policy)
|
||||
|
||||
def test_validate_public_policy_with_https(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_policy": "https://example.com",
|
||||
}
|
||||
)
|
||||
# Should not raise
|
||||
doc.validate_public_policy()
|
||||
|
||||
def test_validate_public_contacts_invalid_email(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Email", "contact": "invalid-email"},
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
|
||||
|
||||
def test_validate_public_contacts_valid_email(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Email", "contact": "security@example.com"},
|
||||
],
|
||||
}
|
||||
)
|
||||
# Should not raise
|
||||
doc.validate_public_contacts()
|
||||
|
||||
def test_validate_public_contacts_invalid_phone(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Phone", "contact": "not-a-phone"},
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
|
||||
|
||||
def test_validate_public_contacts_valid_phone(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Phone", "contact": "+1234567890"},
|
||||
],
|
||||
}
|
||||
)
|
||||
# Should not raise
|
||||
doc.validate_public_contacts()
|
||||
|
||||
def test_validate_public_contacts_website_without_https(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Website", "contact": "http://example.com"},
|
||||
],
|
||||
}
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_public_contacts)
|
||||
|
||||
def test_validate_public_contacts_valid_website(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_contacts": [
|
||||
{"type": "Website", "contact": "https://example.com"},
|
||||
],
|
||||
}
|
||||
)
|
||||
# Should not raise
|
||||
doc.validate_public_contacts()
|
||||
|
||||
def test_validate_expires_past(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_expires": datetime.now() - timedelta(days=1),
|
||||
}
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_expires)
|
||||
|
||||
def test_validate_expires_future(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_expires": datetime.now() + timedelta(days=365),
|
||||
}
|
||||
)
|
||||
# Should not raise
|
||||
doc.validate_expires()
|
||||
|
||||
def test_public_expires_section_future_date(self):
|
||||
future_date = datetime(2027, 12, 31, 23, 59, 59)
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_expires": future_date,
|
||||
}
|
||||
)
|
||||
section = doc.public_expires_section
|
||||
self.assertIn("2027-12-31T23:59:59", section)
|
||||
|
||||
def test_public_expires_section_string(self):
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Security Settings",
|
||||
"public_expires": "2027-12-31T23:59:59",
|
||||
}
|
||||
)
|
||||
section = doc.public_expires_section
|
||||
self.assertIn("2027-12-31T23:59:59", section)
|
||||
|
||||
def test_public_expires_section_default(self):
|
||||
doc = frappe.get_doc({"doctype": "Security Settings"})
|
||||
section = doc.public_expires_section
|
||||
# Default is 1 year from now
|
||||
self.assertIn("Expires:", section)
|
||||
self.assertIn("T", section) # ISO format
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"actions": [],
|
||||
"creation": "2026-04-11 13:06:29.308243",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"type",
|
||||
"contact"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Type",
|
||||
"options": "Website\nEmail\nPhone",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "contact",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Contact",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 12:50:25.814560",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Security Settings Contact",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class SecuritySettingsContact(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
contact: DF.Data
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
type: DF.Literal["Website", "Email", "Phone"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"actions": [],
|
||||
"creation": "2026-04-11 12:53:09.006649",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"language"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "language",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Language",
|
||||
"options": "Language",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-14 12:50:44.554462",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Core",
|
||||
"name": "Security Settings Language",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Copyright (c) 2026, Frappe Technologies and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class SecuritySettingsLanguage(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
language: DF.Link
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
|
@ -232,6 +232,10 @@ scheduler_events = {
|
|||
"0 */3 * * *": [
|
||||
"frappe.search.sqlite_search.build_index_if_not_exists",
|
||||
],
|
||||
# Daily at 6:00 AM.
|
||||
"0 6 * * *": [
|
||||
"frappe.core.doctype.security_settings.security_settings_alert.check_security_txt_expiry",
|
||||
],
|
||||
},
|
||||
"all": [
|
||||
"frappe.email.queue.flush",
|
||||
|
|
|
|||
20
frappe/templates/emails/security_txt_expiry_alert.html
Normal file
20
frappe/templates/emails/security_txt_expiry_alert.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<h3>{{_("Security.txt will expire soon!")}}</h3>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||
<tr>
|
||||
<td><strong>{{_("Site")}}</strong></td>
|
||||
<td>{{ site }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>{{_("Expires")}}</strong></td>
|
||||
<td>{{ expires.strftime('%B %-d, %Y %-I:%M %p') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>{{_("Days Remaining")}}</strong></td>
|
||||
<td>{{ days_remaining }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>
|
||||
<em> {{_("Please update your security settings from desk.")}} </em>
|
||||
</p>
|
||||
Loading…
Add table
Reference in a new issue