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

1import logging 

2import os 

3from pathlib import Path 

4 

5from django.conf import settings 

6 

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 

11 

12logger = logging.getLogger("paperless.filehandling") 

13 

14 

15class UnsafeFilePathError(Exception): 

16 """ 

17 Raised when a path generated for a document would land outside of its root. 

18 """ 

19 

20 

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) 

30 

31 

32def create_source_path_directory(source_path: Path) -> None: 

33 source_path.parent.mkdir(parents=True, exist_ok=True) 

34 

35 

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 

39 

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. 

42 

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 

47 

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 

60 

61 # go one level up 

62 directory = directory.parent 

63 

64 

65def generate_unique_filename(doc, *, archive_filename=False) -> Path: 

66 """ 

67 Generates a unique filename for doc in settings.ORIGINALS_DIR. 

68 

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. 

73 

74 If archive_filename is True, return a unique archive filename instead. 

75 

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 

85 

86 base_filename = generate_filename(doc, archive_filename=archive_filename) 

87 

88 # If generating archive filenames, try to make a name that is similar to 

89 # the original filename first. 

90 

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") 

100 

101 if simple_pdf_name == old_filename or not (root / simple_pdf_name).exists(): 

102 return simple_pdf_name 

103 

104 file_extension = ".pdf" if archive_filename else doc.file_type 

105 filename_stem = base_filename.name.removesuffix(file_extension) 

106 counter = 0 

107 

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 ) 

114 

115 if new_filename == old_filename: 

116 # still the same as before. 

117 return new_filename 

118 

119 if (root / new_filename).exists(): 

120 counter += 1 

121 else: 

122 return new_filename 

123 

124 

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 

132 

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) 

139 

140 rendered_filename = rendered_filename.replace( 

141 "-none-", 

142 "none", 

143 ) # backward compatibility 

144 

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 

152 

153 return rendered_filename 

154 

155 

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 

171 

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 

185 

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) 

191 

192 counter_str = f"_{counter:02}" if counter else "" 

193 filetype_str = ".pdf" if archive_filename else doc.file_type 

194 

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 

200 

201 # Build the final filename with counter and filetype 

202 final_filename = f"{base_filename}{version_suffix}{counter_str}{filetype_str}" 

203 

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) 

215 

216 return full_path