first
This commit is contained in:
commit
fa9d154db9
260 files changed
+17643
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
from .models import Attachment,AttachmentPolicy
|
||||
admin.site.register([Attachment,AttachmentPolicy])
|
||||
@@ -0,0 +1,2 @@
|
||||
from django.apps import AppConfig
|
||||
class AttachmentsConfig(AppConfig): name="apps.attachments"
|
||||
@@ -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.
@@ -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")]
|
||||
@@ -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
|
||||
@@ -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")]
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user