Skip to content

Commit

Permalink
Merge branch 'master' into pre-release
Browse files Browse the repository at this point in the history
  • Loading branch information
kennethjiang committed Oct 20, 2023
2 parents a463069 + 9964016 commit 6e08e41
Show file tree
Hide file tree
Showing 16 changed files with 215 additions and 124 deletions.
7 changes: 1 addition & 6 deletions backend/api/viewsets.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
from django.core.handlers.wsgi import WSGIRequest
from django.shortcuts import get_object_or_404
from django.http import Http404
import os
Expand Down Expand Up @@ -29,7 +28,6 @@
from django.db.models.functions import TruncDay
from django.db.models import Sum, Max, Count, fields, Case, Value, When


from .utils import report_validationerror
from .authentication import CsrfExemptSessionAuthentication
from app.models import (
Expand Down Expand Up @@ -240,7 +238,7 @@ def bulk_delete(self, request):
return Response(status=status.HTTP_204_NO_CONTENT)

@action(detail=True, methods=['get'])
def prediction_json(self, request: WSGIRequest, pk) -> Response:
def prediction_json(self, request, pk) -> Response:
p: Print = get_object_or_404(
self.get_queryset().select_related('printer'),
pk=pk)
Expand All @@ -253,9 +251,6 @@ def prediction_json(self, request: WSGIRequest, pk) -> Response:
'If-Modified-Since': request.headers.get('if-modified-since'),
'If-None-Match': request.headers.get('if-none-match'),
}
# Add original request headers to pass through user authentication (if same host)
if p.prediction_json_url.startswith(f"{request.scheme}://{request.get_host()}"):
headers = {**headers, **request.headers}

