Compare commits

...

5 commits

Author SHA1 Message Date
622e63ea8f stash 2026-08-17 01:55:53 +02:00
0f51f6e33f stash 2026-08-16 23:52:58 +02:00
3b494dfa37 stash 2026-08-16 19:00:05 +02:00
82a27ce2a8 stash 2026-08-16 18:58:53 +02:00
c0f70004eb stash 2026-08-16 15:15:38 +02:00
45 changed files with 3222 additions and 519 deletions

View file

@ -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

View file

@ -130,7 +130,9 @@ def getUserInfo(request):
return Response({'profile_picture_id': 'File does not exist.'}, status=400) return Response({'profile_picture_id': 'File does not exist.'}, status=400)
user.save() 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() old_file.delete()
return Response({ return Response({

View file

@ -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'),
),
]

View file

@ -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.http import HttpResponse
from django.urls import path from django.urls import path
from django.db.models import Q from django.db.models import Q
from django.conf import settings 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 drf_yasg.utils import swagger_auto_schema
from PIL import Image
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -11,6 +20,29 @@ from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthentication from authentication.signature_auth import SignatureAuthentication
from files.models import File 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) @swagger_auto_schema(method='GET', auto_schema=None)
@api_view(['GET']) @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 # 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 # 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. # 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: try:
file = File.objects.filter( file = _accessible_files(request).get(file=hash_path)
Q(connected_items__owner__in=request.user.friends_or_self()) |
Q(profile_picture_users__in=request.user.friends_or_self()) # The access-control lookup above must happen before this check - otherwise a bare
).distinct().get( # hash + If-None-Match would let anyone probe "does a file with this hash exist" for
file=hash_path) # 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: if settings.SERVE_X_ACCEL_REDIRECT:
return HttpResponse(status=status.HTTP_200_OK, return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type, content_type=file.mime_type,
headers={ headers={
'X-Accel-Redirect': f'/redirect_media/{hash_path}', 'X-Accel-Redirect': f'/redirect_media/{hash_path}',
}) # TODO Expires and Cache-Control **cache_headers,
})
else: 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, return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type, 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/<size>/`
# 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: except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
urlpatterns = [ urlpatterns = [
path('<int:size>/<path:hash_path>/', thumbnail_urls),
path('<path:hash_path>', media_urls), path('<path:hash_path>', media_urls),
] ]

View file

@ -1,4 +1,7 @@
from types import SimpleNamespace
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.db import models, IntegrityError from django.db import models, IntegrityError
from django.db.models import Model from django.db.models import Model
@ -40,6 +43,18 @@ class FileManager(models.Manager):
else: else:
raise ValueError('data must be a base64 encoded string or file and hash must be provided') raise ValueError('data must be a base64 encoded string or file and hash must be provided')
if not self.filter(hash=kwargs['hash']).exists(): 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) return super().create(**kwargs)
else: else:
raise IntegrityError('File with this hash already exists') raise IntegrityError('File with this hash already exists')

View file

@ -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.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.db import IntegrityError, transaction
from django.test import Client, override_settings from django.test import Client, override_settings
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
from toolshed.tests import InventoryTestMixin from toolshed.tests import InventoryTestMixin
from nacl.hash import sha256 from nacl.hash import sha256
from nacl.encoding import HexEncoder from nacl.encoding import HexEncoder
from PIL import Image
import base64 import base64
from files.media_urls import THUMBNAIL_SIZES
from files.models import File from files.models import File
anonymous_client = Client() anonymous_client = Client()
@ -105,6 +112,23 @@ class FilesTestCase(FilesTestMixin, ToolshedTestCase):
self.assertEqual(File.objects.count(), 3) self.assertEqual(File.objects.count(), 3)
self.assertEqual(countdir(DefaultStorage(), ''), 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): class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, ToolshedTestCase):
def setUp(self): def setUp(self):
@ -120,28 +144,29 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
self.f['item2'].files.add(self.f['test_file1']) self.f['item2'].files.add(self.f['test_file1'])
# def test_file_url(self): @override_settings(SERVE_X_ACCEL_REDIRECT=True)
# reply = client.get( def test_file_url(self):
# f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}", reply = client.get(
# self.f['local_user1']) f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
# self.assertEqual(reply.status_code, 200) self.f['local_user1'])
# self.assertEqual(reply.headers['X-Accel-Redirect'], self.assertEqual(reply.status_code, 200)
# 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['X-Accel-Redirect'],
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type) f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}")
# reply = client.get( self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", reply = client.get(
# self.f['local_user1']) f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
# self.assertEqual(reply.status_code, 200) self.f['local_user1'])
# self.assertEqual(reply.headers['X-Accel-Redirect'], self.assertEqual(reply.status_code, 200)
# 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['X-Accel-Redirect'],
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}")
# reply = client.get( self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
# f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", reply = client.get(
# self.f['local_user2']) f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
# self.assertEqual(reply.status_code, 200) self.f['local_user2'])
# self.assertEqual(reply.headers['X-Accel-Redirect'], self.assertEqual(reply.status_code, 200)
# 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['X-Accel-Redirect'],
# self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type) 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): def test_file_url_fail(self):
reply = client.get('/media/{}/'.format('nonexistent'), self.f['local_user1']) 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.f['ext_user1'])
self.assertEqual(reply.status_code, 404) 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")

View file

@ -25,6 +25,7 @@ MarkupSafe==2.1.3
openapi-codec==1.3.2 openapi-codec==1.3.2
packaging==23.1 packaging==23.1
pycparser==2.21 pycparser==2.21
Pillow==10.4.0
PyNaCl==1.5.0 PyNaCl==1.5.0
python-dotenv==1.0.0 python-dotenv==1.0.0
pytz==2023.3 pytz==2023.3

View file

@ -73,8 +73,8 @@ admin.site.register(StorageLocation, StorageLocationAdmin)
class WorkflowInstanceAdmin(admin.ModelAdmin): class WorkflowInstanceAdmin(admin.ModelAdmin):
list_display = ('name', 'state', 'owner', 'created_at', 'updated_at') list_display = ('slug', 'state', 'owner', 'created_at', 'updated_at')
search_fields = ('name', 'owner__username') search_fields = ('slug', 'owner__username')
list_filter = ('state', 'created_at', 'owner') list_filter = ('state', 'created_at', 'owner')
readonly_fields = ('created_at', 'updated_at') readonly_fields = ('created_at', 'updated_at')

View file

@ -7,7 +7,7 @@ from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthenticationLocal from authentication.signature_auth import SignatureAuthenticationLocal
from files.models import File from files.models import File
from files.serializers import FileSerializer from files.serializers import FileSerializer
from toolshed.models import InventoryItem from toolshed.models import InventoryItem, WorkflowInstance
@api_view(['GET']) @api_view(['GET'])
@ -30,6 +30,16 @@ def get_item_files(request, item_id):
def post_item_file(request, item_id): def post_item_file(request, item_id):
try: try:
item = InventoryItem.objects.get(id=item_id, owner=request.user) 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) serializer = FileSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
file = serializer.save() file = serializer.save()
@ -40,6 +50,31 @@ def post_item_file(request, item_id):
return Response(status=status.HTTP_404_NOT_FOUND) 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']) @api_view(['POST', 'GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal]) @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) item = InventoryItem.objects.get(id=item_id, owner=request.user)
file = item.files.get(id=file_id) file = item.files.get(id=file_id)
item.files.remove(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() file.delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
except InventoryItem.DoesNotExist: 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) 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 = [ urlpatterns = [
path('files/', list_all_files), path('files/', list_all_files),
path('item_files/<int:item_id>/', item_files), path('item_files/<int:item_id>/', item_files),
path('item_files/<int:item_id>/<int:file_id>/', delete_item_file), path('item_files/<int:item_id>/<int:file_id>/', delete_item_file),
path('staged_files/<int:workflow_id>/', staged_files),
path('staged_files/<int:workflow_id>/<str:file_hash>/', delete_staged_file),
] ]

View file

@ -7,6 +7,7 @@ from rest_framework.response import Response
from authentication.models import ToolshedUser, KnownIdentity from authentication.models import ToolshedUser, KnownIdentity
from authentication.signature_auth import SignatureAuthentication from authentication.signature_auth import SignatureAuthentication
from files.models import File
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
@ -120,7 +121,13 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
def perform_destroy(self, instance): def perform_destroy(self, instance):
if instance.owner == self.request.user.user.get(): if instance.owner == self.request.user.user.get():
staged_file_ids = list(instance.staged_files.values_list('id', flat=True))
instance.delete() 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') router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')

View file

@ -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',
),
]

View file

@ -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'),
),
]

View file

@ -135,14 +135,15 @@ class StorageLocation(models.Model):
class WorkflowInstance(models.Model): class WorkflowInstance(models.Model):
name = models.CharField(max_length=255) slug = models.CharField(max_length=255)
state = 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. 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') 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.state})" return f"{self.slug} ({self.state})"

View file

@ -284,14 +284,15 @@ def delete_user_account(user):
def _delete_orphaned_files(file_ids): def _delete_orphaned_files(file_ids):
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced. """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) A File is considered orphaned once no InventoryItem, no ToolshedUser (profile picture), and no
references it anymore. Returns the number of files deleted. WorkflowInstance (staged file) references it anymore. Returns the number of files deleted.
""" """
from files.models import File from files.models import File
deleted = 0 deleted = 0
for file_obj in File.objects.filter(id__in=file_ids): 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 continue
file_obj.file.delete(save=False) file_obj.file.delete(save=False)
file_obj.delete() file_obj.delete()

View file

@ -203,9 +203,18 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class WorkflowInstanceSerializer(serializers.ModelSerializer): class WorkflowInstanceSerializer(serializers.ModelSerializer):
owner = serializers.StringRelatedField(read_only=True) 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: class Meta:
model = WorkflowInstance model = WorkflowInstance
fields = ['id', 'name', 'state', 'payload', 'owner', 'created_at', 'updated_at'] fields = ['id', 'slug', 'state', 'payload', 'owner', 'staged_files', 'created_at', 'updated_at']
read_only_fields = ['owner', '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))

View file

@ -1,48 +1,49 @@
version: '3.8' version: '3.8'
name: dev
services: services:
backend-a: backend-a:
build: build:
context: ../backend/ context: ../../backend/
dockerfile: ../deploy/dev/Dockerfile.backend dockerfile: ../deploy/dev/Dockerfile.backend
environment: environment:
TOOLSHED_DB_PATH: /mnt/db.sqlite3 TOOLSHED_DB_PATH: /mnt/db.sqlite3
TOOLSHED_USERFILES_PATH: /mnt/userfiles TOOLSHED_USERFILES_PATH: /mnt/userfiles
TOOLSHED_SETUP_PATH: /mnt/testdata.py TOOLSHED_SETUP_PATH: /mnt/testdata.py
volumes: volumes:
- ../backend:/code - ../../backend:/code
- ../deploy/dev/instance_a/a.env:/code/.env - ./instance_a/a.env:/code/.env
- ../deploy/dev/instance_a/testdata.py:/mnt/testdata.py - ./instance_a/testdata.py:/mnt/testdata.py
- ../deploy/dev/instance_a/a.sqlite3:/mnt/db.sqlite3 - ./instance_a/a.sqlite3:/mnt/db.sqlite3
- ../deploy/dev/instance_a/userfiles:/mnt/userfiles - ./instance_a/userfiles:/mnt/userfiles
expose: expose:
- 8000 - 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
backend-b: backend-b:
build: build:
context: ../backend/ context: ../../backend/
dockerfile: ../deploy/dev/Dockerfile.backend dockerfile: ../deploy/dev/Dockerfile.backend
environment: environment:
TOOLSHED_DB_PATH: /mnt/db.sqlite3 TOOLSHED_DB_PATH: /mnt/db.sqlite3
TOOLSHED_USERFILES_PATH: /mnt/userfiles TOOLSHED_USERFILES_PATH: /mnt/userfiles
TOOLSHED_SETUP_PATH: /mnt/testdata.py TOOLSHED_SETUP_PATH: /mnt/testdata.py
volumes: volumes:
- ../backend:/code - ../../backend:/code
- ../deploy/dev/instance_b/b.env:/code/.env - ./instance_b/b.env:/code/.env
- ../deploy/dev/instance_b/testdata.py:/mnt/testdata.py - ./instance_b/testdata.py:/mnt/testdata.py
- ../deploy/dev/instance_b/b.sqlite3:/mnt/db.sqlite3 - ./instance_b/b.sqlite3:/mnt/db.sqlite3
- ../deploy/dev/instance_b/userfiles:/mnt/userfiles - ./instance_b/userfiles:/mnt/userfiles
expose: expose:
- 8000 - 8000
command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure" command: bash -c "python configure.py; python configure.py testdata; python manage.py runserver 0.0.0.0:8000 --insecure"
frontend: frontend:
build: build:
context: ../frontend/ context: ../../frontend/
dockerfile: ../deploy/dev/Dockerfile.frontend dockerfile: ../deploy/dev/Dockerfile.frontend
volumes: volumes:
- ../frontend:/app - ../../frontend:/app
- /app/node_modules - /app/node_modules
expose: expose:
- 5173 - 5173
@ -50,11 +51,11 @@ services:
wiki: wiki:
build: build:
context: ../ context: ../../
dockerfile: deploy/dev/Dockerfile.wiki dockerfile: deploy/dev/Dockerfile.wiki
volumes: volumes:
- ../mkdocs.yml:/wiki/mkdocs.yml - ../../mkdocs.yml:/wiki/mkdocs.yml
- ../docs:/wiki/docs - ../../docs:/wiki/docs
expose: expose:
- 8001 - 8001
command: mkdocs serve --dev-addr=0.0.0.0:8001 command: mkdocs serve --dev-addr=0.0.0.0:8001
@ -62,12 +63,12 @@ services:
proxy-a: proxy-a:
build: build:
context: ./ context: ./
dockerfile: dev/Dockerfile.proxy dockerfile: Dockerfile.proxy
volumes: volumes:
- ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro - ./instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
- ./dev/instance_a/dns.json:/var/www/dns.json:ro - ./instance_a/dns.json:/var/www/dns.json:ro
- ./dev/instance_a/domains.json:/var/www/domains.json:ro - ./instance_a/domains.json:/var/www/domains.json:ro
- ./dev/instance_a/userfiles:/var/www/userfiles:ro - ./instance_a/userfiles:/var/www/userfiles:ro
ports: ports:
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
- "127.0.0.3:5353:5353" - "127.0.0.3:5353:5353"
@ -75,19 +76,19 @@ services:
proxy-b: proxy-b:
build: build:
context: ./ context: ./
dockerfile: dev/Dockerfile.proxy dockerfile: Dockerfile.proxy
volumes: volumes:
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro - ./instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
- ./dev/instance_b/userfiles:/var/www/userfiles:ro - ./instance_b/userfiles:/var/www/userfiles:ro
ports: ports:
- "127.0.0.2:8080:8080" - "127.0.0.2:8080:8080"
dns: dns:
build: build:
context: ./dev/ context: ./
dockerfile: Dockerfile.dns dockerfile: Dockerfile.dns
volumes: volumes:
- ./dev/zone.json:/dns/zone.json - ./zone.json:/dns/zone.json
expose: expose:
- 8053 - 8053
networks: networks:

View file

@ -1,6 +1,8 @@
events {} events {}
http { http {
client_max_body_size 128M;
upstream backend { upstream backend {
server backend-a:8000; server backend-a:8000;
} }

View file

@ -1,6 +1,8 @@
events {} events {}
http { http {
client_max_body_size 128M;
upstream backend { upstream backend {
server backend-b:8000; server backend-b:8000;
} }

2
deploy/prod/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.secrets/
inventory.yml

View file

@ -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

View file

@ -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"]

View file

@ -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/"]

View file

@ -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/"]

205
deploy/prod/README.md Normal file
View file

@ -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.<handle domain>.` (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 <your-web-domain> A
```
**b) SRV record — handle domain → web domain + port.** Use port 443: the
federation protocol is HTTPS-only.
```sh
dig _toolshed-server._tcp.<your-handle-domain> 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/<inventory-hostname>_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 <that-host>`):
- **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=<branch/tag/commit>`
for a one-off deploy of something else.

View file

@ -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

539
deploy/prod/playbook.yml Normal file
View file

@ -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

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="wrapper"> <div class="wrapper">
<Sidebar/> <Sidebar/>
<div class="main"> <div class="main" id="page">
<nav class="navbar navbar-expand navbar-light navbar-bg"> <nav class="navbar navbar-expand navbar-light navbar-bg">
<a class="sidebar-toggle d-flex" @click="toggleSidebar"> <a class="sidebar-toggle d-flex" @click="toggleSidebar">
<i class="hamburger align-self-center"></i> <i class="hamburger align-self-center"></i>
@ -55,6 +55,7 @@ export default {
toggleSidebar() { toggleSidebar() {
closeAllDropdowns(); closeAllDropdowns();
document.getElementById("sidebar").classList.toggle("collapsed"); document.getElementById("sidebar").classList.toggle("collapsed");
document.getElementById("page").classList.toggle("expanded");
}, },
}, },
} }

