Coverage for open_webui/utils/files.py: 17%
133 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1import asyncio
2import base64
3import io
4import mimetypes
5import re
6from pathlib import Path
7from typing import Optional
9import aiofiles
10from fastapi import (
11 APIRouter,
12 Depends,
13 HTTPException,
14 Request,
15 UploadFile,
16)
17from open_webui.env import (
18 AIOHTTP_CLIENT_ALLOW_REDIRECTS,
19 AIOHTTP_CLIENT_SESSION_SSL,
20 ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK,
21)
22from open_webui.models.chats import Chats
23from open_webui.models.files import Files
24from open_webui.retrieval.web.utils import get_ssrf_safe_session, validate_url
25from open_webui.routers.files import upload_file_handler
26from open_webui.utils.access_control.files import has_access_to_file
27from open_webui.routers.images import (
28 get_image_data,
29 upload_image,
30)
31from open_webui.storage.provider import Storage
33BASE64_IMAGE_URL_PREFIX = re.compile(r'data:image/\w+;base64,', re.IGNORECASE)
34MARKDOWN_IMAGE_URL_PATTERN = re.compile(r'!\[(.*?)\]\((.+?)\)', re.IGNORECASE)
35FILE_CONTENT_URL_PATTERN = re.compile(r'^/api/v1/files/([^/?#]+)/content(?:[?#]|$)')
37# Extension-based MIME fallback, only used when ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK is True.
38_IMAGE_MIME_FALLBACK = {
39 '.webp': 'image/webp',
40 '.png': 'image/png',
41 '.jpg': 'image/jpeg',
42 '.jpeg': 'image/jpeg',
43 '.gif': 'image/gif',
44 '.svg': 'image/svg+xml',
45 '.bmp': 'image/bmp',
46 '.tiff': 'image/tiff',
47 '.tif': 'image/tiff',
48 '.ico': 'image/x-icon',
49 '.heic': 'image/heic',
50 '.heif': 'image/heif',
51 '.avif': 'image/avif',
52}
55async def get_image_base64_from_url(url: str, user=None) -> Optional[str]:
56 try:
57 if url.startswith('http'):
58 from open_webui.models.config import Config
60 max_bytes = None
61 try:
62 max_size_mb = int(await Config.get('rag.file.max_size') or 0)
63 except (TypeError, ValueError):
64 max_size_mb = 0
65 if max_size_mb > 0:
66 max_bytes = max_size_mb * 1024 * 1024
68 # Validate URL to prevent SSRF attacks against local/private networks.
69 # allow_redirects=False prevents redirect-based SSRF: validate_url() is
70 # called only on the originally-submitted URL; following 3xx redirects
71 # without re-validation would let an attacker reach private IPs via a
72 # public host that redirects internally (e.g. cloud-metadata exfil).
73 await asyncio.to_thread(validate_url, url)
74 # Fetch through an SSRF-safe session that re-checks the connect-time IP, so a
75 # rebinding DNS answer that passed validate_url cannot reach an internal address.
76 async with get_ssrf_safe_session() as session:
77 async with session.get(
78 url,
79 ssl=AIOHTTP_CLIENT_SESSION_SSL,
80 allow_redirects=AIOHTTP_CLIENT_ALLOW_REDIRECTS,
81 headers={'Accept-Encoding': 'identity'},
82 ) as response:
83 response.raise_for_status()
84 # Accept-Encoding is only a request; the sender can still compress and pick our decompressed size.
85 encodings = response.headers.getall('Content-Encoding', ())
86 if any(encoding.lower() not in ('', 'identity') for encoding in encodings):
87 return None
88 image_data = bytearray()
89 total = 0
90 async for chunk in response.content.iter_chunked(64 * 1024):
91 total += len(chunk)
92 if max_bytes is not None and total > max_bytes:
93 return None
94 image_data.extend(chunk)
95 encoded_string = base64.b64encode(image_data).decode('utf-8')
96 content_type = response.headers.get('Content-Type', 'image/png')
97 return f'data:{content_type};base64,{encoded_string}'
98 else:
99 # Non-URL string — treat as file_id. Delegate to the canonical
100 # file-ID resolver which enforces ownership/access checks.
101 file_id_match = FILE_CONTENT_URL_PATTERN.match(url)
102 return await get_image_base64_from_file_id(file_id_match.group(1) if file_id_match else url, user=user)
104 except Exception:
105 return None
108async def get_image_url_from_base64(request, base64_image_string, metadata, user):
109 if BASE64_IMAGE_URL_PREFIX.match(base64_image_string):
110 image_url = ''
111 # Extract base64 image data from the line
112 image_data, content_type = await get_image_data(base64_image_string)
113 if image_data is not None:
114 _, image_file = await upload_image(
115 request,
116 image_data,
117 content_type,
118 metadata,
119 user,
120 )
121 image_url = image_file['url']
123 return image_url
124 return None
127async def convert_markdown_base64_images(request, content: str, metadata, user):
128 MIN_REPLACEMENT_URL_LENGTH = 1024
129 result_parts = []
130 last_end = 0
132 for match in MARKDOWN_IMAGE_URL_PATTERN.finditer(content):
133 result_parts.append(content[last_end : match.start()])
134 base64_string = match.group(2)
135 if len(base64_string) > MIN_REPLACEMENT_URL_LENGTH:
136 url = await get_image_url_from_base64(request, base64_string, metadata, user)
137 if url:
138 result_parts.append(f'')
139 else:
140 result_parts.append(match.group(0))
141 else:
142 result_parts.append(match.group(0))
143 last_end = match.end()
145 result_parts.append(content[last_end:])
146 return ''.join(result_parts)
149def load_b64_audio_data(b64_str):
150 try:
151 if ',' in b64_str:
152 header, b64_data = b64_str.split(',', 1)
153 else:
154 b64_data = b64_str
155 header = 'data:audio/wav;base64'
156 audio_data = base64.b64decode(b64_data)
157 content_type = header.split(';')[0].split(':')[1] if ';' in header else 'audio/wav'
158 return audio_data, content_type
159 except Exception as e:
160 print(f'Error decoding base64 audio data: {e}')
161 return None, None
164async def upload_audio(request, audio_data, content_type, metadata, user):
165 audio_format = mimetypes.guess_extension(content_type)
166 file = UploadFile(
167 file=io.BytesIO(audio_data),
168 filename=f'generated-{audio_format}', # will be converted to a unique ID on upload_file
169 headers={
170 'content-type': content_type,
171 },
172 )
173 file_item = await upload_file_handler(
174 request,
175 file=file,
176 metadata=metadata,
177 process=False,
178 user=user,
179 )
180 url = request.app.url_path_for('get_file_content_by_id', id=file_item.id)
181 return url
184async def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
185 if 'data:audio/wav;base64' in base64_audio_string:
186 audio_url = ''
187 # Extract base64 audio data from the line
188 audio_data, content_type = load_b64_audio_data(base64_audio_string)
189 if audio_data is not None:
190 audio_url = await upload_audio(
191 request,
192 audio_data,
193 content_type,
194 metadata,
195 user,
196 )
197 return audio_url
198 return None
201async def get_file_url_from_base64(request, base64_file_string, metadata, user):
202 if BASE64_IMAGE_URL_PREFIX.match(base64_file_string):
203 return await get_image_url_from_base64(request, base64_file_string, metadata, user)
204 elif 'data:audio/wav;base64' in base64_file_string:
205 return await get_audio_url_from_base64(request, base64_file_string, metadata, user)
206 return None
209async def get_image_base64_from_file_id(id: str, user=None) -> Optional[str]:
210 file = await Files.get_file_by_id(id)
211 if not file:
212 return None
214 # Gate file-by-id resolution by ownership to prevent exfiltration.
215 # A caller could place another user's file_id in an image_url field;
216 # without this check the server reads the file from disk, inlines it
217 # base64 into the LLM request, and the content leaks via OCR/describe.
218 # Owner, admin, and explicit read-grant holders are allowed.
219 if user is None:
220 return None
221 if file.user_id != user.id and user.role != 'admin' and not await has_access_to_file(file.id, 'read', user):
222 return None
224 try:
225 file_path = await asyncio.to_thread(Storage.get_file, file.path)
226 file_path = Path(file_path)
228 # Check if the file already exists in the cache
229 if file_path.is_file():
230 async with aiofiles.open(file_path, 'rb') as image_file:
231 encoded_string = base64.b64encode(await image_file.read()).decode('utf-8')
232 content_type = mimetypes.guess_type(file_path.name)[0] or (file.meta or {}).get('content_type')
233 if not content_type and ENABLE_IMAGE_CONTENT_TYPE_EXTENSION_FALLBACK:
234 content_type = _IMAGE_MIME_FALLBACK.get(file_path.suffix.lower())
235 if not content_type:
236 return None
237 return f'data:{content_type};base64,{encoded_string}'
238 else:
239 return None
240 except Exception:
241 return None