r = requests.get(url=p.prediction_json_url,
timeout=PREDICTION_FETCH_TIMEOUT,
Expand Down
49 changes: 49 additions & 0 deletions backend/app/management/commands/resign_media_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from typing import List

from django.core.management.base import BaseCommand

from app.models import Print, PrinterEvent, GCodeFile, models, PrintShotFeedback
from lib.url_signing import new_signed_url
from lib.utils import printProgressBar


class Command(BaseCommand):
help = '(re-)signs all media URLs. Must be run once after updating, and any time the Django SECRET_KEY is rotated'

@staticmethod
def _resign_urls_on_model(obj: models.Model, url_fields: List[str]):
changed = False
total_rows = len(obj.objects.all())
print(f"Resigning {obj.__name__} URLs ({total_rows} rows)...")
for idx, row in enumerate(obj.objects.all()):
for url_field in url_fields:
url = getattr(row, url_field)
if url:
setattr(row, url_field, new_signed_url(url))
changed = True
if changed:
row.save()
if idx % 20 == 0:
printProgressBar(idx + 1, total_rows)
printProgressBar(1, 1)

def resign_urls(self):
self._resign_urls_on_model(
obj=GCodeFile, # type: ignore
url_fields=['url', 'thumbnail1_url', 'thumbnail2_url', 'thumbnail3_url']
)
self._resign_urls_on_model(
obj=Print, # type: ignore
url_fields=['video_url', 'tagged_video_url', 'poster_url', 'prediction_json_url']
)
self._resign_urls_on_model(
obj=PrinterEvent, # type: ignore
url_fields=['image_url']
)
self._resign_urls_on_model(
obj=PrintShotFeedback, # type: ignore
url_fields=['image_url']
)

def handle(self, *args, **options):
self.resign_urls()
5 changes: 2 additions & 3 deletions backend/app/urls.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
from django.urls import path, re_path, include
from django.urls import path, re_path
from django.views.generic.base import RedirectView

from .views import web_views
from .views import mobile_views
from .views.fs_media.urls import media_patterns

from .views import tunnelv2_views

urlpatterns = [
path('', web_views.index, name='index'),
path('accounts/login/', web_views.SocialAccountAwareLoginView.as_view(), name="account_login"),
path('accounts/signup/', web_views.SocialAccountAwareSignupView.as_view(), name="account_signup"),
path('media/', include(media_patterns)),
path('media/<path:file_path>', web_views.serve_jpg_file), # semi hacky solution to serve image files
path('printers/', web_views.printers, name='printers'),
re_path('printers/wizard/(?P<route>([^/]+/)*)$', web_views.new_printer),
path('printers/<int:pk>/', web_views.edit_printer),
Expand Down
16 changes: 0 additions & 16 deletions backend/app/views/fs_media/urls.py

This file was deleted.

78 changes: 0 additions & 78 deletions backend/app/views/fs_media/views.py

This file was deleted.

2 changes: 1 addition & 1 deletion backend/app/views/tunnelv2_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def retrieve_klipper_host_info(octoprinttunnel):
resp = _tunnel_http_req_and_wait_for_resp(octoprinttunnel, "/server/config", "get", {}, b'')
server_port = json.loads(resp.content.decode('utf-8')).get('result', {}).get('config', {}).get('server', {}).get('port')

return {'server_ip': ip_addr, 'server_port': server_port}
return {'server_ip': ip_addr, 'server_port': server_port, 'linked_name': octoprinttunnel.printer.name}

@save_static_etag
@condition(etag_func=fetch_static_etag)
Expand Down
18 changes: 17 additions & 1 deletion backend/app/views/web_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.shortcuts import render, redirect
from django.views import View
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, JsonResponse, HttpResponseRedirect
from django.http import HttpResponse, JsonResponse, HttpResponseForbidden
from django.contrib import messages
from django.urls import reverse
from django.conf import settings
Expand All @@ -19,6 +19,7 @@

from allauth.account.views import LoginView, SignupView

from lib.url_signing import HmacSignedUrl
from lib.view_helpers import get_print_or_404, get_printer_or_404, get_paginator, get_template_path

from app.models import (User, Printer, SharedResource, GCodeFile, NotificationSetting)
Expand Down Expand Up @@ -238,6 +239,21 @@ def printer_events(request):
return render(request, 'printer_events.html')


### Misc ####

# Was surprised to find there is no built-in way in django to serve uploaded files in both debug and production mode
def serve_jpg_file(request, file_path):
url = HmacSignedUrl(request.get_full_path())
if not url.is_authorized():
return HttpResponseForbidden("You do not have permission to view this media")
full_path = os.path.join(settings.MEDIA_ROOT, file_path)

if not os.path.exists(full_path):
raise Http404("Requested file does not exist")
with open(full_path, 'rb') as fh:
return HttpResponse(fh, content_type=('video/mp4' if file_path.endswith('.mp4') else 'image/jpeg'))


# Health check that touches DB and redis
def health_check(request):
User.objects.all()[:1]
Expand Down
6 changes: 4 additions & 2 deletions backend/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,14 @@ def get_bool(key, default):

VERSION = os.environ.get('VERSION', '')

DEFAULT_SECRET_KEY = 'cg#p$g+j9tax!#a3cup@1$8obt2_+&k3q+pmu)5%asj6yjpkag'
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get(
'DJANGO_SECRET_KEY', 'cg#p$g+j9tax!#a3cup@1$8obt2_+&k3q+pmu)5%asj6yjpkag')
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', None)
if not SECRET_KEY:
SECRET_KEY = DEFAULT_SECRET_KEY

SESSION_COOKIE_AGE = 60 * 60 * 24 * 60 # User login session is 2 months
SESSION_SAVE_EVERY_REQUEST = True
Expand Down
1 change: 0 additions & 1 deletion backend/lib/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import json
from typing import List, Optional


REDIS = redis.Redis.from_url(
settings.REDIS_URL, charset="utf-8", decode_responses=True)

Expand Down
6 changes: 5 additions & 1 deletion backend/lib/fs_file_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from shutil import copyfileobj, rmtree

from lib import site
from lib.url_signing import new_signed_url


def save_file_obj(dest_path, file_obj, container, content_type):
fqp = path.join(settings.MEDIA_ROOT, container, dest_path)
Expand All @@ -14,7 +16,9 @@ def save_file_obj(dest_path, file_obj, container, content_type):
copyfileobj(file_obj, dest_file)

uri = '{}{}/{}'.format(settings.MEDIA_URL, container, dest_path)
return settings.INTERNAL_MEDIA_HOST + uri, site.build_full_url(uri)
internal_url = new_signed_url(settings.INTERNAL_MEDIA_HOST + uri)
external_url = new_signed_url(site.build_full_url(uri))
return internal_url, external_url

def list_dir(dir_path, container):
fqp = path.join(settings.MEDIA_ROOT, container, dir_path)
Expand Down
16 changes: 16 additions & 0 deletions backend/lib/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,19 @@ def build_full_url(url):
domain_name = Site.objects.first().domain
normalized_url = re.sub(r'^/', '', url)
return '{}{}/{}'.format(protocol, domain_name, normalized_url)


this_site_url = build_full_url('')


def url_points_to_this_site(url: str) -> bool:
"""
Returns True if given 'url' points to this site, else False
"""
# Using a global variable here avoids calling database each time, but requires
# restarting the application any time the site domain name is changed.
#
# Could also cache in redis to avoid need for restarting if desired, but may
# add some overhead.
global this_site_url
return True if url.startswith(this_site_url) else False
73 changes: 73 additions & 0 deletions backend/lib/url_signing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import base64
import hashlib
from datetime import timedelta

from django.utils import timezone
from urllib.parse import urlparse, parse_qs, urlunparse, ParseResult
from dataclasses import dataclass, field, InitVar
from typing import Optional, List, Union
import hmac
from django.conf import settings
import logging

LOGGER = logging.getLogger(__name__)


def calculate_hmac_digest(path: str):
"""Returns base64 encoded digest given a 'path'"""
digest = hmac.digest(
key=settings.SECRET_KEY.encode(),
msg=path.encode(),
digest=hashlib.sha256
)
return base64.urlsafe_b64encode(str(digest).encode()).decode()


def new_signed_url(url_str: str) -> str:
"""
Signs a URL based on a given
Signature is appended in the form of URL parameters in the query string. Note that
the entire query string will be replaced (everything after '?' in the URL).
"""
parsed_url = urlparse(url_str)
digest = calculate_hmac_digest(parsed_url.path)
signed_url = parsed_url._replace(query=f"digest={digest}")
return urlunparse(signed_url)


@dataclass
class HmacSignedUrl:
"""This dataclass provides functions to check the validity of an HMAC signed url"""
url_str: InitVar[str]

# Calculated fields are set during __post_init__
path: str = field(init=False)
supplied_digest: str = field(init=False)

# Internal fields (don't show in repr)
_parsed_url: ParseResult = field(init=False, repr=False)
_url_params: dict = field(init=False, repr=False)

def __post_init__(self, url_str: str):
self._parsed_url = urlparse(url_str)
self._url_params = parse_qs(self._parsed_url.query)
self.path = self._parsed_url.path
self.supplied_digest = self._get_single_url_param('digest', None)
if self.supplied_digest is None:
raise ValueError("Must supply a 'digest' parameter to check authorization")

def _get_single_url_param(self, key: str, default=None) -> str:
"""
Returns first URL parameter value by key name, or (default) if empty.
This function is necessary because parse_qs() returns a dictionary of lists
since urls can contain duplicate query parameters.
"""
vals: List = self._url_params.get(key, [])
return vals[0] if vals else default

def is_authorized(self) -> bool:
"""Returns True if the supplied digest matches the calculated digest, else False"""
calculated_digest = calculate_hmac_digest(path=self.path)
return hmac.compare_digest(self.supplied_digest, calculated_digest)
Loading

0 comments on commit 6e08e41

Please sign in to comment.