Coverage for documents/file_handling.py: 30%
96 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
1import logging
2import os
3from pathlib import Path
5from django.conf import settings
7from documents.models import Document
8from documents.templating.filepath import is_safe_relative_path
9from documents.templating.filepath import validate_filepath_template_and_render
10from documents.templating.utils import convert_format_str_to_template_format
12logger = logging.getLogger("paperless.filehandling")
15class UnsafeFilePathError(Exception):
16 """
17 Raised when a path generated for a document would land outside of its root.
18 """
21def validate_path_in_root(path: Path, root: Path) -> None:
22 """
23 Ensures the given absolute path is contained within root, the
24 equivalent guard for the later move.
25 """
26 if not path.resolve().is_relative_to(root.resolve()):
27 msg = f"Refusing to write file outside of root {root}: {path}."
28 logger.warning(msg)
29 raise UnsafeFilePathError(msg)
32def create_source_path_directory(source_path: Path) -> None:
33 source_path.parent.mkdir(parents=True, exist_ok=True)
36def delete_empty_directories(directory: Path, root: Path) -> None:
37 if not directory.is_dir(): 37 ↛ 38line 37 didn't jump to line 38 because the condition on line 37 was never true
38 return
40 if not directory.is_relative_to(root): 40 ↛ 46line 40 didn't jump to line 46 because the condition on line 40 was never true
41 # don't do anything outside our originals folder.
43 # append os.path.set so that we avoid these cases:
44 # directory = /home/originals2/test
45 # root = /home/originals ("/" gets appended and startswith fails)
46 return
48 # Go up in the directory hierarchy and try to delete all directories
49 while directory != root: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true
50 if not list(directory.iterdir()):
51 # it's empty
52 try:
53 directory.rmdir()
54 except OSError:
55 # whatever. empty directories aren't that bad anyway.
56 return
57 else:
58 # it's not empty.
59 return
61 # go one level up
62 directory = directory.parent
65def generate_unique_filename(doc, *, archive_filename=False) -> Path:
66 """
67 Generates a unique filename for doc in settings.ORIGINALS_DIR.
69 The returned filename is guaranteed to be either the current filename
70 of the document if unchanged, or a new filename that does not correspondent
71 to any existing files. The function will append _01, _02, etc to the
72 filename before the extension to avoid conflicts.
74 If archive_filename is True, return a unique archive filename instead.
76 """
77 if archive_filename:
78 old_filename: Path | None = (
79 Path(doc.archive_filename) if doc.archive_filename else None
80 )
81 root = settings.ARCHIVE_DIR
82 else:
83 old_filename = Path(doc.filename) if doc.filename else None
84 root = settings.ORIGINALS_DIR
86 base_filename = generate_filename(doc, archive_filename=archive_filename)
88 # If generating archive filenames, try to make a name that is similar to
89 # the original filename first.
91 if archive_filename and doc.filename:
92 # Try to create a simple PDF version based on the original filename
93 # but preserve any directory structure from the template
94 if str(base_filename.parent) != ".":
95 # Has directory structure, preserve it
96 simple_pdf_name = base_filename.parent / (Path(doc.filename).stem + ".pdf")
97 else:
98 # No directory structure
99 simple_pdf_name = Path(Path(doc.filename).stem + ".pdf")
101 if simple_pdf_name == old_filename or not (root / simple_pdf_name).exists():
102 return simple_pdf_name
104 file_extension = ".pdf" if archive_filename else doc.file_type
105 filename_stem = base_filename.name.removesuffix(file_extension)
106 counter = 0
108 while True:
109 new_filename = base_filename
110 if counter:
111 new_filename = base_filename.with_name(
112 f"{filename_stem}_{counter:02}{file_extension}",
113 )
115 if new_filename == old_filename:
116 # still the same as before.
117 return new_filename
119 if (root / new_filename).exists():
120 counter += 1
121 else:
122 return new_filename
125def format_filename(document: Document, template_str: str) -> str | None:
126 rendered_filename = validate_filepath_template_and_render(
127 template_str,
128 document,
129 )
130 if rendered_filename is None:
131 return None
133 # Apply this setting. It could become a filter in the future (or users could use |default)
134 if settings.FILENAME_FORMAT_REMOVE_NONE:
135 rendered_filename = rendered_filename.replace("/-none-/", "/")
136 rendered_filename = rendered_filename.replace(" -none-", "")
137 rendered_filename = rendered_filename.replace("-none-", "")
138 rendered_filename = rendered_filename.strip(os.sep)
140 rendered_filename = rendered_filename.replace(
141 "-none-",
142 "none",
143 ) # backward compatibility
145 # Validate again after remove none
146 if not is_safe_relative_path(rendered_filename):
147 logger.warning(
148 "Filename became unsafe after placeholder removal, "
149 "falling back to default naming",
150 )
151 return None
153 return rendered_filename
156def generate_filename(
157 doc: Document,
158 *,
159 counter=0,
160 archive_filename=False,
161 use_format=True,
162) -> Path:
163 # version docs use the root document for formatting, just with a suffix
164 context_doc = doc if doc.root_document_id is None else doc.root_document
165 version_suffix = (
166 f"_v{doc.version_index}"
167 if doc.root_document_id is not None and doc.version_index is not None
168 else ""
169 )
170 base_path: Path | None = None
172 # Determine the source of the format string
173 if use_format: 173 ↛ 184line 173 didn't jump to line 184 because the condition on line 173 was always true
174 if context_doc.storage_path is not None: 174 ↛ 175line 174 didn't jump to line 175 because the condition on line 174 was never true
175 filename_format = context_doc.storage_path.path
176 elif settings.FILENAME_FORMAT is not None: 176 ↛ 178line 176 didn't jump to line 178 because the condition on line 176 was never true
177 # Maybe convert old to new style
178 filename_format = convert_format_str_to_template_format(
179 settings.FILENAME_FORMAT,
180 )
181 else:
182 filename_format = None
183 else:
184 filename_format = None
186 # If we have one, render it
187 if filename_format is not None: 187 ↛ 188line 187 didn't jump to line 188 because the condition on line 187 was never true
188 rendered_path: str | None = format_filename(context_doc, filename_format)
189 if rendered_path:
190 base_path = Path(rendered_path)
192 counter_str = f"_{counter:02}" if counter else ""
193 filetype_str = ".pdf" if archive_filename else doc.file_type
195 if base_path: 195 ↛ 197line 195 didn't jump to line 197 because the condition on line 195 was never true
196 # Split the path into directory and filename parts
197 directory = base_path.parent
198 # Use the full name (not just stem) as the base filename
199 base_filename = base_path.name
201 # Build the final filename with counter and filetype
202 final_filename = f"{base_filename}{version_suffix}{counter_str}{filetype_str}"
204 # If we have a directory component, include it
205 if str(directory) != ".":
206 full_path = directory / final_filename
207 else:
208 full_path = Path(final_filename)
209 else:
210 # No template, use document ID
211 final_filename = (
212 f"{context_doc.pk:07}{version_suffix}{counter_str}{filetype_str}"
213 )
214 full_path = Path(final_filename)
216 return full_path