This commit is contained in:
Timothy Jaeryang Baek 2026-10-24 04:50:12 +09:00
parent 385d8c1d02
commit 2262073146
5 changed files with 81 additions and 11 deletions

View file

@ -2531,7 +2531,8 @@ class ChatTable:
chat = await session.get(Chat, id)
chat.folder_id = folder_id
chat.updated_at = int(time.time())
chat.last_read_at = int(time.time())
if chat.user_id == user_id:
chat.last_read_at = int(time.time())
chat.pinned = False
if folder_id is not None:
# Folder listings only show unarchived chats, so moving an archived

View file

@ -40,7 +40,7 @@ from open_webui.models.tags import TagModel, Tags
from open_webui.socket.main import get_event_emitter
from open_webui.tasks import get_response_streams_by_chat_id, has_active_tasks, stop_item_tasks
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.access_control.folders import has_folder_write_access
from open_webui.utils.access_control.folders import has_chat_move_access, has_folder_write_access
from open_webui.utils.auth import bearer_security, get_admin_user, get_current_user, get_verified_user
from open_webui.utils.chat_fork import build_fork_history
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
@ -1452,7 +1452,20 @@ async def update_chat_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
chat = await Chats.get_chat_by_id(id, db=db)
if chat and chat.user_id != user.id:
folder = await Folders.get_folder_by_id_and_user_id(chat.folder_id, user.id, db=db) if chat.folder_id else None
if (
not folder
or set(form_data.chat) != {'title'}
or form_data.variables is not None
or form_data.folder_id is not None
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.ACCESS_PROHIBITED)
title = form_data.chat['title']
if not isinstance(title, str) or not title.strip():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT('Invalid title'))
form_data.chat['title'] = title.strip()
if chat:
touch = 'history' in form_data.chat or 'messages' in form_data.chat
chat = await Chats.update_chat_by_id(id, form_data.chat, db=db, touch=touch)
@ -1474,6 +1487,8 @@ async def update_chat_by_id(
subject_id=id,
data={'title': chat.title},
)
if chat.user_id != user.id:
return await shared_chat_response(chat, user, db=db)
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(
@ -2291,7 +2306,10 @@ async def update_chat_folder_id_by_id(
user=Depends(get_verified_user),
db: AsyncSession = Depends(get_async_session),
):
chat = await Chats.get_chat_by_id_and_user_id(id, user.id, db=db)
chat = await Chats.get_chat_by_id(id, db=db)
if chat and chat.user_id != user.id:
if not await has_chat_move_access(user.id, chat.folder_id, form_data.folder_id, db=db):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())
if chat:
# None is allowed: it moves the chat out of any folder.
if form_data.folder_id is not None and not await has_folder_write_access(user.id, form_data.folder_id, db=db):
@ -2308,6 +2326,8 @@ async def update_chat_folder_id_by_id(
subject_id=id,
data={'folder_id': form_data.folder_id},
)
if chat.user_id != user.id:
return await shared_chat_response(chat, user, db=db)
return ChatResponse.model_validate(chat, from_attributes=True)
else:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT())

View file

