refactor: File APIs

Restructured and moved most APIs under frappe.core.api.file namespace.
Changed some obvious security gaps (like using get_list instead of
get_all for an endpoint), styled, added type hints and made minor performance
enhancements.

Changes
* download_file API
    * Move API to handler.py
    * Check for permissions via File.is_downloadable instead
* Moved APIs to new namespace: `frappe.core.api.file`
* Backwards compatibility
    * Added APIs to override_whitelisted_methods to maintain existing
      client endpoints
    * Imported APIs to controller's namespace to avoid breaking external
      app usages
This commit is contained in:
Gavin D'souza 2022-03-15 15:04:01 +05:30
parent aaf50d28ee
commit 59e45a2e2f
5 changed files with 172 additions and 137 deletions

View file

123
frappe/core/api/file.py Normal file
View file

@ -0,0 +1,123 @@
import json
from typing import Dict, List
import frappe
from frappe.utils import cint, cstr
from frappe.core.doctype.file.file import File, setup_folder_path
@frappe.whitelist()
def unzip_file(name: str):
"""Unzip the given file and make file records for each of the extracted files"""
file: File = frappe.get_doc("File", name)
return file.unzip()
@frappe.whitelist()
def get_attached_images(doctype: str, names: List[str]) -> frappe._dict:
"""get list of image urls attached in form
returns {name: ['image.jpg', 'image.png']}"""
if isinstance(names, str):
names = json.loads(names)
img_urls = frappe.db.get_list(
"File",
filters={
"attached_to_doctype": doctype,
"attached_to_name": ("in", names),
"is_folder": 0,
},
fields=["file_url", "attached_to_name as docname"],
)
out = frappe._dict()
for i in img_urls:
out[i.docname] = out.get(i.docname, [])
out[i.docname].append(i.file_url)
return out
@frappe.whitelist()
def get_files_in_folder(folder: str, start: int = 0, page_length: int = 20) -> Dict:
start = cint(start)
page_length = cint(page_length)
attachment_folder = frappe.db.get_value(
"File",
"Home/Attachments",
["name", "file_name", "file_url", "is_folder", "modified"],
as_dict=1,
)
files = frappe.get_list(
"File",
{"folder": folder},
["name", "file_name", "file_url", "is_folder", "modified"],
start=start,
page_length=page_length + 1,
)
if folder == "Home" and attachment_folder not in files:
files.insert(0, attachment_folder)
return {"files": files[:page_length], "has_more": len(files) > page_length}
@frappe.whitelist()
def get_files_by_search_text(text: str) -> List[Dict]:
if not text:
return []
text = "%" + cstr(text).lower() + "%"
return frappe.get_list(
"File",
fields=["name", "file_name", "file_url", "is_folder", "modified"],
filters={"is_folder": False},
or_filters={
"file_name": ("like", text),
"file_url": text,
"name": ("like", text),
},
order_by="modified desc",
limit=20,
)
@frappe.whitelist(allow_guest=True)
def get_max_file_size() -> int:
return cint(frappe.conf.get("max_file_size")) or 10485760
@frappe.whitelist()
def create_new_folder(file_name: str, folder: str) -> File:
"""create new folder under current parent folder"""
file = frappe.new_doc("File")
file.file_name = file_name
file.is_folder = 1
file.folder = folder
file.insert(ignore_if_duplicate=True)
return file
@frappe.whitelist()
def move_file(file_list: List[File], new_parent: str, old_parent: str) -> None:
if isinstance(file_list, str):
file_list = json.loads(file_list)
for file_obj in file_list:
setup_folder_path(file_obj.get("name"), new_parent)
# recalculate sizes
frappe.get_doc("File", old_parent).save()
frappe.get_doc("File", new_parent).save()
@frappe.whitelist()
def zip_files(files: str):
files = frappe.parse_json(files)
frappe.response["filename"] = "files.zip"
frappe.response["filecontent"] = File.zip_files(files)
frappe.response["type"] = "download"

View file

