diff --git a/app/modules/email_integration/services.py b/app/modules/email_integration/services.py index 58537a9..84e2210 100644 --- a/app/modules/email_integration/services.py +++ b/app/modules/email_integration/services.py @@ -252,18 +252,33 @@ def get_or_create_email_setting(db: Session, tenant_id: int, branch_id: int | No ).scalar_one_or_none() if row: return row + + settings = get_settings() + row = EmailSetting( tenant_id=tenant_id, branch_id=branch_id, - smtp_host="smtp.hostinger.com", - smtp_port=465, + + # SMTP defaults from Coolify / environment + smtp_host=getattr(settings, "SMTP_HOST", None) or "mail.vavalam.com", + smtp_port=int(getattr(settings, "SMTP_PORT", 465) or 465), smtp_security="SSL", - imap_host="imap.hostinger.com", - imap_port=993, + smtp_username=getattr(settings, "SMTP_USERNAME", None) or "no-reply@vavalam.com", + smtp_password=getattr(settings, "SMTP_PASSWORD", None), + + # Sender defaults + from_email=getattr(settings, "SMTP_FROM_EMAIL", None) or "no-reply@vavalam.com", + from_name=getattr(settings, "SMTP_FROM_NAME", None) or "ARRR ERP", + + # IMAP defaults from Coolify / environment + imap_host=getattr(settings, "IMAP_HOST", None) or "mail.vavalam.com", + imap_port=int(getattr(settings, "IMAP_PORT", 993) or 993), imap_security="SSL", + created_by_user_id=actor_user_id, updated_by_user_id=actor_user_id, ) + db.add(row) db.flush() seed_default_email_templates(db, tenant_id=tenant_id, branch_id=branch_id) diff --git a/app/modules/employees/templates/employees/documents.html b/app/modules/employees/templates/employees/documents.html index bf7625f..75eb0fd 100644 --- a/app/modules/employees/templates/employees/documents.html +++ b/app/modules/employees/templates/employees/documents.html @@ -14,7 +14,7 @@
-
+

{{ employee_upload_help }}

diff --git a/app/modules/employees/templates/employees/self_documents.html b/app/modules/employees/templates/employees/self_documents.html index e384452..63ba799 100644 --- a/app/modules/employees/templates/employees/self_documents.html +++ b/app/modules/employees/templates/employees/self_documents.html @@ -14,7 +14,7 @@
-
+

{{ employee_upload_help }}

diff --git a/app/modules/employees/ui.py b/app/modules/employees/ui.py index e35ff26..6cd840d 100644 --- a/app/modules/employees/ui.py +++ b/app/modules/employees/ui.py @@ -6,6 +6,7 @@ from uuid import uuid4 from fastapi import APIRouter, File, Form, Request, UploadFile from fastapi.responses import RedirectResponse, StreamingResponse +from sqlalchemy import select from app.core.db.common import CommonSessionLocal from app.core.security.csrf import get_or_create_csrf_token, validate_csrf @@ -13,6 +14,7 @@ from app.core.security.session_auth import get_current_user from app.core.templating import templates from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.permission_guard import require_permission +from app.modules.core.tenancy.settings_models import BranchSettings from app.modules.core.iam.profile_service import save_user_profile_photo, update_user_public_profile from app.modules.employees.import_service import ( build_template_workbook, @@ -173,6 +175,7 @@ def _base_ctx(request: Request, db, current_user, **ctx): "offboarding_task_statuses": OFFBOARDING_TASK_STATUS, "payroll_run_statuses": PAYROLL_RUN_STATUS, "payslip_statuses": PAYSLIP_STATUS, + **_upload_policy_context(), } base.update(ctx) return base @@ -254,6 +257,56 @@ def _form_options(db, current_user, scope, *, include_user_id: int | None = None UPLOAD_ROOT = Path("data/uploads/employee_documents") ALLOWED_DOCUMENT_EXTENSIONS = {".pdf", ".jpg", ".jpeg", ".png", ".webp", ".doc", ".docx", ".xls", ".xlsx"} +DANGEROUS_DOCUMENT_EXTENSIONS = { + ".exe", ".bat", ".cmd", ".com", ".scr", ".pif", + ".sh", ".bash", ".zsh", ".ps1", ".psm1", + ".js", ".jse", ".vbs", ".vbe", ".wsf", + ".msi", ".dll", ".jar", ".apk", +} +DEFAULT_EMPLOYEE_UPLOAD_MAX_MB = 10 + + +def _normalise_extension(ext: str) -> str: + ext = (ext or "").strip().lower() + if not ext: + return "" + return ext if ext.startswith(".") else f".{ext}" + + +def _extensions_from_csv(raw: str | None, *, fallback: set[str] | None = None) -> set[str]: + values = {_normalise_extension(item) for item in (raw or "").split(",") if item.strip()} + values = {item for item in values if item} + return values or set(fallback or ALLOWED_DOCUMENT_EXTENSIONS) + + +def _employee_upload_accept(allowed_exts: set[str] | None = None) -> str: + exts = allowed_exts or ALLOWED_DOCUMENT_EXTENSIONS + return ",".join(sorted(exts)) + + +def _employee_upload_help(max_file_mb: int | None = None, allowed_exts: set[str] | None = None) -> str: + exts = ", ".join(ext.lstrip(".").upper() for ext in sorted(allowed_exts or ALLOWED_DOCUMENT_EXTENSIONS)) + max_mb = max_file_mb or DEFAULT_EMPLOYEE_UPLOAD_MAX_MB + return f"Allowed file types: {exts}. Maximum size: {max_mb} MB." + + +def _branch_upload_settings(db, branch_id: int | None) -> tuple[set[str], int]: + if branch_id: + settings = db.execute(select(BranchSettings).where(BranchSettings.branch_id == int(branch_id))).scalar_one_or_none() + if settings: + allowed = _extensions_from_csv(getattr(settings, "allowed_ext_csv", None), fallback=ALLOWED_DOCUMENT_EXTENSIONS) + max_mb = int(getattr(settings, "max_file_mb", DEFAULT_EMPLOYEE_UPLOAD_MAX_MB) or DEFAULT_EMPLOYEE_UPLOAD_MAX_MB) + return allowed, max(1, max_mb) + return set(ALLOWED_DOCUMENT_EXTENSIONS), DEFAULT_EMPLOYEE_UPLOAD_MAX_MB + + +def _upload_policy_context(db=None, branch_id: int | None = None) -> dict: + allowed, max_mb = _branch_upload_settings(db, branch_id) if db is not None else (set(ALLOWED_DOCUMENT_EXTENSIONS), DEFAULT_EMPLOYEE_UPLOAD_MAX_MB) + return { + "employee_upload_accept": _employee_upload_accept(allowed), + "employee_upload_help": _employee_upload_help(max_mb, allowed), + "employee_upload_max_file_mb": max_mb, + } def _document_type_payload(form): @@ -301,17 +354,39 @@ def _offboarding_payload(form): } -async def _save_employee_upload(upload: UploadFile, *, tenant_id: int, employee_id: int) -> tuple[str, str, str, int | None, str | None]: - original = _safe_upload_filename(upload.filename or "document") +async def _save_employee_upload( + upload: UploadFile, + *, + tenant_id: int, + employee_id: int, + allowed_exts: set[str] | None = None, + max_file_mb: int | None = None, +) -> tuple[str, str, str, int | None, str | None]: + original = _safe_upload_filename(upload.filename or "") + if not original: + raise ValueError("Invalid file name. Please choose a valid document to upload.") + suffix = Path(original).suffix.lower() - if suffix not in ALLOWED_DOCUMENT_EXTENSIONS: - raise ValueError("Unsupported file type. Allowed: PDF, images, Word and Excel files.") + if not suffix: + raise ValueError("Invalid file type. File extension is required.") + + if suffix in DANGEROUS_DOCUMENT_EXTENSIONS: + raise ValueError(f"File type not allowed. Extension {suffix} is blocked for employee document uploads.") + + allowed = allowed_exts or ALLOWED_DOCUMENT_EXTENSIONS + if suffix not in allowed: + allowed_label = ", ".join(ext.lstrip(".").upper() for ext in sorted(allowed)) + raise ValueError(f"File type not allowed. Extension {suffix} is not permitted. Allowed: {allowed_label}.") + data = await upload.read() if not data: - raise ValueError("Please choose a non-empty file to upload.") - max_size = 10 * 1024 * 1024 + raise ValueError("Invalid upload. Please choose a non-empty file to upload.") + + max_mb = max_file_mb or DEFAULT_EMPLOYEE_UPLOAD_MAX_MB + max_size = int(max_mb) * 1024 * 1024 if len(data) > max_size: - raise ValueError("File size should not exceed 10 MB.") + raise ValueError(f"Upload failed. File size should not exceed {max_mb} MB.") + folder = UPLOAD_ROOT / str(tenant_id) / str(employee_id) folder.mkdir(parents=True, exist_ok=True) stored = f"{uuid4().hex}{suffix}" @@ -1295,7 +1370,7 @@ def employee_documents(request: Request, employee_id: int | None = None, status: tenant_id = request.session.get("active_tenant_id") or current_user.tenant_id branch_id = request.session.get("active_branch_id") scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) - return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee_id, status=status), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee_id, selected_status=status, errors=[]) + return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee_id, status=status), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee_id, selected_status=status, errors=[], **_upload_policy_context(db, branch_id)) finally: db.close() @@ -1320,8 +1395,9 @@ async def employee_document_upload(request: Request, employee_id: int, document_ branch_id = request.session.get("active_branch_id") scope = build_employee_scope(db, current_user, tenant_id=tenant_id, branch_id=branch_id) employee = get_employee_or_404(db, employee_id, scope) + allowed_exts, max_file_mb = _branch_upload_settings(db, employee.branch_id) try: - original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id) + original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id, allowed_exts=allowed_exts, max_file_mb=max_file_mb) create_employee_document_record( db, current_user, @@ -1341,7 +1417,7 @@ async def employee_document_upload(request: Request, employee_id: int, document_ ) return RedirectResponse(url=f"/employees/documents?employee_id={employee.id}", status_code=303) except Exception as exc: - return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee.id), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee.id, selected_status=None, errors=[getattr(exc, "detail", str(exc))]) + return _render(request, "modules/employees/templates/employees/documents.html", db, current_user, title="Employee Documents", rows=list_employee_documents(db, scope, employee_id=employee.id), employees=list_employees(db, scope, include_inactive=True), document_types=list_document_types(db, scope), scope=scope, selected_employee_id=employee.id, selected_status=None, errors=[getattr(exc, "detail", str(exc))], **_upload_policy_context(db, employee.branch_id)) finally: db.close() @@ -1412,7 +1488,7 @@ def employee_self_documents(request: Request): if employee: scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) document_types = [dt for dt in list_document_types(db, scope) if dt.allow_employee_upload] - return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=document_types, errors=[]) + return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=document_types, errors=[], **_upload_policy_context(db, employee.branch_id if employee else None)) finally: db.close() @@ -1436,8 +1512,9 @@ async def employee_self_document_upload(request: Request, document_file: UploadF employee = get_employee_for_user(db, current_user) if not employee: return RedirectResponse(url="/employee/register", status_code=303) + allowed_exts, max_file_mb = _branch_upload_settings(db, employee.branch_id) try: - original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id) + original, stored, path, size, content_type = await _save_employee_upload(document_file, tenant_id=employee.tenant_id, employee_id=employee.id, allowed_exts=allowed_exts, max_file_mb=max_file_mb) create_employee_document_record( db, current_user, @@ -1458,7 +1535,7 @@ async def employee_self_document_upload(request: Request, document_file: UploadF return RedirectResponse(url="/employee/documents", status_code=303) except Exception as exc: scope = build_employee_scope(db, current_user, tenant_id=employee.tenant_id, branch_id=employee.branch_id) - return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=[dt for dt in list_document_types(db, scope) if dt.allow_employee_upload], errors=[getattr(exc, "detail", str(exc))]) + return _render(request, "modules/employees/templates/employees/self_documents.html", db, current_user, title="My Documents", employee=employee, rows=list_own_employee_documents(db, current_user), document_types=[dt for dt in list_document_types(db, scope) if dt.allow_employee_upload], errors=[getattr(exc, "detail", str(exc))], **_upload_policy_context(db, employee.branch_id)) finally: db.close() diff --git a/app/modules/notice_cases/service.py b/app/modules/notice_cases/service.py index 1518c0e..ead9e79 100644 --- a/app/modules/notice_cases/service.py +++ b/app/modules/notice_cases/service.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session, joinedload from app.core.settings import get_settings from app.modules.clients.models import Client +from app.modules.core.tenancy.settings_models import BranchSettings from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.notice_cases.models import ( NoticeCase, @@ -381,6 +382,75 @@ def add_order(db: Session, *, case: NoticeCase, user, data: dict) -> NoticeCaseO return row +class UploadValidationError(ValueError): + """Raised when a notice/case document upload violates branch file policy.""" + + +DANGEROUS_DOCUMENT_EXTENSIONS = { + ".exe", ".bat", ".cmd", ".com", ".scr", ".pif", + ".sh", ".bash", ".zsh", ".ps1", ".psm1", + ".js", ".jse", ".vbs", ".vbe", ".wsf", + ".msi", ".dll", ".jar", ".apk", +} + + +def _branch_document_settings(db: Session, branch_id: int | None) -> BranchSettings | None: + if not branch_id: + return None + return db.execute( + select(BranchSettings).where(BranchSettings.branch_id == int(branch_id)) + ).scalar_one_or_none() + + +def _normalise_allowed_extensions(raw: str | None) -> set[str]: + values = raw or "pdf,jpg,jpeg,png,xlsx,xls,docx,zip" + return { + "." + item.strip().lower().lstrip(".") + for item in values.split(",") + if item.strip() + } + + +def notice_case_upload_policy(db: Session, *, branch_id: int | None) -> dict[str, object]: + settings = _branch_document_settings(db, branch_id) + allowed_exts = _normalise_allowed_extensions(getattr(settings, "allowed_ext_csv", None)) + max_file_mb = int(getattr(settings, "max_file_mb", 25) or 25) + if max_file_mb <= 0: + max_file_mb = 25 + return { + "allowed_exts": sorted(allowed_exts), + "allowed_ext_csv": ",".join(ext.lstrip(".") for ext in sorted(allowed_exts)), + "accept": ",".join(sorted(allowed_exts)), + "max_file_mb": max_file_mb, + "max_file_bytes": max_file_mb * 1024 * 1024, + } + + +def _validate_notice_case_upload_policy(db: Session, *, case: NoticeCase, upload_file: UploadFile, original: str) -> bytes: + policy = notice_case_upload_policy(db, branch_id=case.branch_id) + allowed_exts = set(policy["allowed_exts"] or []) + max_file_bytes = int(policy["max_file_bytes"] or (25 * 1024 * 1024)) + + if not original or original in {".", ".."}: + raise UploadValidationError("Invalid filename.") + + ext = Path(original).suffix.lower() + if not ext: + raise UploadValidationError("File extension is required.") + + if ext in DANGEROUS_DOCUMENT_EXTENSIONS: + raise UploadValidationError("Executable or dangerous file types are not allowed.") + + if allowed_exts and ext not in allowed_exts: + raise UploadValidationError("File type not allowed. Allowed types: " + ", ".join(sorted(allowed_exts))) + + data = upload_file.file.read(max_file_bytes + 1) + if len(data) > max_file_bytes: + raise UploadValidationError(f"File too large. Maximum allowed size is {policy['max_file_mb']} MB.") + + return data + + def _storage_root() -> Path: settings = get_settings() base = getattr(settings, "LOCAL_STORAGE_ROOT", None) or getattr(settings, "DOCUMENT_STORAGE_ROOT", None) or "data/storage" @@ -392,14 +462,14 @@ def case_document_absolute_path(document: NoticeCaseDocument) -> Path: def save_case_document(db: Session, *, case: NoticeCase, upload_file: UploadFile, user, title: str, document_type: str, description: str | None, event_id: int | None = None) -> NoticeCaseDocument: - original = Path(upload_file.filename or "case_document.bin").name + original = Path(upload_file.filename or "").name + data = _validate_notice_case_upload_policy(db, case=case, upload_file=upload_file, original=original) ext = Path(original).suffix.lower() stored = f"case_{case.id}_{uuid4().hex}{ext}" fy_folder = f"FY{case.financial_year}" if case.financial_year else "FY_UNASSIGNED" rel = Path("notice_cases") / fy_folder / str(case.tenant_id) / str(case.client_id) / case.case_code / stored absolute = _storage_root() / rel absolute.parent.mkdir(parents=True, exist_ok=True) - data = upload_file.file.read() absolute.write_bytes(data) latest_version = db.execute(select(func.max(NoticeCaseDocument.version_no)).where(NoticeCaseDocument.case_id == case.id, NoticeCaseDocument.title == (title or original))).scalar_one() or 0 row = NoticeCaseDocument( diff --git a/app/modules/notice_cases/templates/notice_cases/detail.html b/app/modules/notice_cases/templates/notice_cases/detail.html index 0400e98..ed2db7d 100644 --- a/app/modules/notice_cases/templates/notice_cases/detail.html +++ b/app/modules/notice_cases/templates/notice_cases/detail.html @@ -27,6 +27,6 @@

Orders

{% for o in case.orders %}
{{ o.order_type }} · {{ o.order_date }} · Demand: {{ o.demand_amount }}
{{ o.summary or '' }}
{% endfor %}
-

Case Documents

{% if can_upload %}
{% endif %}
{% for d in case.documents if not d.is_deleted %}{% else %}{% endfor %}
DocumentTypeUploadedAction
{{ d.title }}
{{ d.original_filename }}
{{ d.document_type }}{{ d.uploaded_at_utc.date() if d.uploaded_at_utc else '' }}Download{% if can_delete_documents %}
{% endif %}
No documents uploaded.
+

Case Documents

{% if upload_error %}
{{ upload_error }}
{% endif %}{% if can_upload %}

Allowed file types: {{ upload_policy.allowed_ext_csv if upload_policy else 'pdf,jpg,jpeg,png,xlsx,xls,docx,zip' }}. Maximum size: {{ upload_policy.max_file_mb if upload_policy else 25 }} MB.

{% endif %}
{% for d in case.documents if not d.is_deleted %}{% else %}{% endfor %}
DocumentTypeUploadedAction
{{ d.title }}
{{ d.original_filename }}
{{ d.document_type }}{{ d.uploaded_at_utc.date() if d.uploaded_at_utc else '' }}Download{% if can_delete_documents %}
{% endif %}
No documents uploaded.
{% endblock %} diff --git a/app/modules/notice_cases/ui.py b/app/modules/notice_cases/ui.py index 9058955..1f016e3 100644 --- a/app/modules/notice_cases/ui.py +++ b/app/modules/notice_cases/ui.py @@ -36,6 +36,8 @@ from app.modules.notice_cases.service import ( list_clients_for_case, list_engagements_for_client, save_case_document, + notice_case_upload_policy, + UploadValidationError, update_case, user_can_access_case, ) @@ -72,8 +74,8 @@ def _base_ctx(request: Request, user, db, **ctx): return base -def _render(request: Request, template_name: str, db, user, **ctx): - return templates.TemplateResponse(template_name, _base_ctx(request, user, db, **ctx)) +def _render(request: Request, template_name: str, db, user, status_code: int = 200, **ctx): + return templates.TemplateResponse(template_name, _base_ctx(request, user, db, **ctx), status_code=status_code) def _redirect_denied(): @@ -298,7 +300,19 @@ def case_detail(request: Request, case_id: int): case = get_case(db, case_id) if not case or not _case_access_allowed(request, db, user, case): return _redirect_denied() - return _render(request, "modules/notice_cases/templates/notice_cases/detail.html", db, user, title=case.case_code, case=case, can_manage=can_manage_notice_cases(db, user), can_upload=can_upload_notice_case_documents(db, user), can_delete_documents=can_delete_notice_case_documents(db, user)) + upload_policy = notice_case_upload_policy(db, branch_id=case.branch_id) + return _render( + request, + "modules/notice_cases/templates/notice_cases/detail.html", + db, + user, + title=case.case_code, + case=case, + can_manage=can_manage_notice_cases(db, user), + can_upload=can_upload_notice_case_documents(db, user), + can_delete_documents=can_delete_notice_case_documents(db, user), + upload_policy=upload_policy, + ) finally: db.close() @@ -445,7 +459,25 @@ def case_document_upload(request: Request, case_id: int, title: str = Form(""), return RedirectResponse(url=f"/notice-cases/{case.id}?year_locked=1", status_code=303) if not file or not file.filename: return RedirectResponse(url=f"/notice-cases/{case_id}?error=missing_file", status_code=303) - save_case_document(db, case=case, upload_file=file, user=user, title=title, document_type=document_type, description=description, event_id=int(event_id) if event_id else None) + try: + save_case_document(db, case=case, upload_file=file, user=user, title=title, document_type=document_type, description=description, event_id=int(event_id) if event_id else None) + except UploadValidationError as exc: + db.rollback() + upload_policy = notice_case_upload_policy(db, branch_id=case.branch_id) + return _render( + request, + "modules/notice_cases/templates/notice_cases/detail.html", + db, + user, + status_code=400, + title=case.case_code, + case=case, + can_manage=can_manage_notice_cases(db, user), + can_upload=can_upload_notice_case_documents(db, user), + can_delete_documents=can_delete_notice_case_documents(db, user), + upload_policy=upload_policy, + upload_error=str(exc), + ) db.commit() return RedirectResponse(url=f"/notice-cases/{case_id}?uploaded=1", status_code=303) finally: