This commit is contained in:
javad committed 2026-09-10 11:34:19 +03:30
commit fa9d154db9
260 files changed
+17643

No files matched your search

View File
Whitespace-only changes.
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
from .models import Attachment,AttachmentPolicy
admin.site.register([Attachment,AttachmentPolicy])
+2
View File
@@ -0,0 +1,2 @@
from django.apps import AppConfig
class AttachmentsConfig(AppConfig): name="apps.attachments"
+21
View File
@@ -0,0 +1,21 @@
import base64
import hashlib
import time
from urllib.parse import quote, urlencode
from django.conf import settings
from django.http import HttpResponseRedirect
def signed_media_redirect(relative_path, *, download=False, lifetime=300):
"""Create an Nginx secure_link URL after Django has authorized the request."""
safe_path = quote(str(relative_path).replace("\\", "/").lstrip("/"), safe="/")
uri = f"/protected-media/{safe_path}"
expires = int(time.time()) + lifetime
source = f"{expires}{uri} {settings.MEDIA_SIGNING_SECRET}".encode()
digest = hashlib.md5(source, usedforsecurity=False).digest()
signature = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
query = {"md5": signature, "expires": expires}
if download:
query["download"] = "1"
return HttpResponseRedirect(f"{uri}?{urlencode(query)}")
@@ -0,0 +1,49 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03
import apps.attachments.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('file', models.FileField(upload_to=apps.attachments.models.attachment_path)),
('original_name', models.CharField(max_length=255)),
('stored_name', models.CharField(max_length=255)),
('relative_path', models.CharField(max_length=500)),
('attachment_type', models.CharField(choices=[('IMAGE', 'تصویر'), ('VIDEO', 'ویدئو'), ('VOICE', 'صدا'), ('PDF', 'PDF'), ('WORD', 'Word'), ('EXCEL', 'Excel'), ('ZIP', 'ZIP'), ('RAR', 'RAR'), ('OTHER', 'سایر')], max_length=20)),
('mime_type', models.CharField(max_length=150)),
('extension', models.CharField(max_length=20)),
('file_size', models.PositiveBigIntegerField()),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'permissions': [('download_attachment', 'Can download attachment'), ('manage_attachment_policy', 'Can manage attachment policy')],
},
),
migrations.CreateModel(
name='AttachmentPolicy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('attachment_type', models.CharField(max_length=20, unique=True)),
('is_enabled', models.BooleanField(default=True)),
('max_file_size', models.PositiveBigIntegerField()),
('max_files_per_message', models.PositiveSmallIntegerField(default=10)),
('allowed_extensions', models.JSONField(default=list)),
('allowed_mime_types', models.JSONField(default=list)),
('sort_order', models.PositiveIntegerField(default=0)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.1.1 on 2026-09-05 07:03
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('attachments', '0001_initial'),
('messaging', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='attachment',
name='message',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attachments', to='messaging.message'),
),
migrations.AddField(
model_name='attachment',
name='uploaded_by',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2026-09-05 08:31
import apps.attachments.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('attachments', '0002_initial'),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='file',
field=models.FileField(max_length=500, upload_to=apps.attachments.models.attachment_path),
),
]
Whitespace-only changes.
+14
View File
@@ -0,0 +1,14 @@
import uuid
from pathlib import Path
from django.conf import settings
from django.db import models
from apps.core.models import PublicIdModel
def attachment_path(instance,filename):
ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message
return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}"
class AttachmentPolicy(models.Model):
attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True)
class Attachment(PublicIdModel,models.Model):
class Type(models.TextChoices): IMAGE="IMAGE","تصویر"; VIDEO="VIDEO","ویدئو"; VOICE="VOICE","صدا"; PDF="PDF","PDF"; WORD="WORD","Word"; EXCEL="EXCEL","Excel"; ZIP="ZIP","ZIP"; RAR="RAR","RAR"; OTHER="OTHER","سایر"
message=models.ForeignKey("messaging.Message",on_delete=models.PROTECT,related_name="attachments"); file=models.FileField(upload_to=attachment_path,max_length=500); original_name=models.CharField(max_length=255); stored_name=models.CharField(max_length=255); relative_path=models.CharField(max_length=500); attachment_type=models.CharField(max_length=20,choices=Type.choices); mime_type=models.CharField(max_length=150); extension=models.CharField(max_length=20); file_size=models.PositiveBigIntegerField(); uploaded_by=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.PROTECT); created_at=models.DateTimeField(auto_now_add=True)
class Meta: permissions=[("download_attachment","Can download attachment"),("manage_attachment_policy","Can manage attachment policy")]
+32
View File
@@ -0,0 +1,32 @@
import mimetypes
import magic
from pathlib import Path
from django.core.exceptions import ValidationError
from .models import Attachment,AttachmentPolicy
TYPE_EXT={"IMAGE":{".jpg",".jpeg",".png",".gif",".webp"},"VIDEO":{".mp4",".webm",".mov"},"VOICE":{".webm",".ogg",".mp3",".m4a",".wav"},"PDF":{".pdf"},"WORD":{".doc",".docx"},"EXCEL":{".xls",".xlsx"},"ZIP":{".zip"},"RAR":{".rar"}}
def classify(ext,mime):
if ext==".webm" and str(mime).startswith("audio/"): return "VOICE"
for kind,extensions in TYPE_EXT.items():
if ext in extensions:return kind
return "OTHER"
def save_attachments(message,files,user):
files=list(files); result=[]
counts={}
for upload in files:
ext=Path(Path(upload.name).name).suffix.lower(); kind=classify(ext,getattr(upload,"content_type", "")); counts[kind]=counts.get(kind,0)+1
for kind,count in counts.items():
policy=AttachmentPolicy.objects.filter(attachment_type=kind,is_enabled=True).first()
if not policy: raise ValidationError(f"نوع فایل {kind} مجاز نیست.")
if count>policy.max_files_per_message: raise ValidationError(f"تعداد فایل‌های {kind} بیش از حد مجاز است.")
for upload in files:
name=Path(upload.name).name[:255]; ext=Path(name).suffix.lower(); declared=getattr(upload,"content_type","") or mimetypes.guess_type(name)[0] or "application/octet-stream"
head=upload.read(8192); upload.seek(0); detected=magic.from_buffer(head,mime=True) or "application/octet-stream"; kind=classify(ext,declared)
policy=AttachmentPolicy.objects.filter(attachment_type=kind,is_enabled=True).first()
if not policy: raise ValidationError(f"نوع فایل {ext or name} مجاز نیست.")
if upload.size>policy.max_file_size: raise ValidationError(f"حجم فایل {name} بیش از حد مجاز است.")
if policy.allowed_extensions and ext not in policy.allowed_extensions: raise ValidationError("پسوند فایل مجاز نیست.")
if policy.allowed_mime_types and detected not in policy.allowed_mime_types: raise ValidationError("نوع محتوای واقعی فایل مجاز نیست.")
compatible={"IMAGE":detected.startswith("image/"),"VIDEO":detected.startswith("video/"),"VOICE":detected.startswith("audio/") or detected in {"video/webm","application/ogg"},"PDF":detected=="application/pdf","ZIP":detected in {"application/zip","application/x-zip-compressed"}}.get(kind,True)
if not compatible: raise ValidationError(f"محتوای واقعی فایل {name} با پسوند آن سازگار نیست.")
obj=Attachment(message=message,file=upload,original_name=name,stored_name="pending",relative_path="pending",attachment_type=kind,mime_type=detected,extension=ext,file_size=upload.size,uploaded_by=user); obj.save(); obj.stored_name=Path(obj.file.name).name; obj.relative_path=obj.file.name; obj.save(update_fields=["stored_name","relative_path"]); result.append(obj)
return result
+4
View File
@@ -0,0 +1,4 @@
from django.urls import path
from .views import download, view
app_name="attachments"
urlpatterns=[path("<uuid:public_id>/view/",view,name="view"),path("<uuid:public_id>/download/",download,name="download")]
+30
View File
@@ -0,0 +1,30 @@
import mimetypes
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from apps.tickets.policies import TicketPolicy
from apps.accounts.magic_access import get_ticket_magic_access
from .delivery import signed_media_redirect
from .models import Attachment
def _authorized_attachment(request,public_id):
attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket
magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket)
if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied
if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied
if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied
return attachment
def view(request,public_id):
attachment=_authorized_attachment(request,public_id)
if settings.USE_NGINX_MEDIA:
return signed_media_redirect(attachment.relative_path)
content_type=attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream"
response=FileResponse(attachment.file.open("rb"),content_type=content_type)
response["Content-Disposition"]=f'inline; filename="{attachment.original_name}"'
response["X-Content-Type-Options"]="nosniff"
return response
def download(request,public_id):
attachment=_authorized_attachment(request,public_id)
if settings.USE_NGINX_MEDIA:
return signed_media_redirect(attachment.relative_path, download=True)
return FileResponse(attachment.file.open("rb"),as_attachment=True,filename=attachment.original_name)