@ -2,11 +2,11 @@
# License: MIT. See LICENSE
import io
import json
import mimetypes
import os
import re
import shutil
from typing import List
import zipfile
from requests.exceptions import HTTPError, SSLError
@ -16,9 +16,10 @@ from urllib.parse import quote, unquote
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, get_hook_method
from frappe.utils import call_hook_method, cint, encode, get_files_path, get_hook_method
from frappe.utils.image import strip_exif_data, optimize_image
from frappe.utils.file_manager import is_safe_path, safe_b64decode
from frappe.core.api.file import *
from .exceptions import MaxFileSizeReachedError, FolderNotEmpty
from .utils import *
@ -313,7 +314,7 @@ class File(Document):
self.flags.on_rollback = True
self.on_trash()
def unzip(self):
def unzip(self) -> List[File]:
'''Unzip current file and replace it by its children'''
if not self.file_url.endswith(".zip"):
frappe.throw(_("{0} is not a zip file").format(self.file_name))
@ -578,44 +579,6 @@ def on_doctype_update():
frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"])
@frappe.whitelist()
def create_new_folder(file_name, folder):
""" create new folder under current parent folder """
file = frappe.new_doc("File")
file.file_name = file_name
file.is_folder = 1
file.folder = folder
file.insert(ignore_if_duplicate=True)
return file
@frappe.whitelist()
def move_file(file_list, new_parent, old_parent):
if isinstance(file_list, str):
file_list = json.loads(file_list)
for file_obj in file_list:
setup_folder_path(file_obj.get("name"), new_parent)
# recalculate sizes
frappe.get_doc("File", old_parent).save()
frappe.get_doc("File", new_parent).save()
@frappe.whitelist()
def zip_files(files):
files = frappe.parse_json(files)
frappe.response["filename"] = "files.zip"
frappe.response["filecontent"] = File.zip_files(files)
frappe.response["type"] = "download"
@frappe.whitelist()
def get_max_file_size():
return cint(frappe.conf.get('max_file_size')) or 10485760
def has_permission(doc, ptype=None, user=None):
has_access = False
user = user or frappe.session.user
@ -648,92 +611,3 @@ def has_permission(doc, ptype=None, user=None):
pass
return has_access
@frappe.whitelist()
def download_file(file_url):
"""
Download file using token and REST API. Valid session or
token is required to download private files.
Method : GET
Endpoint : frappe.core.doctype.file.file.download_file
URL Params : file_name = /path/to/file relative to site path
"""
file_doc = frappe.get_doc("File", {"file_url": file_url})
file_doc.check_permission("read")
frappe.local.response.filename = os.path.basename(file_url)
frappe.local.response.filecontent = file_doc.get_content()
frappe.local.response.type = "download"
@frappe.whitelist()
def unzip_file(name):
'''Unzip the given file and make file records for each of the extracted files'''
file_obj: File = frappe.get_doc('File', name)
return file_obj.unzip()
@frappe.whitelist()
def get_attached_images(doctype, names):
'''get list of image urls attached in form
returns {name: ['image.jpg', 'image.png']}'''
if isinstance(names, str):
names = json.loads(names)
img_urls = frappe.db.get_list('File', filters={
'attached_to_doctype': doctype,
'attached_to_name': ('in', names),
'is_folder': 0
}, fields=['file_url', 'attached_to_name as docname'])
out = frappe._dict()
for i in img_urls:
out[i.docname] = out.get(i.docname, [])
out[i.docname].append(i.file_url)
return out
@frappe.whitelist()
def get_files_in_folder(folder, start=0, page_length=20):
start = cint(start)
page_length = cint(page_length)
attachment_folder = frappe.db.get_value('File',
'Home/Attachments',
['name', 'file_name', 'file_url', 'is_folder', 'modified'],
as_dict=1
)
files = frappe.db.get_list('File',
{ 'folder': folder },
['name', 'file_name', 'file_url', 'is_folder', 'modified'],
start=start,
page_length=page_length + 1
)
if folder == 'Home' and attachment_folder not in files:
files.insert(0, attachment_folder)
return {
'files': files[:page_length],
'has_more': len(files) > page_length
}
@frappe.whitelist()
def get_files_by_search_text(text):
if not text:
return []
text = '%' + cstr(text).lower() + '%'
return frappe.get_all('File',
fields=['name', 'file_name', 'file_url', 'is_folder', 'modified'],
filters={'is_folder': False},
or_filters={'file_name': ('like', text), 'file_url': text, 'name': ('like', text)},
order_by='modified desc',
limit=20
)

View file

@ -1,18 +1,24 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
import os
from mimetypes import guess_type
from typing import TYPE_CHECKING
from werkzeug.wrappers import Response
import frappe
import frappe.utils
import frappe.sessions
from frappe.utils import cint
import frappe.utils
from frappe import _, is_whitelisted
from frappe.utils.response import build_response
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
from frappe.utils import cint
from frappe.utils.csvutils import build_csv_response
from frappe.utils.image import optimize_image
from mimetypes import guess_type
from frappe.core.doctype.server_script.server_script_utils import get_server_script_map
from frappe.utils.response import build_response
if TYPE_CHECKING:
from frappe.core.doctype.file.file import File
ALLOWED_MIMETYPES = ('image/png', 'image/jpeg', 'application/pdf', 'application/msword',
@ -209,6 +215,25 @@ def upload_file():
return ret
@frappe.whitelist(allow_guest=True)
def download_file(file_url: str):
"""
Download file using token and REST API. Valid session or
token is required to download private files.
Method : GET
Endpoints : download_file, frappe.core.doctype.file.file.download_file
URL Params : file_name = /path/to/file relative to site path
"""
file: "File" = frappe.get_doc("File", {"file_url": file_url})
if not file.is_downloadable():
raise frappe.PermissionError
frappe.local.response.filename = os.path.basename(file_url)
frappe.local.response.filecontent = file.get_content()
frappe.local.response.type = "download"
def get_attr(cmd):
"""get method object from cmd"""
if '.' in cmd:
@ -218,6 +243,7 @@ def get_attr(cmd):
frappe.log("method:" + cmd)
return method
@frappe.whitelist(allow_guest=True)
def ping():
return "pong"
@ -225,8 +251,8 @@ def ping():
def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
"""run a whitelisted controller method"""
import json
import inspect
import json
if not args:
args = arg or ""

View file

@ -383,3 +383,15 @@ global_search_doctypes = {
{"doctype": "Web Form"}
]
}
override_whitelisted_methods = {
"frappe.core.doctype.file.file.download_file": "download_file",
"frappe.core.doctype.file.file.unzip_file": "frappe.core.api.file.unzip_file",
"frappe.core.doctype.file.file.get_attached_images": "frappe.core.api.file.get_attached_images",
"frappe.core.doctype.file.file.get_files_in_folder": "frappe.core.api.file.get_files_in_folder",
"frappe.core.doctype.file.file.get_files_by_search_text": "frappe.core.api.file.get_files_by_search_text",
"frappe.core.doctype.file.file.get_max_file_size": "frappe.core.api.file.get_max_file_size",
"frappe.core.doctype.file.file.create_new_folder": "frappe.core.api.file.create_new_folder",
"frappe.core.doctype.file.file.move_file": "frappe.core.api.file.move_file",
"frappe.core.doctype.file.file.zip_files": "frappe.core.api.file.zip_files",
}