fix error in gitignore
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# live_monitoring app
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class LiveMonitoringConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.live_monitoring"
|
||||
@@ -0,0 +1,59 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-15 09:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('workflows', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Camera',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('facility', models.CharField(blank=True, max_length=200)),
|
||||
('processing_line', models.CharField(blank=True, max_length=200)),
|
||||
('stream_url', models.URLField()),
|
||||
('auth_config', models.JSONField(blank=True, default=dict)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LiveStream',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('name', models.CharField(blank=True, max_length=200)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('camera', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='streams', to='live_monitoring.camera')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='WorkflowStepCamera',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('camera', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflow_steps', to='live_monitoring.camera')),
|
||||
('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cameras', to='workflows.workflowstep')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.db import models
|
||||
|
||||
from apps.core.models import TimeStampedModel, UUIDModel
|
||||
|
||||
|
||||
class Camera(UUIDModel, TimeStampedModel):
|
||||
name = models.CharField(max_length=200)
|
||||
facility = models.CharField(max_length=200, blank=True)
|
||||
processing_line = models.CharField(max_length=200, blank=True)
|
||||
stream_url = models.URLField()
|
||||
# Never expose credentials to the frontend; keep auth config server-side.
|
||||
auth_config = models.JSONField(default=dict, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name
|
||||
|
||||
|
||||
class LiveStream(UUIDModel, TimeStampedModel):
|
||||
camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="streams")
|
||||
name = models.CharField(max_length=200, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name or self.camera.name
|
||||
|
||||
|
||||
class WorkflowStepCamera(UUIDModel):
|
||||
step = models.ForeignKey("workflows.WorkflowStep", on_delete=models.CASCADE, related_name="cameras")
|
||||
camera = models.ForeignKey(Camera, on_delete=models.CASCADE, related_name="workflow_steps")
|
||||
@@ -0,0 +1,10 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.live_monitoring.models import Camera
|
||||
|
||||
|
||||
class CameraSerializer(serializers.ModelSerializer):
|
||||
# auth_config is never exposed to the frontend.
|
||||
class Meta:
|
||||
model = Camera
|
||||
fields = ["id", "name", "facility", "processing_line", "stream_url", "active"]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Secure live-stream access.
|
||||
|
||||
Camera credentials are never exposed. A customer can request short-lived,
|
||||
HMAC-signed access tokens for streams authorized for their own orders; the
|
||||
`verify` endpoint validates the token and returns the stream URL.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from apps.live_monitoring.models import Camera, WorkflowStepCamera
|
||||
from apps.orders.models import ProcessingOrder
|
||||
from apps.workflows.models import WorkflowStep
|
||||
|
||||
|
||||
class StreamAccessService:
|
||||
def can_access(self, user, camera: Camera) -> bool:
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
if user.is_superuser:
|
||||
return True
|
||||
version_ids = ProcessingOrder.objects.filter(customer__user=user).values_list(
|
||||
"workflow_version_id", flat=True
|
||||
)
|
||||
step_ids = WorkflowStep.objects.filter(
|
||||
version_id__in=version_ids, live_monitoring_enabled=True
|
||||
).values_list("id", flat=True)
|
||||
return WorkflowStepCamera.objects.filter(camera=camera, step_id__in=step_ids).exists()
|
||||
|
||||
def issue_token(self, camera: Camera, ttl: int = 3600) -> tuple:
|
||||
payload = {"camera": str(camera.id), "exp": int(time.time()) + ttl}
|
||||
raw = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=")
|
||||
sig = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{raw}.{sig}", payload["exp"]
|
||||
|
||||
def verify_token(self, token: str):
|
||||
try:
|
||||
raw, sig = token.split(".", 1)
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
expected = hmac.new(settings.SECRET_KEY.encode(), raw.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected, sig):
|
||||
return None
|
||||
try:
|
||||
pad = raw + "=" * (-len(raw) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(pad).decode())
|
||||
except Exception:
|
||||
return None
|
||||
if payload.get("exp", 0) < int(time.time()):
|
||||
return None
|
||||
return payload
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.live_monitoring.models import Camera
|
||||
from apps.live_monitoring.services import StreamAccessService
|
||||
|
||||
|
||||
class StreamAccessTests(TestCase):
|
||||
def setUp(self):
|
||||
self.camera = Camera.objects.create(name="Cam1", stream_url="https://example.com/live.m3u8")
|
||||
self.service = StreamAccessService()
|
||||
|
||||
def test_issue_and_verify_token(self):
|
||||
token, exp = self.service.issue_token(self.camera)
|
||||
payload = self.service.verify_token(token)
|
||||
self.assertIsNotNone(payload)
|
||||
self.assertEqual(payload["camera"], str(self.camera.id))
|
||||
self.assertGreater(payload["exp"], exp - 1)
|
||||
|
||||
def test_tampered_token_rejected(self):
|
||||
token, _ = self.service.issue_token(self.camera)
|
||||
raw, _sig = token.split(".", 1)
|
||||
self.assertIsNone(self.service.verify_token(raw + ".deadbeef"))
|
||||
|
||||
def test_expired_token_rejected(self):
|
||||
token, _ = self.service.issue_token(self.camera, ttl=-1)
|
||||
self.assertIsNone(self.service.verify_token(token))
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from apps.live_monitoring.views import CameraViewSet, VerifyStreamView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("cameras", CameraViewSet, basename="camera")
|
||||
|
||||
urlpatterns = [
|
||||
path("streams/verify/", VerifyStreamView.as_view(), name="verify-stream"),
|
||||
*router.urls,
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
from rest_framework import decorators, mixins, permissions, response, status, views, viewsets
|
||||
|
||||
from apps.core.errors import error_response
|
||||
from apps.live_monitoring.models import Camera
|
||||
from apps.live_monitoring.serializers import CameraSerializer
|
||||
from apps.live_monitoring.services import StreamAccessService
|
||||
from apps.rbac.permissions import ActionPermission
|
||||
|
||||
|
||||
class CameraViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
queryset = Camera.objects.filter(active=True)
|
||||
serializer_class = CameraSerializer
|
||||
permission_classes = [ActionPermission]
|
||||
code_map = {"list": "processing.view", "retrieve": "processing.view", "access": "processing.view"}
|
||||
|
||||
@decorators.action(detail=True, methods=["post"])
|
||||
def access(self, request, pk=None):
|
||||
camera = self.get_object()
|
||||
service = StreamAccessService()
|
||||
if not service.can_access(request.user, camera):
|
||||
return response.Response(
|
||||
error_response("STREAM_FORBIDDEN", "You do not have access to this stream."),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
token, expires_at = service.issue_token(camera)
|
||||
access_url = request.build_absolute_uri(f"/api/v1/live-monitoring/streams/verify/?token={token}")
|
||||
return response.Response({"token": token, "expires_at": expires_at, "access_url": access_url})
|
||||
|
||||
|
||||
class VerifyStreamView(views.APIView):
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get(self, request):
|
||||
token = request.query_params.get("token")
|
||||
payload = StreamAccessService().verify_token(token)
|
||||
if not payload:
|
||||
return response.Response(
|
||||
error_response("INVALID_TOKEN", "Invalid or expired stream token."),
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
try:
|
||||
camera = Camera.objects.get(pk=payload["camera"], active=True)
|
||||
except Camera.DoesNotExist:
|
||||
return response.Response(
|
||||
error_response("CAMERA_NOT_FOUND", "Camera not found."),
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return response.Response({"stream_url": camera.stream_url})
|
||||
Reference in New Issue
Block a user