diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index b9563979b5..e56121ce08 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -95,7 +95,7 @@ jobs: run: | pip install pip-audit cd ${GITHUB_WORKSPACE} - pip-audit --desc on --ignore-vuln PYSEC-2023-312 . + pip-audit --desc on --ignore-vuln PYSEC-2023-312 --ignore-vuln CVE-2026-4539 . precommit: name: 'Pre-Commit' diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index ab2ebb0f45..b35427d7f7 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -74,7 +74,7 @@ jobs: - name: Download artifacts uses: actions/download-artifact@v8.0.1 - name: Upload coverage data - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: name: Server token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 6aa8572243..c533f36b7e 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -59,7 +59,7 @@ jobs: - name: Download artifacts uses: actions/download-artifact@v8.0.1 - name: Upload python coverage data - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: name: UIBackend token: ${{ secrets.CODECOV_TOKEN }} @@ -68,7 +68,7 @@ jobs: exclude: coverage-js* flags: server-ui - name: Upload JS coverage data - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: name: Cypress token: ${{ secrets.CODECOV_TOKEN }} diff --git a/frappe/__init__.py b/frappe/__init__.py index 8996a7fbe3..2a9a508921 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1573,6 +1573,7 @@ from frappe.config import get_common_site_config, get_conf, get_site_config from frappe.core.doctype.system_settings.system_settings import get_system_settings from frappe.model.document import ( get_doc, + get_docs, get_lazy_doc, copy_doc, new_doc, diff --git a/frappe/api/v1.py b/frappe/api/v1.py index 523c0d0f54..75bb9a7f3b 100644 --- a/frappe/api/v1.py +++ b/frappe/api/v1.py @@ -97,7 +97,8 @@ def get_values_for_link_and_dynamic_link_fields(doc_dict): doctype = field.options if field.fieldtype == "Link" else doc_dict.get(field.options) - link_doc = frappe.get_doc(doctype, doc_fieldvalue) + link_doc = frappe.get_doc(doctype, doc_fieldvalue, check_permission="read") + link_doc.apply_fieldlevel_read_permissions() doc_dict.update({field.fieldname: link_doc}) diff --git a/frappe/app.py b/frappe/app.py index 6a07c9c223..7f4e4bcfac 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -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) diff --git a/frappe/auth.py b/frappe/auth.py index 4267c60f73..347f6b4e3a 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -155,7 +155,9 @@ class LoginManager: self.authenticate(user=user, pwd=pwd) if self.force_user_to_reset_password(): doc = frappe.get_doc("User", self.user) - frappe.local.response["redirect_to"] = doc.reset_password(send_email=False, password_expired=True) + frappe.local.response["redirect_to"] = doc._reset_password( + send_email=False, password_expired=True + ) frappe.local.response["message"] = "Password Reset" return False diff --git a/frappe/client.py b/frappe/client.py index 63bcd0a687..71c26448fa 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -114,7 +114,7 @@ def get( doc.check_permission() doc.apply_fieldlevel_read_permissions() - return doc.as_dict() + return doc.as_dict(no_nulls=True) @frappe.whitelist() diff --git a/frappe/commands/testing.py b/frappe/commands/testing.py index 78468b0fd3..394b4c29cf 100644 --- a/frappe/commands/testing.py +++ b/frappe/commands/testing.py @@ -159,11 +159,14 @@ def main( discover_all_tests(apps, runner) results = [] + global unittest_runner for app, category, suite in runner.iterRun(): click.secho( f"\nRunning {suite.countTestCases()} {category} tests for {app}", fg="cyan", bold=True ) - results.append([app, category, runner.run(suite)]) + main_runner = unittest_runner if junit_xml_output and unittest_runner else runner + res = main_runner.run(suite) + results.append([app, category, res]) success = all(r.wasSuccessful() for _, _, r in results) if not success: diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 60607fd11f..ca94bed751 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -108,6 +108,19 @@ def build( print("Compiling translations for", app) compile_translations(app, force=force) + run_after_build_hook(apps) + + +def run_after_build_hook(apps): + from importlib import import_module + + for app in apps: + for fn in frappe.get_hooks("after_build", app_name=app): + modulename = ".".join(fn.split(".")[:-1]) + methodname = fn.split(".")[-1] + method = getattr(import_module(modulename), methodname) + method() + @click.command("watch") @click.option("--apps", help="Watch assets for specific apps") diff --git a/frappe/core/api/user_invitation.py b/frappe/core/api/user_invitation.py index 5390caa154..1e685e6c28 100644 --- a/frappe/core/api/user_invitation.py +++ b/frappe/core/api/user_invitation.py @@ -143,7 +143,7 @@ def _accept_invitation(key: str, in_test: bool) -> None: # set redirect_to redirect_to = frappe.utils.get_url(invitation.get_redirect_to_path()) if should_update_password: - redirect_to = f"{user.reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}" + redirect_to = f"{user._reset_password()}&redirect_to=/{invitation.get_redirect_to_path()}" # GET requests do not cause an implicit commit frappe.db.commit() # nosemgrep diff --git a/frappe/core/doctype/communication/mixins.py b/frappe/core/doctype/communication/mixins.py index b2aaeb3979..318fa785ec 100644 --- a/frappe/core/doctype/communication/mixins.py +++ b/frappe/core/doctype/communication/mixins.py @@ -20,6 +20,22 @@ class CommunicationEmailMixin: parent_doc = get_parent_doc(self) return parent_doc.owner if parent_doc else None + def get_notification_recipient(self): + """Get notification recipient of the communication docs parent. + + Calls `get_notification_email` on the parent if available; otherwise returns the owner. + This uses `run_method` so hooks can customize recipients per app/site. + """ + parent_doc = get_parent_doc(self) + if not parent_doc: + return None + + notification_email = parent_doc.run_method("get_notification_email") + if notification_email: + return notification_email + + return parent_doc.owner + def get_all_email_addresses(self, exclude_displayname=False): """Get all Email addresses mentioned in the doc along with display name.""" return ( @@ -60,7 +76,7 @@ class CommunicationEmailMixin: """Build cc list to send an email. * if email copy is requested by sender, then add sender to CC. - * If this doc is created through inbound mail, then add doc owner to cc list + * If this doc is created through inbound mail, then add the notification recipient to CC * remove all the thread_notify disabled users. * Remove standard users from email list """ @@ -77,9 +93,9 @@ class CommunicationEmailMixin: cc.append(sender) if is_inbound_mail_communcation: - # inform parent document owner incase communication is created through inbound mail - if doc_owner := self.get_owner(): - cc.append(doc_owner) + # inform the configured notification recipient in case communication is created inbound + if notification_recipient := self.get_notification_recipient(): + cc.append(notification_recipient) cc = set(cc) - {self.sender_mailid} assignees = set(self.get_assignees()) - {self.sender_mailid} # Check and remove If user disabled notifications for incoming emails on assigned document. diff --git a/frappe/core/doctype/custom_docperm/custom_docperm.json b/frappe/core/doctype/custom_docperm/custom_docperm.json index 00a47a0113..2c8e5ee41e 100644 --- a/frappe/core/doctype/custom_docperm/custom_docperm.json +++ b/frappe/core/doctype/custom_docperm/custom_docperm.json @@ -227,7 +227,7 @@ ], "grid_page_length": 50, "links": [], - "modified": "2025-05-22 16:59:35.484376", + "modified": "2026-04-09 11:13:35.484376", "modified_by": "Administrator", "module": "Core", "name": "Custom DocPerm", @@ -239,6 +239,7 @@ "delete": 1, "email": 1, "export": 1, + "import": 1, "print": 1, "read": 1, "report": 1, diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 3ddc070fc5..c356da366f 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -13,6 +13,7 @@ from frappe.core.doctype.version.version import get_diff from frappe.model import no_value_fields from frappe.utils import cint, cstr, duration_to_seconds, flt, update_progress_bar from frappe.utils.csvutils import get_csv_content_from_google_sheets, read_csv_content +from frappe.utils.data import escape_html from frappe.utils.xlsxutils import ( read_xls_file_from_attached_file, read_xlsx_file_from_attached_file, @@ -727,7 +728,9 @@ class Row: elif df.fieldtype == "Link": exists = self.link_exists(value, df) if not exists: - msg = _("Value {0} missing for {1}").format(frappe.bold(value), frappe.bold(df.options)) + msg = _("Value {0} missing for {1}").format( + frappe.bold(escape_html(cstr(value))), frappe.bold(df.options) + ) self.warnings.append( { "row": self.row_number, @@ -746,7 +749,8 @@ class Row: "col": col.column_number, "field": df_as_json(df), "message": _("Value {0} must in {1} format").format( - frappe.bold(value), frappe.bold(get_user_format(col.date_format)) + frappe.bold(escape_html(cstr(value))), + frappe.bold(get_user_format(col.date_format)), ), } ) @@ -761,7 +765,8 @@ class Row: "col": col.column_number, "field": df_as_json(df), "message": _("Value {0} must in {1} format").format( - frappe.bold(value), frappe.bold(get_user_format(col.date_format)) + frappe.bold(escape_html(cstr(value))), + frappe.bold(get_user_format(col.date_format)), ), } ) @@ -774,7 +779,7 @@ class Row: "col": col.column_number, "field": df_as_json(df), "message": _("Value {0} must be in the valid duration format: d h m s").format( - frappe.bold(value) + frappe.bold(escape_html(cstr(value))) ), } ) @@ -1045,7 +1050,7 @@ class Column: ] not_exists = list(set(values) - set(exists)) if not_exists: - missing_values = ", ".join(not_exists) + missing_values = ", ".join(escape_html(v) for v in not_exists) message = _("The following values do not exist for {0}: {1}") self.warnings.append( { @@ -1088,7 +1093,7 @@ class Column: invalid = values - set(options) if invalid: valid_values = ", ".join(frappe.bold(o) for o in options) - invalid_values = ", ".join(frappe.bold(i) for i in invalid) + invalid_values = ", ".join(frappe.bold(escape_html(i)) for i in invalid) message = _("The following values are invalid: {0}. Values must be one of {1}") self.warnings.append( { diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index 2dd4bb48a2..ee9c9de937 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -48,7 +48,13 @@ frappe.ui.form.on("File", { const field = frm.get_field("attached_to_name"); field.$input_wrapper .find(".control-value") - .html(`${frappe.utils.get_form_link(frm.doctype, frm.docname, true)}`); + .html( + `${frappe.utils.get_form_link( + frm.doc.attached_to_doctype, + frm.doc.attached_to_name, + true + )}` + ); } }, diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 996180c768..b1fa044e95 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -115,6 +115,16 @@ class File(Document): if self.is_folder: return + if self.flags.copy_from_existing_file: + # Preserve the normal insert lifecycle for hooks and validations, but skip + # reprocessing an existing blob that is already referenced by `file_url`. + if not self.file_url: + frappe.throw( + _("File URL is required when copying an existing attachment."), + exc=frappe.MandatoryError, + ) + return + if self.is_remote_file: self.validate_remote_file() else: @@ -128,6 +138,29 @@ class File(Document): if not self.is_folder: self.create_attachment_record() + def create_attachment_copy( + self, + attached_to_doctype: str, + attached_to_name: str, + attached_to_field: str | None = None, + ignore_permissions: bool = False, + ): + """Efficiently copy an attachment from one document to another by reusing `file_url`.""" + if self.is_folder: + frappe.throw(_("Cannot attach a folder to a document")) + + attachment = frappe.copy_doc(self) + attachment.update( + { + "attached_to_doctype": attached_to_doctype, + "attached_to_name": attached_to_name, + "attached_to_field": attached_to_field, + } + ) + attachment.folder = None + attachment.flags.copy_from_existing_file = True + return attachment.insert(ignore_permissions=ignore_permissions) + def validate(self): if self.is_folder: return diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index fb14b30075..db0c0a4b42 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -254,6 +254,66 @@ class TestSameContent(IntegrationTestCase): limit_property.delete() frappe.clear_cache(doctype="ToDo") + def test_create_attachment_copy(self): + doctype, docname = make_test_doc() + source_file = frappe.get_doc( + { + "doctype": "File", + "file_name": f"existing-file-{frappe.generate_hash(length=8)}.txt", + "content": "Existing attachment content", + } + ).insert() + comment_count_before = frappe.db.count( + "Comment", {"reference_doctype": doctype, "reference_name": docname} + ) + + copied_file = source_file.create_attachment_copy(doctype, docname) + comment_count_after = frappe.db.count( + "Comment", {"reference_doctype": doctype, "reference_name": docname} + ) + + self.assertNotEqual(copied_file.name, source_file.name) + self.assertEqual(copied_file.file_url, source_file.file_url) + self.assertEqual(copied_file.attached_to_doctype, doctype) + self.assertEqual(copied_file.attached_to_name, docname) + self.assertEqual( + copied_file.folder, + frappe.db.get_value("File", {"is_attachments_folder": 1}), + ) + self.assertEqual(comment_count_after, comment_count_before + 1) + + def test_create_attachment_copy_respects_attachment_limit(self): + doctype, docname = make_test_doc() + from frappe.custom.doctype.property_setter.property_setter import make_property_setter + + limit_property = make_property_setter("ToDo", None, "max_attachments", 1, "int", for_doctype=True) + source_file_1 = frappe.get_doc( + { + "doctype": "File", + "file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt", + "content": "Existing attachment content 1", + } + ).insert() + source_file_2 = frappe.get_doc( + { + "doctype": "File", + "file_name": f"existing-limit-file-{frappe.generate_hash(length=8)}.txt", + "content": "Existing attachment content 2", + } + ).insert() + + try: + source_file_1.create_attachment_copy(doctype, docname) + self.assertRaises( + frappe.exceptions.AttachmentLimitReached, + source_file_2.create_attachment_copy, + doctype, + docname, + ) + finally: + limit_property.delete() + frappe.clear_cache(doctype="ToDo") + def test_utf8_bom_content_decoding(self): utf8_bom_content = test_content1.encode("utf-8-sig") _file: frappe.Document = frappe.get_doc( diff --git a/frappe/core/doctype/package_release/package_release.py b/frappe/core/doctype/package_release/package_release.py index b298a10d37..af2d83e820 100644 --- a/frappe/core/doctype/package_release/package_release.py +++ b/frappe/core/doctype/package_release/package_release.py @@ -93,8 +93,9 @@ class PackageRelease(Document): def export_package_files(self, package): # write readme - with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme: - readme.write(package.readme) + if package.readme: + with open(frappe.get_site_path("packages", package.package_name, "README.md"), "w") as readme: + readme.write(package.readme) # write license if package.license: diff --git a/frappe/core/doctype/report/report.js b/frappe/core/doctype/report/report.js index 4f1c0092a1..525eac411b 100644 --- a/frappe/core/doctype/report/report.js +++ b/frappe/core/doctype/report/report.js @@ -55,6 +55,17 @@ frappe.ui.form.on("Report", { }, }; }); + + frm.set_query("default_print_format", () => { + return { + filters: { + print_format_for: "Report", + report: frm.doc.name, + print_format_type: "JS", + disabled: 0, + }, + }; + }); }, ref_doctype: function (frm) { diff --git a/frappe/core/doctype/report/report.json b/frappe/core/doctype/report/report.json index 519184d57b..ad8a758982 100644 --- a/frappe/core/doctype/report/report.json +++ b/frappe/core/doctype/report/report.json @@ -14,6 +14,7 @@ "column_break_4", "report_type", "letter_head", + "default_print_format", "add_total_row", "disabled", "prepared_report", @@ -99,7 +100,7 @@ "depends_on": "eval: doc.is_standard == \"No\"", "fieldname": "letter_head", "fieldtype": "Link", - "label": "Letter Head", + "label": "Default Letter Head", "options": "Letter Head" }, { @@ -202,12 +203,18 @@ "fieldname": "add_translate_data", "fieldtype": "Check", "label": "Add Translate Data" + }, + { + "fieldname": "default_print_format", + "fieldtype": "Link", + "label": "Default Print Format", + "options": "Print Format" } ], "idx": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-08-28 18:28:32.510719", + "modified": "2026-03-31 14:42:49.829920", "modified_by": "Administrator", "module": "Core", "name": "Report", diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py index 1c08b3d533..c99b697d04 100644 --- a/frappe/core/doctype/report/report.py +++ b/frappe/core/doctype/report/report.py @@ -32,6 +32,7 @@ class Report(Document): add_total_row: DF.Check add_translate_data: DF.Check columns: DF.Table[ReportColumn] + default_print_format: DF.Link | None disabled: DF.Check filters: DF.Table[ReportFilter] is_standard: DF.Literal["No", "Yes"] @@ -82,6 +83,9 @@ class Report(Document): if self.report_type == "Report Builder": self.update_report_json() + if self.default_print_format and self.has_value_changed("default_print_format"): + self.validate_default_print_format() + def before_insert(self): self.set_doctype_roles() @@ -408,6 +412,23 @@ class Report(Document): return data + def validate_default_print_format(self): + pf = frappe.db.get_value( + "Print Format", + self.default_print_format, + ["report", "print_format_for", "print_format_type", "disabled"], + as_dict=True, + ) + + if ( + not pf + or pf.report != self.name + or pf.print_format_for != "Report" + or pf.print_format_type != "JS" + or pf.disabled + ): + frappe.throw(_("Selected Print Format is invalid for this Report.")) + @frappe.whitelist() def toggle_disable(self, disable: bool): if not self.has_permission("write"): diff --git a/frappe/core/doctype/security_settings/__init__.py b/frappe/core/doctype/security_settings/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/security_settings/security_settings.js b/frappe/core/doctype/security_settings/security_settings.js new file mode 100644 index 0000000000..05cbf3c9bd --- /dev/null +++ b/frappe/core/doctype/security_settings/security_settings.js @@ -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( + `
` + ); + }, +}); diff --git a/frappe/core/doctype/security_settings/security_settings.json b/frappe/core/doctype/security_settings/security_settings.json new file mode 100644 index 0000000000..bbaf9b7c16 --- /dev/null +++ b/frappe/core/doctype/security_settings/security_settings.json @@ -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": [] +} diff --git a/frappe/core/doctype/security_settings/security_settings.py b/frappe/core/doctype/security_settings/security_settings.py new file mode 100644 index 0000000000..478e4e909b --- /dev/null +++ b/frappe/core/doctype/security_settings/security_settings.py @@ -0,0 +1,116 @@ +# Copyright (c) 2026, Frappe Technologies and contributors +# For license information, please see license.txt + +from datetime import UTC, 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, + ] + ) + + "\n" + ) + + @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) + if isinstance(expires, str): + expires = datetime.fromisoformat(expires) + expires = expires.replace(microsecond=0) + expires = expires.astimezone(UTC) + value = expires.strftime("%Y-%m-%dT%H:%M:%SZ") + 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")) diff --git a/frappe/core/doctype/security_settings/security_settings_alert.py b/frappe/core/doctype/security_settings/security_settings_alert.py new file mode 100644 index 0000000000..bdf899e721 --- /dev/null +++ b/frappe/core/doctype/security_settings/security_settings_alert.py @@ -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}" diff --git a/frappe/core/doctype/security_settings/test_security_settings.py b/frappe/core/doctype/security_settings/test_security_settings.py new file mode 100644 index 0000000000..9052e407cc --- /dev/null +++ b/frappe/core/doctype/security_settings/test_security_settings.py @@ -0,0 +1,270 @@ +# Copyright (c) 2026, Frappe Technologies and Contributors +# License: MIT. See LICENSE + +from datetime import UTC, 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): + from datetime import timezone + + future_date = datetime(2027, 12, 31, 23, 59, 59, tzinfo=UTC) + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_expires": future_date, + } + ) + section = doc.public_expires_section + self.assertIn("2027-12-31T23:59:59Z", section) + + def test_public_expires_section_string(self): + doc = frappe.get_doc( + { + "doctype": "Security Settings", + "public_expires": "2027-12-31T23:59:59+00:00", + } + ) + section = doc.public_expires_section + self.assertIn("2027-12-31T23:59:59Z", 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 diff --git a/frappe/core/doctype/security_settings_contact/__init__.py b/frappe/core/doctype/security_settings_contact/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/security_settings_contact/security_settings_contact.json b/frappe/core/doctype/security_settings_contact/security_settings_contact.json new file mode 100644 index 0000000000..d1ac7d46d0 --- /dev/null +++ b/frappe/core/doctype/security_settings_contact/security_settings_contact.json @@ -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": [] +} diff --git a/frappe/core/doctype/security_settings_contact/security_settings_contact.py b/frappe/core/doctype/security_settings_contact/security_settings_contact.py new file mode 100644 index 0000000000..2bca260e81 --- /dev/null +++ b/frappe/core/doctype/security_settings_contact/security_settings_contact.py @@ -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 diff --git a/frappe/core/doctype/security_settings_language/__init__.py b/frappe/core/doctype/security_settings_language/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/security_settings_language/security_settings_language.json b/frappe/core/doctype/security_settings_language/security_settings_language.json new file mode 100644 index 0000000000..803c8e82de --- /dev/null +++ b/frappe/core/doctype/security_settings_language/security_settings_language.json @@ -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": [] +} diff --git a/frappe/core/doctype/security_settings_language/security_settings_language.py b/frappe/core/doctype/security_settings_language/security_settings_language.py new file mode 100644 index 0000000000..72648ae494 --- /dev/null +++ b/frappe/core/doctype/security_settings_language/security_settings_language.py @@ -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 diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 75d1bd978a..3530f92f98 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -114,6 +114,8 @@ "enable_telemetry", "search_section", "link_field_results_limit", + "column_break_nebx", + "allow_clearing_link_fields", "api_logging_section", "log_api_requests" ], @@ -783,13 +785,23 @@ "fieldname": "only_allow_system_managers_to_upload_public_files", "fieldtype": "Check", "label": "Only allow System Managers to upload public files" + }, + { + "fieldname": "column_break_nebx", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "Adds a clear (\u00d7) button to Link fields, allowing users to quickly remove the selected value.", + "fieldname": "allow_clearing_link_fields", + "fieldtype": "Check", + "label": "Allow Clearing Link Fields" } ], - "hide_toolbar": 1, "icon": "fa fa-cog", "issingle": 1, "links": [], - "modified": "2026-02-24 14:27:04.763075", + "modified": "2026-04-14 16:26:19.634212", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/system_settings/system_settings.py b/frappe/core/doctype/system_settings/system_settings.py index 687fd006df..2f26dad6d2 100644 --- a/frappe/core/doctype/system_settings/system_settings.py +++ b/frappe/core/doctype/system_settings/system_settings.py @@ -18,6 +18,7 @@ class SystemSettings(Document): if TYPE_CHECKING: from frappe.types import DF + allow_clearing_link_fields: DF.Check allow_consecutive_login_attempts: DF.Int allow_error_traceback: DF.Check allow_guests_to_upload_files: DF.Check diff --git a/frappe/core/doctype/translation/test_translation.py b/frappe/core/doctype/translation/test_translation.py index 7a28d068d3..0da71d3dc8 100644 --- a/frappe/core/doctype/translation/test_translation.py +++ b/frappe/core/doctype/translation/test_translation.py @@ -16,16 +16,20 @@ class TestTranslation(IntegrationTestCase): clear_cache() def test_doctype(self): - translation_data = get_translation_data() - for lang, (source_string, new_translation) in translation_data.items(): + doctype = "Translation" + meta = frappe.get_meta(doctype) + source_string = meta.get_label("translated_text") + + for lang in ["de", "bs", "zh", "hr", "en", "sv"]: frappe.local.lang = lang - original_translation = _(source_string) + original_translation = _(source_string, context=doctype) + new_translation = f"{original_translation} Customized" - docname = create_translation(lang, source_string, new_translation) - self.assertEqual(_(source_string), new_translation) + docname = create_translation(lang, source_string, new_translation, context=doctype) + self.assertEqual(_(source_string, context=doctype), new_translation) - frappe.delete_doc("Translation", docname) - self.assertEqual(_(source_string), original_translation) + frappe.delete_doc(doctype, docname) + self.assertEqual(_(source_string, context=doctype), original_translation) def test_parent_language(self): data = { @@ -60,37 +64,54 @@ class TestTranslation(IntegrationTestCase): source = "User" self.assertNotEqual(_(source, lang="de"), _(source, lang="es")) - def test_html_content_data_translation(self): - # ruff: noqa: RUF001 + def test_html_content_translation(self): source = """ - MacBook Air lasts up to an incredible 12 hours between charges. So from your morning coffee to - your evening commute, you can work unplugged. When it’s time to kick back and relax, - you can get up to 12 hours of iTunes movie playback. And with up to 30 days of standby time, - you can go away for weeks and pick up where you left off.Whatever the task, - fifth-generation Intel Core i5 and i7 processors with Intel HD Graphics 6000 are up to it.{{ doc.name }} Billed{{ doc.name }} Abgerechnet