@ -32,3 +32,28 @@ async def has_folder_write_access(user_id: str, folder_id: str, db: AsyncSession
if not folder:
return False
return await has_folder_access(user_id, folder, 'write', db)
async def has_chat_move_access(
user_id: str, source_folder_id: str | None, target_folder_id: str | None, db: AsyncSession | None = None
) -> bool:
"""Only the owner may reorganize another user's chat within the same folder tree."""
async def get_owned_folder_root_id(folder_id):
seen_ids = set()
shared_root_id = None
while folder_id and folder_id not in seen_ids:
seen_ids.add(folder_id)
folder = await Folders.get_folder_by_id(folder_id, db=db)
if not folder or folder.user_id != user_id:
return None
if await AccessGrants.get_grants_by_resource('folder', folder.id, db=db):
shared_root_id = folder.id
if not folder.parent_id:
# A private parent must not let moves escape a shared subtree.
return shared_root_id or folder.id
folder_id = folder.parent_id
return None
source_root_id = await get_owned_folder_root_id(source_folder_id)
return source_root_id is not None and source_root_id == await get_owned_folder_root_id(target_folder_id)

View file

@ -57,6 +57,7 @@
import ChatParticipants from '$lib/components/common/ChatParticipants.svelte';
import ChatIcon from './icons/Chat.svelte';
import MoreHorizontalIcon from './icons/MoreHorizontal.svelte';
import EditPencilIcon from './icons/EditPencil.svelte';
import SparklesIcon from './icons/Sparkles.svelte';
import ArchiveBoxIcon from '$lib/components/icons/ArchiveBox.svelte';
import GarbageBinIcon from '$lib/components/icons/GarbageBin.svelte';
@ -96,6 +97,8 @@
export let selected = false;
export let shiftKey = false;
export let readonly = false;
export let canMove = false;
export let canRename = false;
export let ownerName: string | null = null;
export let ownerUserId: string | null = null;
@ -211,7 +214,7 @@
};
const renameChatFromDoubleClick = async (e: MouseEvent) => {
if (readonly || selecting || e.shiftKey) return;
if ((readonly && !canRename) || selecting || e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
@ -249,9 +252,13 @@
if (!title) {
toast.error($i18n.t('Title cannot be an empty string.'));
} else {
await updateChatById(localStorage.token, id, {
const res = await updateChatById(localStorage.token, id, {
title: title
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (!res) return;
if (id === $chatId) {
_chatTitle.set(title);
@ -573,7 +580,9 @@
dir="auto"
class="text-left self-center overflow-hidden w-full h-5 truncate {unread
? 'font-normal text-gray-800 dark:text-gray-200'
: ''} {($mobile || showInlineActions) && !readonly && !selecting ? 'pr-12' : ''}"
: ''} {($mobile || showInlineActions) && (!readonly || canRename) && !selecting
? 'pr-12'
: ''}"
>
{title}
</div>
@ -588,7 +597,7 @@
{/if}
<!-- Time ago indicator -->
{#if (updatedAt ?? createdAt) && !showInlineActions && !($mobile && !readonly)}
{#if (updatedAt ?? createdAt) && !showInlineActions && !($mobile && (!readonly || canRename))}
<div
class="shrink-0 self-center text-[0.625rem] text-gray-400 dark:text-gray-500 pl-2"
aria-hidden="true"
@ -632,7 +641,7 @@
data-sidebar-chat-readonly={readonly}
bind:this={itemElement}
class=" w-full {className} relative group"
draggable={!confirmEdit && !readonly && !selecting}
draggable={!confirmEdit && (!readonly || canMove) && !selecting}
on:mouseenter={() => {
mouseOver = true;
}}
@ -763,7 +772,7 @@
</div>
{/if}
{#if !readonly && !selecting}
{#if (!readonly || canRename) && !selecting}
<div
id="sidebar-chat-item-menu"
class="{$mobile
@ -774,7 +783,20 @@
? 'right-[0.5rem]'
: 'right-1'} inset-y-0 mr-1.5 flex items-center"
>
{#if confirmEdit}
{#if readonly}
{#if !confirmEdit}
<Tooltip content={$i18n.t('Rename')} className="flex self-center z-10 items-end">
<button
type="button"
aria-label={$i18n.t('Rename')}
class="flex size-5 items-center justify-center self-center dark:hover:text-white transition"
on:click={renameHandler}
>
<EditPencilIcon className="size-3.5" strokeWidth="1.5" />
</button>
</Tooltip>
{/if}
{:else if confirmEdit}
<div
class="flex self-center items-center space-x-1.5 z-10 translate-y-[0.5px] -translate-x-[0.5px]"
>

View file

@ -1013,6 +1013,8 @@
? chat.user_id
: null}
readonly={chat.user_id !== $user?.id}
canMove={folders[folderId]?.user_id === $user?.id}
canRename={folders[folderId]?.user_id === $user?.id}
{shiftKey}
onReadStateChange={applyReadState}
on:change={(e) => {