Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
S
Son Of Anton
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Son Of Anton
Commits
d55ca0c4
Commit
d55ca0c4
authored
Mar 19, 2026
by
Mahmoud Aglan
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fghfghjfghfb nfgmn
parent
41f45d5e
Changes
6
Expand all
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
808 additions
and
18 deletions
+808
-18
Dockerfile
Dockerfile
+5
-17
attachment_routes.py
backend/routes/attachment_routes.py
+129
-0
attachment_routes_15.py
backend/routes/attachment_routes_15.py
+159
-0
attachment_routes_16.py
backend/routes/attachment_routes_16.py
+165
-0
attachment_service.py
backend/services/attachment_service.py
+348
-0
requirements.txt
requirements.txt
+2
-1
No files found.
Dockerfile
View file @
d55ca0c4
...
...
@@ -4,22 +4,10 @@
FROM
node:20-alpine AS frontend-build
WORKDIR
/build/frontend
# Copy everything so lockfile, configs (vite, tailwind, postcss) are all present
COPY
frontend/package.json frontend/package-lock.json* ./
RUN
npm
install
--legacy-peer-deps
COPY
frontend/ ./
# Install deps: use ci if lockfile exists, otherwise install and generate one
RUN if
[
-f
package-lock.json
]
;
then
\
echo
"📦 Found package-lock.json — running npm ci"
&&
\
npm ci
--legacy-peer-deps
;
\
else
\
echo
"⚠️ No package-lock.json — running npm install"
&&
\
npm
install
--legacy-peer-deps
;
\
fi
&&
\
npm cache clean
--force
# Build production bundle
RUN
NODE_ENV
=
production npm run build
RUN
npm run build
# ============================================
# Stage 2: Python Backend + Serve Frontend
...
...
@@ -28,6 +16,7 @@ FROM python:3.11-slim
RUN
apt-get update
&&
apt-get
install
-y
--no-install-recommends
\
build-essential
\
ffmpeg
\
&&
rm
-rf
/var/lib/apt/lists/
*
WORKDIR
/app
...
...
@@ -40,12 +29,11 @@ COPY backend/ ./backend/
COPY
--from=frontend-build /build/frontend/dist ./frontend/dist
# Warm up the ChromaDB embedding model so first request is fast
# Using a separate script file to avoid all quoting issues
COPY
warmup.py /tmp/warmup.py
RUN
python /tmp/warmup.py
&&
rm
/tmp/warmup.py
# Create persistent data directories
RUN
mkdir
-p
/data/chromadb /data/uploads
RUN
mkdir
-p
/data/chromadb /data/uploads
/data/uploads/chat_attachments
ENV
PYTHONUNBUFFERED=1
...
...
backend/routes/attachment_routes.py
0 → 100644
View file @
d55ca0c4
"""
Chat attachment upload, serve, and delete routes.
"""
import
os
from
fastapi
import
APIRouter
,
Depends
,
HTTPException
,
UploadFile
,
File
from
fastapi.responses
import
FileResponse
from
sqlalchemy.orm
import
Session
from
backend.database
import
get_db
from
backend.models
import
User
,
Chat
,
ChatAttachment
from
backend.auth
import
get_current_user
from
backend.services
import
attachment_service
from
backend.config
import
MAX_ATTACHMENT_BYTES
router
=
APIRouter
()
@
router
.
post
(
"/chats/{chat_id}/attachments"
)
async
def
upload_attachments
(
chat_id
:
str
,
files
:
list
[
UploadFile
]
=
File
(
...
),
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Upload one or more files as chat attachments. Returns attachment metadata."""
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
chat_id
,
Chat
.
user_id
==
user
.
id
)
.
first
()
if
not
chat
:
raise
HTTPException
(
404
,
"Chat not found"
)
results
=
[]
for
file
in
files
:
filename
=
file
.
filename
or
"file"
try
:
content
=
await
file
.
read
()
if
len
(
content
)
>
MAX_ATTACHMENT_BYTES
:
results
.
append
({
"error"
:
f
"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB."
,
})
continue
meta
=
attachment_service
.
save_attachment
(
chat_id
=
chat_id
,
filename
=
filename
,
content
=
content
,
content_type
=
file
.
content_type
,
)
att
=
ChatAttachment
(
id
=
meta
[
"id"
],
chat_id
=
chat_id
,
filename
=
meta
[
"filename"
],
original_filename
=
meta
[
"original_filename"
],
mime_type
=
meta
[
"mime_type"
],
file_type
=
meta
[
"file_type"
],
file_size
=
meta
[
"file_size"
],
storage_path
=
meta
[
"storage_path"
],
text_extract
=
meta
.
get
(
"text_extract"
),
)
db
.
add
(
att
)
db
.
commit
()
db
.
refresh
(
att
)
results
.
append
(
_att_dict
(
att
))
except
Exception
as
e
:
results
.
append
({
"error"
:
f
"Failed to upload {filename}: {str(e)}"
})
return
{
"attachments"
:
results
}
@
router
.
get
(
"/attachments/{attachment_id}/file"
)
def
serve_attachment
(
attachment_id
:
str
,
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Serve an attachment file. Validates user owns the chat."""
att
=
db
.
query
(
ChatAttachment
)
.
filter
(
ChatAttachment
.
id
==
attachment_id
)
.
first
()
if
not
att
:
raise
HTTPException
(
404
,
"Attachment not found"
)
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
att
.
chat_id
)
.
first
()
if
not
chat
or
(
chat
.
user_id
!=
user
.
id
and
user
.
role
!=
"superadmin"
):
raise
HTTPException
(
403
,
"Access denied"
)
if
not
os
.
path
.
exists
(
att
.
storage_path
):
raise
HTTPException
(
404
,
"File not found on disk"
)
return
FileResponse
(
att
.
storage_path
,
media_type
=
att
.
mime_type
,
filename
=
att
.
original_filename
,
)
@
router
.
delete
(
"/attachments/{attachment_id}"
)
def
delete_attachment
(
attachment_id
:
str
,
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Delete a single attachment."""
att
=
db
.
query
(
ChatAttachment
)
.
filter
(
ChatAttachment
.
id
==
attachment_id
)
.
first
()
if
not
att
:
raise
HTTPException
(
404
)
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
att
.
chat_id
)
.
first
()
if
not
chat
or
(
chat
.
user_id
!=
user
.
id
and
user
.
role
!=
"superadmin"
):
raise
HTTPException
(
403
)
attachment_service
.
delete_attachment_file
(
att
.
storage_path
)
db
.
delete
(
att
)
db
.
commit
()
return
{
"ok"
:
True
}
def
_att_dict
(
att
:
ChatAttachment
)
->
dict
:
return
{
"id"
:
att
.
id
,
"chat_id"
:
att
.
chat_id
,
"message_id"
:
att
.
message_id
,
"filename"
:
att
.
filename
,
"original_filename"
:
att
.
original_filename
,
"mime_type"
:
att
.
mime_type
,
"file_type"
:
att
.
file_type
,
"file_size"
:
att
.
file_size
,
"created_at"
:
str
(
att
.
created_at
),
}
\ No newline at end of file
backend/routes/attachment_routes_15.py
0 → 100644
View file @
d55ca0c4
"""
Chat attachment upload, serve, and delete routes.
"""
import
os
from
typing
import
Optional
from
fastapi
import
APIRouter
,
Depends
,
HTTPException
,
UploadFile
,
File
,
Query
from
fastapi.responses
import
FileResponse
from
sqlalchemy.orm
import
Session
from
backend.database
import
get_db
from
backend.models
import
User
,
Chat
,
ChatAttachment
from
backend.auth
import
get_current_user
,
decode_token
from
backend.services
import
attachment_service
from
backend.config
import
MAX_ATTACHMENT_BYTES
router
=
APIRouter
()
@
router
.
post
(
"/chats/{chat_id}/attachments"
)
async
def
upload_attachments
(
chat_id
:
str
,
files
:
list
[
UploadFile
]
=
File
(
...
),
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Upload one or more files as chat attachments. Returns attachment metadata."""
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
chat_id
,
Chat
.
user_id
==
user
.
id
)
.
first
()
if
not
chat
:
raise
HTTPException
(
404
,
"Chat not found"
)
results
=
[]
for
file
in
files
:
filename
=
file
.
filename
or
"file"
try
:
content
=
await
file
.
read
()
if
len
(
content
)
>
MAX_ATTACHMENT_BYTES
:
results
.
append
({
"error"
:
f
"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB."
,
})
continue
meta
=
attachment_service
.
save_attachment
(
chat_id
=
chat_id
,
filename
=
filename
,
content
=
content
,
content_type
=
file
.
content_type
,
)
att
=
ChatAttachment
(
id
=
meta
[
"id"
],
chat_id
=
chat_id
,
filename
=
meta
[
"filename"
],
original_filename
=
meta
[
"original_filename"
],
mime_type
=
meta
[
"mime_type"
],
file_type
=
meta
[
"file_type"
],
file_size
=
meta
[
"file_size"
],
storage_path
=
meta
[
"storage_path"
],
text_extract
=
meta
.
get
(
"text_extract"
),
)
db
.
add
(
att
)
db
.
commit
()
db
.
refresh
(
att
)
results
.
append
(
_att_dict
(
att
))
except
Exception
as
e
:
results
.
append
({
"error"
:
f
"Failed to upload {filename}: {str(e)}"
})
return
{
"attachments"
:
results
}
@
router
.
get
(
"/attachments/{attachment_id}/file"
)
def
serve_attachment
(
attachment_id
:
str
,
token
:
Optional
[
str
]
=
Query
(
None
),
user
:
Optional
[
User
]
=
Depends
(
_optional_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""
Serve an attachment file.
Supports both Bearer header auth and ?token= query param
(needed for <img> tags that can't send headers).
"""
# Try query param auth if header auth didn't work
if
user
is
None
and
token
:
try
:
payload
=
decode_token
(
token
)
user
=
db
.
query
(
User
)
.
filter
(
User
.
id
==
payload
[
"sub"
])
.
first
()
except
Exception
:
pass
if
user
is
None
:
raise
HTTPException
(
401
,
"Authentication required"
)
att
=
db
.
query
(
ChatAttachment
)
.
filter
(
ChatAttachment
.
id
==
attachment_id
)
.
first
()
if
not
att
:
raise
HTTPException
(
404
,
"Attachment not found"
)
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
att
.
chat_id
)
.
first
()
if
not
chat
or
(
chat
.
user_id
!=
user
.
id
and
user
.
role
!=
"superadmin"
):
raise
HTTPException
(
403
,
"Access denied"
)
if
not
os
.
path
.
exists
(
att
.
storage_path
):
raise
HTTPException
(
404
,
"File not found on disk"
)
return
FileResponse
(
att
.
storage_path
,
media_type
=
att
.
mime_type
,
filename
=
att
.
original_filename
,
)
@
router
.
delete
(
"/attachments/{attachment_id}"
)
def
delete_attachment
(
attachment_id
:
str
,
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Delete a single attachment."""
att
=
db
.
query
(
ChatAttachment
)
.
filter
(
ChatAttachment
.
id
==
attachment_id
)
.
first
()
if
not
att
:
raise
HTTPException
(
404
)
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
att
.
chat_id
)
.
first
()
if
not
chat
or
(
chat
.
user_id
!=
user
.
id
and
user
.
role
!=
"superadmin"
):
raise
HTTPException
(
403
)
attachment_service
.
delete_attachment_file
(
att
.
storage_path
)
db
.
delete
(
att
)
db
.
commit
()
return
{
"ok"
:
True
}
def
_optional_current_user
(
db
:
Session
=
Depends
(
get_db
),
):
"""
A dependency that tries to get current user but returns None on failure.
This allows the endpoint to also accept ?token= query param.
"""
# This is a placeholder — the actual auth is handled in the route
# by checking both header and query param
return
None
def
_att_dict
(
att
:
ChatAttachment
)
->
dict
:
return
{
"id"
:
att
.
id
,
"chat_id"
:
att
.
chat_id
,
"message_id"
:
att
.
message_id
,
"filename"
:
att
.
filename
,
"original_filename"
:
att
.
original_filename
,
"mime_type"
:
att
.
mime_type
,
"file_type"
:
att
.
file_type
,
"file_size"
:
att
.
file_size
,
"created_at"
:
str
(
att
.
created_at
),
}
\ No newline at end of file
backend/routes/attachment_routes_16.py
0 → 100644
View file @
d55ca0c4
"""
Chat attachment upload, serve, and delete routes.
"""
import
os
from
typing
import
Optional
from
fastapi
import
APIRouter
,
Depends
,
HTTPException
,
UploadFile
,
File
,
Query
,
Request
from
fastapi.responses
import
FileResponse
from
sqlalchemy.orm
import
Session
from
backend.database
import
get_db
from
backend.models
import
User
,
Chat
,
ChatAttachment
from
backend.auth
import
get_current_user
,
decode_token
from
backend.services
import
attachment_service
from
backend.config
import
MAX_ATTACHMENT_BYTES
router
=
APIRouter
()
def
_get_user_from_request
(
request
:
Request
,
db
:
Session
,
token_param
:
Optional
[
str
]
=
None
)
->
User
:
"""
Resolve user from either:
1. Authorization: Bearer <token> header
2. ?token=<token> query parameter (for img/video tags)
"""
raw_token
=
None
# Try header first
auth_header
=
request
.
headers
.
get
(
"authorization"
,
""
)
if
auth_header
.
startswith
(
"Bearer "
):
raw_token
=
auth_header
[
7
:]
# Fall back to query param
if
not
raw_token
and
token_param
:
raw_token
=
token_param
if
not
raw_token
:
raise
HTTPException
(
401
,
"Authentication required"
)
payload
=
decode_token
(
raw_token
)
user
=
db
.
query
(
User
)
.
filter
(
User
.
id
==
payload
[
"sub"
])
.
first
()
if
not
user
or
not
user
.
is_active
:
raise
HTTPException
(
401
,
"User not found or inactive"
)
return
user
@
router
.
post
(
"/chats/{chat_id}/attachments"
)
async
def
upload_attachments
(
chat_id
:
str
,
files
:
list
[
UploadFile
]
=
File
(
...
),
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Upload one or more files as chat attachments. Returns attachment metadata."""
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
chat_id
,
Chat
.
user_id
==
user
.
id
)
.
first
()
if
not
chat
:
raise
HTTPException
(
404
,
"Chat not found"
)
results
=
[]
for
file
in
files
:
filename
=
file
.
filename
or
"file"
try
:
content
=
await
file
.
read
()
if
len
(
content
)
>
MAX_ATTACHMENT_BYTES
:
results
.
append
({
"error"
:
f
"File too large: {filename} ({len(content) // 1024 // 1024}MB). Max {MAX_ATTACHMENT_BYTES // 1024 // 1024}MB."
,
})
continue
meta
=
attachment_service
.
save_attachment
(
chat_id
=
chat_id
,
filename
=
filename
,
content
=
content
,
content_type
=
file
.
content_type
,
)
att
=
ChatAttachment
(
id
=
meta
[
"id"
],
chat_id
=
chat_id
,
filename
=
meta
[
"filename"
],
original_filename
=
meta
[
"original_filename"
],
mime_type
=
meta
[
"mime_type"
],
file_type
=
meta
[
"file_type"
],
file_size
=
meta
[
"file_size"
],
storage_path
=
meta
[
"storage_path"
],
text_extract
=
meta
.
get
(
"text_extract"
),
)
db
.
add
(
att
)
db
.
commit
()
db
.
refresh
(
att
)
results
.
append
(
_att_dict
(
att
))
except
Exception
as
e
:
results
.
append
({
"error"
:
f
"Failed to upload {filename}: {str(e)}"
})
return
{
"attachments"
:
results
}
@
router
.
get
(
"/attachments/{attachment_id}/file"
)
def
serve_attachment
(
attachment_id
:
str
,
request
:
Request
,
token
:
Optional
[
str
]
=
Query
(
None
),
db
:
Session
=
Depends
(
get_db
),
):
"""
Serve an attachment file.
Supports both Bearer header auth and ?token= query param
(needed for <img> tags that can't send headers).
"""
user
=
_get_user_from_request
(
request
,
db
,
token
)
att
=
db
.
query
(
ChatAttachment
)
.
filter
(
ChatAttachment
.
id
==
attachment_id
)
.
first
()
if
not
att
:
raise
HTTPException
(
404
,
"Attachment not found"
)
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
att
.
chat_id
)
.
first
()
if
not
chat
or
(
chat
.
user_id
!=
user
.
id
and
user
.
role
!=
"superadmin"
):
raise
HTTPException
(
403
,
"Access denied"
)
if
not
os
.
path
.
exists
(
att
.
storage_path
):
raise
HTTPException
(
404
,
"File not found on disk"
)
return
FileResponse
(
att
.
storage_path
,
media_type
=
att
.
mime_type
,
filename
=
att
.
original_filename
,
)
@
router
.
delete
(
"/attachments/{attachment_id}"
)
def
delete_attachment
(
attachment_id
:
str
,
user
:
User
=
Depends
(
get_current_user
),
db
:
Session
=
Depends
(
get_db
),
):
"""Delete a single attachment."""
att
=
db
.
query
(
ChatAttachment
)
.
filter
(
ChatAttachment
.
id
==
attachment_id
)
.
first
()
if
not
att
:
raise
HTTPException
(
404
)
chat
=
db
.
query
(
Chat
)
.
filter
(
Chat
.
id
==
att
.
chat_id
)
.
first
()
if
not
chat
or
(
chat
.
user_id
!=
user
.
id
and
user
.
role
!=
"superadmin"
):
raise
HTTPException
(
403
)
attachment_service
.
delete_attachment_file
(
att
.
storage_path
)
db
.
delete
(
att
)
db
.
commit
()
return
{
"ok"
:
True
}
def
_att_dict
(
att
:
ChatAttachment
)
->
dict
:
return
{
"id"
:
att
.
id
,
"chat_id"
:
att
.
chat_id
,
"message_id"
:
att
.
message_id
,
"filename"
:
att
.
filename
,
"original_filename"
:
att
.
original_filename
,
"mime_type"
:
att
.
mime_type
,
"file_type"
:
att
.
file_type
,
"file_size"
:
att
.
file_size
,
"created_at"
:
str
(
att
.
created_at
),
}
\ No newline at end of file
backend/services/attachment_service.py
0 → 100644
View file @
d55ca0c4
This diff is collapsed.
Click to expand it.
requirements.txt
View file @
d55ca0c4
...
...
@@ -8,4 +8,5 @@ python-multipart==0.0.20
httpx
==0.28.1
chromadb
==0.6.3
PyPDF2
==3.0.1
pydantic
==2.10.4
\ No newline at end of file
pydantic
==2.10.4
Pillow
==11.1.0
\ No newline at end of file
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment