diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index 9e12f8f..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:alpine -WORKDIR /app -RUN apk add --no-cache gcc musl-dev python3-dev -COPY requirements.txt /app -RUN pip install --upgrade pip && pip install -r requirements.txt -COPY . /app -RUN python configure.py -RUN python manage.py collectstatic --noinput -CMD python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure -# TODO serve static files with nginx and remove --insecure -EXPOSE 8000 - - diff --git a/backend/authentication/api.py b/backend/authentication/api.py index f5c4542..1e3f09c 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -130,7 +130,9 @@ def getUserInfo(request): return Response({'profile_picture_id': 'File does not exist.'}, status=400) user.save() - if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0: + if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 \ + and old_file.profile_picture_users.count() == 0 and old_file.staged_by_workflows.count() == 0: + old_file.file.delete(save=False) old_file.delete() return Response({ diff --git a/backend/authentication/migrations/0004_alter_accountpreference_id.py b/backend/authentication/migrations/0004_alter_accountpreference_id.py new file mode 100644 index 0000000..967b2fb --- /dev/null +++ b/backend/authentication/migrations/0004_alter_accountpreference_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2026-08-09 13:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_accountpreference'), + ] + + operations = [ + migrations.AlterField( + model_name='accountpreference', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/files/media_urls.py b/backend/files/media_urls.py index 92566ed..7c5e2ff 100644 --- a/backend/files/media_urls.py +++ b/backend/files/media_urls.py @@ -1,8 +1,17 @@ +import io +import os +from datetime import timedelta + +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django.http import HttpResponse from django.urls import path from django.db.models import Q from django.conf import settings +from django.utils.http import http_date +from django.utils.timezone import now from drf_yasg.utils import swagger_auto_schema +from PIL import Image from rest_framework import status from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import IsAuthenticated @@ -11,6 +20,29 @@ from rest_framework.response import Response from authentication.signature_auth import SignatureAuthentication from files.models import File +THUMBNAIL_SIZES = (32, 64, 256) + + +def _accessible_files(request): + # Shared by media_urls and thumbnail_urls so both endpoints always agree on who can see + # what - a file is visible if the requester is friends-or-self with whatever currently + # references it (an inventory item, a profile picture) or it's their own staged photo. + return File.objects.filter( + Q(connected_items__owner__in=request.user.friends_or_self()) | + Q(profile_picture_users__in=request.user.friends_or_self()) | + Q(staged_by_workflows__owner__in=request.user.user.all()) + ).distinct() + + +def _cache_headers(etag): + # Content is addressed by its own hash and can never change under a given URL, so caches + # (and the conditional-GET checks in both views below) can treat it as immutable forever. + return { + 'ETag': etag, + 'Cache-Control': 'max-age=31536000, private, immutable', + 'Expires': http_date((now() + timedelta(days=365)).timestamp()), + } + @swagger_auto_schema(method='GET', auto_schema=None) @api_view(['GET']) @@ -23,29 +55,112 @@ def media_urls(request, hash_path): # is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it # follows the X-Accel-Redirect and serves the file itself, so the CORS header for that # case has to be configured in nginx's `location /redirect_media/` block instead. + # + # Looked up by the derived storage path (not by raw hash) because FileSerializer.name + # (files/serializers.py) - used everywhere a file URL is handed to the frontend, e.g. + # AuthenticatedImage's `src` - already returns this path via Django's FileField.url, and + # the existing test suite (files/tests.py MediaUrlTestCase) exercises it this way too. try: - file = File.objects.filter( - Q(connected_items__owner__in=request.user.friends_or_self()) | - Q(profile_picture_users__in=request.user.friends_or_self()) - ).distinct().get( - file=hash_path) + file = _accessible_files(request).get(file=hash_path) + + # The access-control lookup above must happen before this check - otherwise a bare + # hash + If-None-Match would let anyone probe "does a file with this hash exist" for + # files they can't actually see. + if request.META.get('HTTP_IF_NONE_MATCH') == file.hash: + return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) + + cache_headers = _cache_headers(file.hash) if settings.SERVE_X_ACCEL_REDIRECT: return HttpResponse(status=status.HTTP_200_OK, content_type=file.mime_type, headers={ 'X-Accel-Redirect': f'/redirect_media/{hash_path}', - }) # TODO Expires and Cache-Control + **cache_headers, + }) else: + # Read via the FieldFile itself (works against whatever storage backend is + # actually configured) rather than assuming file.file.path is a real filesystem + # path - the test suite swaps in an in-memory backend where that isn't true. + with file.file.open('rb') as fh: + content = fh.read() return HttpResponse(status=status.HTTP_200_OK, content_type=file.mime_type, - content=open(file.file.path, 'rb').read()) + headers=cache_headers, + content=content) + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +def _thumbnail_rel_path(file_hash, size): + # Mirrors files/models.py's hash_upload() sharding, under its own `thumbnails//` + # subtree - reachable through the same nginx `/redirect_media/` alias as originals when + # served from real disk, no separate nginx location needed. + return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6], + file_hash[6:] + '.jpg') + + +@swagger_auto_schema(method='GET', auto_schema=None) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([SignatureAuthentication]) +def thumbnail_urls(request, size, hash_path): + if size not in THUMBNAIL_SIZES: + return Response(status=status.HTTP_404_NOT_FOUND) + + try: + file = _accessible_files(request).get(file=hash_path) + + etag = f'{file.hash}_{size}' + if request.META.get('HTTP_IF_NONE_MATCH') == etag: + return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) + + # Read/write through the default storage backend, same as File.file itself, rather + # than a hand-rolled filesystem path - correct regardless of storage backend (real + # disk in production, in-memory under the test runner) and keeps the cache in the + # same place originals live. + rel_path = _thumbnail_rel_path(file.hash, size) + if not default_storage.exists(rel_path): + # Thumbnails are always re-encoded as JPEG regardless of the original format - + # smaller and simpler than preserving e.g. PNG transparency at this scale. + with file.file.open('rb') as fh: + image = Image.open(fh) + image.thumbnail((size, size)) + # Flatten through RGBA before dropping to RGB - some modes (grayscale+alpha, + # palette-with-transparency, RGBA) store meaningless color/luminance data under + # fully transparent pixels (often zeroed out, i.e. black). Converting straight + # to RGB reveals that instead of "nothing there"; compositing onto an opaque + # background first shows what the image is actually supposed to look like. + rgba = image.convert('RGBA') + flattened = Image.new('RGB', rgba.size, (255, 255, 255)) + flattened.paste(rgba, mask=rgba.getchannel('A')) + buffer = io.BytesIO() + flattened.save(buffer, 'JPEG', quality=90) + default_storage.save(rel_path, ContentFile(buffer.getvalue())) + + cache_headers = _cache_headers(etag) + + if settings.SERVE_X_ACCEL_REDIRECT: + return HttpResponse(status=status.HTTP_200_OK, + content_type='image/jpeg', + headers={ + 'X-Accel-Redirect': f'/redirect_media/{rel_path}', + **cache_headers, + }) + else: + with default_storage.open(rel_path, 'rb') as fh: + content = fh.read() + return HttpResponse(status=status.HTTP_200_OK, + content_type='image/jpeg', + headers=cache_headers, + content=content) except File.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) urlpatterns = [ + path('//', thumbnail_urls), path('', media_urls), ] diff --git a/backend/files/models.py b/backend/files/models.py index 8e9dde4..bff9121 100644 --- a/backend/files/models.py +++ b/backend/files/models.py @@ -1,4 +1,7 @@ +from types import SimpleNamespace + from django.core.files.base import ContentFile +from django.core.files.storage import default_storage from django.db import models, IntegrityError from django.db.models import Model @@ -40,6 +43,18 @@ class FileManager(models.Manager): else: raise ValueError('data must be a base64 encoded string or file and hash must be provided') if not self.filter(hash=kwargs['hash']).exists(): + # The upload path is derived entirely from the hash (hash_upload, above), and hash + # is DB-unique - so if no File row owns this hash yet, anything already sitting at + # its computed path is necessarily a stale orphan (e.g. left behind by a bug in + # cleanup code that deleted a File row without removing its stored bytes, or a + # crashed upload). Clear it before saving instead of letting Django's storage layer + # invent an alternate filename to avoid the "collision" - a suffixed name would + # silently break every part of the app that derives this file's URL purely from its + # hash (media serving, thumbnail generation, item/avatar attachment), and no future + # caller could ever discover it again. + expected_path = hash_upload(SimpleNamespace(hash=kwargs['hash']), '') + if default_storage.exists(expected_path): + default_storage.delete(expected_path) return super().create(**kwargs) else: raise IntegrityError('File with this hash already exists') diff --git a/backend/files/tests.py b/backend/files/tests.py index 2d68e88..05ecb38 100644 --- a/backend/files/tests.py +++ b/backend/files/tests.py @@ -1,13 +1,20 @@ +import io +import os +import zlib + +from django.conf import settings from django.core.files.base import ContentFile -from django.core.files.storage import DefaultStorage +from django.core.files.storage import DefaultStorage, default_storage from django.db import IntegrityError, transaction from django.test import Client, override_settings from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin from toolshed.tests import InventoryTestMixin from nacl.hash import sha256 from nacl.encoding import HexEncoder +from PIL import Image import base64 +from files.media_urls import THUMBNAIL_SIZES from files.models import File anonymous_client = Client() @@ -105,6 +112,23 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase): self.assertEqual(File.objects.count(), 3) self.assertEqual(countdir(DefaultStorage(), ''), 3) + def test_file_upload_reclaims_stale_orphan_at_canonical_path(self): + # Reproduces a real incident: a File row gets deleted without its underlying stored + # bytes being removed (e.g. a bug in some cleanup call site), leaving an orphan sitting + # at the exact path hash_upload() would compute for that content. A later upload of the + # same content must land back on that canonical path - not get silently suffixed by + # Django's default collision-avoidance, which would make it unreachable to every part + # of the app that derives a file's URL purely from its hash. + expected_path = f"{self.f['hash4'][:2]}/{self.f['hash4'][2:4]}/{self.f['hash4'][4:6]}/{self.f['hash4'][6:]}" + default_storage.save(expected_path, ContentFile(self.f['test_content4'])) + self.assertTrue(default_storage.exists(expected_path)) + self.assertFalse(File.objects.filter(hash=self.f['hash4']).exists()) + + file = File.objects.create(mime_type='text/plain', data=self.f['encoded_content4']) + + self.assertEqual(file.file.name, expected_path) + self.assertEqual(file.file.read(), self.f['test_content4']) + class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase): def setUp(self): @@ -120,28 +144,29 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh self.f['item2'].files.add(self.f['test_file1']) -# def test_file_url(self): -# reply = client.get( -# f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}", -# self.f['local_user1']) -# self.assertEqual(reply.status_code, 200) -# self.assertEqual(reply.headers['X-Accel-Redirect'], -# f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}") -# self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type) -# reply = client.get( -# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", -# self.f['local_user1']) -# self.assertEqual(reply.status_code, 200) -# self.assertEqual(reply.headers['X-Accel-Redirect'], -# f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") -# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) -# reply = client.get( -# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", -# self.f['local_user2']) -# self.assertEqual(reply.status_code, 200) -# self.assertEqual(reply.headers['X-Accel-Redirect'], -# f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") -# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) + @override_settings(SERVE_X_ACCEL_REDIRECT=True) + def test_file_url(self): + reply = client.get( + f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}", + self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.headers['X-Accel-Redirect'], + f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}") + self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type) + reply = client.get( + f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", + self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.headers['X-Accel-Redirect'], + f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") + self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) + reply = client.get( + f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", + self.f['local_user2']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.headers['X-Accel-Redirect'], + f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") + self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) def test_file_url_fail(self): reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1']) @@ -195,3 +220,138 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh self.f['ext_user1']) self.assertEqual(reply.status_code, 404) + +class ThumbnailUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase): + def setUp(self): + super().setUp() + self.prepare_files() + self.prepare_users() + self.prepare_categories() + self.prepare_tags() + self.prepare_properties() + self.prepare_inventory() + + # Each test method gets its own distinct image content (and therefore its own content + # hash / thumbnail cache path) - InMemoryStorage isn't reset between test methods within + # a run, so sharing one fixed image across methods risks one test's cached (or, in + # test_thumbnail_served_from_cache_on_second_request's case, deliberately corrupted) + # thumbnail leaking into another test's assertions. + seed = zlib.crc32(self._testMethodName.encode()) % 256 + buffer = io.BytesIO() + Image.new('RGB', (800, 600), (seed, 255 - seed, 128)).save(buffer, 'PNG') + image_bytes = buffer.getvalue() + self.f['image_hash'] = sha256(image_bytes, encoder=HexEncoder).decode('utf-8') + self.f['image_file'] = File.objects.create( + mime_type='image/png', data=base64.b64encode(image_bytes).decode('utf-8')) + self.f['item1'].files.add(self.f['image_file']) + + def _thumb_url(self, size, image_hash=None): + h = image_hash or self.f['image_hash'] + return f"/media/{size}/{h[:2]}/{h[2:4]}/{h[4:6]}/{h[6:]}/" + + def _thumb_rel_path(self, size): + h = self.f['image_hash'] + return os.path.join('thumbnails', str(size), h[:2], h[2:4], h[4:6], h[6:] + '.jpg') + + def test_thumbnail_sizes_available(self): + # Documents the fixed size allow-list this test suite exercises against - update both + # if files/media_urls.py's THUMBNAIL_SIZES ever changes. + self.assertEqual(THUMBNAIL_SIZES, (32, 64, 256)) + + @override_settings(SERVE_X_ACCEL_REDIRECT=False) + def test_thumbnail_generates_resized_jpeg(self): + self.assertFalse(default_storage.exists(self._thumb_rel_path(64))) + + reply = client.get(self._thumb_url(64), self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertEqual(reply.headers['Content-Type'], 'image/jpeg') + + generated = Image.open(io.BytesIO(reply.content)) + self.assertEqual(generated.format, 'JPEG') + # Aspect-ratio-preserving fit within a 64x64 box, not a crop to exactly 64x64. + self.assertLessEqual(max(generated.size), 64) + self.assertAlmostEqual(generated.size[0] / generated.size[1], 800 / 600, places=2) + + @override_settings(SERVE_X_ACCEL_REDIRECT=False) + def test_thumbnail_flattens_transparency_instead_of_going_black(self): + # Reproduces a real incident: an 'LA' (grayscale + alpha) source whose fully-transparent + # region has zeroed-out luminance underneath, as many image tools produce. Converting + # straight to RGB (dropping alpha without compositing) reveals that zeroed data - the + # whole thumbnail comes out solid black even though the visible (opaque) content isn't. + half_transparent = Image.new('LA', (200, 200)) + pixels = half_transparent.load() + for x in range(200): + for y in range(200): + if x < 100: + pixels[x, y] = (0, 0) # transparent, zeroed-out luminance underneath + else: + pixels[x, y] = (255, 255) # fully opaque, bright content + + buffer = io.BytesIO() + half_transparent.save(buffer, 'PNG') + image_bytes = buffer.getvalue() + image_hash = sha256(image_bytes, encoder=HexEncoder).decode('utf-8') + image_file = File.objects.create( + mime_type='image/png', data=base64.b64encode(image_bytes).decode('utf-8')) + self.f['item1'].files.add(image_file) + + reply = client.get(self._thumb_url(64, image_hash=image_hash), self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + + generated = Image.open(io.BytesIO(reply.content)).convert('L') + # The opaque (right) half must stay bright; a naive RGB conversion would blacken it too. + self.assertGreater(generated.getpixel((generated.width - 1, generated.height // 2)), 200) + self.assertNotEqual(generated.getextrema(), (0, 0)) + + @override_settings(SERVE_X_ACCEL_REDIRECT=False) + def test_thumbnail_served_from_cache_on_second_request(self): + client.get(self._thumb_url(64), self.f['local_user1']) + rel_path = self._thumb_rel_path(64) + with default_storage.open(rel_path, 'rb') as f: + cached_bytes = f.read() + + # Overwrite the cached file with a marker so a correct implementation must serve this + # exact content back rather than regenerating it from the original. + default_storage.delete(rel_path) + default_storage.save(rel_path, ContentFile(cached_bytes + b'MARKER')) + + reply = client.get(self._thumb_url(64), self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + self.assertTrue(reply.content.endswith(b'MARKER')) + + def test_thumbnail_invalid_size(self): + reply = client.get(self._thumb_url(100), self.f['local_user1']) + self.assertEqual(reply.status_code, 404) + self.assertFalse(default_storage.exists(self._thumb_rel_path(100))) + + def test_thumbnail_not_found(self): + reply = client.get(self._thumb_url(64, image_hash='0' * 64), self.f['local_user1']) + self.assertEqual(reply.status_code, 404) + + def test_thumbnail_anonymous(self): + reply = anonymous_client.get(self._thumb_url(64)) + self.assertEqual(reply.status_code, 403) + + def test_thumbnail_not_friend(self): + # local_user1/local_user2 are friends in these fixtures (see prepare_inventory) - the + # denied case needs a stranger to that friendship instead. + reply = client.get(self._thumb_url(64), self.f['ext_user1']) + self.assertEqual(reply.status_code, 404) + self.assertFalse(default_storage.exists(self._thumb_rel_path(64))) + + def test_thumbnail_conditional_get(self): + reply = client.get(self._thumb_url(64), self.f['local_user1']) + etag = reply.headers['ETag'] + self.assertEqual(etag, f"{self.f['image_hash']}_64") + + reply = client.get(self._thumb_url(64), self.f['local_user1'], HTTP_IF_NONE_MATCH=etag) + self.assertEqual(reply.status_code, 304) + + @override_settings(SERVE_X_ACCEL_REDIRECT=True) + def test_thumbnail_x_accel_redirect(self): + reply = client.get(self._thumb_url(64), self.f['local_user1']) + self.assertEqual(reply.status_code, 200) + h = self.f['image_hash'] + self.assertEqual(reply.headers['X-Accel-Redirect'], + f"/redirect_media/thumbnails/64/{h[:2]}/{h[2:4]}/{h[4:6]}/{h[6:]}.jpg") + diff --git a/backend/requirements.txt b/backend/requirements.txt index 06b3ce1..ec83fc6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,6 +25,7 @@ MarkupSafe==2.1.3 openapi-codec==1.3.2 packaging==23.1 pycparser==2.21 +Pillow==10.4.0 PyNaCl==1.5.0 python-dotenv==1.0.0 pytz==2023.3 diff --git a/backend/toolshed/admin.py b/backend/toolshed/admin.py index bbd1525..a3d11a1 100644 --- a/backend/toolshed/admin.py +++ b/backend/toolshed/admin.py @@ -73,8 +73,8 @@ admin.site.register(StorageLocation, StorageLocationAdmin) class WorkflowInstanceAdmin(admin.ModelAdmin): - list_display = ('name', 'state', 'owner', 'created_at', 'updated_at') - search_fields = ('name', 'owner__username') + list_display = ('slug', 'state', 'owner', 'created_at', 'updated_at') + search_fields = ('slug', 'owner__username') list_filter = ('state', 'created_at', 'owner') readonly_fields = ('created_at', 'updated_at') diff --git a/backend/toolshed/api/files.py b/backend/toolshed/api/files.py index b56e4dc..7c2435c 100644 --- a/backend/toolshed/api/files.py +++ b/backend/toolshed/api/files.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from authentication.signature_auth import SignatureAuthenticationLocal from files.models import File from files.serializers import FileSerializer -from toolshed.models import InventoryItem +from toolshed.models import InventoryItem, WorkflowInstance @api_view(['GET']) @@ -30,6 +30,16 @@ def get_item_files(request, item_id): def post_item_file(request, item_id): try: item = InventoryItem.objects.get(id=item_id, owner=request.user) + if 'file_hash' in request.data: + # Attach a file the caller already staged on one of their own workflows, identified + # by its content hash (which the client already computed before ever uploading it), + # instead of re-uploading bytes that are already stored server-side. + try: + file = File.objects.get(hash=request.data['file_hash'], staged_by_workflows__owner=request.user) + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + item.files.add(file) + return Response(FileSerializer(file).data, status=status.HTTP_201_CREATED) serializer = FileSerializer(data=request.data) if serializer.is_valid(): file = serializer.save() @@ -40,6 +50,31 @@ def post_item_file(request, item_id): return Response(status=status.HTTP_404_NOT_FOUND) +def get_staged_files(request, workflow_id): + try: + workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user) + # Hash alone identifies a staged file (client and server hash content the same way, and + # bytes are fetchable from a hash-derived storage path) - useful mainly for discovering + # what another session/device already staged on this workflow, unlike the fuller + # FileSerializer representation item_files uses. + return Response(list(workflow.staged_files.values_list('hash', flat=True))) + except WorkflowInstance.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + +def post_staged_file(request, workflow_id): + try: + workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user) + serializer = FileSerializer(data=request.data) + if serializer.is_valid(): + file = serializer.save() + workflow.staged_files.add(file) + return Response({'hash': file.hash}, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except WorkflowInstance.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + @api_view(['POST', 'GET']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthenticationLocal]) @@ -58,7 +93,9 @@ def delete_item_file(request, item_id, file_id, format=None): # /item_files/ item = InventoryItem.objects.get(id=item_id, owner=request.user) file = item.files.get(id=file_id) item.files.remove(file_id) - if file.connected_items.count() == 0: + if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ + and file.staged_by_workflows.count() == 0: + file.file.delete(save=False) file.delete() return Response(status=status.HTTP_204_NO_CONTENT) except InventoryItem.DoesNotExist: @@ -67,8 +104,39 @@ def delete_item_file(request, item_id, file_id, format=None): # /item_files/ return Response(status=status.HTTP_404_NOT_FOUND) +@api_view(['POST', 'GET']) +@permission_classes([IsAuthenticated]) +@authentication_classes([SignatureAuthenticationLocal]) +def staged_files(request, workflow_id, format=None): # /staged_files/ + if request.method == 'GET': + return get_staged_files(request, workflow_id) + elif request.method == 'POST': + return post_staged_file(request, workflow_id) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +@authentication_classes([SignatureAuthenticationLocal]) +def delete_staged_file(request, workflow_id, file_hash, format=None): # /staged_files/ + try: + workflow = WorkflowInstance.objects.get(id=workflow_id, owner=request.user) + file = workflow.staged_files.get(hash=file_hash) + workflow.staged_files.remove(file) + if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ + and file.staged_by_workflows.count() == 0: + file.file.delete(save=False) + file.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except WorkflowInstance.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + except File.DoesNotExist: + return Response(status=status.HTTP_404_NOT_FOUND) + + urlpatterns = [ path('files/', list_all_files), path('item_files//', item_files), path('item_files///', delete_item_file), + path('staged_files//', staged_files), + path('staged_files///', delete_staged_file), ] diff --git a/backend/toolshed/api/inventory.py b/backend/toolshed/api/inventory.py index 5846e03..e28e296 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from authentication.models import ToolshedUser, KnownIdentity from authentication.signature_auth import SignatureAuthentication +from files.models import File from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer @@ -120,7 +121,13 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet): def perform_destroy(self, instance): if instance.owner == self.request.user.user.get(): + staged_file_ids = list(instance.staged_files.values_list('id', flat=True)) instance.delete() + for file in File.objects.filter(id__in=staged_file_ids): + if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \ + and file.staged_by_workflows.count() == 0: + file.file.delete(save=False) + file.delete() router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items') diff --git a/backend/toolshed/migrations/0010_rename_name_workflowinstance_slug.py b/backend/toolshed/migrations/0010_rename_name_workflowinstance_slug.py new file mode 100644 index 0000000..8787453 --- /dev/null +++ b/backend/toolshed/migrations/0010_rename_name_workflowinstance_slug.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.2 on 2026-08-09 13:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('toolshed', '0009_alter_workflowinstance_payload'), + ] + + operations = [ + migrations.RenameField( + model_name='workflowinstance', + old_name='name', + new_name='slug', + ), + ] diff --git a/backend/toolshed/migrations/0011_workflowinstance_staged_files.py b/backend/toolshed/migrations/0011_workflowinstance_staged_files.py new file mode 100644 index 0000000..628f0b2 --- /dev/null +++ b/backend/toolshed/migrations/0011_workflowinstance_staged_files.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.2 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('files', '0001_initial'), + ('toolshed', '0010_rename_name_workflowinstance_slug'), + ] + + operations = [ + migrations.AddField( + model_name='workflowinstance', + name='staged_files', + field=models.ManyToManyField(blank=True, related_name='staged_by_workflows', to='files.file'), + ), + ] diff --git a/backend/toolshed/models.py b/backend/toolshed/models.py index 47a790c..bd34747 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -135,14 +135,15 @@ class StorageLocation(models.Model): class WorkflowInstance(models.Model): - name = models.CharField(max_length=255) + slug = models.CharField(max_length=255) state = models.CharField(max_length=255) payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend. owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows') + staged_files = models.ManyToManyField(File, related_name='staged_by_workflows', blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return f"{self.name} ({self.state})" + return f"{self.slug} ({self.state})" diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index 9a6c5f5..f0704cb 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -284,14 +284,15 @@ def delete_user_account(user): def _delete_orphaned_files(file_ids): """Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. - A File is considered orphaned once no InventoryItem and no ToolshedUser (profile picture) - references it anymore. Returns the number of files deleted. + A File is considered orphaned once no InventoryItem, no ToolshedUser (profile picture), and no + WorkflowInstance (staged file) references it anymore. Returns the number of files deleted. """ from files.models import File deleted = 0 for file_obj in File.objects.filter(id__in=file_ids): - if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists(): + if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists() \ + or file_obj.staged_by_workflows.exists(): continue file_obj.file.delete(save=False) file_obj.delete() diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 43c335b..a42a8c7 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -203,9 +203,18 @@ class InventoryItemSerializer(serializers.ModelSerializer): class WorkflowInstanceSerializer(serializers.ModelSerializer): owner = serializers.StringRelatedField(read_only=True) + # Hash is enough to identify a staged file (the client computes the same SHA-256 the backend + # does, and can fetch bytes from a hash-derived storage path) - for anything staged by *this* + # session there's nothing more to say, and for a file staged elsewhere (another device/tab), + # hash is what lets this session recognize and fetch it. Unlike InventoryItemSerializer.files, + # no fuller FileSerializer representation is needed here. + staged_files = serializers.SerializerMethodField() class Meta: model = WorkflowInstance - fields = ['id', 'name', 'state', 'payload', 'owner', 'created_at', 'updated_at'] - read_only_fields = ['owner', 'created_at', 'updated_at'] + fields = ['id', 'slug', 'state', 'payload', 'owner', 'staged_files', 'created_at', 'updated_at'] + read_only_fields = ['owner', 'staged_files', 'created_at', 'updated_at'] + + def get_staged_files(self, obj): + return list(obj.staged_files.values_list('hash', flat=True)) diff --git a/deploy/docker-compose.override.yml b/deploy/dev/docker-compose.yml similarity index 55% rename from deploy/docker-compose.override.yml rename to deploy/dev/docker-compose.yml index 6d542d7..abd20f2 100644 --- a/deploy/docker-compose.override.yml +++ b/deploy/dev/docker-compose.yml @@ -1,48 +1,49 @@ version: '3.8' +name: dev services: backend-a: build: - context: ../backend/ + context: ../../backend/ dockerfile: ../deploy/dev/Dockerfile.backend environment: TOOLSHED_DB_PATH: /mnt/db.sqlite3 TOOLSHED_USERFILES_PATH: /mnt/userfiles TOOLSHED_SETUP_PATH: /mnt/testdata.py volumes: - - ../backend:/code - - ../deploy/dev/instance_a/a.env:/code/.env - - ../deploy/dev/instance_a/testdata.py:/mnt/testdata.py - - ../deploy/dev/instance_a/a.sqlite3:/mnt/db.sqlite3 - - ../deploy/dev/instance_a/userfiles:/mnt/userfiles + - ../../backend:/code + - ./instance_a/a.env:/code/.env + - ./instance_a/testdata.py:/mnt/testdata.py + - ./instance_a/a.sqlite3:/mnt/db.sqlite3 + - ./instance_a/userfiles:/mnt/userfiles expose: - 8000 command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" backend-b: build: - context: ../backend/ + context: ../../backend/ dockerfile: ../deploy/dev/Dockerfile.backend environment: TOOLSHED_DB_PATH: /mnt/db.sqlite3 TOOLSHED_USERFILES_PATH: /mnt/userfiles TOOLSHED_SETUP_PATH: /mnt/testdata.py volumes: - - ../backend:/code - - ../deploy/dev/instance_b/b.env:/code/.env - - ../deploy/dev/instance_b/testdata.py:/mnt/testdata.py - - ../deploy/dev/instance_b/b.sqlite3:/mnt/db.sqlite3 - - ../deploy/dev/instance_b/userfiles:/mnt/userfiles + - ../../backend:/code + - ./instance_b/b.env:/code/.env + - ./instance_b/testdata.py:/mnt/testdata.py + - ./instance_b/b.sqlite3:/mnt/db.sqlite3 + - ./instance_b/userfiles:/mnt/userfiles expose: - 8000 command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" frontend: build: - context: ../frontend/ + context: ../../frontend/ dockerfile: ../deploy/dev/Dockerfile.frontend volumes: - - ../frontend:/app + - ../../frontend:/app - /app/node_modules expose: - 5173 @@ -50,11 +51,11 @@ services: wiki: build: - context: ../ + context: ../../ dockerfile: deploy/dev/Dockerfile.wiki volumes: - - ../mkdocs.yml:/wiki/mkdocs.yml - - ../docs:/wiki/docs + - ../../mkdocs.yml:/wiki/mkdocs.yml + - ../../docs:/wiki/docs expose: - 8001 command: mkdocs serve --dev-addr=0.0.0.0:8001 @@ -62,12 +63,12 @@ services: proxy-a: build: context: ./ - dockerfile: dev/Dockerfile.proxy + dockerfile: Dockerfile.proxy volumes: - - ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro - - ./dev/instance_a/dns.json:/var/www/dns.json:ro - - ./dev/instance_a/domains.json:/var/www/domains.json:ro - - ./dev/instance_a/userfiles:/var/www/userfiles:ro + - ./instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro + - ./instance_a/dns.json:/var/www/dns.json:ro + - ./instance_a/domains.json:/var/www/domains.json:ro + - ./instance_a/userfiles:/var/www/userfiles:ro ports: - "127.0.0.1:8080:8080" - "127.0.0.3:5353:5353" @@ -75,19 +76,19 @@ services: proxy-b: build: context: ./ - dockerfile: dev/Dockerfile.proxy + dockerfile: Dockerfile.proxy volumes: - - ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro - - ./dev/instance_b/userfiles:/var/www/userfiles:ro + - ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro + - ./instance_b/userfiles:/var/www/userfiles:ro ports: - "127.0.0.2:8080:8080" dns: build: - context: ./dev/ + context: ./ dockerfile: Dockerfile.dns volumes: - - ./dev/zone.json:/dns/zone.json + - ./zone.json:/dns/zone.json expose: - 8053 networks: diff --git a/deploy/dev/instance_a/nginx-a.dev.conf b/deploy/dev/instance_a/nginx-a.dev.conf index c661fb3..b1a762f 100644 --- a/deploy/dev/instance_a/nginx-a.dev.conf +++ b/deploy/dev/instance_a/nginx-a.dev.conf @@ -1,6 +1,8 @@ events {} http { + client_max_body_size 128M; + upstream backend { server backend-a:8000; } diff --git a/deploy/dev/instance_b/nginx-b.dev.conf b/deploy/dev/instance_b/nginx-b.dev.conf index e181011..4fda10c 100644 --- a/deploy/dev/instance_b/nginx-b.dev.conf +++ b/deploy/dev/instance_b/nginx-b.dev.conf @@ -1,6 +1,8 @@ events {} http { + client_max_body_size 128M; + upstream backend { server backend-b:8000; } diff --git a/deploy/prod/.gitignore b/deploy/prod/.gitignore new file mode 100644 index 0000000..cb88020 --- /dev/null +++ b/deploy/prod/.gitignore @@ -0,0 +1,2 @@ +.secrets/ +inventory.yml diff --git a/deploy/prod/Dockerfile b/deploy/prod/Dockerfile deleted file mode 100644 index cbff043..0000000 --- a/deploy/prod/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:alpine as builder -WORKDIR /app -COPY ./package.json /app/package.json -COPY . /app -RUN npm install -RUN npm run build - - -FROM nginx:alpine as runner -RUN apk add --update npm -WORKDIR /app -COPY --from=builder /app/dist /usr/share/nginx/html -COPY ./nginx.conf /etc/nginx/nginx.conf -EXPOSE 80 diff --git a/deploy/prod/Dockerfile.backend b/deploy/prod/Dockerfile.backend new file mode 100644 index 0000000..1509d6a --- /dev/null +++ b/deploy/prod/Dockerfile.backend @@ -0,0 +1,28 @@ +# Production image for the Django backend. +# Runs migrations then serves the app with gunicorn on port 8000. +# Static files are collected at build time into /app/staticfiles and +# served by the backend itself behind the host nginx reverse proxy. + +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DJANGO_SETTINGS_MODULE=backend.settings + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt gunicorn + +COPY . . + +RUN python manage.py collectstatic --noinput + +EXPOSE 8000 + +CMD ["sh", "-c", "python manage.py migrate --noinput && exec gunicorn backend.wsgi:application --bind 0.0.0.0:8000 --workers 3"] diff --git a/deploy/prod/Dockerfile.frontend b/deploy/prod/Dockerfile.frontend new file mode 100644 index 0000000..12dec8d --- /dev/null +++ b/deploy/prod/Dockerfile.frontend @@ -0,0 +1,18 @@ +# Build-only image for the Vue frontend. +# It is never run as a service: ansible builds this image once, runs it +# with the host output directory bind-mounted at /output, the container +# copies the compiled static build into it, and exits. Nginx on the host +# then serves that directory directly. + +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +COPY extras/ ./extras/ +RUN npm ci +COPY . . +RUN npm run build + +FROM alpine AS export +COPY --from=build /app/dist /dist +VOLUME /output +CMD ["sh", "-c", "rm -rf /output/* && cp -a /dist/. /output/"] diff --git a/deploy/prod/Dockerfile.wiki b/deploy/prod/Dockerfile.wiki new file mode 100644 index 0000000..48b9b6e --- /dev/null +++ b/deploy/prod/Dockerfile.wiki @@ -0,0 +1,17 @@ +# Build-only image for the project wiki (mkdocs). +# It is never run as a service: ansible builds this image once, runs it +# with the host output directory bind-mounted at /output, the container +# copies the built static site into it, and exits. Nginx on the host +# then serves that directory directly, the same way it does the frontend. + +FROM python:3.11-slim AS build +WORKDIR /wiki +RUN pip install --no-cache-dir mkdocs +COPY mkdocs.yml ./ +COPY docs/ ./docs/ +RUN mkdocs build + +FROM alpine AS export +COPY --from=build /wiki/site /site +VOLUME /output +CMD ["sh", "-c", "rm -rf /output/* && cp -a /site/. /output/"] diff --git a/deploy/prod/README.md b/deploy/prod/README.md new file mode 100644 index 0000000..8d32f55 --- /dev/null +++ b/deploy/prod/README.md @@ -0,0 +1,205 @@ +# Toolshed production deployment — manual steps + +`playbook.yml` automates installing docker.io and nginx (plus certbot, and +obtaining/renewing a TLS certificate with it, on hosts that manage their own +— see `behind_tls_proxy` below), building the backend, frontend and wiki +images, exporting the frontend and wiki static builds for nginx to serve, +writing the small `/local/domains` and `/local/dns` fixture files the +frontend fetches directly (registration domain list and DoH resolver +preference — see `toolshed_register_domains`/`toolshed_doh_resolvers` in +`playbook.yml`), configuring nginx, and installing the `toolshed-backend` +systemd service. It does **not** set up the target server or DNS. Those are +manual, one-time steps and are covered here. Seeding the backend's shared +reference data is also a manual, one-time step — see +[First superuser & shared reference data](#5-first-superuser--shared-reference-data). + +## 1. Server & firewall + +- A Debian/Ubuntu host reachable over SSH. +- Copy `inventory.example.yml` to `inventory.yml` (git-ignored, since it + holds real hostnames/IPs) and fill in your host(s) — see + [Per-deployment configuration](#2-per-deployment-configuration). +- Inbound TCP 80 open in the firewall/security group. Also open 443 unless + `behind_tls_proxy: true` — and keep both open permanently, not just for the + initial deploy: certbot's renewal timer needs 80 for the ACME HTTP-01 + challenge and 443 for HTTPS traffic for as long as this host is live. + +## 2. Per-deployment configuration + +Each entry under `hosts:` in `inventory.yml` is its own independent +deployment (its own repo checkout, database, domain, systemd service and +Django `SECRET_KEY` — nothing is shared between hosts). Set these as +host_vars directly on each host entry, not via `-e` on the command line, +so a single `inventory.yml` can hold several unrelated deployments safely: + +```yaml +toolshed: + hosts: + my-server: + ansible_host: 203.0.113.10 + ansible_user: deploy + toolshed_domain: toolshed.webdomain.tld + toolshed_handle_domain: yourtoolshed.tld # optional, see below + toolshed_repo_url: git@example.com:your-org/toolshed.git + behind_tls_proxy: false +``` + +- `toolshed_domain` — the **web domain**: the nginx `server_name`, Django + `ALLOWED_HOSTS`, and the hostname you'll point a TLS cert at — e.g. + `toolshed.webdomain.tld`. Required, no default. This is not necessarily the + same as the **handle domain** your users log in with (the part after `@` + in `user@yourtoolshed.tld`) — see [DNS](#3-dns) for how those two relate. +- `toolshed_handle_domain` — the **handle domain**, only needed when it's + different from `toolshed_domain`. Omit it when the two are the same (it + then defaults to `toolshed_domain`). Set so nginx/Django accept requests + for either domain, whichever ends up as the `Host` header. +- `toolshed_repo_url` — the git remote the playbook checks out and builds + from. Required, no default. +- `toolshed_version` — the branch, tag or commit to check out and build. + Optional, defaults to `stable`. +- `behind_tls_proxy` — `true` if TLS for this host is already terminated by + something in front of it (e.g. an external reverse proxy or load + balancer) that forwards plain HTTP here; `false` if this nginx has to + terminate TLS itself. This controls two things: + - Whether nginx trusts an upstream `X-Forwarded-Proto` header or sets its + own — get this wrong and Django's `SECURE_PROXY_SSL_HEADER` check + (`backend/backend/settings.py`) will treat every request as insecure or, + flipped the other way, treat plain HTTP as secure. + - Whether the playbook manages TLS at all. When `false`, it automatically + obtains a Let's Encrypt certificate via certbot and switches nginx over + to it — nothing to do manually beyond DNS (below). certbot's own systemd + timer keeps renewing it afterwards, independent of the playbook. +- `toolshed_letsencrypt_email` — required whenever `behind_tls_proxy` is + `false`; the account email certbot registers the certificate under + (used only for renewal-failure notices). Ignored otherwise. +- `http_port` — optional, defaults to `80`. Only relevant when + `behind_tls_proxy: true` and whatever's in front of this host forwards to + a nonstandard port instead of 80. +- `doh_resolvers` — optional, defaults to `["1.1.1.1", "8.8.8.8"]` (the same + hardcoded fallback the frontend itself uses, see `frontend/src/dns.js`). + DNS-over-HTTPS resolvers the frontend uses to look up a handle domain's + `_toolshed-server._tcp` SRV record before it has a cached preference. + Written to `/local/dns` at deploy time; only worth overriding as a + host_var (or `-e doh_resolvers='["9.9.9.9"]'`) if you want this + deployment to prefer a specific resolver. + +## 3. DNS + +There are two distinct domains at play here, and it's easy to conflate them: + +- **Web domain** — the machine's actual hostname: nginx `server_name`, + Django `ALLOWED_HOSTS`, your TLS cert, what's in `toolshed_domain`. This is + what an A/AAAA record has to resolve to the server's IP for. +- **Handle domain** — the part after the `@` in a username, e.g. + `user@yourtoolshed.tld`. Toolshed usernames don't encode a server address + directly; the frontend resolves the handle domain to a server via an SRV + record, `_toolshed-server._tcp..` (see + `frontend/src/store.js`, `lookupServer`). What's in `toolshed_handle_domain` + (see [Per-deployment configuration](#2-per-deployment-configuration)) only + makes nginx/Django accept it as a `Host` header — publishing the actual SRV + record is still a separate, manual DNS step, covered below. + +The SRV lookup happens for every login, not just federation with other +servers, so **every** deployment needs it published for its own handle +domain — even a standalone server that only ever serves itself. + +These two domains can be **the same** or **completely different**, and +that's exactly the choice between an A record and an SRV record: + +- **Same domain**: if `yourtoolshed.tld` is both the web domain and the + handle domain, it needs both an A record (so the domain itself resolves to + the server) and an SRV record that happens to point back at itself. +- **Different domains**: the handle domain only needs the SRV record — no A + record of its own — pointing at whatever web domain the server actually + lives at. This is useful when the handle you give out (short, brandable, + independent of hosting) shouldn't have to match wherever the box is + actually deployed (a subdomain of a shared hosting provider, an internal + service name, etc.). + +**a) A/AAAA record — web domain → server IP:** + +```sh +dig A +``` + +**b) SRV record — handle domain → web domain + port.** Use port 443: the +federation protocol is HTTPS-only. + +```sh +dig _toolshed-server._tcp. SRV +``` + +For example, with a handle domain of `yourtoolshed.tld` and a web domain of +`toolshed.webdomain.tld`: + +``` +$ dig _toolshed-server._tcp.yourtoolshed.tld srv +_toolshed-server._tcp.yourtoolshed.tld. 300 IN SRV 10 10 443 toolshed.webdomain.tld. + +$ dig toolshed.webdomain.tld A +toolshed.webdomain.tld. 300 IN A 203.0.113.10 +``` + +If you instead want `yourtoolshed.tld` itself to be the web domain too, its +SRV record just points at itself (`... SRV 10 10 443 yourtoolshed.tld.`) and +it additionally needs its own A record. + +## 4. Secrets + +`toolshed_secret_key` is generated once per host by the playbook (via the +`password` lookup, keyed by the host's inventory name) and stored as +`.secrets/_secret_key` on the *control* machine, not on +the target. Back these files up — losing one invalidates all sessions and +signed cookies for that deployment on its next redeploy. They're git-ignored +on purpose; never commit them. + +## 5. First superuser & shared reference data + +The production backend image only runs `migrate` and `collectstatic` at +startup (see `Dockerfile.backend`) — unlike the dev compose setup, it never +runs the interactive `configure.py`. Two things dev gets "for free" from that +script therefore need doing manually, once, after a host's backend container +is first up (run these on the target host itself, or prefix with +`ssh `): + +- **Superuser account:** + + ```sh + docker exec -it toolshed-backend python manage.py createsuperuser + ``` + +- **Shared reference data** (the standard categories/properties/tags + shipped in `backend/shared_data/*.json` — tools, electrical, screws, IT, + etc.): without this step a fresh deployment starts with none of them. + Run `configure.py` interactively (the `-it` flags matter — the script's + prompts only appear with a real tty) and answer "yes" when it asks to + import them: + + ```sh + docker exec -it toolshed-backend python configure.py + ``` + + The other prompts it asks first (create `.env`, create a database) are + harmless to answer "yes" to as well: the container already gets its real + `SECRET_KEY`/`ALLOWED_HOSTS`/db path from the environment (see + `backend.env` below), those checks just look for files at paths relative + to `/app` that don't exist in this container, and re-running `migrate` + against the real database is idempotent. You can say "no" to the + superuser prompt here if you already created one above. + +## 6. Running the playbook + +Always target one host at a time with `--limit` — running against the whole +`toolshed` group in one invocation would apply every host's own +`toolshed_domain`/`toolshed_repo_url` correctly (they're per-host vars, see +[Per-deployment configuration](#2-per-deployment-configuration)), but rolls +out all deployments back-to-back in one run, which is rarely what you want: + +```sh +ansible-playbook -i inventory.yml playbook.yml --limit my-server +``` + +Re-run it to roll out a new version to that host. It deploys whatever +`toolshed_version` is set for that host (`stable` by default) — set the +host_var for a persistent change, or pass `-e toolshed_version=` +for a one-off deploy of something else. diff --git a/deploy/prod/inventory.example.yml b/deploy/prod/inventory.example.yml new file mode 100644 index 0000000..9979f81 --- /dev/null +++ b/deploy/prod/inventory.example.yml @@ -0,0 +1,42 @@ +--- +# Copy this file to inventory.yml (git-ignored) and fill in your real +# hosts. Each entry under hosts: is an independent deployment - see the +# README's "Per-deployment configuration" section for what each var means. + +toolshed: + hosts: + my-server: + ansible_host: 203.0.113.10 + ansible_user: deploy + # toolshed_domain is the "web domain" - see the README's DNS section + # for how this relates to the separate "handle domain" your users + # log in with (user@yourtoolshed.tld). + toolshed_domain: toolshed.webdomain.tld + # Optional - only needed if the handle domain differs from the web + # domain above. Omit it entirely when they're the same. + toolshed_handle_domain: yourtoolshed.tld + toolshed_repo_url: git@example.com:your-org/toolshed.git + # Optional - branch, tag or commit to deploy. Defaults to "stable". + toolshed_version: stable + # true if something in front of this host already terminates TLS + # (reverse proxy/load balancer), false if this nginx must do it itself. + behind_tls_proxy: false + # Required whenever behind_tls_proxy is false: the playbook obtains + # its own Let's Encrypt certificate via certbot, which needs an + # account email for renewal notices. + toolshed_letsencrypt_email: admin@example.com + + # A second, unrelated deployment behind an existing TLS-terminating + # proxy - remove this if you only run one instance. Here the handle + # domain and web domain are the same, so toolshed_handle_domain is + # simply omitted, and toolshed_letsencrypt_email isn't needed since + # this nginx never handles TLS itself. + my-other-server: + ansible_host: my-other-server.example.com + ansible_user: deploy + toolshed_domain: toolshed.example.com + toolshed_repo_url: git@example.com:your-org/toolshed.git + behind_tls_proxy: true + # Only needed if the proxy in front forwards to something other than + # port 80 on this host. + http_port: 8080 diff --git a/deploy/prod/playbook.yml b/deploy/prod/playbook.yml new file mode 100644 index 0000000..a225dae --- /dev/null +++ b/deploy/prod/playbook.yml @@ -0,0 +1,539 @@ +--- +# Production deploy for toolshed. +# +# - installs docker.io and nginx on the target (plus certbot, unless +# behind_tls_proxy is true) +# - checks out the source and builds the backend and frontend docker images +# - runs the frontend image once to export its static build, which nginx +# then serves directly (the frontend image is never run as a service) +# - configures nginx (inline template, no separate .conf file) and, unless +# behind_tls_proxy is true, obtains/renews a Let's Encrypt certificate via +# certbot and switches nginx over to it automatically - no manual TLS step +# - installs and manages a systemd service that runs the backend container +# +# Usage (each host is its own independent deployment - always target one +# at a time, never the whole "toolshed" group in one run): +# ansible-playbook -i inventory.yml playbook.yml --limit my-server +# +# toolshed_repo_url, toolshed_domain, toolshed_handle_domain (optional), +# toolshed_version (optional, defaults to "stable"), behind_tls_proxy and +# toolshed_letsencrypt_email (required unless behind_tls_proxy is true) are +# per-deployment and must be set as host_vars in inventory.yml (copy +# inventory.example.yml) rather than here or via -e, so that each host in +# the "toolshed" group can point at its own repo/domain/branch. They're read +# with `mandatory`/`default()` below instead of being declared in play +# `vars:`, since play vars always take precedence over inventory host_vars +# and would otherwise silently override whatever is set per-host. + +- name: Deploy toolshed + hosts: toolshed + become: true + + vars: + toolshed_src_dir: /opt/toolshed/src + toolshed_data_dir: /opt/toolshed/data + toolshed_dist_dir: /var/www/toolshed + + toolshed_backend_image: toolshed-backend + toolshed_frontend_image: toolshed-frontend-builder + toolshed_wiki_image: toolshed-wiki-builder + toolshed_backend_container: toolshed-backend + toolshed_backend_port: 8000 + toolshed_wiki_dist_dir: /var/www/toolshed-wiki + toolshed_local_dir: /var/www/toolshed-local + # Domain(s) this server accepts registrations for (the "handle domain" - + # see the README's DNS section). Served as a static /local/domains + # fixture that the frontend's registration/pairing forms fetch to + # populate their domain dropdown (frontend/src/views/Register.vue, + # Pairing.vue) - without it that dropdown is just empty. + toolshed_register_domains: "{{ [toolshed_handle_domain | default(toolshed_domain)] | unique }}" + # DoH resolvers the frontend falls back to for SRV lookups when it has + # no cached preference yet, served as a static /local/dns fixture. These + # match the frontend's own hardcoded fallback (frontend/src/dns.js), so + # this mostly makes the choice explicit and per-host overridable (e.g. + # -e doh_resolvers='["9.9.9.9"]') rather than changing behavior. + toolshed_doh_resolvers: "{{ doh_resolvers | default(['1.1.1.1', '8.8.8.8']) }}" + # Docker tags can't contain "/", but toolshed_version is a git ref and + # branch names like "jedi/proto/frontend" do - sanitize before using it + # as an image tag. The raw value is still used as-is for the actual git + # checkout, where slashes are fine. + toolshed_image_tag: "{{ (toolshed_version | default('stable')) | replace('/', '-') }}" + + toolshed_debug: "False" + # Plain HTTP listen port. Only relevant behind an external proxy that + # forwards to something other than 80 (see http_port in inventory.yml); + # when this nginx terminates TLS itself, the public port is always 443. + toolshed_http_port: "{{ http_port | default(80) }}" + toolshed_letsencrypt_webroot: /var/www/letsencrypt + # Nginx sets its own X-Forwarded-Proto from $scheme when it terminates + # TLS itself. Behind an external TLS-terminating proxy, $scheme at this + # nginx is always "http" (the proxy already stripped TLS one hop + # earlier), so overwriting the header with $scheme would tell Django + # every request is insecure. In that case pass through the proxy's own + # header instead. + toolshed_x_forwarded_proto: >- + {{ '$http_x_forwarded_proto' if (behind_tls_proxy | default(false) | bool) else '$scheme' }} + # The web domain (toolshed_domain, mandatory) and the handle domain + # (toolshed_handle_domain, optional - defaults to the web domain when + # they're the same) both need to be accepted by nginx/Django, since + # either may show up as the Host header depending on how the admin set + # up DNS for this deployment. Deduplicated so setting them equal + # doesn't produce a repeated entry. + toolshed_hostnames: >- + {{ [toolshed_domain | mandatory('toolshed_domain must be set as a host_var for ' ~ inventory_hostname), + toolshed_handle_domain | default(toolshed_domain)] | unique }} + # Generated once per host on the controller and reused on every + # subsequent run against that host, keyed by inventory_hostname so + # separate deployments never end up sharing a Django SECRET_KEY. + toolshed_secret_key: >- + {{ lookup('ansible.builtin.password', + playbook_dir ~ '/.secrets/' ~ inventory_hostname ~ '_secret_key length=64 chars=ascii_letters,digits') }} + + # Rendered twice against the same var (see the tasks below): once before + # a certificate exists (serves the site plainly over toolshed_http_port, + # or over 80/plain-HTTP forever if behind_tls_proxy), and once after + # certbot has obtained one, at which point the plain HTTP vhost switches + # to a redirect and a 443 vhost with the real content appears. Whichever + # of those two states applies, toolshed_cert (a registered `stat` result, + # undefined/false until it's checked) decides which one renders - this + # is the "another nginx config" from a single inline template, driven by + # behind_tls_proxy and certificate state rather than a separate file. + toolshed_nginx_conf: | + upstream toolshed_backend { + server 127.0.0.1:{{ toolshed_backend_port }}; + } + + {% macro toolshed_locations() %} + location /api { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /auth { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /media { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /djangoadmin { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /docs { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto {{ toolshed_x_forwarded_proto }}; + proxy_pass http://toolshed_backend; + } + + location /static { + proxy_pass http://toolshed_backend/static; + } + + location /wiki/ { + alias {{ toolshed_wiki_dist_dir }}/; + try_files $uri $uri/ =404; + } + + location = /wiki { + return 301 /wiki/; + } + + # Static fixtures the frontend fetches directly (registration + # domain list, DoH resolver preference) - see toolshed_register_domains + # and toolshed_doh_resolvers above. + location /local/ { + alias {{ toolshed_local_dir }}/; + try_files $uri.json =404; + add_header Content-Type application/json; + } + + # Vue-router history mode: fall back to index.html for + # any path that isn't a real static file. + location / { + try_files $uri $uri/ /index.html; + } + {% endmacro %} + + {% if behind_tls_proxy | default(false) | bool %} + server { + listen {{ toolshed_http_port }}; + listen [::]:{{ toolshed_http_port }}; + server_name {{ toolshed_hostnames | join(' ') }}; + + client_max_body_size 128M; + root {{ toolshed_dist_dir }}; + index index.html; + {{ toolshed_locations() }} + } + {% else %} + {% set tls_active = toolshed_cert.stat.exists | default(false) %} + server { + listen {{ toolshed_http_port }}; + listen [::]:{{ toolshed_http_port }}; + server_name {{ toolshed_hostnames | join(' ') }}; + + location /.well-known/acme-challenge/ { + root {{ toolshed_letsencrypt_webroot }}; + } + {% if tls_active %} + + location / { + return 301 https://$host$request_uri; + } + {% else %} + + client_max_body_size 128M; + root {{ toolshed_dist_dir }}; + index index.html; + {{ toolshed_locations() }} + {% endif %} + } + {% if tls_active %} + + server { + listen 443 ssl; + listen [::]:443 ssl; + server_name {{ toolshed_hostnames | join(' ') }}; + + ssl_certificate /etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/{{ toolshed_domain }}/privkey.pem; + + client_max_body_size 128M; + root {{ toolshed_dist_dir }}; + index index.html; + {{ toolshed_locations() }} + } + {% endif %} + {% endif %} + + tasks: + - name: Install docker.io and nginx + ansible.builtin.apt: + name: + - docker.io + - nginx + state: present + update_cache: true + + - name: Install certbot + ansible.builtin.apt: + name: certbot + state: present + when: not (behind_tls_proxy | default(false) | bool) + + - name: Ensure docker is running and enabled + ansible.builtin.systemd: + name: docker + state: started + enabled: true + + - name: Ensure nginx is running and enabled + ansible.builtin.systemd: + name: nginx + state: started + enabled: true + + - name: Checkout toolshed source + ansible.builtin.git: + repo: "{{ toolshed_repo_url | mandatory('toolshed_repo_url must be set as a host_var for ' ~ inventory_hostname) }}" + dest: "{{ toolshed_src_dir }}" + version: "{{ toolshed_version | default('stable') }}" + force: true + # frontend/extras is registered as a submodule but unused and its + # pinned commit isn't fetchable from upstream - don't let a broken + # submodule block the checkout. + recursive: false + + - name: Create toolshed system user + ansible.builtin.user: + name: toolshed + system: true + shell: /usr/sbin/nologin + home: "{{ toolshed_data_dir }}" + create_home: false + register: toolshed_user + + - name: Create backend data directories + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: toolshed + group: toolshed + mode: "0750" + loop: + - "{{ toolshed_data_dir }}" + - "{{ toolshed_data_dir }}/userfiles" + + - name: Create frontend static output directory + ansible.builtin.file: + path: "{{ toolshed_dist_dir }}" + state: directory + owner: www-data + group: www-data + mode: "0755" + + - name: Write backend environment file + ansible.builtin.copy: + dest: "{{ toolshed_data_dir }}/backend.env" + # Root-owned and unreadable by the toolshed user on purpose: this is + # read by the docker daemon (root) via --env-file at container + # start and injected directly as env vars, so the containerized app + # - which runs as the toolshed user, see the systemd unit below - + # never needs filesystem access to its own SECRET_KEY. + owner: root + group: root + mode: "0600" + content: | + DEBUG={{ toolshed_debug }} + SECRET_KEY={{ toolshed_secret_key }} + ALLOWED_HOSTS={{ toolshed_hostnames | join(',') }} + SERVE_X_ACCEL_REDIRECT=False + TOOLSHED_DB_PATH=/data/db.sqlite3 + TOOLSHED_USERFILES_PATH=/data/userfiles + notify: restart backend + + - name: Build backend docker image + ansible.builtin.command: + cmd: >- + docker build -t {{ toolshed_backend_image }}:{{ toolshed_image_tag }} + -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.backend {{ toolshed_src_dir }}/backend + changed_when: true + notify: restart backend + + - name: Tag backend image as latest + ansible.builtin.command: + cmd: docker tag {{ toolshed_backend_image }}:{{ toolshed_image_tag }} {{ toolshed_backend_image }}:latest + changed_when: true + notify: restart backend + + - name: Build frontend builder docker image + ansible.builtin.command: + cmd: >- + docker build -t {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} + -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.frontend {{ toolshed_src_dir }}/frontend + changed_when: true + + - name: Run frontend builder once to export the static build + ansible.builtin.command: + cmd: docker run --rm -v {{ toolshed_dist_dir }}:/output {{ toolshed_frontend_image }}:{{ toolshed_image_tag }} + changed_when: true + + - name: Fix ownership of exported frontend build + ansible.builtin.file: + path: "{{ toolshed_dist_dir }}" + owner: www-data + group: www-data + recurse: true + + - name: Create wiki static output directory + ansible.builtin.file: + path: "{{ toolshed_wiki_dist_dir }}" + state: directory + owner: www-data + group: www-data + mode: "0755" + + - name: Build wiki builder docker image + ansible.builtin.command: + cmd: >- + docker build -t {{ toolshed_wiki_image }}:{{ toolshed_image_tag }} + -f {{ toolshed_src_dir }}/deploy/prod/Dockerfile.wiki {{ toolshed_src_dir }} + changed_when: true + + - name: Run wiki builder once to export the static site + ansible.builtin.command: + cmd: docker run --rm -v {{ toolshed_wiki_dist_dir }}:/output {{ toolshed_wiki_image }}:{{ toolshed_image_tag }} + changed_when: true + + - name: Fix ownership of exported wiki build + ansible.builtin.file: + path: "{{ toolshed_wiki_dist_dir }}" + owner: www-data + group: www-data + recurse: true + + - name: Create local fixtures directory + ansible.builtin.file: + path: "{{ toolshed_local_dir }}" + state: directory + owner: www-data + group: www-data + mode: "0755" + + - name: Write registration domain list fixture + ansible.builtin.copy: + dest: "{{ toolshed_local_dir }}/domains.json" + owner: www-data + group: www-data + mode: "0644" + content: "{{ toolshed_register_domains | to_nice_json }}" + + - name: Write DoH resolver fixture + ansible.builtin.copy: + dest: "{{ toolshed_local_dir }}/dns.json" + owner: www-data + group: www-data + mode: "0644" + content: "{{ toolshed_doh_resolvers | to_nice_json }}" + + - name: Create ACME HTTP-01 challenge webroot + ansible.builtin.file: + path: "{{ toolshed_letsencrypt_webroot }}" + state: directory + owner: www-data + group: www-data + mode: "0755" + when: not (behind_tls_proxy | default(false) | bool) + + - name: Check for an existing Let's Encrypt certificate + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" + register: toolshed_cert + when: not (behind_tls_proxy | default(false) | bool) + + - name: Configure nginx site for toolshed (bootstrap) + ansible.builtin.copy: + dest: /etc/nginx/sites-available/toolshed.conf + owner: root + group: root + mode: "0644" + content: "{{ toolshed_nginx_conf }}" + notify: reload nginx + + - name: Remove default nginx site + ansible.builtin.file: + path: /etc/nginx/sites-enabled/default + state: absent + notify: reload nginx + + - name: Enable toolshed nginx site + ansible.builtin.file: + src: /etc/nginx/sites-available/toolshed.conf + dest: /etc/nginx/sites-enabled/toolshed.conf + state: link + notify: reload nginx + + # Certbot's webroot check (below) needs nginx already serving the + # bootstrap config from the tasks above, so force the reload now + # instead of waiting for the end of the play. + - name: Apply the bootstrap nginx config now + ansible.builtin.meta: flush_handlers + + - name: Ensure the certbot renewal deploy-hook directory exists + ansible.builtin.file: + path: /etc/letsencrypt/renewal-hooks/deploy + state: directory + mode: "0755" + when: not (behind_tls_proxy | default(false) | bool) + + - name: Reload nginx after certbot renews a certificate + ansible.builtin.copy: + dest: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh + owner: root + group: root + mode: "0755" + content: | + #!/bin/sh + systemctl reload nginx + when: not (behind_tls_proxy | default(false) | bool) + + - name: Obtain or renew the Let's Encrypt certificate + ansible.builtin.command: + cmd: >- + certbot certonly --webroot -w {{ toolshed_letsencrypt_webroot }} + -d {{ toolshed_hostnames | join(' -d ') }} + --non-interactive --agree-tos + -m {{ toolshed_letsencrypt_email | mandatory('toolshed_letsencrypt_email must be set as a host_var for ' ~ inventory_hostname ~ ' since behind_tls_proxy is false there') }} + register: toolshed_certbot + changed_when: "'Certificate not yet due for renewal' not in toolshed_certbot.stdout" + when: not (behind_tls_proxy | default(false) | bool) + + - name: Re-check the certificate now that certbot has run + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ toolshed_domain }}/fullchain.pem" + register: toolshed_cert + when: not (behind_tls_proxy | default(false) | bool) + + - name: Configure nginx site for toolshed (final) + ansible.builtin.copy: + dest: /etc/nginx/sites-available/toolshed.conf + owner: root + group: root + mode: "0644" + content: "{{ toolshed_nginx_conf }}" + notify: reload nginx + + - name: Install systemd unit for the backend container + ansible.builtin.copy: + dest: /etc/systemd/system/toolshed-backend.service + owner: root + group: root + mode: "0644" + content: | + [Unit] + Description=Toolshed backend (Django) container + After=docker.service network-online.target + Requires=docker.service + Wants=network-online.target + + [Service] + TimeoutStartSec=0 + Restart=always + ExecStartPre=-/usr/bin/docker stop {{ toolshed_backend_container }} + ExecStartPre=-/usr/bin/docker rm {{ toolshed_backend_container }} + ExecStart=/usr/bin/docker run --rm --name {{ toolshed_backend_container }} \ + --user {{ toolshed_user.uid }}:{{ toolshed_user.group }} \ + --env-file {{ toolshed_data_dir }}/backend.env \ + -v {{ toolshed_data_dir }}:/data \ + -p 127.0.0.1:{{ toolshed_backend_port }}:8000 \ + {{ toolshed_backend_image }}:latest + ExecStop=/usr/bin/docker stop {{ toolshed_backend_container }} + + [Install] + WantedBy=multi-user.target + notify: restart backend + + - name: Ensure toolshed-backend service is enabled and started + ansible.builtin.systemd: + name: toolshed-backend + daemon_reload: true + enabled: true + state: started + + handlers: + - name: validate nginx config + ansible.builtin.command: nginx -t + listen: reload nginx + changed_when: false + + - name: reload nginx + ansible.builtin.systemd: + name: nginx + state: reloaded + listen: reload nginx + + - name: restart backend + ansible.builtin.systemd: + name: toolshed-backend + daemon_reload: true + state: restarted + listen: restart backend diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue index 8ec828c..d9a1ac2 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -1,7 +1,7 @@