Skip to content

Commit 65cd9b1

Browse files
Merge remote-tracking branch 'upstream/dev' into dev
2 parents c52dfa5 + 665f95e commit 65cd9b1

24 files changed

Lines changed: 632 additions & 97 deletions

File tree

backend/open_webui/models/prompts.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010

1111

1212
from pydantic import BaseModel, ConfigDict
13-
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
13+
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON, or_, func, cast
14+
1415

1516
from open_webui.utils.access_control import has_access
1617

@@ -85,7 +86,18 @@ class PromptAccessResponse(PromptUserResponse):
8586
write_access: Optional[bool] = False
8687

8788

89+
class PromptListResponse(BaseModel):
90+
items: list[PromptUserResponse]
91+
total: int
92+
93+
94+
class PromptAccessListResponse(BaseModel):
95+
items: list[PromptAccessResponse]
96+
total: int
97+
98+
8899
class PromptForm(BaseModel):
100+
89101
command: str
90102
name: str # Changed from title
91103
content: str
@@ -227,7 +239,109 @@ def get_prompts_by_user_id(
227239
or has_access(user_id, permission, prompt.access_control, user_group_ids)
228240
]
229241

242+
def search_prompts(
243+
self,
244+
user_id: str,
245+
filter: dict = {},
246+
skip: int = 0,
247+
limit: int = 30,
248+
db: Optional[Session] = None,
249+
) -> PromptListResponse:
250+
with get_db_context(db) as db:
251+
from open_webui.models.users import User, UserModel
252+
253+
# Join with User table for user filtering and sorting
254+
query = db.query(Prompt, User).outerjoin(User, User.id == Prompt.user_id)
255+
query = query.filter(Prompt.is_active == True)
256+
257+
if filter:
258+
query_key = filter.get("query")
259+
if query_key:
260+
query = query.filter(
261+
or_(
262+
Prompt.name.ilike(f"%{query_key}%"),
263+
Prompt.command.ilike(f"%{query_key}%"),
264+
Prompt.content.ilike(f"%{query_key}%"),
265+
User.name.ilike(f"%{query_key}%"),
266+
User.email.ilike(f"%{query_key}%"),
267+
)
268+
)
269+
270+
view_option = filter.get("view_option")
271+
if view_option == "created":
272+
query = query.filter(Prompt.user_id == user_id)
273+
elif view_option == "shared":
274+
query = query.filter(Prompt.user_id != user_id)
275+
276+
# Apply access control filtering
277+
group_ids = filter.get("group_ids", [])
278+
filter_user_id = filter.get("user_id")
279+
280+
if filter_user_id:
281+
# User must have access: owner OR public OR explicit access
282+
access_conditions = [
283+
Prompt.user_id == filter_user_id, # Owner
284+
Prompt.access_control == None, # Public
285+
]
286+
query = query.filter(or_(*access_conditions))
287+
288+
tag = filter.get("tag")
289+
if tag:
290+
# Search for tag in JSON array field
291+
like_pattern = f'%"{tag.lower()}"%'
292+
tags_text = func.lower(cast(Prompt.tags, String))
293+
query = query.filter(tags_text.like(like_pattern))
294+
295+
order_by = filter.get("order_by")
296+
direction = filter.get("direction")
297+
298+
if order_by == "name":
299+
if direction == "asc":
300+
query = query.order_by(Prompt.name.asc())
301+
else:
302+
query = query.order_by(Prompt.name.desc())
303+
elif order_by == "created_at":
304+
if direction == "asc":
305+
query = query.order_by(Prompt.created_at.asc())
306+
else:
307+
query = query.order_by(Prompt.created_at.desc())
308+
elif order_by == "updated_at":
309+
if direction == "asc":
310+
query = query.order_by(Prompt.updated_at.asc())
311+
else:
312+
query = query.order_by(Prompt.updated_at.desc())
313+
else:
314+
query = query.order_by(Prompt.updated_at.desc())
315+
else:
316+
query = query.order_by(Prompt.updated_at.desc())
317+
318+
# Count BEFORE pagination
319+
total = query.count()
320+
321+
if skip:
322+
query = query.offset(skip)
323+
if limit:
324+
query = query.limit(limit)
325+
326+
items = query.all()
327+
328+
prompts = []
329+
for prompt, user in items:
330+
prompts.append(
331+
PromptUserResponse(
332+
**PromptModel.model_validate(prompt).model_dump(),
333+
user=(
334+
UserResponse(**UserModel.model_validate(user).model_dump())
335+
if user
336+
else None
337+
),
338+
)
339+
)
340+
341+
return PromptListResponse(items=prompts, total=total)
342+
230343
def update_prompt_by_command(
344+
231345
self,
232346
command: str,
233347
form_data: PromptForm,

backend/open_webui/routers/images.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from open_webui.config import CACHE_DIR
1818
from open_webui.constants import ERROR_MESSAGES
19+
from open_webui.retrieval.web.utils import validate_url
1920
from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS
2021

2122
from open_webui.models.chats import Chats
@@ -881,6 +882,8 @@ async def load_url_image(data):
881882
return data
882883

883884
if data.startswith("http://") or data.startswith("https://"):
885+
# Validate URL to prevent SSRF attacks against local/private networks
886+
validate_url(data)
884887
r = await asyncio.to_thread(requests.get, data)
885888
r.raise_for_status()
886889

@@ -910,7 +913,8 @@ async def load_url_image(data):
910913
if isinstance(form_data.image, str):
911914
form_data.image = await load_url_image(form_data.image)
912915
elif isinstance(form_data.image, list):
913-
form_data.image = [await load_url_image(img) for img in form_data.image]
916+
# Load all images in parallel for better performance
917+
form_data.image = list(await asyncio.gather(*[load_url_image(img) for img in form_data.image]))
914918
except Exception as e:
915919
raise HTTPException(status_code=400, detail=ERROR_MESSAGES.DEFAULT(e))
916920

backend/open_webui/routers/prompts.py

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
PromptForm,
66
PromptUserResponse,
77
PromptAccessResponse,
8+
PromptAccessListResponse,
89
PromptModel,
910
Prompts,
1011
)
12+
from open_webui.models.groups import Groups
1113
from open_webui.models.prompt_history import (
1214
PromptHistories,
1315
PromptHistoryModel,
@@ -34,6 +36,8 @@ class PromptMetadataForm(BaseModel):
3436

3537
router = APIRouter()
3638

39+
PAGE_ITEM_COUNT = 30
40+
3741

3842
############################
3943
# GetPrompts
@@ -67,26 +71,57 @@ async def get_prompt_tags(
6771
return sorted(list(tags))
6872

6973

70-
@router.get("/list", response_model=list[PromptAccessResponse])
74+
@router.get("/list", response_model=PromptAccessListResponse)
7175
async def get_prompt_list(
72-
user=Depends(get_verified_user), db: Session = Depends(get_session)
76+
query: Optional[str] = None,
77+
view_option: Optional[str] = None,
78+
tag: Optional[str] = None,
79+
order_by: Optional[str] = None,
80+
direction: Optional[str] = None,
81+
page: Optional[int] = 1,
82+
user=Depends(get_verified_user),
83+
db: Session = Depends(get_session),
7384
):
74-
if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
75-
prompts = Prompts.get_prompts(db=db)
76-
else:
77-
prompts = Prompts.get_prompts_by_user_id(user.id, "read", db=db)
78-
79-
return [
80-
PromptAccessResponse(
81-
**prompt.model_dump(),
82-
write_access=(
83-
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
84-
or user.id == prompt.user_id
85-
or has_access(user.id, "write", prompt.access_control, db=db)
86-
),
87-
)
88-
for prompt in prompts
89-
]
85+
limit = PAGE_ITEM_COUNT
86+
87+
page = max(1, page)
88+
skip = (page - 1) * limit
89+
90+
filter = {}
91+
if query:
92+
filter["query"] = query
93+
if view_option:
94+
filter["view_option"] = view_option
95+
if tag:
96+
filter["tag"] = tag
97+
if order_by:
98+
filter["order_by"] = order_by
99+
if direction:
100+
filter["direction"] = direction
101+
102+
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
103+
groups = Groups.get_groups_by_member_id(user.id, db=db)
104+
if groups:
105+
filter["group_ids"] = [group.id for group in groups]
106+
107+
filter["user_id"] = user.id
108+
109+
result = Prompts.search_prompts(user.id, filter=filter, skip=skip, limit=limit, db=db)
110+
111+
return PromptAccessListResponse(
112+
items=[
113+
PromptAccessResponse(
114+
**prompt.model_dump(),
115+
write_access=(
116+
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
117+
or user.id == prompt.user_id
118+
or has_access(user.id, "write", prompt.access_control, db=db)
119+
),
120+
)
121+
for prompt in result.items
122+
],
123+
total=result.total,
124+
)
90125

91126

92127
############################

backend/open_webui/tools/builtin.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from open_webui.models.channels import Channels, ChannelMember, Channel
3737
from open_webui.models.messages import Messages, Message
3838
from open_webui.models.groups import Groups
39+
from open_webui.utils.sanitize import strip_markdown_code_fences
3940

4041
log = logging.getLogger(__name__)
4142

@@ -370,6 +371,9 @@ async def execute_code(
370371
return json.dumps({"error": "Request context not available"})
371372

372373
try:
374+
# Strip markdown fences if model included them
375+
code = strip_markdown_code_fences(code)
376+
373377
# Import blocked modules from config (same as middleware)
374378
from open_webui.config import CODE_INTERPRETER_BLOCKED_MODULES
375379

backend/open_webui/utils/files.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from open_webui.models.chats import Chats
1919
from open_webui.models.files import Files
2020
from open_webui.routers.files import upload_file_handler
21+
from open_webui.retrieval.web.utils import validate_url
2122

2223
import mimetypes
2324
import base64
@@ -33,6 +34,8 @@
3334
def get_image_base64_from_url(url: str) -> Optional[str]:
3435
try:
3536
if url.startswith("http"):
37+
# Validate URL to prevent SSRF attacks against local/private networks
38+
validate_url(url)
3639
# Download the image from the URL
3740
response = requests.get(url)
3841
response.raise_for_status()

src/lib/apis/prompts/index.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,67 @@ export const getPromptTags = async (token: string = '') => {
136136
return res;
137137
};
138138

139+
export const getPromptItems = async (
140+
token: string = '',
141+
query: string | null,
142+
viewOption: string | null,
143+
selectedTag: string | null,
144+
orderBy: string | null,
145+
direction: string | null,
146+
page: number
147+
) => {
148+
let error = null;
149+
150+
const searchParams = new URLSearchParams();
151+
if (query) {
152+
searchParams.append('query', query);
153+
}
154+
if (viewOption) {
155+
searchParams.append('view_option', viewOption);
156+
}
157+
if (selectedTag) {
158+
searchParams.append('tag', selectedTag);
159+
}
160+
if (orderBy) {
161+
searchParams.append('order_by', orderBy);
162+
}
163+
if (direction) {
164+
searchParams.append('direction', direction);
165+
}
166+
if (page) {
167+
searchParams.append('page', page.toString());
168+
}
169+
170+
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list?${searchParams.toString()}`, {
171+
method: 'GET',
172+
headers: {
173+
Accept: 'application/json',
174+
'Content-Type': 'application/json',
175+
authorization: `Bearer ${token}`
176+
}
177+
})
178+
.then(async (res) => {
179+
if (!res.ok) throw await res.json();
180+
return res.json();
181+
})
182+
.then((json) => {
183+
return json;
184+
})
185+
.catch((err) => {
186+
error = err;
187+
console.error(err);
188+
return null;
189+
});
190+
191+
if (error) {
192+
throw error;
193+
}
194+
195+
return res;
196+
};
197+
139198
export const getPromptList = async (token: string = '') => {
199+
140200
let error = null;
141201

142202
const res = await fetch(`${WEBUI_API_BASE_URL}/prompts/list`, {

0 commit comments

Comments
 (0)