converter
This commit is contained in:
@@ -2,6 +2,8 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt update && apt install ffmpeg -y
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
|
from django.contrib.auth.models import AbstractUser, UserManager, Group, Permission
|
||||||
from django.core.validators import RegexValidator, MinLengthValidator, MaxValueValidator, MinValueValidator
|
from django.core.validators import RegexValidator
|
||||||
|
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -61,6 +62,10 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
|||||||
email_verified = models.BooleanField(default=False)
|
email_verified = models.BooleanField(default=False)
|
||||||
email = models.EmailField(unique=True, db_index=True)
|
email = models.EmailField(unique=True, db_index=True)
|
||||||
|
|
||||||
|
# + fields for email verification flow
|
||||||
|
email_verification_token = models.CharField(max_length=128, null=True, blank=True, db_index=True)
|
||||||
|
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
gdpr = models.BooleanField(default=False)
|
gdpr = models.BooleanField(default=False)
|
||||||
is_active = models.BooleanField(default=False)
|
is_active = models.BooleanField(default=False)
|
||||||
|
|
||||||
@@ -125,4 +130,31 @@ class CustomUser(SoftDeleteModel, AbstractUser):
|
|||||||
|
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def generate_email_verification_token(self, length: int = 48, save: bool = True) -> str:
|
||||||
|
token = get_random_string(length=length)
|
||||||
|
self.email_verification_token = token
|
||||||
|
self.email_verification_sent_at = timezone.now()
|
||||||
|
if save:
|
||||||
|
self.save(update_fields=["email_verification_token", "email_verification_sent_at"])
|
||||||
|
return token
|
||||||
|
|
||||||
|
def verify_email_token(self, token: str, max_age_hours: int = 48, save: bool = True) -> bool:
|
||||||
|
if not token or not self.email_verification_token:
|
||||||
|
return False
|
||||||
|
# optional expiry check
|
||||||
|
if self.email_verification_sent_at:
|
||||||
|
age = timezone.now() - self.email_verification_sent_at
|
||||||
|
if age > timedelta(hours=max_age_hours):
|
||||||
|
return False
|
||||||
|
if token != self.email_verification_token:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.email_verified:
|
||||||
|
self.email_verified = True
|
||||||
|
# clear token after success
|
||||||
|
self.email_verification_token = None
|
||||||
|
self.email_verification_sent_at = None
|
||||||
|
if save:
|
||||||
|
self.save(update_fields=["email_verified", "email_verification_token", "email_verification_sent_at"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -10,76 +10,115 @@ from .models import CustomUser
|
|||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
|
|
||||||
@shared_task
|
def send_email_with_context(recipients, subject, message=None, template_name=None, html_template_name=None, context=None):
|
||||||
def send_password_reset_email_task(user_id):
|
"""
|
||||||
|
General function to send emails with a specific context.
|
||||||
|
Supports rendering plain text and HTML templates.
|
||||||
|
Converts `user` in context to a plain dict to avoid template access to the model.
|
||||||
|
"""
|
||||||
|
if isinstance(recipients, str):
|
||||||
|
recipients = [recipients]
|
||||||
|
|
||||||
|
html_message = None
|
||||||
|
if template_name or html_template_name:
|
||||||
|
# Best effort to resolve both templates if only one provided
|
||||||
|
if not template_name and html_template_name:
|
||||||
|
template_name = html_template_name.replace(".html", ".txt")
|
||||||
|
if not html_template_name and template_name:
|
||||||
|
html_template_name = template_name.replace(".txt", ".html")
|
||||||
|
|
||||||
|
ctx = dict(context or {})
|
||||||
|
# Sanitize user if someone passes the model by mistake
|
||||||
|
if "user" in ctx and not isinstance(ctx["user"], dict):
|
||||||
try:
|
try:
|
||||||
user = CustomUser.objects.get(pk=user_id)
|
ctx["user"] = _build_user_template_ctx(ctx["user"])
|
||||||
except CustomUser.DoesNotExist:
|
except Exception:
|
||||||
error_msg = f"Task send_password_reset_email has failed. Invalid User ID was sent."
|
ctx["user"] = {}
|
||||||
logger.error(error_msg)
|
|
||||||
raise Exception(error_msg)
|
message = render_to_string(template_name, ctx)
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
html_message = render_to_string(html_template_name, ctx)
|
||||||
token = password_reset_token.make_token(user)
|
|
||||||
reset_url = f"{settings.FRONTEND_URL}/reset-password/{uid}/{token}"
|
try:
|
||||||
html_message = render_to_string(
|
send_mail(
|
||||||
'emails/password_reset.html',
|
subject=subject,
|
||||||
{'reset_url': reset_url}
|
message=message or "",
|
||||||
|
from_email=None,
|
||||||
|
recipient_list=recipients,
|
||||||
|
fail_silently=False,
|
||||||
|
html_message=html_message,
|
||||||
)
|
)
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
||||||
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
|
logger.debug(f"\nEMAIL OBSAH:\n{message}\nKONEC OBSAHU")
|
||||||
send_email_with_context(
|
return True
|
||||||
recipients=user.email,
|
except Exception as e:
|
||||||
subject="Obnova hesla",
|
logger.error(f"E-mail se neodeslal: {e}")
|
||||||
message=None,
|
return False
|
||||||
html_message=html_message
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only email verification for user registration
|
|
||||||
|
def _build_user_template_ctx(user: CustomUser) -> dict:
|
||||||
|
"""
|
||||||
|
Return a plain dict for templates instead of passing the DB model.
|
||||||
|
Provides aliases to avoid template errors (firstname vs first_name).
|
||||||
|
Adds a backward-compatible key 'get_full_name' for templates using `user.get_full_name`.
|
||||||
|
"""
|
||||||
|
first_name = getattr(user, "first_name", "") or ""
|
||||||
|
last_name = getattr(user, "last_name", "") or ""
|
||||||
|
full_name = f"{first_name} {last_name}".strip()
|
||||||
|
return {
|
||||||
|
"id": user.pk,
|
||||||
|
"email": getattr(user, "email", "") or "",
|
||||||
|
"first_name": first_name,
|
||||||
|
"firstname": first_name, # alias for templates using `firstname`
|
||||||
|
"last_name": last_name,
|
||||||
|
"lastname": last_name, # alias for templates using `lastname`
|
||||||
|
"full_name": full_name,
|
||||||
|
"get_full_name": full_name, # compatibility for templates using method-style access
|
||||||
|
}
|
||||||
|
|
||||||
|
#----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# This function sends an email to the user for email verification after registration.
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_email_verification_task(user_id):
|
def send_email_verification_task(user_id):
|
||||||
try:
|
try:
|
||||||
user = CustomUser.objects.get(pk=user_id)
|
user = CustomUser.objects.get(pk=user_id)
|
||||||
except CustomUser.DoesNotExist:
|
except CustomUser.DoesNotExist:
|
||||||
error_msg = f"Task send_email_verification_task has failed. Invalid User ID was sent."
|
logger.info(f"Task send_email_verification has failed. Invalid User ID was sent.")
|
||||||
logger.error(error_msg)
|
return 0
|
||||||
raise Exception(error_msg)
|
|
||||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||||
token = account_activation_token.make_token(user)
|
# {changed} generate and store a per-user token
|
||||||
verification_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
token = user.generate_email_verification_token()
|
||||||
html_message = render_to_string(
|
verify_url = f"{settings.FRONTEND_URL}/email-verification/?uidb64={uid}&token={token}"
|
||||||
'emails/email_verification.html',
|
|
||||||
{'verification_url': verification_url}
|
context = {
|
||||||
)
|
"user": _build_user_template_ctx(user),
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
"action_url": verify_url,
|
||||||
logger.debug("\nEMAIL OBSAH:\n", html_message, "\nKONEC OBSAHU")
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
|
"cta_label": "Ověřit e‑mail",
|
||||||
|
}
|
||||||
|
|
||||||
send_email_with_context(
|
send_email_with_context(
|
||||||
recipients=user.email,
|
recipients=user.email,
|
||||||
subject="Ověření e-mailu",
|
subject="Ověření e‑mailu",
|
||||||
message=None,
|
template_name="email/email_verification.txt",
|
||||||
html_message=html_message
|
html_template_name="email/email_verification.html",
|
||||||
|
context=context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def send_email_with_context(recipients, subject, message=None, html_message=None):
|
@shared_task
|
||||||
"""
|
def send_email_test_task(email):
|
||||||
General function to send emails with a specific context.
|
context = {
|
||||||
"""
|
"action_url": settings.FRONTEND_URL,
|
||||||
if isinstance(recipients, str):
|
"frontend_url": settings.FRONTEND_URL,
|
||||||
recipients = [recipients]
|
"cta_label": "Otevřít aplikaci",
|
||||||
|
}
|
||||||
try:
|
send_email_with_context(
|
||||||
send_mail(
|
recipients=email,
|
||||||
subject=subject,
|
subject="Testovací e‑mail",
|
||||||
message=message if message else '',
|
template_name="email/test.txt",
|
||||||
from_email=None,
|
html_template_name="email/test.html",
|
||||||
recipient_list=recipients,
|
context=context,
|
||||||
fail_silently=False,
|
|
||||||
html_message=html_message
|
|
||||||
)
|
)
|
||||||
if settings.EMAIL_BACKEND == 'django.core.mail.backends.console.EmailBackend':
|
|
||||||
logger.debug("\nEMAIL OBSAH:\n", html_message if html_message else message, "\nKONEC OBSAHU")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"E-mail se neodeslal: {e}")
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
<table style="background-color:#031D44; font-family:'Exo', Arial, sans-serif; width:100%;" align="center" border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:20px;">
|
|
||||||
<table border="0" cellspacing="20" cellpadding="0" style="max-width:600px; width:100%;" align="center">
|
|
||||||
<!-- Nadpis -->
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:20px; color:#ffffff; font-size:30px; font-weight:bold; border-radius:8px; text-decoration:underline;">
|
|
||||||
Nabídka tvorby webových stránek
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="color:#CAF0F8; border-radius:8px; font-size:18px; line-height:1.6;">
|
|
||||||
<p style="margin:0;">
|
|
||||||
Jsme <strong>malý tým</strong>, který se snaží prorazit a přinášet moderní řešení za férové ceny.
|
|
||||||
Nabízíme také <strong>levný hosting</strong> a <strong>SSL zabezpečení zdarma</strong>.
|
|
||||||
</p>
|
|
||||||
<p style="margin:10px 0 0;">
|
|
||||||
Dbáme na <strong>bezpečnost</strong>, používáme <strong>moderní frameworky</strong>
|
|
||||||
a rozhodně nejsme součástí „gerontosaurů“ – <strong>PHP nepoužíváme</strong>.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Balíčky Nadpis -->
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding-top:30px; color:#ffffff; font-size:28px; font-weight:bold; text-decoration:underline;">
|
|
||||||
Balíčky
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<!-- Balíčky (jednotlivé) -->
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; background:#3a8bb7; color:#CAF0F8; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
|
|
||||||
<h2 style="margin:0; color:#CAF0F8;">BASIC</h2>
|
|
||||||
<ul style="padding-left:20px; margin:10px 0;">
|
|
||||||
<li>Jednoduchá prezentační webová stránka</li>
|
|
||||||
<li>Moderní a responzivní design (PC, tablety, mobily)</li>
|
|
||||||
<li>Max. počet stránek: 5</li>
|
|
||||||
<li>Seřízení vlastní domény, a k tomu<span style="text-decoration: underline;">SSL certifikát zdarma</span></li>
|
|
||||||
</ul>
|
|
||||||
<p style="font-size:16px; background-color:#24719f; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
|
|
||||||
Cena: 5 000 Kč (jednorázově) + 100 Kč / měsíc
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; background:#70A288; color:#ffffff; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
|
|
||||||
<h2 style="margin:0; color:#ffffff;">STANDARD</h2>
|
|
||||||
<ul style="padding-left:20px; margin:10px 0;">
|
|
||||||
<li>Vše z balíčku BASIC</li>
|
|
||||||
<li>Kontaktní formulář (přijde vám poptávka na e-mail)</li>
|
|
||||||
<li>Priorita při vývoji (cca 2 týdny)</li>
|
|
||||||
<li>Základní SEO</li>
|
|
||||||
<li>Max. počet stránek: 10</li>
|
|
||||||
</ul>
|
|
||||||
<p style="font-size:16px; background-color:#508845; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
|
|
||||||
Cena: 7 500 Kč (jednorázově) + 250 Kč / měsíc
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; background:#87a9da; color:#031D44; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
|
|
||||||
<h2 style="margin:0; color:#031D44;">PREMIUM</h2>
|
|
||||||
<ul style="padding-left:20px; margin:10px 0;">
|
|
||||||
<li>Vše z balíčku STANDARD</li>
|
|
||||||
<li>Vaše firma na Google Maps díky plně nastavenému Google Business Profile</li>
|
|
||||||
<li>Pokročilé SEO</li>
|
|
||||||
<li>Měsíční report návštěvnosti</li>
|
|
||||||
<li>Možnost drobných úprav</li>
|
|
||||||
<li>Neomezený počet stránek</li>
|
|
||||||
</ul>
|
|
||||||
<p style="font-size:16px; background-color:#4c7bbd; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
|
|
||||||
Cena: od 9 500 Kč (jednorázově) + 400 Kč / měsíc
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
<tr>
|
|
||||||
<td style="padding:20px; background:#04395E; color:#CAF0F8; border-radius:15px; line-height:1.6; font-size:16px; width:100%;">
|
|
||||||
<h2 style="margin:0; color:#CAF0F8;">CUSTOM</h2>
|
|
||||||
<ul style="padding-left:20px; margin:10px 0;">
|
|
||||||
<li>Kompletně na míru</li>
|
|
||||||
<li>Možnost e-shopu a rezervačních systémů</li>
|
|
||||||
<li>Integrace API a platební brány</li>
|
|
||||||
<li>Pokročilé SEO a marketing</li>
|
|
||||||
</ul>
|
|
||||||
<p style="font-size:16px; background-color:#216085; padding:12px; color:#ffffff; font-weight:bold; margin:0; border-radius:8px;">
|
|
||||||
Cena: dohodou
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<table style="width:100%; background-color:#031D44; font-family:'Exo', Arial, sans-serif;" align="center" border="0" cellspacing="0" cellpadding="0">
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="padding:20px; color:#CAF0F8;">
|
|
||||||
<p style="margin:0; font-size:20px; font-weight:bold;">Máte zájem o některý z balíčků?</p>
|
|
||||||
<p>Stačí odpovědět na tento e-mail nebo mě kontaktovat:</p>
|
|
||||||
<p>
|
|
||||||
<a href="mailto:brunovontor@gmail.com" style="color:#CAF0F8; text-decoration:underline;">brunovontor@gmail.com</a><br>
|
|
||||||
<a href="tel:+420605512624" style="color:#CAF0F8; text-decoration:underline;">+420 605 512 624</a><br>
|
|
||||||
<a href="https://vontor.cz" style="color:#CAF0F8; text-decoration:underline;">vontor.cz</a>
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<!-- Responsivní CSS -->
|
|
||||||
<style>
|
|
||||||
@media only screen and (max-width: 600px) {
|
|
||||||
table[class="responsive-table"] {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
font-size: 16px !important;
|
|
||||||
padding: 10px !important;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 20px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,19 +1,46 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="cs">
|
<html lang="cs">
|
||||||
<head>
|
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
||||||
<meta charset="UTF-8">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
||||||
<title>Ověření e-mailu</title>
|
<tr>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<td align="center" style="padding:24px;">
|
||||||
</head>
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
||||||
<body>
|
<tr>
|
||||||
<div class="container mt-5">
|
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
||||||
<div class="card">
|
Ověření e‑mailu
|
||||||
<div class="card-body">
|
</td>
|
||||||
<h2 class="card-title">Ověření e-mailu</h2>
|
</tr>
|
||||||
<p class="card-text">Ověřte svůj e-mail kliknutím na odkaz níže:</p>
|
<tr>
|
||||||
<a href="{{ verification_url }}" class="btn btn-success">Ověřit e-mail</a>
|
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
</div>
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||||
</div>
|
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||||
</div>
|
{% endwith %}
|
||||||
</body>
|
<p style="margin:0 0 16px 0;">Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na tlačítko níže.</p>
|
||||||
|
|
||||||
|
{% if action_url and cta_label %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
|
{{ cta_label }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
||||||
|
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
7
backend/account/templates/emails/email_verification.txt
Normal file
7
backend/account/templates/emails/email_verification.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
|
||||||
|
|
||||||
|
Děkujeme za registraci. Prosíme, ověřte svou e‑mailovou adresu kliknutím na následující odkaz:
|
||||||
|
|
||||||
|
{{ action_url }}
|
||||||
|
|
||||||
|
Pokud jste účet nevytvořili vy, tento e‑mail ignorujte.
|
||||||
@@ -1,19 +1,46 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="cs">
|
<html lang="cs">
|
||||||
<head>
|
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
||||||
<meta charset="UTF-8">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
||||||
<title>Obnova hesla</title>
|
<tr>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<td align="center" style="padding:24px;">
|
||||||
</head>
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
||||||
<body>
|
<tr>
|
||||||
<div class="container mt-5">
|
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
||||||
<div class="card">
|
Obnova hesla
|
||||||
<div class="card-body">
|
</td>
|
||||||
<h2 class="card-title">Obnova hesla</h2>
|
</tr>
|
||||||
<p class="card-text">Pro obnovu hesla klikněte na následující odkaz:</p>
|
<tr>
|
||||||
<a href="{{ reset_url }}" class="btn btn-primary">Obnovit heslo</a>
|
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
</div>
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}
|
||||||
</div>
|
<p style="margin:0 0 12px 0;">Dobrý den{% if name %} {{ name }}{% endif %},</p>
|
||||||
</div>
|
{% endwith %}
|
||||||
</body>
|
<p style="margin:0 0 12px 0;">Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu. Pokud jste o změnu nepožádali, tento e‑mail ignorujte.</p>
|
||||||
|
|
||||||
|
{% if action_url and cta_label %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
|
{{ cta_label }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
||||||
|
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
7
backend/account/templates/emails/password_reset.txt
Normal file
7
backend/account/templates/emails/password_reset.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{% with name=user.first_name|default:user.firstname|default:user.get_full_name %}Dobrý den{% if name %} {{ name }}{% endif %},{% endwith %}
|
||||||
|
|
||||||
|
Obdrželi jste tento e‑mail, protože byla požádána obnova hesla k vašemu účtu.
|
||||||
|
Pokud jste o změnu nepožádali, tento e‑mail ignorujte.
|
||||||
|
|
||||||
|
Pro nastavení nového hesla použijte tento odkaz:
|
||||||
|
{{ action_url }}
|
||||||
44
backend/account/templates/emails/test.html
Normal file
44
backend/account/templates/emails/test.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="cs">
|
||||||
|
<body style="margin:0; padding:0; background-color:#f5f7fb;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f5f7fb;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:24px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border:1px solid #e5e7eb;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color:#111827; color:#ffffff; font-family:Arial, Helvetica, sans-serif; font-size:18px; font-weight:bold; padding:16px 20px;">
|
||||||
|
Testovací e‑mail
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#1f2937; line-height:1.6;">
|
||||||
|
<p style="margin:0 0 12px 0;">Dobrý den,</p>
|
||||||
|
<p style="margin:0 0 16px 0;">Toto je testovací e‑mail z aplikace e‑tržnice.</p>
|
||||||
|
|
||||||
|
{% if action_url and cta_label %}
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:16px 0;">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#2563eb" style="border-radius:6px;">
|
||||||
|
<a href="{{ action_url }}" target="_blank" style="display:inline-block; padding:10px 16px; font-family:Arial, Helvetica, sans-serif; font-size:14px; color:#ffffff; text-decoration:none; font-weight:bold;">
|
||||||
|
{{ cta_label }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0; color:#6b7280; font-size:12px;">Pokud tlačítko nefunguje, zkopírujte do prohlížeče tento odkaz:<br><span style="word-break:break-all;">{{ action_url }}</span></p>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; margin-top:12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="font-family:Arial, Helvetica, sans-serif; font-size:12px; color:#6b7280;">
|
||||||
|
Tento e‑mail byl odeslán z aplikace e‑tržnice.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
backend/account/templates/emails/test.txt
Normal file
6
backend/account/templates/emails/test.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
Dobrý den,
|
||||||
|
|
||||||
|
Toto je testovací e‑mail z aplikace e‑tržnice.
|
||||||
|
|
||||||
|
Odkaz na aplikaci:
|
||||||
|
{{ action_url }}
|
||||||
59
backend/env
Normal file
59
backend/env
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# ------------------ NGINX ------------------
|
||||||
|
FRONTEND_URL=http://192.168.67.98
|
||||||
|
#FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ CORE ------------------
|
||||||
|
DEBUG=True
|
||||||
|
SSL=False
|
||||||
|
DJANGO_SECRET_KEY=CHANGE_ME_SECURE_RANDOM_KEY
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------ DATABASE (Postgres in Docker) ------------------
|
||||||
|
USE_DOCKER_DB=True
|
||||||
|
DATABASE_ENGINE=django.db.backends.postgresql
|
||||||
|
DATABASE_HOST=db
|
||||||
|
DATABASE_PORT=5432
|
||||||
|
|
||||||
|
POSTGRES_DB=djangoDB
|
||||||
|
POSTGRES_USER=dockerDBuser
|
||||||
|
POSTGRES_PASSWORD=AWSJeMocDrahaZalezitost
|
||||||
|
|
||||||
|
# Legacy/unused (was: USE_PRODUCTION_DB) removed
|
||||||
|
|
||||||
|
# ------------------ MEDIA / STATIC ------------------
|
||||||
|
#MEDIA_URL=http://192.168.67.98/media/
|
||||||
|
|
||||||
|
# ------------------ REDIS / CACHING / CHANNELS ------------------
|
||||||
|
# Was REDIS=... (not used). Docker expects REDIS_PASSWORD.
|
||||||
|
REDIS_PASSWORD=passwd
|
||||||
|
|
||||||
|
# ------------------ CELERY ------------------
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
|
CELERY_ACCEPT_CONTENT=json
|
||||||
|
CELERY_TASK_SERIALIZER=json
|
||||||
|
CELERY_TIMEZONE=Europe/Prague
|
||||||
|
CELERY_BEAT_SCHEDULER=django_celery_beat.schedulers:DatabaseScheduler
|
||||||
|
|
||||||
|
# ------------------ EMAIL (dev/prod logic in settings) ------------------
|
||||||
|
EMAIL_HOST_DEV=kerio4.vitkovice.cz
|
||||||
|
EMAIL_PORT_DEV=465
|
||||||
|
EMAIL_USER_DEV=Test.django@vitkovice.cz
|
||||||
|
EMAIL_USER_PASSWORD_DEV=PRneAP0819b
|
||||||
|
# DEFAULT_FROM_EMAIL_DEV unused in settings; kept for reference
|
||||||
|
DEFAULT_FROM_EMAIL_DEV=Test.django@vitkovice.cz
|
||||||
|
|
||||||
|
# ------------------ AWS (disabled unless USE_AWS=True) ------------------
|
||||||
|
USE_AWS=False
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_STORAGE_BUCKET_NAME=
|
||||||
|
AWS_S3_REGION_NAME=eu-central-1
|
||||||
|
|
||||||
|
# ------------------ JWT / TOKENS (lifetimes defined in code) ------------------
|
||||||
|
# (No env vars needed; kept placeholder section)
|
||||||
|
|
||||||
|
# ------------------ MISC ------------------
|
||||||
|
# FRONTEND_URL_DEV not used; rely on FRONTEND_URL
|
||||||
|
# Add any extra custom vars below
|
||||||
13
backend/thirdparty/downloader/admin.py
vendored
13
backend/thirdparty/downloader/admin.py
vendored
@@ -1,9 +1,10 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import DownloaderModel
|
from .models import DownloaderRecord
|
||||||
|
|
||||||
@admin.register(DownloaderModel)
|
@admin.register(DownloaderRecord)
|
||||||
class DownloaderModelAdmin(admin.ModelAdmin):
|
class DownloaderRecordAdmin(admin.ModelAdmin):
|
||||||
list_display = ("id", "status", "ext", "requested_format", "vcodec", "acodec", "is_audio_only", "download_time")
|
list_display = ("id", "url", "format", "length_of_media", "file_size", "download_time")
|
||||||
list_filter = ("status", "ext", "vcodec", "acodec", "is_audio_only", "extractor")
|
list_filter = ("format",)
|
||||||
search_fields = ("title", "video_id", "url")
|
search_fields = ("url",)
|
||||||
|
ordering = ("-download_time",)
|
||||||
readonly_fields = ("download_time",)
|
readonly_fields = ("download_time",)
|
||||||
|
|||||||
30
backend/thirdparty/downloader/migrations/0001_initial.py
vendored
Normal file
30
backend/thirdparty/downloader/migrations/0001_initial.py
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-10-29 14:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DownloaderRecord',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('url', models.URLField()),
|
||||||
|
('download_time', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('format', models.CharField(max_length=50)),
|
||||||
|
('length_of_media', models.IntegerField(help_text='Length of media in seconds')),
|
||||||
|
('file_size', models.BigIntegerField(help_text='File size in bytes')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/thirdparty/downloader/migrations/__init__.py
vendored
Normal file
0
backend/thirdparty/downloader/migrations/__init__.py
vendored
Normal file
87
backend/thirdparty/downloader/models.py
vendored
87
backend/thirdparty/downloader/models.py
vendored
@@ -1,92 +1,15 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from vontor_cz.models import SoftDeleteModel
|
||||||
|
|
||||||
# 7áznamy pro donwloader, co lidé nejvíc stahujou a v jakém formátu
|
# 7áznamy pro donwloader, co lidé nejvíc stahujou a v jakém formátu
|
||||||
class DownloaderModel(models.Model):
|
class DownloaderRecord(SoftDeleteModel):
|
||||||
url = models.URLField()
|
url = models.URLField()
|
||||||
download_time = models.DateTimeField(auto_now_add=True)
|
download_time = models.DateTimeField(auto_now_add=True)
|
||||||
status = models.CharField(max_length=50)
|
|
||||||
|
|
||||||
# yt-dlp metadata (flattened for stats)
|
format = models.CharField(max_length=50)
|
||||||
requested_format = models.CharField(max_length=100, blank=True, null=True, db_index=True)
|
|
||||||
format_id = models.CharField(max_length=50, blank=True, null=True, db_index=True)
|
|
||||||
ext = models.CharField(max_length=20, blank=True, null=True, db_index=True)
|
|
||||||
vcodec = models.CharField(max_length=50, blank=True, null=True, db_index=True)
|
|
||||||
acodec = models.CharField(max_length=50, blank=True, null=True, db_index=True)
|
|
||||||
width = models.IntegerField(blank=True, null=True)
|
|
||||||
height = models.IntegerField(blank=True, null=True)
|
|
||||||
fps = models.FloatField(blank=True, null=True)
|
|
||||||
abr = models.FloatField(blank=True, null=True) # audio bitrate
|
|
||||||
vbr = models.FloatField(blank=True, null=True) # video bitrate
|
|
||||||
tbr = models.FloatField(blank=True, null=True) # total bitrate
|
|
||||||
asr = models.IntegerField(blank=True, null=True) # audio sample rate
|
|
||||||
filesize = models.BigIntegerField(blank=True, null=True)
|
|
||||||
duration = models.FloatField(blank=True, null=True)
|
|
||||||
title = models.CharField(max_length=512, blank=True, null=True)
|
|
||||||
extractor = models.CharField(max_length=100, blank=True, null=True, db_index=True)
|
|
||||||
extractor_key = models.CharField(max_length=100, blank=True, null=True)
|
|
||||||
video_id = models.CharField(max_length=128, blank=True, null=True, db_index=True)
|
|
||||||
webpage_url = models.URLField(blank=True, null=True)
|
|
||||||
is_audio_only = models.BooleanField(default=False, db_index=True)
|
|
||||||
|
|
||||||
# client/context
|
length_of_media = models.IntegerField(help_text="Length of media in seconds")
|
||||||
user = models.ForeignKey(getattr(settings, "AUTH_USER_MODEL", "auth.User"), on_delete=models.SET_NULL, null=True, blank=True)
|
file_size = models.BigIntegerField(help_text="File size in bytes")
|
||||||
ip_address = models.GenericIPAddressField(blank=True, null=True)
|
|
||||||
user_agent = models.TextField(blank=True, null=True)
|
|
||||||
|
|
||||||
# diagnostics
|
|
||||||
error_message = models.TextField(blank=True, null=True)
|
|
||||||
|
|
||||||
# full raw yt-dlp info for future analysis
|
|
||||||
raw_info = models.JSONField(blank=True, null=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["download_time"]),
|
|
||||||
models.Index(fields=["ext", "is_audio_only"]),
|
|
||||||
models.Index(fields=["requested_format"]),
|
|
||||||
models.Index(fields=["extractor"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"DownloaderModel {self.id} - {self.status} at {self.download_time.strftime('%d-%m-%Y %H:%M:%S')}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_ydl_info(cls, *, info: dict, requested_format: str | None = None, status: str = "success",
|
|
||||||
url: str | None = None, user=None, ip_address: str | None = None,
|
|
||||||
user_agent: str | None = None, error_message: str | None = None):
|
|
||||||
# Safe getters
|
|
||||||
def g(k, default=None):
|
|
||||||
return info.get(k, default)
|
|
||||||
|
|
||||||
instance = cls(
|
|
||||||
url=url or g("webpage_url") or g("original_url"),
|
|
||||||
status=status,
|
|
||||||
requested_format=requested_format,
|
|
||||||
format_id=g("format_id"),
|
|
||||||
ext=g("ext"),
|
|
||||||
vcodec=g("vcodec"),
|
|
||||||
acodec=g("acodec"),
|
|
||||||
width=g("width") if isinstance(g("width"), int) else None,
|
|
||||||
height=g("height") if isinstance(g("height"), int) else None,
|
|
||||||
fps=g("fps"),
|
|
||||||
abr=g("abr"),
|
|
||||||
vbr=g("vbr"),
|
|
||||||
tbr=g("tbr"),
|
|
||||||
asr=g("asr"),
|
|
||||||
filesize=g("filesize") or g("filesize_approx"),
|
|
||||||
duration=g("duration"),
|
|
||||||
title=g("title"),
|
|
||||||
extractor=g("extractor"),
|
|
||||||
extractor_key=g("extractor_key"),
|
|
||||||
video_id=g("id"),
|
|
||||||
webpage_url=g("webpage_url"),
|
|
||||||
is_audio_only=(g("vcodec") in (None, "none")),
|
|
||||||
user=user if getattr(user, "is_authenticated", False) else None,
|
|
||||||
ip_address=ip_address,
|
|
||||||
user_agent=user_agent,
|
|
||||||
error_message=error_message,
|
|
||||||
raw_info=info,
|
|
||||||
)
|
|
||||||
instance.save()
|
|
||||||
return instance
|
|
||||||
74
backend/thirdparty/downloader/serializers.py
vendored
74
backend/thirdparty/downloader/serializers.py
vendored
@@ -1,69 +1,9 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import DownloaderModel
|
|
||||||
|
|
||||||
class DownloaderLogSerializer(serializers.ModelSerializer):
|
class DownloaderStatsSerializer(serializers.Serializer):
|
||||||
# Optional raw yt-dlp info dict
|
total_downloads = serializers.IntegerField()
|
||||||
info = serializers.DictField(required=False)
|
avg_length_of_media = serializers.FloatField(allow_null=True)
|
||||||
|
avg_file_size = serializers.FloatField(allow_null=True)
|
||||||
class Meta:
|
total_length_of_media = serializers.IntegerField(allow_null=True)
|
||||||
model = DownloaderModel
|
total_file_size = serializers.IntegerField(allow_null=True)
|
||||||
fields = (
|
most_common_format = serializers.CharField(allow_null=True)
|
||||||
"id",
|
|
||||||
"url",
|
|
||||||
"status",
|
|
||||||
"requested_format",
|
|
||||||
"format_id",
|
|
||||||
"ext",
|
|
||||||
"vcodec",
|
|
||||||
"acodec",
|
|
||||||
"width",
|
|
||||||
"height",
|
|
||||||
"fps",
|
|
||||||
"abr",
|
|
||||||
"vbr",
|
|
||||||
"tbr",
|
|
||||||
"asr",
|
|
||||||
"filesize",
|
|
||||||
"duration",
|
|
||||||
"title",
|
|
||||||
"extractor",
|
|
||||||
"extractor_key",
|
|
||||||
"video_id",
|
|
||||||
"webpage_url",
|
|
||||||
"is_audio_only",
|
|
||||||
"error_message",
|
|
||||||
"raw_info",
|
|
||||||
"info", # virtual input
|
|
||||||
)
|
|
||||||
read_only_fields = ("id", "raw_info")
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
|
||||||
info = validated_data.pop("info", None)
|
|
||||||
request = self.context.get("request")
|
|
||||||
user = getattr(request, "user", None) if request else None
|
|
||||||
ip_address = None
|
|
||||||
user_agent = None
|
|
||||||
if request:
|
|
||||||
xff = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
||||||
ip_address = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR"))
|
|
||||||
user_agent = request.META.get("HTTP_USER_AGENT")
|
|
||||||
|
|
||||||
if info:
|
|
||||||
return DownloaderModel.from_ydl_info(
|
|
||||||
info=info,
|
|
||||||
requested_format=validated_data.get("requested_format"),
|
|
||||||
status=validated_data.get("status", "success"),
|
|
||||||
url=validated_data.get("url"),
|
|
||||||
user=user,
|
|
||||||
ip_address=ip_address,
|
|
||||||
user_agent=user_agent,
|
|
||||||
error_message=validated_data.get("error_message"),
|
|
||||||
)
|
|
||||||
# Fallback: create from flattened fields only
|
|
||||||
return DownloaderModel.objects.create(
|
|
||||||
user=user,
|
|
||||||
ip_address=ip_address,
|
|
||||||
user_agent=user_agent,
|
|
||||||
raw_info=None,
|
|
||||||
**validated_data
|
|
||||||
)
|
|
||||||
10
backend/thirdparty/downloader/urls.py
vendored
10
backend/thirdparty/downloader/urls.py
vendored
@@ -1,13 +1,9 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import DownloaderFormatsView, DownloaderFileView, DownloaderStatsView
|
from .views import Downloader, DownloaderStats
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Probe formats for a URL (size-checked)
|
# Probe formats for a URL (size-checked)
|
||||||
path("formats/", DownloaderFormatsView.as_view(), name="downloader-formats"),
|
path("download/", Downloader.as_view(), name="downloader-download"),
|
||||||
|
|
||||||
# Download selected format (enforces size limit)
|
path("stats/", DownloaderStats.as_view(), name="downloader-stats"),
|
||||||
path("download/", DownloaderFileView.as_view(), name="downloader-download"),
|
|
||||||
|
|
||||||
# Aggregated statistics
|
|
||||||
path("stats/", DownloaderStatsView.as_view(), name="downloader-stats"),
|
|
||||||
]
|
]
|
||||||
|
|||||||
818
backend/thirdparty/downloader/views.py
vendored
818
backend/thirdparty/downloader/views.py
vendored
@@ -1,553 +1,305 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
from django.db.models import Count
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework.permissions import AllowAny
|
|
||||||
from rest_framework import status
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import StreamingHttpResponse, JsonResponse
|
|
||||||
from django.utils.text import slugify
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
|
||||||
|
|
||||||
# docs + schema helpers
|
|
||||||
from rest_framework import serializers
|
|
||||||
from drf_spectacular.utils import (
|
|
||||||
extend_schema,
|
|
||||||
OpenApiExample,
|
|
||||||
OpenApiParameter,
|
|
||||||
OpenApiTypes,
|
|
||||||
OpenApiResponse,
|
|
||||||
inline_serializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
import os
|
|
||||||
import math
|
|
||||||
import json
|
|
||||||
import tempfile
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
|
||||||
from urllib.parse import quote as urlquote
|
|
||||||
|
|
||||||
from .models import DownloaderModel
|
|
||||||
from .serializers import DownloaderLogSerializer
|
|
||||||
|
|
||||||
# ---------------------- Inline serializers for documentation only ----------------------
|
# ---------------------- Inline serializers for documentation only ----------------------
|
||||||
# Using inline_serializer to avoid creating new files.
|
# Using inline_serializer to avoid creating new files.
|
||||||
|
|
||||||
FormatOptionSchema = inline_serializer(
|
import yt_dlp
|
||||||
name="FormatOption",
|
import tempfile
|
||||||
fields={
|
import os
|
||||||
"format_id": serializers.CharField(allow_null=True),
|
import shutil
|
||||||
"ext": serializers.CharField(allow_null=True),
|
|
||||||
"vcodec": serializers.CharField(allow_null=True),
|
from rest_framework import serializers
|
||||||
"acodec": serializers.CharField(allow_null=True),
|
from rest_framework.views import APIView
|
||||||
"fps": serializers.FloatField(allow_null=True),
|
from rest_framework.response import Response
|
||||||
"tbr": serializers.FloatField(allow_null=True),
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
"abr": serializers.FloatField(allow_null=True),
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
"vbr": serializers.FloatField(allow_null=True),
|
from drf_spectacular.types import OpenApiTypes
|
||||||
"asr": serializers.IntegerField(allow_null=True),
|
from django.conf import settings
|
||||||
"filesize": serializers.IntegerField(allow_null=True),
|
from django.http import StreamingHttpResponse
|
||||||
"filesize_approx": serializers.IntegerField(allow_null=True),
|
from django.utils.text import slugify
|
||||||
"estimated_size_bytes": serializers.IntegerField(allow_null=True),
|
# NEW: aggregations and timeseries helpers
|
||||||
"size_ok": serializers.BooleanField(),
|
from django.db import models
|
||||||
"format_note": serializers.CharField(allow_null=True),
|
from django.utils import timezone
|
||||||
"resolution": serializers.CharField(allow_null=True),
|
from django.db.models.functions import TruncDay, TruncHour
|
||||||
"audio_only": serializers.BooleanField(),
|
from .models import DownloaderRecord
|
||||||
},
|
|
||||||
|
# Allowed container formats for output/remux
|
||||||
|
FORMAT_CHOICES = ("mp4", "mkv", "webm", "flv", "mov", "avi", "ogg")
|
||||||
|
FORMAT_HELP = (
|
||||||
|
"Choose container format: "
|
||||||
|
"mp4 (H.264 + AAC, most compatible), "
|
||||||
|
"mkv (flexible, lossless container), "
|
||||||
|
"webm (VP9/AV1 + Opus), "
|
||||||
|
"flv (legacy), mov (Apple-friendly), "
|
||||||
|
"avi (older), ogg (mostly obsolete)."
|
||||||
)
|
)
|
||||||
|
|
||||||
FormatsRequestSchema = inline_serializer(
|
# Minimal mime map by extension
|
||||||
name="FormatsRequest",
|
MIME_BY_EXT = {
|
||||||
fields={"url": serializers.URLField()},
|
"mp4": "video/mp4",
|
||||||
)
|
"mkv": "video/x-matroska",
|
||||||
|
"webm": "video/webm",
|
||||||
|
"flv": "video/x-flv",
|
||||||
|
"mov": "video/quicktime",
|
||||||
|
"avi": "video/x-msvideo",
|
||||||
|
"ogg": "video/ogg",
|
||||||
|
}
|
||||||
|
|
||||||
FormatsResponseSchema = inline_serializer(
|
class Downloader(APIView):
|
||||||
name="FormatsResponse",
|
|
||||||
fields={
|
|
||||||
"title": serializers.CharField(allow_null=True),
|
|
||||||
"duration": serializers.FloatField(allow_null=True),
|
|
||||||
"extractor": serializers.CharField(allow_null=True),
|
|
||||||
"video_id": serializers.CharField(allow_null=True),
|
|
||||||
"max_size_bytes": serializers.IntegerField(),
|
|
||||||
"options": serializers.ListField(child=FormatOptionSchema),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
DownloadRequestSchema = inline_serializer(
|
|
||||||
name="DownloadRequest",
|
|
||||||
fields={
|
|
||||||
"url": serializers.URLField(),
|
|
||||||
"format_id": serializers.CharField(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
ErrorResponseSchema = inline_serializer(
|
|
||||||
name="ErrorResponse",
|
|
||||||
fields={
|
|
||||||
"detail": serializers.CharField(),
|
|
||||||
"error": serializers.CharField(required=False),
|
|
||||||
"estimated_size_bytes": serializers.IntegerField(required=False),
|
|
||||||
"max_bytes": serializers.IntegerField(required=False),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
# ---------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _estimate_size_bytes(fmt: Dict[str, Any], duration: Optional[float]) -> Optional[int]:
|
|
||||||
"""Estimate (or return exact) size in bytes for a yt-dlp format."""
|
|
||||||
# Prefer exact sizes from yt-dlp
|
|
||||||
if fmt.get("filesize"):
|
|
||||||
return int(fmt["filesize"])
|
|
||||||
if fmt.get("filesize_approx"):
|
|
||||||
return int(fmt["filesize_approx"])
|
|
||||||
# Estimate via total bitrate (tbr is in Kbps)
|
|
||||||
if duration and fmt.get("tbr"):
|
|
||||||
try:
|
|
||||||
kbps = float(fmt["tbr"])
|
|
||||||
return int((kbps * 1000 / 8) * float(duration))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _format_option(fmt: Dict[str, Any], duration: Optional[float], max_bytes: int) -> Dict[str, Any]:
|
|
||||||
"""Project yt-dlp format dict to a compact option object suitable for UI."""
|
|
||||||
est = _estimate_size_bytes(fmt, duration)
|
|
||||||
w = fmt.get("width")
|
|
||||||
h = fmt.get("height")
|
|
||||||
resolution = f"{w}x{h}" if w and h else None
|
|
||||||
return {
|
|
||||||
"format_id": fmt.get("format_id"),
|
|
||||||
"ext": fmt.get("ext"),
|
|
||||||
"vcodec": fmt.get("vcodec"),
|
|
||||||
"acodec": fmt.get("acodec"),
|
|
||||||
"fps": fmt.get("fps"),
|
|
||||||
"tbr": fmt.get("tbr"),
|
|
||||||
"abr": fmt.get("abr"),
|
|
||||||
"vbr": fmt.get("vbr"),
|
|
||||||
"asr": fmt.get("asr"),
|
|
||||||
"filesize": fmt.get("filesize"),
|
|
||||||
"filesize_approx": fmt.get("filesize_approx"),
|
|
||||||
"estimated_size_bytes": est,
|
|
||||||
"size_ok": (est is not None and est <= max_bytes),
|
|
||||||
"format_note": fmt.get("format_note"),
|
|
||||||
"resolution": resolution,
|
|
||||||
"audio_only": (fmt.get("vcodec") in (None, "none")),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _client_meta(request) -> Tuple[Optional[Any], Optional[str], Optional[str]]:
|
|
||||||
"""Extract current user, client IP and User-Agent."""
|
|
||||||
xff = request.META.get("HTTP_X_FORWARDED_FOR")
|
|
||||||
ip = (xff.split(",")[0].strip() if xff else request.META.get("REMOTE_ADDR"))
|
|
||||||
ua = request.META.get("HTTP_USER_AGENT")
|
|
||||||
user = getattr(request, "user", None)
|
|
||||||
return user, ip, ua
|
|
||||||
|
|
||||||
# Safe logger: swallow DB errors if table is missing/not migrated yet
|
|
||||||
def _log_safely(*, info, requested_format, status: str, url: str, user, ip_address: str, user_agent: str, error_message: str | None = None):
|
|
||||||
try:
|
|
||||||
DownloaderModel.from_ydl_info(
|
|
||||||
info=info,
|
|
||||||
requested_format=requested_format,
|
|
||||||
status=status,
|
|
||||||
url=url,
|
|
||||||
user=user,
|
|
||||||
ip_address=ip_address,
|
|
||||||
user_agent=user_agent,
|
|
||||||
error_message=error_message,
|
|
||||||
)
|
|
||||||
except (OperationalError, ProgrammingError):
|
|
||||||
# migrations not applied or table missing – ignore
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
# never break the request on logging failures
|
|
||||||
pass
|
|
||||||
|
|
||||||
class DownloaderFormatsView(APIView):
|
|
||||||
"""Probe media URL and return available formats with estimated sizes and limit flags."""
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
authentication_classes = []
|
authentication_classes = []
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
tags=["downloader"],
|
tags=["downloader"],
|
||||||
operation_id="downloader_formats",
|
summary="Get video info from URL",
|
||||||
summary="List available formats for a media URL",
|
parameters=[
|
||||||
description="Uses yt-dlp to extract formats and estimates size. Applies max size policy.",
|
inline_serializer(
|
||||||
request=FormatsRequestSchema,
|
name="VideoInfoParams",
|
||||||
responses={
|
fields={
|
||||||
200: FormatsResponseSchema,
|
"url": serializers.URLField(help_text="Video URL to analyze"),
|
||||||
400: OpenApiResponse(response=ErrorResponseSchema),
|
|
||||||
500: OpenApiResponse(response=ErrorResponseSchema),
|
|
||||||
},
|
},
|
||||||
examples=[
|
)
|
||||||
OpenApiExample(
|
|
||||||
"Formats request",
|
|
||||||
value={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
|
|
||||||
request_only=True,
|
|
||||||
),
|
|
||||||
OpenApiExample(
|
|
||||||
"Formats response (excerpt)",
|
|
||||||
value={
|
|
||||||
"title": "Example Title",
|
|
||||||
"duration": 213.0,
|
|
||||||
"extractor": "youtube",
|
|
||||||
"video_id": "dQw4w9WgXcQ",
|
|
||||||
"max_size_bytes": 209715200,
|
|
||||||
"options": [
|
|
||||||
{
|
|
||||||
"format_id": "140",
|
|
||||||
"ext": "m4a",
|
|
||||||
"vcodec": "none",
|
|
||||||
"acodec": "mp4a.40.2",
|
|
||||||
"fps": None,
|
|
||||||
"tbr": 128.0,
|
|
||||||
"abr": 128.0,
|
|
||||||
"vbr": None,
|
|
||||||
"asr": 44100,
|
|
||||||
"filesize": None,
|
|
||||||
"filesize_approx": 3342334,
|
|
||||||
"estimated_size_bytes": 3400000,
|
|
||||||
"size_ok": True,
|
|
||||||
"format_note": "tiny",
|
|
||||||
"resolution": None,
|
|
||||||
"audio_only": True,
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
},
|
|
||||||
response_only=True,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
"""POST to probe a media URL and list available formats."""
|
|
||||||
try:
|
|
||||||
import yt_dlp
|
|
||||||
except Exception:
|
|
||||||
return Response({"detail": "yt-dlp not installed. pip install yt-dlp"}, status=500)
|
|
||||||
|
|
||||||
url = request.data.get("url")
|
|
||||||
if not url:
|
|
||||||
return Response({"detail": "Missing 'url'."}, status=400)
|
|
||||||
|
|
||||||
max_bytes = getattr(settings, "DOWNLOADER_MAX_SIZE_BYTES", 200 * 1024 * 1024)
|
|
||||||
ydl_opts = {
|
|
||||||
"skip_download": True,
|
|
||||||
"quiet": True,
|
|
||||||
"no_warnings": True,
|
|
||||||
"noprogress": True,
|
|
||||||
"ignoreerrors": True,
|
|
||||||
"socket_timeout": getattr(settings, "DOWNLOADER_TIMEOUT", 120),
|
|
||||||
"extract_flat": False,
|
|
||||||
"allow_playlist": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
||||||
info = ydl.extract_info(url, download=False)
|
|
||||||
except Exception as e:
|
|
||||||
# log probe error
|
|
||||||
user, ip, ua = _client_meta(request)
|
|
||||||
_log_safely(
|
|
||||||
info={"webpage_url": url},
|
|
||||||
requested_format=None,
|
|
||||||
status="probe_error",
|
|
||||||
url=url,
|
|
||||||
user=user,
|
|
||||||
ip_address=ip,
|
|
||||||
user_agent=ua,
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
return Response({"detail": "Failed to extract formats", "error": str(e)}, status=400)
|
|
||||||
|
|
||||||
duration = info.get("duration")
|
|
||||||
formats = info.get("formats") or []
|
|
||||||
options: List[Dict[str, Any]] = []
|
|
||||||
for f in formats:
|
|
||||||
options.append(_format_option(f, duration, max_bytes))
|
|
||||||
|
|
||||||
# optional: sort by size then by resolution desc
|
|
||||||
def sort_key(o):
|
|
||||||
size = o["estimated_size_bytes"] if o["estimated_size_bytes"] is not None else math.inf
|
|
||||||
res = 0
|
|
||||||
if o["resolution"]:
|
|
||||||
try:
|
|
||||||
w, h = o["resolution"].split("x")
|
|
||||||
res = int(w) * int(h)
|
|
||||||
except Exception:
|
|
||||||
res = 0
|
|
||||||
return (size, -res)
|
|
||||||
|
|
||||||
options_sorted = sorted(options, key=sort_key)[:50]
|
|
||||||
|
|
||||||
# Log probe
|
|
||||||
user, ip, ua = _client_meta(request)
|
|
||||||
_log_safely(
|
|
||||||
info=info,
|
|
||||||
requested_format=None,
|
|
||||||
status="probe_ok",
|
|
||||||
url=url,
|
|
||||||
user=user,
|
|
||||||
ip_address=ip,
|
|
||||||
user_agent=ua,
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
"title": info.get("title"),
|
|
||||||
"duration": duration,
|
|
||||||
"extractor": info.get("extractor"),
|
|
||||||
"video_id": info.get("id"),
|
|
||||||
"max_size_bytes": max_bytes,
|
|
||||||
"options": options_sorted,
|
|
||||||
})
|
|
||||||
|
|
||||||
class DownloaderFileView(APIView):
|
|
||||||
"""Download selected format if under max size, then stream the file back."""
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
authentication_classes = []
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["downloader"],
|
|
||||||
operation_id="downloader_download",
|
|
||||||
summary="Download a selected format and stream file",
|
|
||||||
description="Downloads with a strict max filesize guard and streams as application/octet-stream.",
|
|
||||||
request=DownloadRequestSchema,
|
|
||||||
responses={
|
|
||||||
200: OpenApiTypes.BINARY, # was OpenApiResponse(..., media_type="application/octet-stream")
|
|
||||||
400: OpenApiResponse(response=ErrorResponseSchema),
|
|
||||||
413: OpenApiResponse(response=ErrorResponseSchema),
|
|
||||||
500: OpenApiResponse(response=ErrorResponseSchema),
|
|
||||||
},
|
|
||||||
examples=[
|
|
||||||
OpenApiExample(
|
|
||||||
"Download request",
|
|
||||||
value={"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", "format_id": "140"},
|
|
||||||
request_only=True,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def post(self, request):
|
|
||||||
"""POST to download a media URL in the selected format."""
|
|
||||||
try:
|
|
||||||
import yt_dlp
|
|
||||||
except Exception:
|
|
||||||
return Response({"detail": "yt-dlp not installed. pip install yt-dlp"}, status=500)
|
|
||||||
|
|
||||||
url = request.data.get("url")
|
|
||||||
fmt_id = request.data.get("format_id")
|
|
||||||
if not url or not fmt_id:
|
|
||||||
return Response({"detail": "Missing 'url' or 'format_id'."}, status=400)
|
|
||||||
|
|
||||||
max_bytes = getattr(settings, "DOWNLOADER_MAX_SIZE_BYTES", 200 * 1024 * 1024)
|
|
||||||
timeout = getattr(settings, "DOWNLOADER_TIMEOUT", 120)
|
|
||||||
tmp_dir = getattr(settings, "DOWNLOADER_TMP_DIR", os.path.join(settings.BASE_DIR, "tmp", "downloader"))
|
|
||||||
os.makedirs(tmp_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# First, extract info to check/estimate size
|
|
||||||
probe_opts = {
|
|
||||||
"skip_download": True,
|
|
||||||
"quiet": True,
|
|
||||||
"no_warnings": True,
|
|
||||||
"noprogress": True,
|
|
||||||
"ignoreerrors": True,
|
|
||||||
"socket_timeout": timeout,
|
|
||||||
"extract_flat": False,
|
|
||||||
"allow_playlist": False,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
with yt_dlp.YoutubeDL(probe_opts) as ydl:
|
|
||||||
info = ydl.extract_info(url, download=False)
|
|
||||||
except Exception as e:
|
|
||||||
user, ip, ua = _client_meta(request)
|
|
||||||
_log_safely(
|
|
||||||
info={"webpage_url": url},
|
|
||||||
requested_format=fmt_id,
|
|
||||||
status="precheck_error",
|
|
||||||
url=url,
|
|
||||||
user=user,
|
|
||||||
ip_address=ip,
|
|
||||||
user_agent=ua,
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
return Response({"detail": "Failed to analyze media", "error": str(e)}, status=400)
|
|
||||||
|
|
||||||
duration = info.get("duration")
|
|
||||||
selected = None
|
|
||||||
for f in (info.get("formats") or []):
|
|
||||||
if str(f.get("format_id")) == str(fmt_id):
|
|
||||||
selected = f
|
|
||||||
break
|
|
||||||
if not selected:
|
|
||||||
return Response({"detail": f"format_id '{fmt_id}' not found"}, status=400)
|
|
||||||
# Enforce size policy
|
|
||||||
est_size = _estimate_size_bytes(selected, duration)
|
|
||||||
if est_size is not None and est_size > max_bytes:
|
|
||||||
user, ip, ua = _client_meta(request)
|
|
||||||
_log_safely(
|
|
||||||
info=selected,
|
|
||||||
requested_format=fmt_id,
|
|
||||||
status="blocked_by_size",
|
|
||||||
url=url,
|
|
||||||
user=user,
|
|
||||||
ip_address=ip,
|
|
||||||
user_agent=ua,
|
|
||||||
error_message=f"Estimated size {est_size} > max {max_bytes}",
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
{"detail": "File too large for this server", "estimated_size_bytes": est_size, "max_bytes": max_bytes},
|
|
||||||
status=413,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Now download with strict max_filesize guard
|
|
||||||
ydl_opts = {
|
|
||||||
"format": str(fmt_id),
|
|
||||||
"quiet": True,
|
|
||||||
"no_warnings": True,
|
|
||||||
"noprogress": True,
|
|
||||||
"socket_timeout": timeout,
|
|
||||||
"retries": 3,
|
|
||||||
"outtmpl": os.path.join(tmp_dir, "%(id)s.%(ext)s"),
|
|
||||||
"max_filesize": max_bytes, # hard cap during download
|
|
||||||
"concurrent_fragment_downloads": 1,
|
|
||||||
"http_chunk_size": 1024 * 1024, # 1MB chunks to reduce memory
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
||||||
# Will raise if max_filesize exceeded during transfer
|
|
||||||
result = ydl.extract_info(url, download=True)
|
|
||||||
# yt-dlp returns result for entries or single; get final info
|
|
||||||
if "requested_downloads" in result and result["requested_downloads"]:
|
|
||||||
rd = result["requested_downloads"][0]
|
|
||||||
filepath = rd.get("filepath") or rd.get("__final_filename")
|
|
||||||
else:
|
|
||||||
# fallback
|
|
||||||
filepath = result.get("requested_downloads", [{}])[0].get("filepath") or result.get("_filename")
|
|
||||||
except Exception as e:
|
|
||||||
user, ip, ua = _client_meta(request)
|
|
||||||
_log_safely(
|
|
||||||
info=selected,
|
|
||||||
requested_format=fmt_id,
|
|
||||||
status="download_error",
|
|
||||||
url=url,
|
|
||||||
user=user,
|
|
||||||
ip_address=ip,
|
|
||||||
user_agent=ua,
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
return Response({"detail": "Download failed", "error": str(e)}, status=400)
|
|
||||||
|
|
||||||
if not filepath or not os.path.exists(filepath):
|
|
||||||
return Response({"detail": "Downloaded file not found"}, status=500)
|
|
||||||
|
|
||||||
# Build a safe filename
|
|
||||||
base_title = info.get("title") or "video"
|
|
||||||
ext = os.path.splitext(filepath)[1].lstrip(".") or (selected.get("ext") or "bin")
|
|
||||||
safe_name = f"{slugify(base_title)[:80]}.{ext}"
|
|
||||||
|
|
||||||
# Log success
|
|
||||||
user, ip, ua = _client_meta(request)
|
|
||||||
try:
|
|
||||||
selected_info = dict(selected)
|
|
||||||
selected_info["filesize"] = os.path.getsize(filepath)
|
|
||||||
_log_safely(
|
|
||||||
info=selected_info,
|
|
||||||
requested_format=fmt_id,
|
|
||||||
status="success",
|
|
||||||
url=url,
|
|
||||||
user=user,
|
|
||||||
ip_address=ip,
|
|
||||||
user_agent=ua,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Stream file and remove after sending
|
|
||||||
def file_generator(path: str):
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(8192)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
yield chunk
|
|
||||||
try:
|
|
||||||
os.remove(path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
resp = StreamingHttpResponse(file_generator(filepath), content_type="application/octet-stream")
|
|
||||||
# Include both plain and RFC 5987 encoded filename
|
|
||||||
resp["Content-Disposition"] = (
|
|
||||||
f'attachment; filename="{safe_name}"; filename*=UTF-8\'\'{urlquote(safe_name)}'
|
|
||||||
)
|
|
||||||
# Expose headers so the browser can read them via XHR/fetch
|
|
||||||
resp["X-Filename"] = safe_name
|
|
||||||
resp["Access-Control-Expose-Headers"] = "Content-Disposition, X-Filename, Content-Length, Content-Type"
|
|
||||||
try:
|
|
||||||
resp["Content-Length"] = str(os.path.getsize(filepath))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
resp["X-Content-Type-Options"] = "nosniff"
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
# Simple stats view (aggregations for UI charts)
|
|
||||||
class DownloaderStatsView(APIView):
|
|
||||||
permission_classes = [AllowAny]
|
|
||||||
|
|
||||||
@extend_schema(
|
|
||||||
tags=["downloader"],
|
|
||||||
operation_id="downloader_stats",
|
|
||||||
summary="Aggregated downloader statistics",
|
|
||||||
description="Returns top extensions, requested formats, codecs and audio/video split.",
|
|
||||||
responses={
|
responses={
|
||||||
200: inline_serializer(
|
200: inline_serializer(
|
||||||
name="DownloaderStats",
|
name="VideoInfoResponse",
|
||||||
fields={
|
fields={
|
||||||
"top_ext": serializers.ListField(
|
"title": serializers.CharField(),
|
||||||
child=inline_serializer(name="ExtCount", fields={
|
"duration": serializers.IntegerField(allow_null=True),
|
||||||
"ext": serializers.CharField(allow_null=True),
|
"thumbnail": serializers.URLField(allow_null=True),
|
||||||
"count": serializers.IntegerField(),
|
"video_resolutions": serializers.ListField(child=serializers.CharField()),
|
||||||
})
|
"audio_resolutions": serializers.ListField(child=serializers.CharField()),
|
||||||
),
|
|
||||||
"top_requested_format": serializers.ListField(
|
|
||||||
child=inline_serializer(name="RequestedFormatCount", fields={
|
|
||||||
"requested_format": serializers.CharField(allow_null=True),
|
|
||||||
"count": serializers.IntegerField(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
"top_vcodec": serializers.ListField(
|
|
||||||
child=inline_serializer(name="VCodecCount", fields={
|
|
||||||
"vcodec": serializers.CharField(allow_null=True),
|
|
||||||
"count": serializers.IntegerField(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
"top_acodec": serializers.ListField(
|
|
||||||
child=inline_serializer(name="ACodecCount", fields={
|
|
||||||
"acodec": serializers.CharField(allow_null=True),
|
|
||||||
"count": serializers.IntegerField(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
"audio_vs_video": serializers.ListField(
|
|
||||||
child=inline_serializer(name="AudioVsVideo", fields={
|
|
||||||
"is_audio_only": serializers.BooleanField(),
|
|
||||||
"count": serializers.IntegerField(),
|
|
||||||
})
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
|
400: inline_serializer(
|
||||||
|
name="ErrorResponse",
|
||||||
|
fields={"error": serializers.CharField()},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""GET to retrieve aggregated downloader statistics."""
|
url = request.data.get("url") or request.query_params.get("url")
|
||||||
top_ext = list(DownloaderModel.objects.values("ext").annotate(count=Count("id")).order_by("-count")[:10])
|
if not url:
|
||||||
top_formats = list(DownloaderModel.objects.values("requested_format").annotate(count=Count("id")).order_by("-count")[:10])
|
return Response({"error": "URL is required"}, status=400)
|
||||||
top_vcodec = list(DownloaderModel.objects.values("vcodec").annotate(count=Count("id")).order_by("-count")[:10])
|
|
||||||
top_acodec = list(DownloaderModel.objects.values("acodec").annotate(count=Count("id")).order_by("-count")[:10])
|
ydl_options = {
|
||||||
audio_vs_video = list(DownloaderModel.objects.values("is_audio_only").annotate(count=Count("id")).order_by("-count"))
|
"quiet": True,
|
||||||
return Response({
|
}
|
||||||
"top_ext": top_ext,
|
try:
|
||||||
"top_requested_format": top_formats,
|
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
||||||
"top_vcodec": top_vcodec,
|
info = ydl.extract_info(url, download=False)
|
||||||
"top_acodec": top_acodec,
|
except Exception:
|
||||||
"audio_vs_video": audio_vs_video,
|
return Response({"error": "Failed to retrieve video info"}, status=400)
|
||||||
})
|
|
||||||
|
formats = info.get("formats", []) or []
|
||||||
|
|
||||||
|
# Video: collect unique heights and sort desc
|
||||||
|
heights = {
|
||||||
|
int(f.get("height"))
|
||||||
|
for f in formats
|
||||||
|
if f.get("vcodec") != "none" and isinstance(f.get("height"), int)
|
||||||
|
}
|
||||||
|
video_resolutions = [f"{h}p" for h in sorted(heights, reverse=True)]
|
||||||
|
|
||||||
|
# Audio: collect unique bitrates (abr kbps), fallback to tbr when abr missing
|
||||||
|
bitrates = set()
|
||||||
|
for f in formats:
|
||||||
|
if f.get("acodec") != "none" and f.get("vcodec") == "none":
|
||||||
|
abr = f.get("abr")
|
||||||
|
tbr = f.get("tbr")
|
||||||
|
val = None
|
||||||
|
if isinstance(abr, (int, float)):
|
||||||
|
val = int(abr)
|
||||||
|
elif isinstance(tbr, (int, float)):
|
||||||
|
val = int(tbr)
|
||||||
|
if val and val > 0:
|
||||||
|
bitrates.add(val)
|
||||||
|
|
||||||
|
audio_resolutions = [f"{b}kbps" for b in sorted(bitrates, reverse=True)]
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"title": info.get("title"),
|
||||||
|
"duration": info.get("duration"),
|
||||||
|
"thumbnail": info.get("thumbnail"),
|
||||||
|
"video_resolutions": video_resolutions,
|
||||||
|
"audio_resolutions": audio_resolutions,
|
||||||
|
},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
tags=["downloader"],
|
||||||
|
summary="Download video from URL",
|
||||||
|
request=inline_serializer(
|
||||||
|
name="DownloadRequest",
|
||||||
|
fields={
|
||||||
|
"url": serializers.URLField(help_text="Video URL to download"),
|
||||||
|
"ext": serializers.ChoiceField(
|
||||||
|
choices=FORMAT_CHOICES,
|
||||||
|
required=False,
|
||||||
|
default="mp4",
|
||||||
|
help_text=FORMAT_HELP,
|
||||||
|
),
|
||||||
|
"format": serializers.ChoiceField(
|
||||||
|
choices=FORMAT_CHOICES,
|
||||||
|
required=False,
|
||||||
|
help_text="Alias of 'ext' (deprecated)."
|
||||||
|
),
|
||||||
|
"video_quality": serializers.IntegerField(
|
||||||
|
required=True,
|
||||||
|
help_text="Target max video height (e.g. 1080)."
|
||||||
|
),
|
||||||
|
"audio_quality": serializers.IntegerField(
|
||||||
|
required=True,
|
||||||
|
help_text="Target max audio bitrate in kbps (e.g. 160)."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
responses={
|
||||||
|
200: OpenApiTypes.BINARY,
|
||||||
|
400: inline_serializer(
|
||||||
|
name="DownloadErrorResponse",
|
||||||
|
fields={
|
||||||
|
"error": serializers.CharField(),
|
||||||
|
"allowed": serializers.ListField(child=serializers.CharField(), required=False),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def post(self, request):
|
||||||
|
url = request.data.get("url")
|
||||||
|
# Accept ext or legacy format param
|
||||||
|
ext = (request.data.get("ext") or request.data.get("format") or "mp4").lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
video_quality = int(request.data.get("video_quality")) # height, e.g., 1080
|
||||||
|
audio_quality = int(request.data.get("audio_quality")) # abr kbps, e.g., 160
|
||||||
|
except Exception:
|
||||||
|
return Response({"error": "Invalid quality parameters, not integers!"}, status=400)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return Response({"error": "URL is required"}, status=400)
|
||||||
|
if ext not in FORMAT_CHOICES:
|
||||||
|
return Response({"error": f"Unsupported extension '{ext}'", "allowed": FORMAT_CHOICES}, status=400)
|
||||||
|
|
||||||
|
# Ensure base tmp dir exists
|
||||||
|
os.makedirs(settings.DOWNLOADER_TMP_DIR, exist_ok=True)
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="downloader_", dir=settings.DOWNLOADER_TMP_DIR)
|
||||||
|
outtmpl = os.path.join(tmpdir, "download.%(ext)s")
|
||||||
|
|
||||||
|
# Build a format selector using requested quality caps
|
||||||
|
# Example: "bv[height<=1080]+ba[abr<=160]/b"
|
||||||
|
video_part = f"bv[height<={video_quality}]" if video_quality else "bv*"
|
||||||
|
audio_part = f"ba[abr<={audio_quality}]" if audio_quality else "ba"
|
||||||
|
|
||||||
|
format_selector = f"{video_part}+{audio_part}/b"
|
||||||
|
|
||||||
|
|
||||||
|
ydl_options = {
|
||||||
|
"format": format_selector, # select by requested quality
|
||||||
|
"merge_output_format": ext, # container
|
||||||
|
"outtmpl": outtmpl, # temp dir
|
||||||
|
"quiet": True,
|
||||||
|
"max_filesize": settings.DOWNLOADER_MAX_SIZE_BYTES,
|
||||||
|
"socket_timeout": settings.DOWNLOADER_TIMEOUT,
|
||||||
|
# remux to container without re-encoding where possible
|
||||||
|
"postprocessors": [
|
||||||
|
{"key": "FFmpegVideoRemuxer", "preferedformat": ext}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
file_path = ""
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(ydl_options) as ydl:
|
||||||
|
info = ydl.extract_info(url, download=True)
|
||||||
|
base = ydl.prepare_filename(info)
|
||||||
|
file_path = base if base.endswith(f".{ext}") else os.path.splitext(base)[0] + f".{ext}"
|
||||||
|
|
||||||
|
# Stats before streaming
|
||||||
|
duration = int((info or {}).get("duration") or 0)
|
||||||
|
size = os.path.getsize(file_path) if os.path.exists(file_path) else 0
|
||||||
|
|
||||||
|
DownloaderRecord.objects.create(
|
||||||
|
url=url,
|
||||||
|
format=ext,
|
||||||
|
length_of_media=duration,
|
||||||
|
file_size=size,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Streaming generator that deletes file & temp dir after send (or on abort)
|
||||||
|
def stream_and_cleanup(path: str, temp_dir: str, chunk_size: int = 8192):
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.remove(path)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
safe_title = slugify(info.get("title") or "video")
|
||||||
|
filename = f"{safe_title}.{ext}"
|
||||||
|
content_type = MIME_BY_EXT.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
|
response = StreamingHttpResponse(
|
||||||
|
streaming_content=stream_and_cleanup(file_path, tmpdir),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
if size:
|
||||||
|
response["Content-Length"] = str(size)
|
||||||
|
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
return Response({"error": str(e)}, status=400)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- STATS FOR GRAPHS ----------------
|
||||||
|
|
||||||
|
from .serializers import DownloaderStatsSerializer
|
||||||
|
from django.db.models import Count, Avg, Sum
|
||||||
|
|
||||||
|
class DownloaderStats(APIView):
|
||||||
|
"""
|
||||||
|
Vrací agregované statistiky z tabulky DownloaderRecord.
|
||||||
|
"""
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
@extend_schema(
|
||||||
|
tags=["downloader"],
|
||||||
|
summary="Get aggregated downloader statistics",
|
||||||
|
responses={200: DownloaderStatsSerializer},
|
||||||
|
)
|
||||||
|
def get(self, request):
|
||||||
|
# agregace číselných polí
|
||||||
|
agg = DownloaderRecord.objects.aggregate(
|
||||||
|
total_downloads=Count("id"),
|
||||||
|
avg_length_of_media=Avg("length_of_media"),
|
||||||
|
avg_file_size=Avg("file_size"),
|
||||||
|
total_length_of_media=Sum("length_of_media"),
|
||||||
|
total_file_size=Sum("file_size"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# zjištění nejčastějšího formátu
|
||||||
|
most_common = (
|
||||||
|
DownloaderRecord.objects.values("format")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
agg["most_common_format"] = most_common["format"] if most_common else None
|
||||||
|
|
||||||
|
serializer = DownloaderStatsSerializer(agg)
|
||||||
|
return Response(serializer.data)
|
||||||
4
backend/thirdparty/stripe/views.py
vendored
4
backend/thirdparty/stripe/views.py
vendored
@@ -5,6 +5,7 @@ from django.http import HttpResponse
|
|||||||
from rest_framework import generics
|
from rest_framework import generics
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
from drf_spectacular.utils import extend_schema
|
||||||
|
|
||||||
from .models import Order
|
from .models import Order
|
||||||
from .serializers import OrderSerializer
|
from .serializers import OrderSerializer
|
||||||
@@ -14,6 +15,9 @@ import stripe
|
|||||||
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
|
||||||
class CreateCheckoutSessionView(APIView):
|
class CreateCheckoutSessionView(APIView):
|
||||||
|
@extend_schema(
|
||||||
|
tags=["stripe"],
|
||||||
|
)
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = OrderSerializer(data=request.data) #obecný serializer
|
serializer = OrderSerializer(data=request.data) #obecný serializer
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|||||||
1
backend/thirdparty/trading212/views.py
vendored
1
backend/thirdparty/trading212/views.py
vendored
@@ -12,6 +12,7 @@ class Trading212AccountCashView(APIView):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
|
tags=["trading212"],
|
||||||
summary="Get Trading212 account cash",
|
summary="Get Trading212 account cash",
|
||||||
responses=Trading212AccountCashSerializer
|
responses=Trading212AccountCashSerializer
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,18 +17,23 @@ from django.core.management.utils import get_random_secret_key
|
|||||||
from django.db import OperationalError, connections
|
from django.db import OperationalError, connections
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import json
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné
|
load_dotenv() # Pouze načte proměnné lokálně, pokud nejsou dostupné
|
||||||
|
|
||||||
|
# Robust boolean parser and SSL flag
|
||||||
|
def _env_bool(key: str, default: bool = False) -> bool:
|
||||||
|
return os.getenv(key, str(default)).strip().lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
|
USE_SSL = _env_bool("SSL", False)
|
||||||
|
|
||||||
#---------------- ENV VARIABLES USECASE--------------
|
#---------------- ENV VARIABLES USECASE--------------
|
||||||
# v jiné app si to importneš skrz: from django.conf import settings
|
# v jiné app si to importneš skrz: from django.conf import settings
|
||||||
# a použiješ takto: settings.FRONTEND_URL
|
# a použiješ takto: settings.FRONTEND_URL
|
||||||
|
|
||||||
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:9000")
|
||||||
FRONTEND_URL_DEV = os.getenv("FRONTEND_URL_DEV", "http://localhost:5173")
|
print(f"FRONTEND_URL: {FRONTEND_URL}\n")
|
||||||
print(f"FRONTEND_URL: {FRONTEND_URL}\nFRONTEND_URL_DEV: {FRONTEND_URL_DEV}\n")
|
|
||||||
|
|
||||||
#-------------------------BASE ⚙️------------------------
|
#-------------------------BASE ⚙️------------------------
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@@ -153,43 +158,81 @@ AUTHENTICATION_BACKENDS = [
|
|||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed = urlparse(FRONTEND_URL)
|
||||||
|
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
'https://domena.cz',
|
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
|
||||||
"https://www.domena.cz",
|
|
||||||
"http://localhost:3000", #react docker
|
"http://192.168.67.98",
|
||||||
"http://localhost:5173" #react dev
|
"https://itsolutions.vontor.cz",
|
||||||
|
"https://react.vontor.cz",
|
||||||
|
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://localhost:9000",
|
||||||
|
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
"http://127.0.0.1:9000",
|
||||||
|
|
||||||
|
# server
|
||||||
|
"http://192.168.67.98",
|
||||||
|
"https://itsolutions.vontor.cz",
|
||||||
|
"https://react.vontor.cz",
|
||||||
|
|
||||||
|
# nginx docker (local)
|
||||||
|
"http://localhost",
|
||||||
|
"http://localhost:80",
|
||||||
|
"http://127.0.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
CORS_ALLOWED_ORIGINS = [
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
f"{parsed.scheme}://{parsed.hostname}:{parsed.port or (443 if parsed.scheme=='https' else 80)}",
|
||||||
|
|
||||||
"http://localhost:5173",
|
"http://localhost:5173",
|
||||||
"http://localhost:3000",
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
"http://localhost:9000",
|
||||||
|
"http://127.0.0.1:9000",
|
||||||
|
|
||||||
|
# server
|
||||||
|
"http://192.168.67.98",
|
||||||
|
"https://itsolutions.vontor.cz",
|
||||||
|
"https://react.vontor.cz",
|
||||||
|
|
||||||
|
# nginx docker (local)
|
||||||
|
"http://localhost",
|
||||||
|
"http://localhost:80",
|
||||||
|
"http://127.0.0.1",
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
CORS_ALLOWED_ORIGINS = [
|
CORS_ALLOWED_ORIGINS = [
|
||||||
"https://www.domena.cz",
|
"http://192.168.67.98",
|
||||||
|
"https://itsolutions.vontor.cz",
|
||||||
|
"https://react.vontor.cz",
|
||||||
|
|
||||||
|
"http://localhost:9000",
|
||||||
|
"http://127.0.0.1:9000",
|
||||||
]
|
]
|
||||||
|
|
||||||
CORS_ALLOW_CREDENTIALS = True
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials
|
CORS_ALLOW_ALL_ORIGINS = False # Tohle musí být false, když používáš credentials
|
||||||
|
|
||||||
|
# Use Lax for http (local), None only when HTTPS is enabled
|
||||||
|
SESSION_COOKIE_SAMESITE = "None" if USE_SSL else "Lax"
|
||||||
|
CSRF_COOKIE_SAMESITE = "None" if USE_SSL else "Lax"
|
||||||
|
|
||||||
print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS)
|
print("CORS_ALLOWED_ORIGINS =", CORS_ALLOWED_ORIGINS)
|
||||||
print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS)
|
print("CSRF_TRUSTED_ORIGINS =", CSRF_TRUSTED_ORIGINS)
|
||||||
print("ALLOWED_HOSTS =", ALLOWED_HOSTS)
|
print("ALLOWED_HOSTS =", ALLOWED_HOSTS)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#--------------------------------END CORS + HOSTs 🌐🔐---------------------------------
|
#--------------------------------END CORS + HOSTs 🌐🔐---------------------------------
|
||||||
|
|
||||||
|
|
||||||
#--------------------------------------SSL 🧾------------------------------------
|
#--------------------------------------SSL 🧾------------------------------------
|
||||||
|
|
||||||
if os.getenv("SSL", "") == "True":
|
|
||||||
USE_SSL = True
|
|
||||||
else:
|
|
||||||
USE_SSL = False
|
|
||||||
|
|
||||||
|
|
||||||
if USE_SSL is True:
|
if USE_SSL is True:
|
||||||
print("SSL turned on!")
|
print("SSL turned on!")
|
||||||
SESSION_COOKIE_SECURE = True
|
SESSION_COOKIE_SECURE = True
|
||||||
@@ -197,7 +240,6 @@ if USE_SSL is True:
|
|||||||
SECURE_SSL_REDIRECT = True
|
SECURE_SSL_REDIRECT = True
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
SECURE_BROWSER_XSS_FILTER = True
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
USE_X_FORWARDED_HOST = True
|
|
||||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
else:
|
else:
|
||||||
SESSION_COOKIE_SECURE = False
|
SESSION_COOKIE_SECURE = False
|
||||||
@@ -205,10 +247,7 @@ else:
|
|||||||
SECURE_SSL_REDIRECT = False
|
SECURE_SSL_REDIRECT = False
|
||||||
SECURE_BROWSER_XSS_FILTER = False
|
SECURE_BROWSER_XSS_FILTER = False
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = False
|
SECURE_CONTENT_TYPE_NOSNIFF = False
|
||||||
USE_X_FORWARDED_HOST = False
|
|
||||||
|
|
||||||
print(f"\nUsing SSL: {USE_SSL}\n")
|
print(f"\nUsing SSL: {USE_SSL}\n")
|
||||||
|
|
||||||
#--------------------------------END-SSL 🧾---------------------------------
|
#--------------------------------END-SSL 🧾---------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -218,8 +257,8 @@ print(f"\nUsing SSL: {USE_SSL}\n")
|
|||||||
#-------------------------------------REST FRAMEWORK 🛠️------------------------------------
|
#-------------------------------------REST FRAMEWORK 🛠️------------------------------------
|
||||||
|
|
||||||
# ⬇️ Základní lifetime konfigurace
|
# ⬇️ Základní lifetime konfigurace
|
||||||
ACCESS_TOKEN_LIFETIME = timedelta(minutes=15)
|
ACCESS_TOKEN_LIFETIME = timedelta(minutes=60)
|
||||||
REFRESH_TOKEN_LIFETIME = timedelta(days=1)
|
REFRESH_TOKEN_LIFETIME = timedelta(days=5)
|
||||||
|
|
||||||
# ⬇️ Nastavení SIMPLE_JWT podle režimu
|
# ⬇️ Nastavení SIMPLE_JWT podle režimu
|
||||||
if DEBUG:
|
if DEBUG:
|
||||||
@@ -228,13 +267,16 @@ if DEBUG:
|
|||||||
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
|
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
|
||||||
|
|
||||||
"AUTH_COOKIE": "access_token",
|
"AUTH_COOKIE": "access_token",
|
||||||
"AUTH_COOKIE_SECURE": False, # není HTTPS
|
"AUTH_COOKIE_REFRESH": "refresh_token",
|
||||||
|
|
||||||
|
"AUTH_COOKIE_DOMAIN": None,
|
||||||
|
"AUTH_COOKIE_SECURE": False,
|
||||||
"AUTH_COOKIE_HTTP_ONLY": True,
|
"AUTH_COOKIE_HTTP_ONLY": True,
|
||||||
"AUTH_COOKIE_PATH": "/",
|
"AUTH_COOKIE_PATH": "/",
|
||||||
"AUTH_COOKIE_SAMESITE": "Lax", # není cross-site
|
"AUTH_COOKIE_SAMESITE": "Lax",
|
||||||
|
|
||||||
"ROTATE_REFRESH_TOKENS": True,
|
"ROTATE_REFRESH_TOKENS": False,
|
||||||
"BLACKLIST_AFTER_ROTATION": True,
|
"BLACKLIST_AFTER_ROTATION": False,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
@@ -242,27 +284,33 @@ else:
|
|||||||
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
|
"REFRESH_TOKEN_LIFETIME": REFRESH_TOKEN_LIFETIME,
|
||||||
|
|
||||||
"AUTH_COOKIE": "access_token",
|
"AUTH_COOKIE": "access_token",
|
||||||
"AUTH_COOKIE_SECURE": True, # HTTPS only
|
"AUTH_COOKIE_REFRESH": "refresh_token",
|
||||||
|
"AUTH_COOKIE_DOMAIN": None,
|
||||||
|
|
||||||
|
# Secure/SameSite based on HTTPS availability
|
||||||
|
"AUTH_COOKIE_SECURE": USE_SSL,
|
||||||
"AUTH_COOKIE_HTTP_ONLY": True,
|
"AUTH_COOKIE_HTTP_ONLY": True,
|
||||||
"AUTH_COOKIE_PATH": "/",
|
"AUTH_COOKIE_PATH": "/",
|
||||||
"AUTH_COOKIE_SAMESITE": "None", # potřebné pro cross-origin
|
"AUTH_COOKIE_SAMESITE": "None" if USE_SSL else "Lax",
|
||||||
|
|
||||||
"ROTATE_REFRESH_TOKENS": True,
|
"ROTATE_REFRESH_TOKENS": True,
|
||||||
"BLACKLIST_AFTER_ROTATION": True,
|
"BLACKLIST_AFTER_ROTATION": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
|
"DATETIME_FORMAT": "%Y-%m-%d %H:%M", # Pavel
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
'account.tokens.CookieJWTAuthentication',
|
# In DEBUG keep Session + JWT + your cookie class for convenience
|
||||||
'rest_framework.authentication.SessionAuthentication',
|
'rest_framework.authentication.SessionAuthentication',
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
'account.tokens.CookieJWTAuthentication',
|
||||||
|
) if DEBUG else (
|
||||||
|
'account.tokens.CookieJWTAuthentication',
|
||||||
),
|
),
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
'rest_framework.permissions.AllowAny',
|
'rest_framework.permissions.AllowAny',
|
||||||
),
|
),
|
||||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||||
|
|
||||||
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
|
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'],
|
||||||
|
|
||||||
'DEFAULT_THROTTLE_RATES': {
|
'DEFAULT_THROTTLE_RATES': {
|
||||||
@@ -270,7 +318,6 @@ REST_FRAMEWORK = {
|
|||||||
'user': '2000/hour', # authenticated
|
'user': '2000/hour', # authenticated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#--------------------------------END REST FRAMEWORK 🛠️-------------------------------------
|
#--------------------------------END REST FRAMEWORK 🛠️-------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -340,23 +387,17 @@ INSTALLED_APPS = INSTALLED_APPS[:-1] + MY_CREATED_APPS + INSTALLED_APPS[-1:]
|
|||||||
# Middleware is a framework of hooks into Django's request/response processing.
|
# Middleware is a framework of hooks into Django's request/response processing.
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
# Middleware that allows your backend to accept requests from other domains (CORS)
|
|
||||||
"corsheaders.middleware.CorsMiddleware",
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
|
||||||
|
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
|
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
|
||||||
#CUSTOM
|
|
||||||
#'tools.middleware.CustomMaxUploadSizeMiddleware',
|
|
||||||
|
|
||||||
|
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',# díky tomu funguje načítaní static files
|
|
||||||
]
|
]
|
||||||
|
|
||||||
#--------------------------------END MIDDLEWARE 🧩---------------------------------
|
#--------------------------------END MIDDLEWARE 🧩---------------------------------
|
||||||
@@ -410,56 +451,42 @@ else:
|
|||||||
#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️---------------------------------
|
#--------------------------------END CACHE + CHANNELS(ws) 📡🗄️---------------------------------
|
||||||
|
|
||||||
#-------------------------------------CELERY 📅------------------------------------
|
#-------------------------------------CELERY 📅------------------------------------
|
||||||
|
|
||||||
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
|
||||||
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL")
|
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL")
|
||||||
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND")
|
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND")
|
||||||
|
# Control via env; default False in DEBUG, True otherwise
|
||||||
|
CELERY_ENABLED = _env_bool("CELERY_ENABLED", default=not DEBUG)
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
CELERY_ENABLED = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import redis
|
import redis
|
||||||
# test connection
|
|
||||||
r = redis.Redis(host='localhost', port=6379, db=0)
|
r = redis.Redis(host='localhost', port=6379, db=0)
|
||||||
r.ping()
|
r.ping()
|
||||||
except Exception:
|
except Exception:
|
||||||
CELERY_BROKER_URL = 'memory://'
|
CELERY_BROKER_URL = 'memory://'
|
||||||
|
CELERY_ENABLED = False
|
||||||
|
|
||||||
CELERY_ACCEPT_CONTENT = os.getenv("CELERY_ACCEPT_CONTENT")
|
def _env_list(key: str, default: list[str]) -> list[str]:
|
||||||
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER")
|
v = os.getenv(key)
|
||||||
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE")
|
if not v:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
parsed = json.loads(v)
|
||||||
|
if isinstance(parsed, (list, tuple)):
|
||||||
|
return list(parsed)
|
||||||
|
if isinstance(parsed, str):
|
||||||
|
return [parsed]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return [s.strip(" '\"") for s in v.strip("[]()").split(",") if s.strip()]
|
||||||
|
|
||||||
|
CELERY_ACCEPT_CONTENT = _env_list("CELERY_ACCEPT_CONTENT", ["json"])
|
||||||
|
CELERY_RESULT_ACCEPT_CONTENT = _env_list("CELERY_RESULT_ACCEPT_CONTENT", ["json"])
|
||||||
|
CELERY_TASK_SERIALIZER = os.getenv("CELERY_TASK_SERIALIZER", "json")
|
||||||
|
CELERY_RESULT_SERIALIZER = os.getenv("CELERY_RESULT_SERIALIZER", "json")
|
||||||
|
CELERY_TIMEZONE = os.getenv("CELERY_TIMEZONE", TIME_ZONE)
|
||||||
CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER")
|
CELERY_BEAT_SCHEDULER = os.getenv("CELERY_BEAT_SCHEDULER")
|
||||||
# if DEBUG:
|
|
||||||
# CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
|
||||||
# try:
|
|
||||||
# import redis
|
|
||||||
# # test connection
|
|
||||||
# r = redis.Redis(host='localhost', port=6379, db=0)
|
|
||||||
# r.ping()
|
|
||||||
# except Exception:
|
|
||||||
# CELERY_BROKER_URL = 'memory://'
|
|
||||||
|
|
||||||
# CELERY_ACCEPT_CONTENT = ['json']
|
|
||||||
# CELERY_TASK_SERIALIZER = 'json'
|
|
||||||
# CELERY_TIMEZONE = 'Europe/Prague'
|
|
||||||
|
|
||||||
# CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
|
||||||
|
|
||||||
# from celery.schedules import crontab
|
|
||||||
|
|
||||||
# CELERY_BEAT_SCHEDULE = {
|
|
||||||
# 'hard_delete_soft_deleted_monthly': {
|
|
||||||
# 'task': 'vontor_cz.tasks.hard_delete_soft_deleted_records',
|
|
||||||
# 'schedule': crontab(minute=0, hour=0, day_of_month=1), # každý první den v měsíci o půlnoci
|
|
||||||
# },
|
|
||||||
# 'delete_old_reservations_monthly': {
|
|
||||||
# 'task': 'account.tasks.delete_old_reservations',
|
|
||||||
# 'schedule': crontab(minute=0, hour=1, day_of_month=1), # každý první den v měsíci v 1:00 ráno
|
|
||||||
# },
|
|
||||||
# }
|
|
||||||
# else:
|
|
||||||
# # Nebo nastav dummy broker, aby se úlohy neodesílaly
|
|
||||||
# CELERY_BROKER_URL = 'memory://' # broker v paměti, pro testování bez Redis
|
|
||||||
|
|
||||||
#-------------------------------------END CELERY 📅------------------------------------
|
#-------------------------------------END CELERY 📅------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -471,66 +498,57 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|||||||
# říka že se úkladá do databáze, místo do cookie
|
# říka že se úkladá do databáze, místo do cookie
|
||||||
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
|
SESSION_ENGINE = 'django.contrib.sessions.backends.db'
|
||||||
|
|
||||||
USE_PRODUCTION_DB = os.getenv("USE_PRODUCTION_DB", "False") == "True"
|
USE_DOCKER_DB = os.getenv("USE_DOCKER_DB", "False") in ["True", "true", "1", True]
|
||||||
|
|
||||||
if USE_PRODUCTION_DB is False:
|
if USE_DOCKER_DB is False:
|
||||||
# DEVELOPMENT
|
# DEV
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.sqlite3', # Database engine
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
'NAME': BASE_DIR / 'db.sqlite3', # Path to the SQLite database file
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
#PRODUCTION
|
# DOCKER/POSTGRES
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': os.getenv('DATABASE_ENGINE'),
|
'ENGINE': os.getenv('DATABASE_ENGINE'),
|
||||||
'NAME': os.getenv('DATABASE_NAME'),
|
'NAME': os.getenv('POSTGRES_DB'),
|
||||||
'USER': os.getenv('DATABASE_USER'),
|
'USER': os.getenv('POSTGRES_USER'),
|
||||||
'PASSWORD': os.getenv('DATABASE_PASSWORD'),
|
'PASSWORD': os.getenv('POSTGRES_PASSWORD'),
|
||||||
'HOST': os.getenv('DATABASE_HOST', "localhost"),
|
'HOST': os.getenv('DATABASE_HOST'),
|
||||||
'PORT': os.getenv('DATABASE_PORT'),
|
'PORT': os.getenv('DATABASE_PORT'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print(f"\nUsing Docker DB: {USE_DOCKER_DB}\nDatabase settings: {DATABASES}\n")
|
||||||
AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser
|
AUTH_USER_MODEL = 'account.CustomUser' #class CustomUser(AbstractUser) best practice to use AbstractUser
|
||||||
|
|
||||||
#--------------------------------END DATABASE 💾---------------------------------
|
#--------------------------------END DATABASE 💾---------------------------------
|
||||||
|
|
||||||
#--------------------------------------PAGE SETTINGS -------------------------------------
|
|
||||||
CONSTANCE_BACKEND = 'constance.backends.database.DatabaseBackend'
|
|
||||||
|
|
||||||
# Configuration for Constance(variables)
|
|
||||||
CONSTANCE_CONFIG = {
|
|
||||||
'BITCOIN_WALLET': ('', 'Public BTC wallet address'),
|
|
||||||
'SUPPORT_EMAIL': ('admin@example.com', 'Support email'),
|
|
||||||
}
|
|
||||||
|
|
||||||
#--------------------------------------EMAIL 📧--------------------------------------
|
#--------------------------------------EMAIL 📧--------------------------------------
|
||||||
|
|
||||||
if DEBUG:
|
EMAIL_BACKEND = os.getenv(
|
||||||
# DEVELOPMENT
|
"EMAIL_BACKEND",
|
||||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' # Use console backend for development
|
'django.core.mail.backends.console.EmailBackend' if DEBUG else 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
# EMAILY SE BUDOU POSÍLAT DO KONZOLE!!!
|
)
|
||||||
else:
|
|
||||||
# PRODUCTION
|
|
||||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
|
||||||
|
|
||||||
EMAIL_HOST = os.getenv("EMAIL_HOST_DEV")
|
EMAIL_HOST = os.getenv("EMAIL_HOST")
|
||||||
EMAIL_PORT = int(os.getenv("EMAIL_PORT_DEV", 465))
|
EMAIL_PORT = int(os.getenv("EMAIL_PORT", 465))
|
||||||
EMAIL_USE_TLS = True # ❌ Keep this OFF when using SSL
|
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "False") in ["True", "true", "1", True]
|
||||||
EMAIL_USE_SSL = False # ✅ Must be True for port 465
|
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "True") in ["True", "true", "1", True]
|
||||||
EMAIL_HOST_USER = os.getenv("EMAIL_USER_DEV")
|
EMAIL_HOST_USER = os.getenv("EMAIL_USER")
|
||||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD_DEV")
|
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_USER_PASSWORD")
|
||||||
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
|
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", EMAIL_HOST_USER)
|
||||||
EMAIL_TIMEOUT = 10
|
EMAIL_TIMEOUT = 30 # seconds
|
||||||
|
|
||||||
print("---------EMAIL----------\nEMAIL_HOST =", os.getenv("EMAIL_HOST_DEV"))
|
|
||||||
print("EMAIL_PORT =", os.getenv("EMAIL_PORT_DEV"))
|
|
||||||
print("EMAIL_USER =", os.getenv("EMAIL_USER_DEV"))
|
|
||||||
print("EMAIL_USER_PASSWORD =", os.getenv("EMAIL_USER_PASSWORD_DEV"), "\n------------------------")
|
|
||||||
|
|
||||||
|
print("---------EMAIL----------")
|
||||||
|
print("EMAIL_HOST =", EMAIL_HOST)
|
||||||
|
print("EMAIL_PORT =", EMAIL_PORT)
|
||||||
|
print("EMAIL_USE_TLS =", EMAIL_USE_TLS)
|
||||||
|
print("EMAIL_USE_SSL =", EMAIL_USE_SSL)
|
||||||
|
print("EMAIL_USER =", EMAIL_HOST_USER)
|
||||||
|
print("------------------------")
|
||||||
#----------------------------------EMAIL END 📧-------------------------------------
|
#----------------------------------EMAIL END 📧-------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -579,9 +597,6 @@ else:
|
|||||||
print(f"\n-------------- USE_AWS: {USE_AWS} --------------")
|
print(f"\n-------------- USE_AWS: {USE_AWS} --------------")
|
||||||
|
|
||||||
if USE_AWS is False:
|
if USE_AWS is False:
|
||||||
# DEVELOPMENT
|
|
||||||
|
|
||||||
|
|
||||||
# Development: Use local file system storage for static files
|
# Development: Use local file system storage for static files
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
"default": {
|
"default": {
|
||||||
@@ -593,14 +608,11 @@ if USE_AWS is False:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Media and Static URL for local dev
|
# Media and Static URL for local dev
|
||||||
MEDIA_URL = '/media/'
|
MEDIA_URL = os.getenv("MEDIA_URL", "/media/")
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||||
|
|
||||||
STATIC_URL = '/static/'
|
STATIC_URL = '/static/'
|
||||||
|
|
||||||
# Local folder for collected static files
|
|
||||||
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
|
STATIC_ROOT = BASE_DIR / 'collectedstaticfiles'
|
||||||
|
|
||||||
elif USE_AWS:
|
elif USE_AWS:
|
||||||
# PRODUCTION
|
# PRODUCTION
|
||||||
|
|
||||||
@@ -638,7 +650,6 @@ elif USE_AWS:
|
|||||||
|
|
||||||
|
|
||||||
print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------")
|
print(f"Static url: {STATIC_URL}\nStatic storage: {STORAGES}\n----------------------------")
|
||||||
|
|
||||||
#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️---------------------------------
|
#--------------------------------END: MEDIA + STATIC 🖼️, AWS ☁️---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,73 +1,64 @@
|
|||||||
import Client from "../Client";
|
import Client from "../Client";
|
||||||
|
|
||||||
export type FormatOption = {
|
// Available output containers (must match backend)
|
||||||
format_id: string;
|
export const FORMAT_EXTS = ["mp4", "mkv", "webm", "flv", "mov", "avi", "ogg"] as const;
|
||||||
ext: string | null;
|
export type FormatExt = (typeof FORMAT_EXTS)[number];
|
||||||
vcodec: string | null;
|
|
||||||
acodec: string | null;
|
|
||||||
fps: number | null;
|
|
||||||
tbr: number | null;
|
|
||||||
abr: number | null;
|
|
||||||
vbr: number | null;
|
|
||||||
asr: number | null;
|
|
||||||
filesize: number | null;
|
|
||||||
filesize_approx: number | null;
|
|
||||||
estimated_size_bytes: number | null;
|
|
||||||
size_ok: boolean;
|
|
||||||
format_note: string | null;
|
|
||||||
resolution: string | null; // e.g. "1920x1080"
|
|
||||||
audio_only: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FormatsResponse = {
|
export type InfoResponse = {
|
||||||
title: string | null;
|
title: string | null;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
extractor: string | null;
|
thumbnail: string | null;
|
||||||
video_id: string | null;
|
video_resolutions: string[]; // e.g. ["2160p", "1440p", "1080p", ...]
|
||||||
max_size_bytes: number;
|
audio_resolutions: string[]; // e.g. ["320kbps", "160kbps", ...]
|
||||||
options: FormatOption[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Probe available formats for a URL (no auth required)
|
// GET info for a URL
|
||||||
export async function probeFormats(url: string): Promise<FormatsResponse> {
|
export async function fetchInfo(url: string): Promise<InfoResponse> {
|
||||||
const res = await Client.public.post("/api/downloader/formats/", { url });
|
const res = await Client.public.get("/api/downloader/download/", {
|
||||||
return res.data as FormatsResponse;
|
params: { url },
|
||||||
|
});
|
||||||
|
return res.data as InfoResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download selected format as a Blob and resolve filename from headers
|
// POST to stream binary immediately; returns { blob, filename }
|
||||||
export async function downloadFormat(url: string, format_id: string): Promise<{ blob: Blob; filename: string }> {
|
export async function downloadImmediate(args: {
|
||||||
|
url: string;
|
||||||
|
ext: FormatExt;
|
||||||
|
videoResolution?: string | number; // "1080p" or 1080
|
||||||
|
audioResolution?: string | number; // "160kbps" or 160
|
||||||
|
}): Promise<{ blob: Blob; filename: string }> {
|
||||||
|
const video_quality = toHeight(args.videoResolution);
|
||||||
|
const audio_quality = toKbps(args.audioResolution);
|
||||||
|
|
||||||
|
if (video_quality == null || audio_quality == null) {
|
||||||
|
throw new Error("Please select both video and audio quality.");
|
||||||
|
}
|
||||||
|
|
||||||
const res = await Client.public.post(
|
const res = await Client.public.post(
|
||||||
"/api/downloader/download/",
|
"/api/downloader/download/",
|
||||||
{ url, format_id },
|
{
|
||||||
|
url: args.url,
|
||||||
|
ext: args.ext,
|
||||||
|
video_quality,
|
||||||
|
audio_quality,
|
||||||
|
},
|
||||||
{ responseType: "blob" }
|
{ responseType: "blob" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Try to parse Content-Disposition filename first, then X-Filename (exposed by backend)
|
|
||||||
const cd = res.headers?.["content-disposition"] as string | undefined;
|
const cd = res.headers?.["content-disposition"] as string | undefined;
|
||||||
const xfn = res.headers?.["x-filename"] as string | undefined;
|
const xfn = res.headers?.["x-filename"] as string | undefined;
|
||||||
const filename =
|
const filename =
|
||||||
parseContentDispositionFilename(cd) ||
|
parseContentDispositionFilename(cd) ||
|
||||||
(xfn && xfn.trim()) ||
|
(xfn && xfn.trim()) ||
|
||||||
inferFilenameFromUrl(url, (res.headers?.["content-type"] as string | undefined)) ||
|
inferFilenameFromUrl(args.url, res.headers?.["content-type"] as string | undefined) ||
|
||||||
"download.bin";
|
`download.${args.ext}`;
|
||||||
|
|
||||||
return { blob: res.data as Blob, filename };
|
return { blob: res.data as Blob, filename };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deprecated types kept for compatibility if referenced elsewhere
|
|
||||||
export type Choices = { file_types: string[]; qualities: string[] };
|
|
||||||
export type DownloadJobResponse = {
|
|
||||||
id: string;
|
|
||||||
status: "pending" | "running" | "finished" | "failed";
|
|
||||||
detail?: string;
|
|
||||||
download_url?: string;
|
|
||||||
progress?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
function parseContentDispositionFilename(cd?: string): string | null {
|
export function parseContentDispositionFilename(cd?: string): string | null {
|
||||||
if (!cd) return null;
|
if (!cd) return null;
|
||||||
// filename*=UTF-8''encoded or filename="plain"
|
|
||||||
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
const utf8Match = cd.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||||
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
if (utf8Match?.[1]) return decodeURIComponent(utf8Match[1]);
|
||||||
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
|
const plainMatch = cd.match(/filename\s*=\s*"([^"]+)"/i) || cd.match(/filename\s*=\s*([^;]+)/i);
|
||||||
@@ -89,16 +80,35 @@ function inferFilenameFromUrl(url: string, contentType?: string): string {
|
|||||||
return "download.bin";
|
return "download.bin";
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentTypeToExt(ct: string): string | null {
|
function contentTypeToExt(ct?: string): string | null {
|
||||||
|
if (!ct) return null;
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
"video/mp4": "mp4",
|
"video/mp4": "mp4",
|
||||||
"audio/mpeg": "mp3",
|
"video/x-matroska": "mkv",
|
||||||
"audio/mp4": "m4a",
|
|
||||||
"audio/aac": "aac",
|
|
||||||
"audio/ogg": "ogg",
|
|
||||||
"video/webm": "webm",
|
"video/webm": "webm",
|
||||||
"audio/webm": "webm",
|
"video/x-flv": "flv",
|
||||||
|
"video/quicktime": "mov",
|
||||||
|
"video/x-msvideo": "avi",
|
||||||
|
"video/ogg": "ogg",
|
||||||
"application/octet-stream": "bin",
|
"application/octet-stream": "bin",
|
||||||
};
|
};
|
||||||
return map[ct] || null;
|
return map[ct] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toHeight(v?: string | number): number | undefined {
|
||||||
|
if (typeof v === "number") return v || undefined;
|
||||||
|
if (!v) return undefined;
|
||||||
|
const m = /^(\d{2,4})p$/i.exec(v.trim());
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? (n as number) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKbps(v?: string | number): number | undefined {
|
||||||
|
if (typeof v === "number") return v || undefined;
|
||||||
|
if (!v) return undefined;
|
||||||
|
const m = /^(\d{2,4})\s*kbps$/i.exec(v.trim());
|
||||||
|
if (m) return parseInt(m[1], 10);
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? (n as number) : undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,59 +1,98 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { probeFormats, downloadFormat, type FormatsResponse, type FormatOption } from "../../api/apps/Downloader";
|
import {
|
||||||
|
fetchInfo,
|
||||||
|
downloadImmediate,
|
||||||
|
FORMAT_EXTS,
|
||||||
|
type InfoResponse,
|
||||||
|
parseContentDispositionFilename,
|
||||||
|
} from "../../api/apps/Downloader";
|
||||||
|
|
||||||
export default function Downloader() {
|
export default function Downloader() {
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [probing, setProbing] = useState(false);
|
const [probing, setProbing] = useState(false);
|
||||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [formats, setFormats] = useState<FormatsResponse | null>(null);
|
const [info, setInfo] = useState<InfoResponse | null>(null);
|
||||||
|
|
||||||
|
const [ext, setExt] = useState<typeof FORMAT_EXTS[number]>("mp4");
|
||||||
|
const [videoRes, setVideoRes] = useState<string | undefined>(undefined);
|
||||||
|
const [audioRes, setAudioRes] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (info?.video_resolutions?.length && !videoRes) {
|
||||||
|
setVideoRes(info.video_resolutions[0]);
|
||||||
|
}
|
||||||
|
if (info?.audio_resolutions?.length && !audioRes) {
|
||||||
|
setAudioRes(info.audio_resolutions[0]);
|
||||||
|
}
|
||||||
|
}, [info]);
|
||||||
|
|
||||||
async function onProbe(e: React.FormEvent) {
|
async function onProbe(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setFormats(null);
|
setInfo(null);
|
||||||
setProbing(true);
|
setProbing(true);
|
||||||
try {
|
try {
|
||||||
const res = await probeFormats(url);
|
const res = await fetchInfo(url);
|
||||||
setFormats(res);
|
setInfo(res);
|
||||||
|
// reset selections from fresh info
|
||||||
|
setVideoRes(res.video_resolutions?.[0]);
|
||||||
|
setAudioRes(res.audio_resolutions?.[0]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.response?.data?.detail || e?.message || "Failed to load formats.");
|
setError(
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.response?.data?.detail ||
|
||||||
|
e?.message ||
|
||||||
|
"Failed to get info."
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setProbing(false);
|
setProbing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onDownload(fmt: FormatOption) {
|
async function onDownload() {
|
||||||
setError(null);
|
setError(null);
|
||||||
setDownloadingId(fmt.format_id);
|
setDownloading(true);
|
||||||
try {
|
try {
|
||||||
const { blob, filename } = await downloadFormat(url, fmt.format_id);
|
const { blob, filename } = await downloadImmediate({
|
||||||
const link = document.createElement("a");
|
url,
|
||||||
|
ext,
|
||||||
|
videoResolution: videoRes,
|
||||||
|
audioResolution: audioRes,
|
||||||
|
});
|
||||||
|
const name = filename || parseContentDispositionFilename("") || `download.${ext}`;
|
||||||
const href = URL.createObjectURL(blob);
|
const href = URL.createObjectURL(blob);
|
||||||
link.href = href;
|
const a = document.createElement("a");
|
||||||
link.download = filename || "download.bin";
|
a.href = href;
|
||||||
document.body.appendChild(link);
|
a.download = name;
|
||||||
link.click();
|
document.body.appendChild(a);
|
||||||
document.body.removeChild(link);
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(href);
|
URL.revokeObjectURL(href);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.response?.data?.detail || e?.message || "Download failed.");
|
setError(
|
||||||
|
e?.response?.data?.error ||
|
||||||
|
e?.response?.data?.detail ||
|
||||||
|
e?.message ||
|
||||||
|
"Download failed."
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadingId(null);
|
setDownloading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canDownload = useMemo(
|
||||||
|
() => !!url && !!ext && !!videoRes && !!audioRes,
|
||||||
|
[url, ext, videoRes, audioRes]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto p-4">
|
<div className="max-w-3xl mx-auto p-4 space-y-4">
|
||||||
<h1 className="text-2xl font-semibold mb-4">Downloader</h1>
|
<h1 className="text-2xl font-semibold">Downloader</h1>
|
||||||
|
|
||||||
{error && (
|
{error && <div className="rounded border border-red-300 bg-red-50 text-red-700 p-2">{error}</div>}
|
||||||
<div className="mb-3 rounded border border-red-300 bg-red-50 text-red-700 p-2">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={onProbe} className="grid gap-3 mb-4">
|
<form onSubmit={onProbe} className="grid gap-3">
|
||||||
<label className="grid gap-1">
|
<label className="grid gap-1">
|
||||||
<span className="text-sm font-medium">URL</span>
|
<span className="text-sm font-medium">URL</span>
|
||||||
<input
|
<input
|
||||||
@@ -65,76 +104,103 @@ export default function Downloader() {
|
|||||||
className="w-full border rounded p-2"
|
className="w-full border rounded p-2"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
|
||||||
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!url || probing}
|
disabled={!url || probing}
|
||||||
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
className="px-3 py-2 rounded bg-blue-600 text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{probing ? "Probing..." : "Find formats"}
|
{probing ? "Probing..." : "Get info"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDownload}
|
||||||
|
disabled={!canDownload || downloading}
|
||||||
|
className="px-3 py-2 rounded bg-emerald-600 text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{downloading ? "Downloading..." : "Download"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{formats && (
|
{info && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-sm text-gray-700">
|
<div className="flex items-start gap-3">
|
||||||
<div><span className="font-medium">Title:</span> {formats.title || "-"}</div>
|
{info.thumbnail && (
|
||||||
<div><span className="font-medium">Duration:</span> {formats.duration ? `${Math.round(formats.duration)} s` : "-"}</div>
|
<img
|
||||||
<div><span className="font-medium">Max size:</span> {formatBytes(formats.max_size_bytes)}</div>
|
src={info.thumbnail}
|
||||||
</div>
|
alt={info.title || "thumbnail"}
|
||||||
|
className="w-40 h-24 object-cover rounded border"
|
||||||
<div className="border rounded overflow-hidden">
|
/>
|
||||||
<div className="grid grid-cols-6 gap-2 p-2 bg-gray-50 text-sm font-medium">
|
|
||||||
<div>Format</div>
|
|
||||||
<div>Resolution</div>
|
|
||||||
<div>Type</div>
|
|
||||||
<div>Note</div>
|
|
||||||
<div>Est. size</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y">
|
|
||||||
{formats.options.map((o) => (
|
|
||||||
<div key={o.format_id} className="grid grid-cols-6 gap-2 p-2 items-center text-sm">
|
|
||||||
<div className="truncate">{o.format_id}{o.ext ? `.${o.ext}` : ""}</div>
|
|
||||||
<div>{o.resolution || (o.audio_only ? "audio" : "-")}</div>
|
|
||||||
<div>{o.audio_only ? "Audio" : "Video"}</div>
|
|
||||||
<div className="truncate">{o.format_note || "-"}</div>
|
|
||||||
<div className={o.size_ok ? "text-gray-800" : "text-red-600"}>
|
|
||||||
{o.estimated_size_bytes ? formatBytes(o.estimated_size_bytes) : (o.filesize || o.filesize_approx) ? "~" + formatBytes((o.filesize || o.filesize_approx)!) : "?"}
|
|
||||||
{!o.size_ok && " (too big)"}
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => onDownload(o)}
|
|
||||||
disabled={!o.size_ok || downloadingId === o.format_id}
|
|
||||||
className="px-2 py-1 rounded bg-emerald-600 text-white disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{downloadingId === o.format_id ? "Downloading..." : "Download"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!formats.options.length && (
|
|
||||||
<div className="text-sm text-gray-600">No formats available.</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="text-sm text-gray-800 space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Title:</span> {info.title || "-"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Duration:</span>{" "}
|
||||||
|
{info.duration ? `${Math.round(info.duration)} s` : "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-3">
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">Container</span>
|
||||||
|
<select
|
||||||
|
value={ext}
|
||||||
|
onChange={(e) => setExt(e.target.value as any)}
|
||||||
|
className="border rounded p-2"
|
||||||
|
>
|
||||||
|
{FORMAT_EXTS.map((x) => (
|
||||||
|
<option key={x} value={x}>
|
||||||
|
{x.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">Video resolution</span>
|
||||||
|
<select
|
||||||
|
value={videoRes || ""}
|
||||||
|
onChange={(e) => setVideoRes(e.target.value || undefined)}
|
||||||
|
className="border rounded p-2"
|
||||||
|
>
|
||||||
|
{info.video_resolutions?.length ? (
|
||||||
|
info.video_resolutions.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="">-</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="grid gap-1">
|
||||||
|
<span className="text-sm font-medium">Audio bitrate</span>
|
||||||
|
<select
|
||||||
|
value={audioRes || ""}
|
||||||
|
onChange={(e) => setAudioRes(e.target.value || undefined)}
|
||||||
|
className="border rounded p-2"
|
||||||
|
>
|
||||||
|
{info.audio_resolutions?.length ? (
|
||||||
|
info.audio_resolutions.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value="">-</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes?: number | null): string {
|
|
||||||
if (!bytes || bytes <= 0) return "-";
|
|
||||||
const units = ["B", "KB", "MB", "GB"];
|
|
||||||
let i = 0;
|
|
||||||
let n = bytes;
|
|
||||||
while (n >= 1024 && i < units.length - 1) {
|
|
||||||
n /= 1024;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user