diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9e12f8f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +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 1e3f09c..f5c4542 100644 --- a/backend/authentication/api.py +++ b/backend/authentication/api.py @@ -130,9 +130,7 @@ 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 and old_file.staged_by_workflows.count() == 0: - old_file.file.delete(save=False) + if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0: 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 deleted file mode 100644 index 967b2fb..0000000 --- a/backend/authentication/migrations/0004_alter_accountpreference_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 7c5e2ff..92566ed 100644 --- a/backend/files/media_urls.py +++ b/backend/files/media_urls.py @@ -1,17 +1,8 @@ -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 @@ -20,29 +11,6 @@ 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']) @@ -55,112 +23,29 @@ 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 = _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) + 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) 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}', - **cache_headers, - }) + }) # TODO Expires and Cache-Control 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, - headers=cache_headers, - content=content) + content=open(file.file.path, 'rb').read()) - 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 bff9121..8e9dde4 100644 --- a/backend/files/models.py +++ b/backend/files/models.py @@ -1,7 +1,4 @@ -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 @@ -43,18 +40,6 @@ 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 05ecb38..2d68e88 100644 --- a/backend/files/tests.py +++ b/backend/files/tests.py @@ -1,20 +1,13 @@ -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, default_storage +from django.core.files.storage import DefaultStorage 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() @@ -112,23 +105,6 @@ 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): @@ -144,29 +120,28 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh self.f['item2'].files.add(self.f['test_file1']) - @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(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']) @@ -220,138 +195,3 @@ 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 ec83fc6..06b3ce1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -25,7 +25,6 @@ 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 a3d11a1..bbd1525 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 = ('slug', 'state', 'owner', 'created_at', 'updated_at') - search_fields = ('slug', 'owner__username') + list_display = ('name', 'state', 'owner', 'created_at', 'updated_at') + search_fields = ('name', '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 7c2435c..b56e4dc 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, WorkflowInstance +from toolshed.models import InventoryItem @api_view(['GET']) @@ -30,16 +30,6 @@ 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() @@ -50,31 +40,6 @@ 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]) @@ -93,9 +58,7 @@ 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 and file.profile_picture_users.count() == 0 \ - and file.staged_by_workflows.count() == 0: - file.file.delete(save=False) + if file.connected_items.count() == 0: file.delete() return Response(status=status.HTTP_204_NO_CONTENT) except InventoryItem.DoesNotExist: @@ -104,39 +67,8 @@ 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 e28e296..5846e03 100644 --- a/backend/toolshed/api/inventory.py +++ b/backend/toolshed/api/inventory.py @@ -7,7 +7,6 @@ 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 @@ -121,13 +120,7 @@ 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 deleted file mode 100644 index 8787453..0000000 --- a/backend/toolshed/migrations/0010_rename_name_workflowinstance_slug.py +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 628f0b2..0000000 --- a/backend/toolshed/migrations/0011_workflowinstance_staged_files.py +++ /dev/null @@ -1,19 +0,0 @@ -# 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 bd34747..47a790c 100644 --- a/backend/toolshed/models.py +++ b/backend/toolshed/models.py @@ -135,15 +135,14 @@ class StorageLocation(models.Model): class WorkflowInstance(models.Model): - slug = models.CharField(max_length=255) + name = 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.slug} ({self.state})" + return f"{self.name} ({self.state})" diff --git a/backend/toolshed/offlinedata.py b/backend/toolshed/offlinedata.py index f0704cb..9a6c5f5 100644 --- a/backend/toolshed/offlinedata.py +++ b/backend/toolshed/offlinedata.py @@ -284,15 +284,14 @@ 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, no ToolshedUser (profile picture), and no - WorkflowInstance (staged file) references it anymore. Returns the number of files deleted. + A File is considered orphaned once no InventoryItem and no ToolshedUser (profile picture) + 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() \ - or file_obj.staged_by_workflows.exists(): + if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists(): continue file_obj.file.delete(save=False) file_obj.delete() diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index a42a8c7..43c335b 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -203,18 +203,9 @@ 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', '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)) + fields = ['id', 'name', 'state', 'payload', 'owner', 'created_at', 'updated_at'] + read_only_fields = ['owner', 'created_at', 'updated_at'] diff --git a/deploy/dev/instance_a/nginx-a.dev.conf b/deploy/dev/instance_a/nginx-a.dev.conf index b1a762f..c661fb3 100644 --- a/deploy/dev/instance_a/nginx-a.dev.conf +++ b/deploy/dev/instance_a/nginx-a.dev.conf @@ -1,8 +1,6 @@ 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 4fda10c..e181011 100644 --- a/deploy/dev/instance_b/nginx-b.dev.conf +++ b/deploy/dev/instance_b/nginx-b.dev.conf @@ -1,8 +1,6 @@ events {} http { - client_max_body_size 128M; - upstream backend { server backend-b:8000; } diff --git a/deploy/dev/docker-compose.yml b/deploy/docker-compose.override.yml similarity index 55% rename from deploy/dev/docker-compose.yml rename to deploy/docker-compose.override.yml index abd20f2..6d542d7 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/docker-compose.override.yml @@ -1,49 +1,48 @@ 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 - - ./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 + - ../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 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 - - ./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 + - ../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 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 @@ -51,11 +50,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 @@ -63,12 +62,12 @@ services: proxy-a: build: context: ./ - dockerfile: Dockerfile.proxy + dockerfile: dev/Dockerfile.proxy volumes: - - ./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 + - ./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 ports: - "127.0.0.1:8080:8080" - "127.0.0.3:5353:5353" @@ -76,19 +75,19 @@ services: proxy-b: build: context: ./ - dockerfile: Dockerfile.proxy + dockerfile: dev/Dockerfile.proxy volumes: - - ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro - - ./instance_b/userfiles:/var/www/userfiles:ro + - ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro + - ./dev/instance_b/userfiles:/var/www/userfiles:ro ports: - "127.0.0.2:8080:8080" dns: build: - context: ./ + context: ./dev/ dockerfile: Dockerfile.dns volumes: - - ./zone.json:/dns/zone.json + - ./dev/zone.json:/dns/zone.json expose: - 8053 networks: diff --git a/deploy/prod/.gitignore b/deploy/prod/.gitignore deleted file mode 100644 index cb88020..0000000 --- a/deploy/prod/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.secrets/ -inventory.yml diff --git a/deploy/prod/Dockerfile b/deploy/prod/Dockerfile new file mode 100644 index 0000000..cbff043 --- /dev/null +++ b/deploy/prod/Dockerfile @@ -0,0 +1,14 @@ +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 deleted file mode 100644 index 1509d6a..0000000 --- a/deploy/prod/Dockerfile.backend +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index 12dec8d..0000000 --- a/deploy/prod/Dockerfile.frontend +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100644 index 48b9b6e..0000000 --- a/deploy/prod/Dockerfile.wiki +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index 8d32f55..0000000 --- a/deploy/prod/README.md +++ /dev/null @@ -1,205 +0,0 @@ -# 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 deleted file mode 100644 index 9979f81..0000000 --- a/deploy/prod/inventory.example.yml +++ /dev/null @@ -1,42 +0,0 @@ ---- -# 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 deleted file mode 100644 index a225dae..0000000 --- a/deploy/prod/playbook.yml +++ /dev/null @@ -1,539 +0,0 @@ ---- -# 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 d9a1ac2..8ec828c 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -1,7 +1,7 @@