feat: protect attached files (#31855)

* feat: protect attached files

* fix: protection does not apply to draft documents

* chore: update descriptions

* feat: hide delete button when file is protected
This commit is contained in:
Raffael Meyer 2025-03-31 16:00:26 +02:00 committed by GitHub
parent fd783f07de
commit 3f5da98b27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 78 additions and 10 deletions

View file

@ -15,6 +15,7 @@
"restrict_to_domain",
"read_only",
"in_create",
"protect_attached_files",
"sb1",
"naming_rule",
"autoname",
@ -683,14 +684,22 @@
"options": "Dynamic\nCompressed"
},
{
"default": "50",
"depends_on": "istable",
"fieldname": "grid_page_length",
"fieldtype": "Int",
"label": "Grid Page Length",
"non_negative": 1
"default": "50",
"depends_on": "istable",
"fieldname": "grid_page_length",
"fieldtype": "Int",
"label": "Grid Page Length",
"non_negative": 1
},
{
"default": "0",
"description": "Users are only able to delete attached files if the document is either in draft or if the document is canceled and they are also able to delete the document.",
"fieldname": "protect_attached_files",
"fieldtype": "Check",
"label": "Protect Attached Files"
}
],
"grid_page_length": 50,
"icon": "fa fa-bolt",
"idx": 6,
"index_web_pages_for_search": 1,
@ -771,7 +780,7 @@
"link_fieldname": "reference_doctype"
}
],
"modified": "2025-02-20 19:05:52.119679",
"modified": "2025-03-27 18:16:53.286909",
"modified_by": "Administrator",
"module": "Core",
"name": "DocType",
@ -801,6 +810,7 @@
}
],
"route": "doctype",
"row_format": "Dynamic",
"search_fields": "module",
"show_name_in_global_search": 1,
"sort_field": "creation",

View file

@ -154,6 +154,7 @@ class DocType(Document):
]
nsm_parent_field: DF.Data | None
permissions: DF.Table[DocPerm]
protect_attached_files: DF.Check
queue_in_background: DF.Check
quick_entry: DF.Check
read_only: DF.Check

View file

@ -14,6 +14,7 @@ from PIL import Image, ImageFile, ImageOps
import frappe
from frappe import _
from frappe.database.schema import SPECIAL_CHAR_PATTERN
from frappe.exceptions import DoesNotExistError
from frappe.model.document import Document
from frappe.permissions import SYSTEM_USER_ROLE, get_doctypes_with_read
from frappe.utils import call_hook_method, cint, get_files_path, get_hook_method, get_url
@ -151,6 +152,7 @@ class File(Document):
if self.is_home_folder or self.is_attachments_folder:
frappe.throw(_("Cannot delete Home and Attachments folders"))
self.validate_empty_folder()
self.validate_protected_file()
self._delete_file_on_disk()
if not self.is_folder:
self.add_comment_in_reference_doc("Attachment Removed", _("Removed {0}").format(self.file_name))
@ -469,6 +471,36 @@ class File(Document):
if self.is_folder and frappe.get_all("File", filters={"folder": self.name}, limit=1):
frappe.throw(_("Folder {0} is not empty").format(self.name), FolderNotEmpty)
def validate_protected_file(self):
"""Throw an exception if this file is attached to a doctype that protects files.
Allows deleting the attached file if the linked document is in draft. If submitted,
deletion is not allowed. If canceled, requires delete permissions on the linked document.
"""
if not (self.attached_to_doctype and self.attached_to_name):
return
try:
ref_doc = frappe.get_doc(self.attached_to_doctype, self.attached_to_name)
except DoesNotExistError:
return
if ref_doc.docstatus == 0:
# If the document is not submitted yet, users can correct wrong attachments
return
if not ref_doc.meta.protect_attached_files:
return
if ref_doc.docstatus == 2 and ref_doc.has_permission("delete"):
# Deletion must still be possible if users have the permission to delete the linked document
return
frappe.throw(
msg=_("This file is attached to a protected document and cannot be deleted."),
title=_("Protected File"),
)
def _delete_file_on_disk(self):
"""If file not attached to any other record, delete it"""
on_disk_file_not_shared = self.content_hash and not frappe.get_all(

View file

@ -33,6 +33,7 @@
"column_break_21",
"allow_copy",
"make_attachments_public",
"protect_attached_files",
"view_settings_section",
"title_field",
"show_title_field_in_link",
@ -407,6 +408,13 @@
"fieldtype": "Int",
"label": "Grid Page Length",
"non_negative": 1
},
{
"default": "0",
"description": "Users are only able to delete attached files if the document is either in draft or if the document is canceled and they are also able to delete the document.",
"fieldname": "protect_attached_files",
"fieldtype": "Check",
"label": "Protect Attached Files"
}
],
"hide_toolbar": 1,
@ -415,7 +423,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-02-21 20:16:50.501895",
"modified": "2025-03-27 18:22:32.618603",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",
@ -439,4 +447,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View file

@ -71,6 +71,7 @@ class CustomizeForm(Document):
"Random",
"By script",
]
protect_attached_files: DF.Check
queue_in_background: DF.Check
quick_entry: DF.Check
search_fields: DF.Data | None
@ -728,6 +729,7 @@ doctype_properties = {
"editable_grid": "Check",
"max_attachments": "Int",
"make_attachments_public": "Check",
"protect_attached_files": "Check",
"track_changes": "Check",
"track_views": "Check",
"allow_auto_repeat": "Check",

View file

@ -130,7 +130,7 @@ frappe.ui.form.Attachments = class Attachments {
</a>`;
let remove_action = null;
if (frappe.model.can_write(this.frm.doctype, this.frm.name)) {
if (this.can_delete_attachment()) {
remove_action = function (target_id) {
frappe.confirm(__("Are you sure you want to delete the attachment?"), function () {
let target_attachment = me
@ -156,6 +156,21 @@ frappe.ui.form.Attachments = class Attachments {
.insertAfter(this.add_attachment_wrapper);
}
can_delete_attachment() {
if (this.frm.meta.protect_attached_files) {
switch (this.frm.doc.docstatus) {
case 0:
return this.frm.has_perm("write");
case 2:
return this.frm.has_perm("write") && this.frm.has_perm("delete");
default:
return false;
}
}
return this.frm.has_perm("write");
}
get_file_url(attachment) {
var file_url = attachment.file_url;
if (!file_url) {