View file

@ -30,14 +30,20 @@ export default {
const jobs = [...files].map((file) => { const jobs = [...files].map((file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
var reader = new FileReader(); var reader = new FileReader();
reader.onload = () => { reader.onload = async () => {
const buffer = reader.result; const buffer = reader.result;
if (!(buffer instanceof ArrayBuffer)) { if (!(buffer instanceof ArrayBuffer)) {
console.log(buffer) console.log(buffer)
reject("Not an ArrayBuffer"); reject("Not an ArrayBuffer");
return;
} }
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); // SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later be
// used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
var base64 = btoa( var base64 = btoa(
data.reduce((a, b) => a + String.fromCharCode(b), '') data.reduce((a, b) => a + String.fromCharCode(b), '')
); );

View file

@ -53,14 +53,20 @@ export default {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let reader = new FileReader(); let reader = new FileReader();
reader.readAsArrayBuffer(file) reader.readAsArrayBuffer(file)
reader.onloadend = () => { reader.onloadend = async () => {
const buffer = reader.result; const buffer = reader.result;
if (!(buffer instanceof ArrayBuffer)) { if (!(buffer instanceof ArrayBuffer)) {
console.log(buffer) console.log(buffer)
reject("Not an ArrayBuffer"); reject("Not an ArrayBuffer");
return;
} }
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); // SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later
// be used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
var base64 = btoa( var base64 = btoa(
data.reduce((a, b) => a + String.fromCharCode(b), '') data.reduce((a, b) => a + String.fromCharCode(b), '')
); );

View file

@ -33,14 +33,20 @@ export default {
const jobs = [...files].map((file) => { const jobs = [...files].map((file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
var reader = new FileReader(); var reader = new FileReader();
reader.onload = () => { reader.onload = async () => {
const buffer = reader.result; const buffer = reader.result;
if (!(buffer instanceof ArrayBuffer)) { if (!(buffer instanceof ArrayBuffer)) {
console.log(buffer) console.log(buffer)
reject("Not an ArrayBuffer"); reject("Not an ArrayBuffer");
return;
} }
const data = new Uint8Array(buffer); const data = new Uint8Array(buffer);
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); // SHA-256 via Web Crypto - must match the backend's own content hash
// (files/models.py, hashlib.sha256) so a hash computed here can later be
// used to identify the same File row server-side without a mismatch.
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
var base64 = btoa( var base64 = btoa(
data.reduce((a, b) => a + String.fromCharCode(b), '') data.reduce((a, b) => a + String.fromCharCode(b), '')
); );

View file

@ -171,11 +171,17 @@ export default {
this.dataImage = undefined; this.dataImage = undefined;
this.open(); this.open();
}, },
save() { async save() {
const mimeType = this.dataImage.split(';')[0].split(':')[1]; const mimeType = this.dataImage.split(';')[0].split(':')[1];
const data = this.dataImage.split(',')[1]; const data = this.dataImage.split(',')[1];
const raw_data = atob(data); const raw_data = atob(data);
const hash = nacl.crypto_hash(raw_data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), ""); // SHA-256 via Web Crypto - must match the backend's own content hash (files/models.py,
// hashlib.sha256) so a hash computed here can later be used to identify the same File
// row server-side without a mismatch.
const bytes = Uint8Array.from(raw_data, c => c.charCodeAt(0));
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
const hash = Array.from(new Uint8Array(hashBuffer))
.map(b => b.toString(16).padStart(2, "0")).join("");
const image = { const image = {
name: hash.slice(0, 12) + ".jpg", name: hash.slice(0, 12) + ".jpg",
size: raw_data.length, size: raw_data.length,

View file

@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
export default { export default {
name: 'BackupRestoreWorkflow', name: 'BackupRestoreWorkflow',
meta: { meta: {
id: 'backup-restore', slug: 'backup-restore',
name: 'Data Backup', title: 'Data Backup',
category: 'System Maintenance', category: 'System Maintenance',
description: 'Create a comprehensive backup of your inventory and settings data.', description: 'Create a comprehensive backup of your inventory and settings data.',
icons: ['b-icon-gear', 'b-icon-download'], icons: ['b-icon-gear', 'b-icon-download'],

View file

@ -279,8 +279,8 @@ export default {
// `@/workflows.js` (via `Component.meta`) to assemble the catalog used // `@/workflows.js` (via `Component.meta`) to assemble the catalog used
// by the Workflows and WorkflowDetail views. // by the Workflows and WorkflowDetail views.
meta: { meta: {
id: 'import-items', slug: 'import-items',
name: 'Bulk Item Import', title: 'Bulk Item Import',
category: 'Data Management', category: 'Data Management',
description: 'Import multiple inventory items from CSV or Excel files with validation.', description: 'Import multiple inventory items from CSV or Excel files with validation.',
icons: ['b-icon-upload', 'b-icon-file-earmark-spreadsheet', 'b-icon-list-check'], icons: ['b-icon-upload', 'b-icon-file-earmark-spreadsheet', 'b-icon-list-check'],

View file

@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
export default { export default {
name: 'ExpiryCheckWorkflow', name: 'ExpiryCheckWorkflow',
meta: { meta: {
id: 'expiry-check', slug: 'expiry-check',
name: 'Expiry Date Check', title: 'Expiry Date Check',
category: 'Quality Control', category: 'Quality Control',
description: 'Identify and handle items approaching or past their expiry dates.', description: 'Identify and handle items approaching or past their expiry dates.',
icons: ['b-icon-clock-history', 'b-icon-exclamation-triangle'], icons: ['b-icon-clock-history', 'b-icon-exclamation-triangle'],

View file

@ -2,78 +2,36 @@
<div class="foto-first-workflow"> <div class="foto-first-workflow">
<!-- Step 1: Photo Capture --> <!-- Step 1: Photo Capture -->
<div v-if="step === '1'" class="foto-first-step-1"> <div v-if="step === '1'" class="foto-first-step-1">
<div class="step-header mb-4">
<h4 class="mb-2">Photo Capture</h4>
<p class="text-muted">Capture or upload item photos to begin the import process.</p>
</div>
<div class="upload-area mb-4"> <div class="staging-area mb-4">
<div class="row"> <drag-drop-file-source @input="addStagedFiles">
<!-- Camera Capture --> <div class="card">
<div class="col-md-6 mb-3"> <div class="card-body text-center">
<div class="card h-100"> <b-icon-upload class="text-primary mb-2" style="font-size: 2.5rem;"></b-icon-upload>
<div class="card-body text-center"> <p class="text-muted small mb-3">Drag and drop photos here, or add them below</p>
<b-icon-camera class="text-primary mb-3" style="font-size: 3rem;"></b-icon-camera> <div class="d-flex justify-content-center gap-2">
<h6>Camera Capture</h6> <fs-file-source @input="addStagedFiles">
<p class="text-muted small">Use your device camera to capture photos</p> <span class="btn btn-outline-success">
<button class="btn btn-primary" @click="startCamera" :disabled="loadingCamera"> <b-icon-upload class="me-1"></b-icon-upload>
<b-icon-camera class="me-1"></b-icon-camera> Upload Files
Start Camera </span>
</button> </fs-file-source>
<camera-file-source @input="addStagedFiles">
<span class="btn btn-outline-primary">
<b-icon-camera class="me-1"></b-icon-camera>
Camera
</span>
</camera-file-source>
<webcam-file-source @input="addStagedFiles">
<span class="btn btn-outline-primary">
<b-icon-camera-video class="me-1"></b-icon-camera-video>
Webcam
</span>
</webcam-file-source>
</div> </div>
</div> </div>
</div> </div>
</drag-drop-file-source>
<!-- File Upload -->
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<b-icon-upload class="text-success mb-3" style="font-size: 3rem;"></b-icon-upload>
<h6>File Upload</h6>
<p class="text-muted small">Upload photos from your device</p>
<input
type="file"
ref="fileInput"
multiple
accept="image/*"
@change="handleFileUpload"
class="d-none"
/>
<button class="btn btn-success" @click="$refs.fileInput.click()" :disabled="loadingCamera">
<b-icon-upload class="me-1"></b-icon-upload>
Upload Photos
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Camera Preview -->
<div v-if="showCamera" class="camera-section mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Camera Preview</h6>
<button class="btn btn-sm btn-outline-secondary" @click="stopCamera">
<b-icon-x></b-icon-x>
</button>
</div>
<div class="card-body">
<div class="camera-container text-center">
<video ref="video" autoplay muted class="camera-preview mb-3"></video>
<div>
<button class="btn btn-primary me-2" @click="capturePhoto" :disabled="!cameraReady">
<b-icon-camera class="me-1"></b-icon-camera>
Capture Photo
</button>
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop class="me-1"></b-icon-stop>
Stop Camera
</button>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Photo Gallery --> <!-- Photo Gallery -->
@ -88,7 +46,15 @@
<div class="row"> <div class="row">
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3"> <div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card"> <div class="card">
<img :src="photo.preview" class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`"> <div class="photo-thumb-wrap">
<transition name="photo-wipe">
<img v-if="photo.dataUrl && !photo.uploaded" key="local" :src="photo.dataUrl"
class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
<authenticated-image v-else key="remote" :src="thumbnailPathForHash(photo.hash)"
:owner="user" img-class="card-img-top photo-thumbnail"
:alt="`Photo ${index + 1}`"/>
</transition>
</div>
<div class="card-body p-2"> <div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Photo {{ index + 1 }}</small> <small class="text-muted">Photo {{ index + 1 }}</small>
@ -108,7 +74,7 @@
<button <button
class="btn btn-primary" class="btn btn-primary"
@click="proceedFromStep1" @click="proceedFromStep1"
:disabled="photos.length === 0 || loadingCamera" :disabled="photos.length === 0"
> >
Next: Process Images Next: Process Images
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right> <b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
@ -223,7 +189,8 @@
<div class="row"> <div class="row">
<div v-for="(image, index) in processedImages" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3"> <div v-for="(image, index) in processedImages" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card"> <div class="card">
<img :src="image.processedUrl" class="card-img-top processed-thumbnail" :alt="`Processed ${index + 1}`"> <img :src="image.processedUrl" class="card-img-top processed-thumbnail"
:alt="`Processed ${index + 1}`">
<div class="card-body p-2"> <div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">{{ image.name }}</small> <small class="text-muted">{{ image.name }}</small>
@ -233,10 +200,13 @@
</div> </div>
<div class="processing-info"> <div class="processing-info">
<small class="text-muted d-block"> <small class="text-muted d-block">
{{ formatFileSize(image.originalSize) }} {{ formatFileSize(image.processedSize) }} {{ formatFileSize(image.originalSize) }}
{{ formatFileSize(image.processedSize) }}
</small> </small>
<small class="text-success"> <small class="text-success">
{{ Math.round(((image.originalSize - image.processedSize) / image.originalSize) * 100) }}% reduced {{
Math.round(((image.originalSize - image.processedSize) / image.originalSize) * 100)
}}% reduced
</small> </small>
</div> </div>
</div> </div>
@ -299,7 +269,8 @@
<!-- Image Preview --> <!-- Image Preview -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card"> <div class="card">
<img :src="currentItem.processedUrl || currentItem.preview" class="card-img-top item-image" alt="Current item"> <img :src="currentItem.processedUrl || currentItem.dataUrl" class="card-img-top item-image"
alt="Current item">
<div class="card-body p-2"> <div class="card-body p-2">
<small class="text-muted">{{ currentItem.name }}</small> <small class="text-muted">{{ currentItem.name }}</small>
</div> </div>
@ -476,9 +447,11 @@
</div> </div>
<div v-if="showCompleted" class="card-body"> <div v-if="showCompleted" class="card-body">
<div class="row"> <div class="row">
<div v-for="(item, index) in completedItems" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-2"> <div v-for="(item, index) in completedItems" :key="index"
class="col-sm-6 col-md-4 col-lg-3 mb-2">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<img :src="item.image.processedUrl || item.image.preview" class="completed-item-thumb me-2" alt="Item"> <img :src="item.image.processedUrl || item.image.dataUrl"
class="completed-item-thumb me-2" alt="Item">
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="fw-bold small">{{ item.details.name }}</div> <div class="fw-bold small">{{ item.details.name }}</div>
<div class="text-muted small">{{ item.details.category || 'No category' }}</div> <div class="text-muted small">{{ item.details.category || 'No category' }}</div>
@ -563,7 +536,8 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div v-if="Object.keys(categoryBreakdown).length > 0" class="row"> <div v-if="Object.keys(categoryBreakdown).length > 0" class="row">
<div v-for="(count, category) in categoryBreakdown" :key="category" class="col-sm-6 col-md-4 col-lg-3 mb-2"> <div v-for="(count, category) in categoryBreakdown" :key="category"
class="col-sm-6 col-md-4 col-lg-3 mb-2">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span class="text-capitalize">{{ category || 'Uncategorized' }}</span> <span class="text-capitalize">{{ category || 'Uncategorized' }}</span>
<span class="badge bg-secondary">{{ count }}</span> <span class="badge bg-secondary">{{ count }}</span>
@ -663,15 +637,19 @@
<div class="card-body"> <div class="card-body">
<!-- Grid View --> <!-- Grid View -->
<div v-if="viewMode === 'grid'" class="row"> <div v-if="viewMode === 'grid'" class="row">
<div v-for="(item, index) in completedItems" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3"> <div v-for="(item, index) in completedItems" :key="index"
class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card h-100"> <div class="card h-100">
<img :src="item.image.processedUrl || item.image.preview" class="card-img-top item-thumb" :alt="item.details.name"> <img :src="item.image.processedUrl || item.image.dataUrl"
class="card-img-top item-thumb" :alt="item.details.name">
<div class="card-body p-2"> <div class="card-body p-2">
<h6 class="card-title mb-1">{{ item.details.name }}</h6> <h6 class="card-title mb-1">{{ item.details.name }}</h6>
<p class="card-text small text-muted mb-1">{{ item.details.category || 'No category' }}</p> <p class="card-text small text-muted mb-1">
{{ item.details.category || 'No category' }}</p>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Qty: {{ item.details.quantity }}</small> <small class="text-muted">Qty: {{ item.details.quantity }}</small>
<small v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</small> <small v-if="item.details.estimated_value"
class="text-success">${{ item.details.estimated_value }}</small>
</div> </div>
</div> </div>
</div> </div>
@ -682,32 +660,35 @@
<div v-else class="table-responsive"> <div v-else class="table-responsive">
<table class="table table-sm"> <table class="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Image</th> <th>Image</th>
<th>Name</th> <th>Name</th>
<th>Category</th> <th>Category</th>
<th>Quantity</th> <th>Quantity</th>
<th>Location</th> <th>Location</th>
<th>Value</th> <th>Value</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(item, index) in completedItems" :key="index"> <tr v-for="(item, index) in completedItems" :key="index">
<td> <td>
<img :src="item.image.processedUrl || item.image.preview" class="list-item-thumb" :alt="item.details.name"> <img :src="item.image.processedUrl || item.image.dataUrl"
</td> class="list-item-thumb" :alt="item.details.name">
<td class="fw-bold">{{ item.details.name }}</td> </td>
<td> <td class="fw-bold">{{ item.details.name }}</td>
<span v-if="item.details.category" class="badge bg-light text-dark">{{ item.details.category }}</span> <td>
<span v-else class="text-muted">-</span> <span v-if="item.details.category"
</td> class="badge bg-light text-dark">{{ item.details.category }}</span>
<td>{{ item.details.quantity }} {{ item.details.unit }}</td> <span v-else class="text-muted">-</span>
<td>{{ item.details.location || '-' }}</td> </td>
<td> <td>{{ item.details.quantity }} {{ item.details.unit }}</td>
<span v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</span> <td>{{ item.details.location || '-' }}</td>
<span v-else class="text-muted">-</span> <td>
</td> <span v-if="item.details.estimated_value"
</tr> class="text-success">${{ item.details.estimated_value }}</span>
<span v-else class="text-muted">-</span>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -722,7 +703,8 @@
<b-icon-check-circle class="text-success mb-3" style="font-size: 3rem;"></b-icon-check-circle> <b-icon-check-circle class="text-success mb-3" style="font-size: 3rem;"></b-icon-check-circle>
<h5 class="mb-3">Ready to Complete Import</h5> <h5 class="mb-3">Ready to Complete Import</h5>
<p class="text-muted mb-4"> <p class="text-muted mb-4">
All {{ finalTotalItems }} items have been processed and are ready to be added to your inventory. All {{ finalTotalItems }} items have been processed and are ready to be added to your
inventory.
This action cannot be undone. This action cannot be undone.
</p> </p>
<div class="d-flex justify-content-center gap-3"> <div class="d-flex justify-content-center gap-3">
@ -755,38 +737,30 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {mapActions, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
import FsFileSource from "@/components/inputs/FsFileSource.vue";
import WebcamFileSource from "@/components/inputs/WebcamFileSource.vue";
/**
* Foto First Bulk Import Workflow
*
* This single component implements every step of the 'foto-first-bulk-import'
* workflow (photo capture, image processing, item detail entry and import
* completion). Keeping the whole workflow in one file avoids splitting
* closely related state (photos, processed images, completed items) across
* many small step components and their prop/emit boundaries.
*/
export default { export default {
name: 'FotoFirstBulkImportWorkflow', name: 'FotoFirstBulkImportWorkflow',
// Metadata describing this workflow, co-located with its implementation
// so there is a single source of truth per workflow type. Consumed by
// `@/workflows.js` (via `Component.meta`) to assemble the catalog used
// by the Workflows and WorkflowDetail views.
meta: { meta: {
id: 'foto-first-bulk-import', slug: 'foto-first-bulk-import',
name: 'Foto First Bulk Import', title: 'Foto First Bulk Import',
category: 'Data Management', category: 'Data Management',
description: 'Capture unlimited photos via mobile camera or upload images, then sequentially enter details for each item.', description: 'Capture unlimited photos via mobile camera or upload images, then sequentially enter details for each item.',
icons: ['b-icon-camera', 'b-icon-pencil-square'], icons: ['b-icon-camera', 'b-icon-pencil-square'],
estimatedDuration: '10-60 minutes', estimatedDuration: '10-60 minutes',
stepDefinitions: [ stepDefinitions: [
{ step: '1', name: 'Photo Capture', description: 'Capture or upload item photos' }, {step: '1', name: 'Photo Capture', description: 'Capture or upload item photos'},
{ step: '2', name: 'Image Processing', description: 'Process and optimize images' }, {step: '2', name: 'Image Processing', description: 'Process and optimize images'},
{ step: '3', name: 'Item Details Entry', description: 'Enter details for each photographed item' }, {step: '3', name: 'Item Details Entry', description: 'Enter details for each photographed item'},
{ step: '4', name: 'Import Completion', description: 'Finalize and save imported items' } {step: '4', name: 'Import Completion', description: 'Finalize and save imported items'}
], ],
getInitialPayload() { getInitialPayload() {
return { return {
photos: [],
processing_options: { processing_options: {
auto_rotate: true, auto_rotate: true,
compress: true, compress: true,
@ -797,6 +771,11 @@ export default {
} }
}, },
components: { components: {
WebcamFileSource,
AuthenticatedImage,
DragDropFileSource,
CameraFileSource,
FsFileSource,
...BIcons ...BIcons
}, },
props: { props: {
@ -816,11 +795,7 @@ export default {
data() { data() {
return { return {
// Step 1: photo capture // Step 1: photo capture
loadingCamera: false,
showCamera: false,
cameraReady: false,
photos: [], photos: [],
stream: null,
// Step 2: image processing // Step 2: image processing
processing: false, processing: false,
@ -852,6 +827,7 @@ export default {
} }
}, },
computed: { computed: {
...mapState(['user']),
// Step 1/2 // Step 1/2
totalPhotos() { totalPhotos() {
return this.photos.length; return this.photos.length;
@ -905,7 +881,6 @@ export default {
this.loadFromPayload(); this.loadFromPayload();
}, },
beforeUnmount() { beforeUnmount() {
this.stopCamera();
this.processedImages.forEach(image => { this.processedImages.forEach(image => {
if (image.processedUrl && image.processedUrl.startsWith('blob:')) { if (image.processedUrl && image.processedUrl.startsWith('blob:')) {
URL.revokeObjectURL(image.processedUrl); URL.revokeObjectURL(image.processedUrl);
@ -913,108 +888,108 @@ export default {
}); });
}, },
methods: { methods: {
...mapActions(['stageFile', 'unstageFile']),
loadFromPayload() { loadFromPayload() {
if (this.payload.photos) this.photos = [...this.payload.photos]; // `photos`' durable state is the WorkflowInstance.staged_files relation itself (kept
// in sync directly by stageFile()/unstageFile(), not by writing to payload) - so it's
// seeded from the prop, not from payload. Entries restored this way have no local
// bytes yet (this session never uploaded them), so `dataUrl` stays null - the gallery
// falls back to fetching a thumbnail by hash via AuthenticatedImage (see below).
this.photos = (this.workflowInstance.staged_files || []).map(hash => ({
hash,
name: null,
size: null,
mime_type: null,
dataUrl: null,
uploaded: true,
timestamp: null
}));
if (this.payload.processing_options) { if (this.payload.processing_options) {
this.processingOptions = { ...this.processingOptions, ...this.payload.processing_options }; this.processingOptions = {...this.processingOptions, ...this.payload.processing_options};
} }
if (this.payload.processed_images) { if (this.payload.processed_images) {
this.processedImages = [...this.payload.processed_images]; this.processedImages = [...this.payload.processed_images];
this.processedCount = this.processedImages.length; this.processedCount = this.processedImages.length;
} }
if (this.payload.completed_items) this.completedItems = [...this.payload.completed_items]; if (this.payload.completed_items) this.completedItems = [...this.payload.completed_items];
if (this.payload.current_item_details) this.currentItemDetails = { ...this.payload.current_item_details }; if (this.payload.current_item_details) this.currentItemDetails = {...this.payload.current_item_details};
if (this.payload.current_item_index !== undefined) this.currentItemIndex = this.payload.current_item_index; if (this.payload.current_item_index !== undefined) this.currentItemIndex = this.payload.current_item_index;
if (this.payload.import_options) this.importOptions = { ...this.importOptions, ...this.payload.import_options }; if (this.payload.import_options) this.importOptions = {...this.importOptions, ...this.payload.import_options};
}, },
// --- Step 1: Photo capture --- // --- Step 1: Photo capture ---
async startCamera() { thumbnailPathForHash(hash, size = 256) {
try { // files/media_urls.py's thumbnail_urls generates (and disk-caches) a resized JPEG
this.loadingCamera = true; // on first request - a gallery card only needs a small image, not the full-size
this.stream = await navigator.mediaDevices.getUserMedia({ // original. Looked up by the derived storage path, mirroring hash_upload()
video: { facingMode: 'environment' } // (files/models.py) - matches how FileSerializer.name already builds file URLs
}); // elsewhere in the app (e.g. AuthenticatedImage's `src` for item files).
this.$refs.video.srcObject = this.stream; return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
this.showCamera = true;
this.cameraReady = true;
} catch (error) {
console.error('Error accessing camera:', error);
alert('Could not access camera. Please check permissions or use file upload instead.');
} finally {
this.loadingCamera = false;
}
}, },
stopCamera() { async addStagedFiles(files) {
if (this.stream) { const new_files = files.filter(file => !this.photos.find(photo => photo.hash === file.hash));
this.stream.getTracks().forEach(track => track.stop()); if (new_files.length === 0) return;
this.stream = null;
}
this.showCamera = false;
this.cameraReady = false;
},
capturePhoto() { const staged = new_files.map(file => ({
if (!this.cameraReady) return; name: file.name,
size: file.size,
mime_type: file.mime_type,
hash: file.hash, // SHA-256, same algorithm the backend hashes File content with
data: file.data,
dataUrl: `data:${file.mime_type};base64,${file.data}`,
uploaded: false,
timestamp: new Date().toISOString()
}));
this.photos.push(...staged);
const canvas = document.createElement('canvas'); // Persist each photo server-side right away, keyed to this workflow instance, so it
const video = this.$refs.video; // survives a reload or a switch to another device. WorkflowInstance.staged_files is
canvas.width = video.videoWidth; // the durable record of this - nothing about photos needs to go into payload too.
canvas.height = video.videoHeight; await Promise.all(staged.map(async ({hash, data, mime_type}) => {
try {
const ctx = canvas.getContext('2d'); await this.stageFile({
ctx.drawImage(video, 0, 0); lifetime_id: this.workflowInstance.id,
file: {data, mime_type}
canvas.toBlob(blob => { });
const photo = { // Once persisted, the gallery can show the server-fetched thumbnail instead
file: blob, // of the local dataUrl (kept around for step 2's client-side processing).
preview: URL.createObjectURL(blob), // Re-lookup by hash rather than mutating the closed-over `photo` object -
name: `camera-photo-${Date.now()}.jpg`, // that reference predates this.photos.push() above, so it's the raw object,
timestamp: new Date().toISOString() // not the reactive proxy Vue tracks; writing to it wouldn't trigger a
}; // re-render.
this.photos.push(photo); const photo = this.photos.find(p => p.hash === hash);
this.updatePhotosPayload(); if (photo) photo.uploaded = true;
}, 'image/jpeg', 0.8); } catch (error) {
}, console.error('Failed to stage photo:', error);
this.photos = this.photos.filter(p => p.hash !== hash);
handleFileUpload(event) {
const files = Array.from(event.target.files);
files.forEach(file => {
if (file.type.startsWith('image/')) {
const photo = {
file: file,
preview: URL.createObjectURL(file),
name: file.name,
timestamp: new Date().toISOString()
};
this.photos.push(photo);
} }
}); }));
this.updatePhotosPayload();
event.target.value = '';
}, },
removePhoto(index) { async removePhoto(index) {
URL.revokeObjectURL(this.photos[index].preview); const [photo] = this.photos.splice(index, 1);
this.photos.splice(index, 1); if (photo) {
this.updatePhotosPayload(); try {
}, await this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash});
} catch (error) {
clearAllPhotos() { console.error('Failed to unstage photo:', error);
if (confirm('Are you sure you want to remove all photos?')) { }
this.photos.forEach(photo => URL.revokeObjectURL(photo.preview));
this.photos = [];
this.updatePhotosPayload();
} }
}, },
updatePhotosPayload() { async clearAllPhotos() {
this.$emit('update', { photos: this.photos }); if (confirm('Are you sure you want to remove all photos?')) {
const removed = this.photos;
this.photos = [];
await Promise.all(removed.map(photo =>
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
.catch(error => console.error('Failed to unstage photo:', error))
));
}
}, },
proceedFromStep1() { proceedFromStep1() {
this.updatePhotosPayload();
this.$emit('next'); this.$emit('next');
}, },
@ -1060,7 +1035,7 @@ export default {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
// Calculate new dimensions // Calculate new dimensions
let { width, height } = this.calculateDimensions( let {width, height} = this.calculateDimensions(
img.width, img.width,
img.height, img.height,
this.processingOptions.max_width, this.processingOptions.max_width,
@ -1076,7 +1051,7 @@ export default {
canvas.toBlob(blob => { canvas.toBlob(blob => {
const processedImage = { const processedImage = {
name: photo.name, name: photo.name,
originalSize: photo.file.size, originalSize: photo.size,
processedSize: blob.size, processedSize: blob.size,
processedUrl: URL.createObjectURL(blob), processedUrl: URL.createObjectURL(blob),
processedFile: blob, processedFile: blob,
@ -1085,7 +1060,7 @@ export default {
resolve(processedImage); resolve(processedImage);
}, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95); }, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95);
}; };
img.src = photo.preview; img.src = photo.dataUrl;
}); });
}, },
@ -1103,7 +1078,7 @@ export default {
height = maxHeight; height = maxHeight;
} }
return { width: Math.round(width), height: Math.round(height) }; return {width: Math.round(width), height: Math.round(height)};
}, },
formatFileSize(bytes) { formatFileSize(bytes) {
@ -1149,7 +1124,7 @@ export default {
const itemData = { const itemData = {
image: this.currentItem, image: this.currentItem,
details: { ...this.currentItemDetails }, details: {...this.currentItemDetails},
saved_at: new Date().toISOString() saved_at: new Date().toISOString()
}; };
@ -1187,7 +1162,7 @@ export default {
); );
if (existingItem) { if (existingItem) {
this.currentItemDetails = { ...existingItem.details }; this.currentItemDetails = {...existingItem.details};
} else { } else {
this.currentItemDetails = this.getDefaultItemDetails(); this.currentItemDetails = this.getDefaultItemDetails();
} }
@ -1199,7 +1174,7 @@ export default {
const itemIndex = this.availableItems.findIndex(img => img === item.image); const itemIndex = this.availableItems.findIndex(img => img === item.image);
if (itemIndex >= 0) { if (itemIndex >= 0) {
this.currentItemIndex = itemIndex; this.currentItemIndex = itemIndex;
this.currentItemDetails = { ...item.details }; this.currentItemDetails = {...item.details};
} }
}, },
@ -1273,30 +1248,59 @@ export default {
</script> </script>
<style scoped> <style scoped>
.camera-preview {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
}
.photo-thumbnail, .photo-thumbnail,
.item-thumb { .item-thumb {
height: 150px; height: 150px;
object-fit: cover; object-fit: cover;
} }
.upload-area .card { /* Stacks the local dataUrl preview and the server-fetched AuthenticatedImage on top of each
other during their crossfade, instead of one disappearing before the other lays out. */
.photo-thumb-wrap {
position: relative;
height: 150px;
overflow: hidden;
}
.photo-thumb-wrap .photo-thumbnail {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.photo-wipe-enter-active,
.photo-wipe-leave-active {
transition: clip-path 0.5s ease, opacity 0.5s ease;
}
.photo-wipe-enter-from {
clip-path: inset(0 100% 0 0);
opacity: 0.6;
}
.photo-wipe-enter-to {
clip-path: inset(0 0 0 0);
opacity: 1;
}
.photo-wipe-leave-from {
opacity: 1;
}
.photo-wipe-leave-to {
opacity: 0;
}
.staging-area .card {
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
.upload-area .card:hover { .staging-area .card:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
.camera-container {
position: relative;
}
.processed-thumbnail { .processed-thumbnail {
height: 120px; height: 120px;
object-fit: cover; object-fit: cover;
@ -1348,5 +1352,4 @@ export default {
border: 2px solid #28a745; border: 2px solid #28a745;
background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%); background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
} }
</style> </style>

File diff suppressed because it is too large Load diff

View file

@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
export default { export default {
name: 'InventoryAuditWorkflow', name: 'InventoryAuditWorkflow',
meta: { meta: {
id: 'inventory-audit', slug: 'inventory-audit',
name: 'Inventory Audit', title: 'Inventory Audit',
category: 'Inventory Management', category: 'Inventory Management',
description: 'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.', description: 'Perform a complete audit of your inventory items, checking quantities, locations, and conditions.',
icons: ['b-icon-list-ul'], icons: ['b-icon-list-ul'],

View file

@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
export default { export default {
name: 'MaintenanceScheduleWorkflow', name: 'MaintenanceScheduleWorkflow',
meta: { meta: {
id: 'maintenance-schedule', slug: 'maintenance-schedule',
name: 'Maintenance Schedule', title: 'Maintenance Schedule',
category: 'Tool Maintenance', category: 'Tool Maintenance',
description: 'Create and execute maintenance schedules for tools and equipment.', description: 'Create and execute maintenance schedules for tools and equipment.',
icons: ['b-icon-tools', 'b-icon-calendar'], icons: ['b-icon-tools', 'b-icon-calendar'],

View file

@ -60,8 +60,8 @@ import * as BIcons from 'bootstrap-icons-vue';
export default { export default {
name: 'StorageOptimizationWorkflow', name: 'StorageOptimizationWorkflow',
meta: { meta: {
id: 'storage-optimization', slug: 'storage-optimization',
name: 'Storage Optimization', title: 'Storage Optimization',
category: 'Storage Management', category: 'Storage Management',
description: 'Analyze and reorganize storage locations for maximum efficiency and accessibility.', description: 'Analyze and reorganize storage locations for maximum efficiency and accessibility.',
icons: ['b-icon-boxes', 'b-icon-diagram-3', 'b-icon-archive'], icons: ['b-icon-boxes', 'b-icon-diagram-3', 'b-icon-archive'],

View file

@ -137,4 +137,41 @@ body {
background-color: var(--bs-table-bg); background-color: var(--bs-table-bg);
background-image: linear-gradient(var(--bs-table-accent-bg), var(--bs-table-accent-bg)); background-image: linear-gradient(var(--bs-table-accent-bg), var(--bs-table-accent-bg));
border-bottom-width: 1px !important; border-bottom-width: 1px !important;
}
@media (min-width: map-get($grid-breakpoints, xl)) {
.main.expanded {
.col-xl-12-ex {
flex: 0 0 100% !important;
max-width: 100% !important;
}
.col-xl-9-ex {
flex: 0 0 75% !important;
max-width: 75% !important;
}
.d-xl-block-ex {
display: block !important;
}
}
}
@media (min-width: map-get($grid-breakpoints, lg)) {
.main.expanded {
.col-lg-12-ex {
flex: 0 0 100% !important;
max-width: 100% !important;
}
.col-lg-9-ex {
flex: 0 0 75% !important;
max-width: 75% !important;
}
.d-lg-block-ex {
display: block !important;
}
}
} }

View file

@ -488,6 +488,26 @@ export default createStore({
await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/') await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/')
state.files = state.files.filter(file => file.id !== file_id) state.files = state.files.filter(file => file.id !== file_id)
}, },
async stageFile({state, dispatch, getters}, {lifetime_id, file}) {
const servers = await dispatch('getHomeServers')
const data = await servers.post(getters.signAuth, '/api/staged_files/' + lifetime_id + '/', file)
if (data.hash) {
return data.hash
}
},
async unstageFile({state, dispatch, getters}, {lifetime_id, file_hash}) {
const servers = await dispatch('getHomeServers')
await servers.delete(getters.signAuth, '/api/staged_files/' + lifetime_id + '/' + file_hash + '/')
},
async commitStagedFile({state, dispatch, getters}, {item_id, file_hash}) {
const servers = await dispatch('getHomeServers')
const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', {file_hash})
if (data.name) {
data.owner = state.user
state.files.push(data)
return data
}
},
async fetchTags({state, commit, dispatch, getters}) { async fetchTags({state, commit, dispatch, getters}) {
if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) { if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) {
return state.tags return state.tags

View file

@ -10,12 +10,11 @@
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<router-link to="/workflows" class="text-decoration-none">Workflows</router-link> <router-link to="/workflows" class="text-decoration-none">Workflows</router-link>
</li> </li>
<li class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.name || workflowInstance?.name || 'Loading...' }}</li> <li class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.title || workflowInstance?.title || 'Loading...' }}</li>
</ol> </ol>
</nav> </nav>
<h1 class="h3 mb-0">{{ workflowDefinition?.name || workflowInstance?.name || 'Workflow Detail' }}</h1>
</div> </div>
<div class="btn-group" role="group"> <div class="btn-group breadcrumb" role="group">
<button class="btn btn-outline-secondary" @click="$router.go(-1)"> <button class="btn btn-outline-secondary" @click="$router.go(-1)">
<b-icon-arrow-left class="me-1"></b-icon-arrow-left> <b-icon-arrow-left class="me-1"></b-icon-arrow-left>
Back Back
@ -43,8 +42,86 @@
<!-- Workflow Content --> <!-- Workflow Content -->
<div v-else-if="workflowInstance" class="row"> <div v-else-if="workflowInstance" class="row">
<!-- Main Content Area -->
<div class="col-xl-9 col-lg-12 col-lg-9-ex">
<!-- Current Step Content -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0">
{{ currentStepDefinition?.name || `Step ${currentStep}` }}
</h5>
<small class="text-muted">{{ currentStepDefinition?.description }}</small>
</div>
<div class="step-navigation">
<button class="btn btn-sm btn-outline-secondary me-1"
@click="handlePrevStep"
:disabled="!canNavigatePrev">
<b-icon-chevron-left></b-icon-chevron-left>
</button>
<span class="mx-2 small">{{ currentStepIndex + 1 }} / {{ totalSteps }}</span>
<button class="btn btn-sm btn-outline-secondary"
@click="handleNextStep"
:disabled="!canNavigateNext">
<b-icon-chevron-right></b-icon-chevron-right>
</button>
</div>
</div>
<div class="card-body">
<!-- Step-specific content based on workflow type and current step -->
<component
v-if="workflowComponent"
:is="workflowComponent"
:workflow-instance="workflowInstance"
:step="currentStep"
:payload="workflowInstance.payload"
@update="handleStepUpdate"
@next="handleNextStep"
@prev="handlePrevStep"
@complete="completeWorkflow"
/>
<!-- Default step content if no specific component -->
<div v-else class="text-center py-5">
<b-icon-gear class="text-muted mb-3" style="font-size: 3rem;"></b-icon-gear>
<h5 class="text-muted">{{ currentStepDefinition?.name || 'Step Content' }}</h5>
<p class="text-muted">{{ currentStepDefinition?.description || 'This step is in progress.' }}</p>
<!-- Step Navigation Buttons -->
<div class="mt-4">
<button v-if="canNavigatePrev"
class="btn btn-outline-secondary me-2"
@click="handlePrevStep">
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<button v-if="canNavigateNext"
class="btn btn-primary"
@click="handleNextStep">
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
<button v-else-if="currentStepIndex === stepDefinitions.length - 1"
class="btn btn-success"
@click="completeWorkflow">
<b-icon-check-circle class="me-1"></b-icon-check-circle>
Complete Workflow
</button>
</div>
</div>
<!-- Debug Info (only in development) -->
<div v-if="true" class="mt-4 border-top pt-3">
<details>
<summary class="text-muted small">Debug Info</summary>
<pre class="small mt-2">{{ JSON.stringify(workflowInstance, null, 2) }}</pre>
</details>
</div>
</div>
</div>
</div>
<!-- Workflow Progress Sidebar --> <!-- Workflow Progress Sidebar -->
<div class="col-lg-3 mb-4"> <div class="col-lg-3 mb-4 d-none d-xl-block d-lg-block-ex">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h6 class="card-title mb-0">Progress Overview</h6> <h6 class="card-title mb-0">Progress Overview</h6>
@ -113,85 +190,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Main Content Area -->
<div class="col-lg-9">
<!-- Current Step Content -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="card-title mb-0">
{{ currentStepDefinition?.name || `Step ${currentStep}` }}
</h5>
<small class="text-muted">{{ currentStepDefinition?.description }}</small>
</div>
<div class="step-navigation">
<button class="btn btn-sm btn-outline-secondary me-1"
@click="handlePrevStep"
:disabled="!canNavigatePrev">
<b-icon-chevron-left></b-icon-chevron-left>
</button>
<span class="mx-2 small">{{ currentStepIndex + 1 }} / {{ totalSteps }}</span>
<button class="btn btn-sm btn-outline-secondary"
@click="handleNextStep"
:disabled="!canNavigateNext">
<b-icon-chevron-right></b-icon-chevron-right>
</button>
</div>
</div>
<div class="card-body">
<!-- Step-specific content based on workflow type and current step -->
<component
v-if="workflowComponent"
:is="workflowComponent"
:workflow-instance="workflowInstance"
:step="currentStep"
:payload="workflowInstance.payload"
@update="handleStepUpdate"
@next="handleNextStep"
@prev="handlePrevStep"
@complete="completeWorkflow"
/>
<!-- Default step content if no specific component -->
<div v-else class="text-center py-5">
<b-icon-gear class="text-muted mb-3" style="font-size: 3rem;"></b-icon-gear>
<h5 class="text-muted">{{ currentStepDefinition?.name || 'Step Content' }}</h5>
<p class="text-muted">{{ currentStepDefinition?.description || 'This step is in progress.' }}</p>
<!-- Step Navigation Buttons -->
<div class="mt-4">
<button v-if="canNavigatePrev"
class="btn btn-outline-secondary me-2"
@click="handlePrevStep">
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<button v-if="canNavigateNext"
class="btn btn-primary"
@click="handleNextStep">
Next
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
<button v-else-if="currentStepIndex === stepDefinitions.length - 1"
class="btn btn-success"
@click="completeWorkflow">
<b-icon-check-circle class="me-1"></b-icon-check-circle>
Complete Workflow
</button>
</div>
</div>
<!-- Debug Info (only in development) -->
<div v-if="$isDevelopment" class="mt-4 border-top pt-3">
<details>
<summary class="text-muted small">Debug Info</summary>
<pre class="small mt-2">{{ JSON.stringify(workflowInstance, null, 2) }}</pre>
</details>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</main> </main>
@ -217,7 +215,7 @@ export default {
}, },
step: { step: {
type: String, type: String,
default: "initial" default: "1"
} }
}, },
data() { data() {
@ -232,13 +230,17 @@ export default {
...mapState(['active_workflows']), ...mapState(['active_workflows']),
currentStep() { currentStep() {
return this.step || "initial"; return this.step || "1";
}, },
workflowDefinition() { workflowDefinition() {
// `name` doubles as the workflow type identifier (e.g. 'import-items'). // `name` doubles as the workflow type identifier (e.g. 'import-items').
if (!this.workflowInstance?.name) return null; if (!this.workflowInstance?.slug) return null;
return getWorkflow(this.workflowInstance.name); return getWorkflow(this.workflowInstance.slug);
},
getWorkflowDisplayName() {
return getWorkflow(this.workflowInstance.slug)?.title;
}, },
stepDefinitions() { stepDefinitions() {
@ -261,7 +263,7 @@ export default {
// Return the single component implementing the whole workflow, if any, // Return the single component implementing the whole workflow, if any,
// using the workflow component registry. The component itself decides // using the workflow component registry. The component itself decides
// what to render based on the `step` prop it receives. // what to render based on the `step` prop it receives.
const workflowType = this.workflowInstance?.name; const workflowType = this.workflowInstance?.slug;
return workflowType ? getWorkflowComponent(workflowType) : null; return workflowType ? getWorkflowComponent(workflowType) : null;
}, },
@ -506,4 +508,8 @@ export default {
.step-navigation { .step-navigation {
min-width: 120px; min-width: 120px;
} }
.btn-group.breadcrumb{
padding: calc(0.5rem - 1px) 1rem;
}
</style> </style>

View file

@ -16,49 +16,51 @@
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th>Workflow</th> <th>Workflow</th>
<th>Status</th> <th>Status</th>
<th>Progress</th> <th>Progress</th>
<th>Started</th> <th>Started</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="workflow in activeWorkflows" :key="workflow.id"> <tr v-for="workflow in activeWorkflows" :key="workflow.id">
<td> <td>
<strong>{{ getWorkflowDisplayName(workflow) }}</strong> <strong>{{ getWorkflowDisplayName(workflow) }}</strong>
<br> <br>
<small class="text-muted">{{ workflow.payload?.workflow_config?.category || 'System' }}</small> <small class="text-muted">{{
</td> workflow.payload?.workflow_config?.category || 'System'
<td> }}</small>
</td>
<td>
<span :class="getStatusBadgeClass(workflow.state)"> <span :class="getStatusBadgeClass(workflow.state)">
{{ workflow.status_display || workflow.state }} {{ workflow.status_display || workflow.state }}
</span> </span>
</td> </td>
<td> <td>
<div class="progress" style="height: 8px;"> <div class="progress" style="height: 8px;">
<div class="progress-bar" <div class="progress-bar"
:style="{ width: workflow.progress_percentage + '%' }" :style="{ width: workflow.progress_percentage + '%' }"
:class="getProgressBarClass(workflow.state)"> :class="getProgressBarClass(workflow.state)">
</div>
</div> </div>
<small class="text-muted">{{ workflow.progress_percentage }}%</small> </div>
</td> <small class="text-muted">{{ workflow.progress_percentage }}%</small>
<td>{{ formatDate(workflow.started_at) }}</td> </td>
<td> <td>{{ formatDate(workflow.started_at) }}</td>
<button class="btn btn-sm btn-outline-primary me-1" <td>
@click="viewWorkflowDetails(workflow)" <button class="btn btn-sm btn-outline-primary me-1"
:disabled="loading"> @click="viewWorkflowDetails(workflow)"
<b-icon-eye></b-icon-eye> :disabled="loading">
</button> <b-icon-eye></b-icon-eye>
<button class="btn btn-sm btn-outline-danger" </button>
@click="abortWorkflowInstance(workflow)" <button class="btn btn-sm btn-outline-danger"
:disabled="loading"> @click="abortWorkflowInstance(workflow)"
<b-icon-x-circle></b-icon-x-circle> :disabled="loading">
</button> <b-icon-x-circle></b-icon-x-circle>
</td> </button>
</tr> </td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -77,21 +79,25 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div v-for="workflow in availableWorkflows" :key="workflow.id" class="col-lg-4 col-md-6 mb-3"> <div v-for="workflow in availableWorkflows" :key="workflow.id"
class="col-lg-4 col-md-6 mb-3">
<div class="card h-100 workflow-card"> <div class="card h-100 workflow-card">
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<template v-for="(icon, index) in workflow.icons" :key="icon"> <template v-for="(icon, index) in workflow.icons" :key="icon">
<div class="workflow-icon me-3"> <div class="workflow-icon me-3">
<component :is="icon" class="text-primary" style="font-size: 1.5rem;"></component> <component :is="icon" class="text-primary"
style="font-size: 1.5rem;"></component>
</div> </div>
<!-- Add arrow between icons, but not after the last one --> <!-- Add arrow between icons, but not after the last one -->
<div v-if="index < workflow.icons.length - 1" class="workflow-arrow me-3"> <div v-if="index < workflow.icons.length - 1"
<b-icon-arrow-right class="text-muted" style="font-size: 1rem;"></b-icon-arrow-right> class="workflow-arrow me-3">
<b-icon-arrow-right class="text-muted"
style="font-size: 1rem;"></b-icon-arrow-right>
</div> </div>
</template> </template>
<div class="workflow-header"> <div class="workflow-header">
<h6 class="card-title mb-1">{{ workflow.name }}</h6> <h6 class="card-title mb-1">{{ workflow.title }}</h6>
<small class="text-muted">{{ workflow.category }}</small> <small class="text-muted">{{ workflow.category }}</small>
</div> </div>
</div> </div>
@ -105,10 +111,9 @@
<span class="badge bg-light text-dark">{{ workflow.steps }} steps</span> <span class="badge bg-light text-dark">{{ workflow.steps }} steps</span>
</div> </div>
<button class="btn btn-primary w-100" <button class="btn btn-primary w-100"
@click="startWorkflow(workflow)" @click="startWorkflow(workflow)">
:disabled="isWorkflowRunning(workflow.id)">
<b-icon-play-fill class="me-1"></b-icon-play-fill> <b-icon-play-fill class="me-1"></b-icon-play-fill>
{{ isWorkflowRunning(workflow.id) ? 'Running...' : 'Start Workflow' }} Start Workflow
</button> </button>
</div> </div>
</div> </div>
@ -127,8 +132,8 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import { mapState, mapActions } from 'vuex'; import {mapState, mapActions} from 'vuex';
import { getAllWorkflows, getWorkflow, buildWorkflowApiPayload } from '@/workflows.js'; import {getAllWorkflows, getWorkflow, buildWorkflowApiPayload} from '@/workflows.js';
export default { export default {
name: 'Workflows', name: 'Workflows',
@ -213,15 +218,8 @@ export default {
minute: '2-digit' minute: '2-digit'
}).format(date); }).format(date);
}, },
isWorkflowRunning(workflowId) {
return this.activeWorkflows.some(active =>
active.name === workflowId &&
active.state === 'running'
);
},
getWorkflowDisplayName(workflow) { getWorkflowDisplayName(workflow) {
// `name` doubles as the workflow type identifier (e.g. 'import-items'). return getWorkflow(workflow.slug)?.title;
return getWorkflow(workflow.name)?.name || workflow.name;
}, },
async startWorkflow(workflow) { async startWorkflow(workflow) {
try { try {
@ -232,11 +230,10 @@ export default {
const workflowData = buildWorkflowApiPayload(workflow); const workflowData = buildWorkflowApiPayload(workflow);
const newWorkflow = await this.createWorkflow(workflowData); const newWorkflow = await this.createWorkflow(workflowData);
console.log('Workflow started successfully:', newWorkflow);
// Immediately navigate to the workflow detail view // Immediately navigate to the workflow detail view
// Get the first step from the workflow definition // Get the first step from the workflow definition
const firstStep = workflow.stepDefinitions?.[0]?.step || "initial"; const firstStep = workflow.stepDefinitions?.[0]?.step || 1;
this.$router.push({ this.$router.push({
name: 'workflow-detail', name: 'workflow-detail',
params: { params: {
@ -257,9 +254,9 @@ export default {
// Navigate to the workflow detail view // Navigate to the workflow detail view
// Use the workflow's current step if available, otherwise use the first step // Use the workflow's current step if available, otherwise use the first step
const currentStep = workflow.current_step || const currentStep = workflow.current_step ||
workflow.payload?.current_step || workflow.payload?.current_step ||
getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step || getWorkflow(workflow.name)?.stepDefinitions?.[0]?.step ||
"initial"; "initial";
this.$router.push({ this.$router.push({
name: 'workflow-detail', name: 'workflow-detail',

View file

@ -24,23 +24,20 @@ import StorageOptimizationWorkflow from '@/components/workflow/workflows/Storage
import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/MaintenanceScheduleWorkflow.vue'; import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/MaintenanceScheduleWorkflow.vue';
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue'; import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue'; import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
/** /**
* Workflows with a fully co-located component + metadata. * Workflows with a fully co-located component + metadata.
*/ */
const implementedWorkflows = [ const workflows = [
{ ...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow }, {...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
{ ...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow }, {...BulkItemImportWorkflow.meta, component: BulkItemImportWorkflow},
{ ...InventoryAuditWorkflow.meta, component: InventoryAuditWorkflow }, {...InventoryAuditWorkflow.meta, component: InventoryAuditWorkflow},
{ ...StorageOptimizationWorkflow.meta, component: StorageOptimizationWorkflow }, {...StorageOptimizationWorkflow.meta, component: StorageOptimizationWorkflow},
{ ...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow }, {...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow},
{ ...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow }, {...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow},
{ ...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow }, {...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow},
]; ];
/**
* The full workflow catalog: every workflow type known to the frontend,
* whether it has a custom UI or not.
*/
const workflows = implementedWorkflows;
/** /**
* Get every workflow in the catalog. * Get every workflow in the catalog.
* @returns {Array<Object>} * @returns {Array<Object>}
@ -48,14 +45,16 @@ const workflows = implementedWorkflows;
export function getAllWorkflows() { export function getAllWorkflows() {
return workflows; return workflows;
} }
/** /**
* Get a single workflow definition by id. * Get a single workflow definition by id.
* @param {string} id * @param {string} id
* @returns {Object|undefined} * @returns {Object|undefined}
*/ */
export function getWorkflow(id) { export function getWorkflow(slug) {
return workflows.find(workflow => workflow.id === id); return workflows.find(workflow => workflow.slug === slug);
} }
/** /**
* Get the Vue component implementing a workflow's UI, if any. * Get the Vue component implementing a workflow's UI, if any.
* @param {string} id * @param {string} id
@ -64,6 +63,7 @@ export function getWorkflow(id) {
export function getWorkflowComponent(id) { export function getWorkflowComponent(id) {
return getWorkflow(id)?.component || null; return getWorkflow(id)?.component || null;
} }
/** /**
* Get all workflows belonging to a category. * Get all workflows belonging to a category.
* @param {string} category * @param {string} category
@ -72,6 +72,7 @@ export function getWorkflowComponent(id) {
export function getWorkflowsByCategory(category) { export function getWorkflowsByCategory(category) {
return workflows.filter(workflow => workflow.category === category); return workflows.filter(workflow => workflow.category === category);
} }
/** /**
* Get all unique categories present in the catalog. * Get all unique categories present in the catalog.
* @returns {Array<string>} * @returns {Array<string>}
@ -79,6 +80,7 @@ export function getWorkflowsByCategory(category) {
export function getWorkflowCategories() { export function getWorkflowCategories() {
return [...new Set(workflows.map(workflow => workflow.category))]; return [...new Set(workflows.map(workflow => workflow.category))];
} }
/** /**
* Build the payload sent to the backend to start a new instance of a * Build the payload sent to the backend to start a new instance of a
* workflow, merging the common `workflow_config` metadata block with the * workflow, merging the common `workflow_config` metadata block with the
@ -89,31 +91,28 @@ export function getWorkflowCategories() {
export function buildWorkflowApiPayload(workflow) { export function buildWorkflowApiPayload(workflow) {
const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {}; const ownPayload = workflow.getInitialPayload ? workflow.getInitialPayload() : {};
return { return {
name: workflow.name, title: workflow.title,
workflow_type: workflow.id, slug: workflow.slug,
state: 'running', state: 'running',
current_step: 1, current_step: 1,
total_steps: workflow.stepDefinitions.length, total_steps: workflow.stepDefinitions.length,
payload: { payload: {
workflow_config: {
name: workflow.name,
description: workflow.description,
category: workflow.category,
estimated_duration: workflow.estimatedDuration
},
...ownPayload ...ownPayload
} }
}; };
} }
/** /**
* The backend stores WorkflowInstance.payload as an opaque string - it never * The backend stores WorkflowInstance.payload as an opaque string - it never
* parses or understands it as JSON. The frontend is fully responsible for * parses or understands it as JSON. The frontend is fully responsible for
* serializing it before sending and deserializing it after receiving. * serializing it before sending and deserializing it after receiving.
*/ */
export function serializeWorkflowPayload(workflow) { export function serializeWorkflowPayload(workflow) {
console.log(workflow);
if (!workflow || !('payload' in workflow)) return workflow; if (!workflow || !('payload' in workflow)) return workflow;
return {...workflow, payload: JSON.stringify(workflow.payload ?? {})}; return {...workflow, payload: JSON.stringify(workflow.payload ?? {})};
} }
export function deserializeWorkflowPayload(workflow) { export function deserializeWorkflowPayload(workflow) {
if (!workflow) return workflow; if (!workflow) return workflow;
let payload = {}; let payload = {};
@ -124,6 +123,7 @@ export function deserializeWorkflowPayload(workflow) {
} }
return {...workflow, payload}; return {...workflow, payload};
} }
export default { export default {
getAllWorkflows, getAllWorkflows,
getWorkflow, getWorkflow,

View file

@ -21,9 +21,9 @@ export default defineConfig({
'Access-Control-Max-Age': '86400', 'Access-Control-Max-Age': '86400',
//'Upgrade-Insecure-Requests': '1', //'Upgrade-Insecure-Requests': '1',
'Content-Security-Policy': 'default-src \'self\';' 'Content-Security-Policy': 'default-src \'self\';'
+ ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\';' + ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\' \'unsafe-inline\';'
+ ' style-src \'self\' \'unsafe-inline\';' + ' style-src \'self\' \'unsafe-inline\';'
+ ' img-src \'self\' * data:;' + ' img-src \'self\' * data: blob:;'
+ ' connect-src * data:', + ' connect-src * data:',
}, },
}, },