I have been facing lots of issue when i decided to switch to uploading my file in frappe to linode bucket.
The file is getting properly uploaded to linode bucket when i am uploading suing side attachment button(that is present in all doctypes) but issue is occurring when i am trying to upload files or images through fields of type attach , attach images, text editor.
The files are getting uploaded to linode but the url in the fields is the old url(/publi/… or /private) and same in the case of text editor where the image src of the image is pointing to the /private or /public address.
This is the code in hooks.py
override_doctype_class = {
“File”: “ricemill.linode_storage.CustomFile”
}
This is the code in linode_storage.py
import os
import re
import frappe
from frappe import _
from frappe.utils import now_datetime, get_url, generate_hash,get_site_path
from frappe.utils.password import get_decrypted_password
from datetime import datetime, timedelta
from frappe import enqueue
from frappe.core.doctype.file.file import File as FrappeFile
try:
import boto3
from botocore.client import Config, ClientError
import mimetypes
except ImportError:
frappe.throw(_(“boto3 is required for Linode integration. Install via bench pip install boto3
”))
----------------------------------------
1) Helper: Construct a boto3 S3 client
----------------------------------------
def get_linode_client():
“”"
Fetch credentials from Linode Storage Settings and return a boto3 client.
“”"
settings = frappe.get_single(“Linode Storage Settings”)
if not (settings.endpoint_url and settings.access_key and settings.secret_key):
frappe.throw(_(“Please configure Linode Storage Settings first.”))
# Decrypt the password field before passing to boto3
secret_key = get_decrypted_password(
"Linode Storage Settings",
"Linode Storage Settings",
"secret_key"
)
return boto3.client(
"s3",
endpoint_url=settings.endpoint_url,
aws_access_key_id=settings.access_key,
aws_secret_access_key=secret_key,
config=Config(signature_version="s3v4"),
# If you want to fetch the region dynamically, you could store it
# in Linode Storage Settings instead of hard‐coding:
region_name=settings.region or "in-maa-1"
)
-----------------------------------------------------
2) Override the built-in File class so we skip local save
-----------------------------------------------------
class CustomFile(FrappeFile):
“”"
1) We override before_insert so that Frappe does NOT call its default save_file().
Instead, we pull ‘content’, write it to a tmp disk file ourselves, and set file_url
to that disk path—so that validate()/exists_on_disk() sees a valid file path.
2) We override validate_file_on_disk() and validate() to be no-ops, because we
know file_url will be a valid disk path at insert time.
3) In after_insert, we take that disk path, upload to Linode, set file_url to
the Linode URL (public or presigned), delete the local disk file, and insert
FileMetadata.
"""
def get_full_path(self):
"""
Return the absolute filesystem path for this File based on file_url.
By wrapping frappe.get_site_path(file_path) in os.path.abspath(),
we guarantee an absolute path like:
/home/.../frappe-bench/sites/dev.lakshmifoods.org/private/files/xyz.png
rather than a relative string.
"""
# file_url is stored as something like "/private/files/xyz.png"
file_path = (self.file_url or "").lstrip("/") # e.g. "private/files/xyz.png"
# frappe.get_site_path("private/files/xyz.png") might return "sites/dev.lakshmifoods.org/private/files/xyz.png"
# Wrapping in abspath() forces it into the real absolute path on disk.
return os.path.abspath(frappe.get_site_path(file_path))
def before_insert(self):
# 0) If we already handled a write, skip
if getattr(self, "_fm_written", False):
return
# 1) If self.content is not provided (i.e. attach‐field path), skip writing—
# Frappe has already written the file to disk at self.file_url.
if not self.get("content"):
# Mark as “written” so after_insert() still runs exactly once
self._fm_written = True
return
# 2) Otherwise, self.content holds Base64‐encoded bytes. Write them to disk.
try:
raw_bytes = self.get_content() # returns bytes
except Exception as e:
frappe.throw(_("Could not read uploaded content: {0}").format(e))
# 3) Build a random filename to avoid collisions
name_part, ext = os.path.splitext(self.file_name or "")
rand_hash = generate_hash(length=8)
disk_name = f"{name_part}_{rand_hash}{ext}"
# 4) Decide private or public folder
folder = "private/files" if self.is_private else "public/files"
site_path = frappe.get_site_path()
full_dir = os.path.join(site_path, folder)
os.makedirs(full_dir, exist_ok=True)
local_path = os.path.join(full_dir, disk_name)
# 5) Write raw_bytes to disk
try:
with open(local_path, "wb") as f:
f.write(raw_bytes)
except Exception as e:
frappe.throw(_("Could not write file to disk: {0}").format(e))
# 6) Point file_url at the new disk path so that exists_on_disk() will pass
rel_path = os.path.join(folder, disk_name).replace("\\", "/")
self.file_url = "/" + rel_path
# 7) Mark as “written” so we do not do this again
self._fm_written = True
def validate_file_on_disk(self):
# 1) check allowed extensions
name, ext = os.path.splitext(self.file_name)
allowed = frappe.get_conf().file_allowed_extensions or ["txt", "pdf", "jpg", "jpeg", "png", "gif", "webp", "svg", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv", "tar", "gz", "mp3", "wav", "mp4", "avi", "mov"]
if ext.lstrip(".").lower() not in allowed:
frappe.throw(_("Files with extension {0} are not allowed.").format(ext))
# 2) file size check
file_size = self.file_size or 0
max_mb = frappe.get_conf().max_file_size_mb or 100
if file_size > max_mb * 1024 * 1024:
frappe.throw(_("Upload exceeds maximum allowed file size of {0} MB.").format(max_mb))
# 3) sanitize the filename
safe_name = re.sub(r'[^a-zA-Z0-9_.\-]', "_", self.file_name or "")
self.file_name = safe_name
# 4) core checks
super(CustomFile, self).validate_file_on_disk()
def validate(self):
# Skip all validation that depends on file_url pointing somewhere else.
return
def after_insert(self):
old_local_url = self.file_url
# frappe.log_error(f"old_local_url: {old_local_url}", "Linode Storage Debug")
if self.custom__linode_handled:
return
# 1a) If file_url already points to an HTTP/S3 URL, skip
if self.file_url and self.file_url.startswith("http"):
frappe.db.set_value("File", self.name, "custom__linode_handled", 1)
return
# # 1) Avoid re-running
# if getattr(self, "_s3_handled", False):
# return
# 1b) If local file is missing, it means this File was created by attaching a previously‐uploaded File. We need to find the ORIGINAL File record whose file_url matched this same local path, then copy its S3 info.
local_path = self.get_full_path()
# frappe.log_error(f"local_path: {local_path}", "Linode Storage Debug")
# frappe.log_error(f"local_path exists: {os.path.exists(local_path)}", "Linode Storage Debug")
if frappe.db.exists("FileMetadata", {"original_url": old_local_url}):
settings = frappe.get_single("Linode Storage Settings")
# frappe.log_error(f"original_file_url field000: {old_local_url}", "Linode Storage Debug")
original_meta = frappe.get_all(
"FileMetadata",
filters={"original_url": old_local_url},
limit_page_length=1,
)
# frappe.log_error(f"original_meta: {original_meta}", "Linode Storage Debug")
if original_meta:
m = frappe.get_doc("FileMetadata", original_meta[0].name)
if m.private:
new_url = f"/api/method/ricemill.linode_storage.get_presigned_download?file_id={m.file_doc}"
frappe.db.set_value("File", self.name, "file_url", new_url)
frappe.db.set_value("File", self.name, "is_private", 1)
# frappe.log_error(f"$$$1$$$$", "Linode Storage Debug")
else:
endpoint = settings.endpoint_url.rstrip("/")
new_url = f"{endpoint}/{m.bucket}/{m.s3_key}"
frappe.db.set_value("File", self.name, "file_url", new_url)
frappe.db.set_value("File", self.name, "is_private", 0)
# frappe.log_error(f"$$$2$$$$", "Linode Storage Debug")
parent_doctype = self.attached_to_doctype
parent_name = self.attached_to_name
if parent_doctype and parent_name and frappe.db.exists(parent_doctype, parent_name):
try:
parent_doc = frappe.get_doc(parent_doctype, parent_name)
# frappe.log_error(f"parent_doc: {parent_doc}", "Linode Storage Debug")
for df in parent_doc.meta.fields:
if df.fieldtype in ("Attach", "Attach Image"):
if parent_doc.get(df.fieldname) == old_local_url:
# frappe.log_error(f"Inside for loop after *****2***", "Linode Storage Debug")
frappe.db.set_value(
parent_doctype, parent_name, df.fieldname, new_url, update_modified=False
)
break
except Exception as e:
frappe.log_error(f"Error updating parent doc: {e}", "Linode Storage Error")
# In all cases, mark as handled and exit
frappe.db.set_value("File", self.name, "custom__linode_handled", 1)
return
# 2) Find FileStorageConfig for this doctype
parent_doctype = self.attached_to_doctype
cfg_list = frappe.get_all(
"FileStorageConfig",
filters={"doctype_name": parent_doctype, "active": 1},
limit_page_length=1
)
if not cfg_list:
frappe.log_error(f"No FileStorageConfig for doctype {parent_doctype}", "Linode Storage Warning")
# No config → we leave the disk file alone and abort
return
config = frappe.get_doc("FileStorageConfig", cfg_list[0].name)
bucket_type = config.bucket_type.strip().lower() # "public" or "private"
folder_name = (config.folder_name or "").strip("/").rstrip("/")
retention_days = config.retention_days or 0
# 3) Build a unique S3 key: "folder_name/<timestamp>_<original_filename>"
timestamp = now_datetime().strftime("%Y%m%d%H%M%S")
# Determine original filename (use file_name if available; otherwise use the basename of file_url)
orig_name = self.file_name or os.path.basename(self.file_url or "")
name_part, ext = os.path.splitext(orig_name.replace(" ", "_"))
unique_filename = f"{timestamp}_{name_part}{ext}"
filename_for_metadata = orig_name or unique_filename
s3_key = f"{folder_name}/{unique_filename}" if folder_name else unique_filename
is_private = (bucket_type == "private")
settings = frappe.get_single("Linode Storage Settings")
s3_client = get_linode_client()
upload_success = False
metadata_success = False
# 4) Upload to Linode
try:
content_type, _ = mimetypes.guess_type(local_path)
if not content_type:
content_type = "application/octet-stream"
extra_args = {}
if not is_private:
extra_args["ACL"] = "public-read"
extra_args["ContentType"] = content_type
extra_args["ContentDisposition"] = "inline"
s3_client.upload_file(
Filename = local_path,
Bucket = (settings.private_bucket if is_private else settings.public_bucket),
Key = s3_key,
ExtraArgs = extra_args
)
upload_success = True
except ClientError as e:
frappe.log_error(message=e, title="Linode Upload Failed")
return
# 5) Update File doc’s URL & is_private flag
if not is_private:
endpoint = settings.endpoint_url.rstrip("/")
public_url = f"{endpoint}/{(settings.public_bucket)}/{s3_key}"
frappe.db.set_value("File", self.name, "file_url", public_url)
frappe.db.set_value("File", self.name, "is_private", 0)
new_http_url = public_url
else:
download_route = f"/api/method/ricemill.linode_storage.get_presigned_download?file_id={self.name}"
frappe.db.set_value("File", self.name, "file_url", download_route)
frappe.db.set_value("File", self.name, "is_private", 1)
new_http_url = download_route
# 6) Insert FileMetadata if it doesn’t exist yet
try:
if not frappe.db.exists("FileMetadata", {"file_doc": self.name}):
meta = frappe.get_doc({
"doctype":"FileMetadata",
"file_doc": self.name,
"attached_to_doctype": self.attached_to_doctype,
"attached_to_name":self.attached_to_name,
"filename": filename_for_metadata,
"bucket": (settings.private_bucket if is_private else settings.public_bucket),
"folder": folder_name,
"s3_key": s3_key,
"uploaded_on": now_datetime(),
"retention_days": retention_days,
"private": (1 if is_private else 0),
"original_url": old_local_url
})
meta.insert(ignore_permissions=True)
metadata_success = True
frappe.db.set_value("File", self.name, "custom__linode_handled", 1)
frappe.db.commit()
# 7) Text editor type fields: replace old local URL with new Linode URL
frappe.enqueue(
"ricemill.linode_storage.replace_url_in_parent", queue='default',
file_name=self.name,
file_url=old_local_url,
attached_to_doctype=self.attached_to_doctype,
attached_to_name=self.attached_to_name,
)
except frappe.DuplicateEntryError:
metadata_success = True
except Exception as e:
frappe.log_error(message=e, title="FileMetadata creation failed")
metadata_success = False
# 8) Mark as handled
if upload_success and metadata_success:
frappe.log_error(f"File {filename_for_metadata} successfully uploaded to Linode Storage", "Linode Storage Success")
# self._s3_handled = True
frappe.log_error(f"File successfully uploaded to Linode Storage", "Linode Storage Success")
def after_update(self):
return self.after_insert()
----------------------------------------
3) Whitelisted endpoint: presigned download
----------------------------------------
@frappe.whitelist(allow_guest=False)
def get_presigned_download(file_id):
frappe.log_error(f"get_presigned_download called for file_id: {file_id}“, “Linode Storage Debug”)
“””
Whitelisted endpoint for downloading a private file.
1. Verify user has read permission on the parent document.
2. Look up FileMetadata by file_doc = file_id.
3. Generate a presigned URL for (bucket, s3_key).
4. Redirect the browser to that presigned URL.
“”"
file_doc = frappe.get_doc(“File”, file_id)
# Permission check: ensure user can read attached document if any
if file_doc.attached_to_doctype and file_doc.attached_to_name:
if not frappe.has_permission(file_doc.attached_to_doctype, "read", file_doc.attached_to_name):
frappe.throw(_("Not permitted to download this file"), frappe.PermissionError)
meta_list = frappe.get_all(
"FileMetadata",
filters={"file_doc": file_id},
limit_page_length=1
)
if not meta_list:
frappe.throw(("FileMetadata not found"), frappe.DoesNotExistError)
meta = frappe.get_doc("FileMetadata", meta_list[0].name)
key = meta.s3_key
settings = frappe.get_single("Linode Storage Settings")
s3 = get_linode_client()
try:
# Determine content type for the object, so the browser can render it inline
content_type, _ = mimetypes.guess_type(meta.filename)
if not content_type:
content_type = "application/octet-stream"
presigned = s3.generate_presigned_url(
ClientMethod='get_object',
Params={
'Bucket': meta.bucket,
'Key': key,
# Force inline display (not download)
'ResponseContentDisposition': f'inline; filename="{meta.filename}"',
# Tell the browser what kind of file this is
'ResponseContentType': content_type
},
ExpiresIn=settings.presign_expiry or 3600
)
except ClientError as e:
frappe.log_error(message=e, title="Error generating presigned URL")
frappe.throw(_("Could not generate presigned URL"))
frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = presigned
Fix-broken image links in text editor fields
def replace_url_in_parent(file_name,file_url, attached_to_doctype, attached_to_name):
frappe.log_error(f"Running replace_url_in_parent for {file_url}", "Linode Debug")
settings = frappe.get_single("Linode Storage Settings")
original_meta = frappe.get_all(
"FileMetadata",
filters={"file_doc": file_name},
limit_page_length=1,
)
frappe.log_error(f"Fetched Metadata: {original_meta}", "Linode Debug")
if original_meta:
m = frappe.get_doc("FileMetadata", original_meta[0].name)
if m.private:
new_url = f"/api/method/ricemill.linode_storage.get_presigned_download?file_id={m.file_doc}"
else:
endpoint = settings.endpoint_url.rstrip("/")
new_url = f"{endpoint}/{m.bucket}/{m.s3_key}"
parent_doc = frappe.get_doc(attached_to_doctype, attached_to_name)
changed = False
for df in parent_doc.meta.fields:
if df.fieldtype in ("Text Editor", "Small Text", "Text", "Code"):
orig_html = getattr(parent_doc, df.fieldname) or ""
pattern = re.escape(f"{file_url}?fid={m.file_doc}")
new_html = re.sub(pattern, new_url, orig_html)
frappe.log_error(f"orig_html for {df.fieldname}=\n{orig_html}", "Linode HTML Debug")
frappe.log_error(f"new_html for {df.fieldname}=\n{new_html}", "Linode HTML Debug")
if new_html != orig_html:
setattr(parent_doc, df.fieldname, new_html)
changed = True
frappe.log_error(f"Changed: {changed}", "Linode Debug")
if changed:
parent_doc.save(ignore_permissions=True)
Can someone help me with this issue ?
is the method of uploading files to linode okay or are there better way of handling this.