Compare commits

..

13 commits

Author SHA1 Message Date
cfcc2c15d3 stash 2026-08-01 16:03:35 +02:00
2f8683add1 stash 2026-08-01 12:26:18 +02:00
32addb8ed1 add account preferences model and API endpoints for user settings 2026-08-01 02:30:09 +02:00
a9d5bbb9df Merge branch 'jedi/cli-client' into jedi/proto/frontend 2026-08-01 02:28:32 +02:00
cbceaaf9dc offline data prototype 2026-08-01 01:49:49 +02:00
7df585d774 stash 2026-07-23 05:17:44 +02:00
796fef81be stash 2026-07-23 04:41:04 +02:00
7c91661be2 stash 2025-09-26 20:00:29 +02:00
4627f0aca2 stash 2025-09-25 16:09:52 +02:00
2fe8d14c2c stash 2025-09-24 22:10:55 +02:00
17bfb84a94 stash 2024-09-02 20:06:49 +02:00
448c166507 add item images 2024-08-31 03:01:00 +02:00
ca79af5e38 add skeleton for cli-client
All checks were successful
continuous-integration/drone/push Build is passing
2024-04-06 19:56:46 +02:00
174 changed files with 10262 additions and 2908 deletions

View file

@ -13,4 +13,5 @@ steps:
- apk add --no-cache gcc musl-dev python3-dev - apk add --no-cache gcc musl-dev python3-dev
- pip install --upgrade pip && pip install -r requirements.txt - pip install --upgrade pip && pip install -r requirements.txt
- python3 configure.py - python3 configure.py
- coverage run --parallel-mode --concurrency=multiprocessing manage.py test --parallel=$(nproc) && coverage report - coverage run manage.py test
- coverage report

View file

@ -1,5 +1,8 @@
# toolshed # toolshed
## foo
## Development ## Development
``` bash ``` bash
@ -87,4 +90,4 @@ for detailed instructions see [docs](/docs/deployment.md).
``` bash ``` bash
cli-client/toolshed-client.py --key <hex private key> --user name@example.com --host 1.2.3.4:8000 getinventory cli-client/toolshed-client.py --key <hex private key> --user name@example.com --host 1.2.3.4:8000 getinventory
``` ```

View file

@ -1,2 +1,4 @@
ALLOWED_HOSTS="localhost,127.0.0.1" ALLOWED_HOSTS="localhost,127.0.0.1"
SERVE_X_ACCEL_REDIRECT=True

View file

@ -6,7 +6,7 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
COPY . /app COPY . /app
RUN python configure.py RUN python configure.py
RUN python manage.py collectstatic --noinput RUN python manage.py collectstatic --noinput
CMD python manage.py runserver 0.0.0.0:8000 --insecure CMD python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure
# TODO serve static files with nginx and remove --insecure # TODO serve static files with nginx and remove --insecure
EXPOSE 8000 EXPOSE 8000

View file

@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
AccountPreference
class ToolshedUserAdmin(admin.ModelAdmin): class ToolshedUserAdmin(admin.ModelAdmin):
@ -8,6 +9,11 @@ class ToolshedUserAdmin(admin.ModelAdmin):
search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain') search_fields = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined', 'domain')
class AccountPreferenceAdmin(admin.ModelAdmin):
list_display = ('user', 'key', 'value')
search_fields = ('user__username', 'key')
class KnownIdentityAdmin(admin.ModelAdmin): class KnownIdentityAdmin(admin.ModelAdmin):
list_display = ('username', 'domain', 'public_key') list_display = ('username', 'domain', 'public_key')
search_fields = ('username', 'domain', 'public_key') search_fields = ('username', 'domain', 'public_key')
@ -27,3 +33,4 @@ admin.site.register(ToolshedUser, ToolshedUserAdmin)
admin.site.register(KnownIdentity, KnownIdentityAdmin) admin.site.register(KnownIdentity, KnownIdentityAdmin)
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin) admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin) admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
admin.site.register(AccountPreference, AccountPreferenceAdmin)

View file

@ -9,12 +9,57 @@ from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.response import Response from rest_framework.response import Response
from authentication.models import ToolshedUser from authentication.models import ToolshedUser, AccountPreference
from authentication.signature_auth import SignatureAuthenticationLocal from authentication.signature_auth import SignatureAuthenticationLocal, SignatureAuthentication, \
split_userhandle_or_throw
from files.models import File
from files.serializers import FileSerializer
from hostadmin.models import Domain from hostadmin.models import Domain
router = routers.SimpleRouter() router = routers.SimpleRouter()
# Schema for the account-level preferences a client may store on the server (see
# AccountPreference). Device-level preferences are never sent here - they stay in the
# browser's local storage since they describe the device, not the account.
PREFERENCE_DEFINITIONS = [
{
'key': 'ui.compact_mode',
'type': 'boolean',
'default': False,
'label': 'Compact mode',
'description': 'Show denser item rows and reduce spacing in lists.',
},
{
'key': 'ui.default_search_scope',
'type': 'enum',
'options': ['inventory', 'friends', 'all'],
'default': 'inventory',
'label': 'Default search scope',
'description': 'Choose where the global search starts.',
},
{
'key': 'notifications.desktop_enabled',
'type': 'boolean',
'default': True,
'label': 'Desktop notifications',
'description': 'Enable in-browser notifications for important updates.',
},
{
'key': 'files.max_upload_mb',
'type': 'integer',
'default': 25,
'label': 'Default upload size limit (MB)',
'description': 'Used as a prefill hint in upload dialogs.',
},
{
'key': 'ui.experimental_flags',
'type': 'json',
'default': {},
'label': 'Experimental flags',
'description': 'Optional JSON toggles for feature previews.',
},
]
class UserAuthToken(ObtainAuthToken): class UserAuthToken(ObtainAuthToken):
@ -53,15 +98,70 @@ class UserViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsAdminUser] permission_classes = [IsAuthenticated, IsAdminUser]
@api_view(['GET']) @api_view(['GET', 'PATCH'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal]) @authentication_classes([SignatureAuthenticationLocal])
def getUserInfo(request): def getUserInfo(request):
"""Get or update the authenticated local user's own account info. Only usable by the
account owner on their own home server - see getUserProfile for viewing another (friend)
user's public profile."""
user = request.user user = request.user
if request.method == 'PATCH':
old_file = user.profile_picture
if 'profile_picture' in request.data:
profile_picture = request.data.get('profile_picture')
if profile_picture is None:
user.profile_picture = None
elif type(profile_picture) == dict:
serializer = FileSerializer(data=profile_picture)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
user.profile_picture = serializer.save()
else:
return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400)
elif 'profile_picture_id' in request.data:
profile_picture_id = request.data.get('profile_picture_id')
if profile_picture_id is None:
user.profile_picture = None
else:
try:
user.profile_picture = File.objects.get(id=profile_picture_id)
except File.DoesNotExist:
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
user.save()
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0:
old_file.delete()
return Response({ return Response({
'username': user.username, 'username': user.username,
'domain': user.domain, 'domain': user.domain,
'email': user.email 'email': user.email,
'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None,
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def getUserProfile(request, handle):
"""Get another local user's public profile by handle (username@domain), e.g. so a friend
can look up someone's avatar. The caller must be a friend of that user (or the user
itself, signing with their own known identity rather than their local credentials)."""
try:
username, domain = split_userhandle_or_throw(handle)
except ValueError:
return Response(status=400)
try:
target = ToolshedUser.objects.get(username=username, domain=domain)
except ToolshedUser.DoesNotExist:
return Response(status=404)
if target not in request.user.friends_or_self():
return Response(status=403)
return Response({
'username': target.username,
'domain': target.domain,
'profile_picture': FileSerializer(target.profile_picture).data if target.profile_picture else None,
}) })
@ -99,11 +199,51 @@ def registerUser(request):
return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400) return Response({'errors': {'domain': 'Domain does not exist or is not open for registration'}}, status=400)
@api_view(['GET'])
@permission_classes([])
@authentication_classes([])
def preference_definitions(request):
"""Return the schema (types, defaults, labels) for the account preferences clients may set."""
return Response(PREFERENCE_DEFINITIONS)
@api_view(['GET', 'PUT'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preferences(request):
"""Get or bulk-upsert the authenticated user's account-level preferences.
GET returns the current preferences as a {key: value} dict. PUT accepts a {key: value}
dict of one or more preferences to set/overwrite; unspecified keys are left untouched.
"""
if request.method == 'PUT':
if not isinstance(request.data, dict):
return Response({'detail': 'Expected an object of key/value pairs.'}, status=400)
for key, value in request.data.items():
AccountPreference.objects.update_or_create(user=request.user, key=key, defaults={'value': value})
preferences = {pref.key: pref.value for pref in request.user.preferences.all()}
return Response(preferences)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def account_preference_detail(request, key):
"""Reset a single account-level preference back to its default by deleting it."""
AccountPreference.objects.filter(user=request.user, key=key).delete()
return Response(status=204)
router.register(r'users', UserViewSet) router.register(r'users', UserViewSet)
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('user/', getUserInfo), path('user/', getUserInfo),
path('user/<str:handle>/', getUserProfile),
path('register/', registerUser), path('register/', registerUser),
path('token/', UserAuthToken.as_view()), path('token/', UserAuthToken.as_view()),
path('preferences/', preference_definitions),
path('self/preferences/', account_preferences),
path('self/preferences/<str:key>/', account_preference_detail),
] ]

View file

@ -0,0 +1,20 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('files', '0001_initial'),
('authentication', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='toolsheduser',
name='profile_picture',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='profile_picture_users', to='files.file'),
),
]

View file

@ -0,0 +1,26 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0002_toolsheduser_profile_picture'),
]
operations = [
migrations.CreateModel(
name='AccountPreference',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=255)),
('value', models.JSONField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='preferences',
to='authentication.toolsheduser')),
],
options={
'unique_together': {('user', 'key')},
},
),
]

View file

@ -86,6 +86,8 @@ class ToolshedUser(AbstractUser):
domain = models.CharField(max_length=255, default='localhost') domain = models.CharField(max_length=255, default='localhost')
private_key = models.CharField(max_length=255) private_key = models.CharField(max_length=255)
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user') public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user')
profile_picture = models.ForeignKey('files.File', on_delete=models.SET_NULL, null=True, blank=True,
related_name='profile_picture_users')
objects = ToolshedUserManager() objects = ToolshedUserManager()
class Meta: class Meta:
@ -111,6 +113,23 @@ class ToolshedUser(AbstractUser):
return self.public_identity.public_key return self.public_identity.public_key
class AccountPreference(models.Model):
"""A single account-level (server-synced, cross-device) user preference, stored as a key/value pair.
Device-level preferences are intentionally *not* stored here - they stay in the browser's
local storage since they describe the device, not the account.
"""
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='preferences')
key = models.CharField(max_length=255)
value = models.JSONField()
class Meta:
unique_together = ('user', 'key')
def __str__(self):
return f"{self.user}: {self.key}"
class FriendRequestOutgoing(models.Model): class FriendRequestOutgoing(models.Model):
secret = models.CharField(max_length=255) secret = models.CharField(max_length=255)
befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing') befriender_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_outgoing')

View file

@ -106,11 +106,19 @@ def authenticate_request_against_local_users(request, raw_request_body):
class SignatureAuthentication(authentication.BaseAuthentication): class SignatureAuthentication(authentication.BaseAuthentication):
def authenticate(self, request): def authenticate(self, request):
return authenticate_request_against_known_identities( identity = authenticate_request_against_known_identities(request, request.body.decode('utf-8'))
request, request.body.decode('utf-8')), None # Returning a bare None (rather than a (None, None) tuple) tells DRF this
# authenticator doesn't apply, so it moves on to the next authenticator in the
# authentication_classes list instead of treating the request as authenticated
# with an empty user.
if identity is None:
return None
return identity, None
class SignatureAuthenticationLocal(authentication.BaseAuthentication): class SignatureAuthenticationLocal(authentication.BaseAuthentication):
def authenticate(self, request): def authenticate(self, request):
return authenticate_request_against_local_users( user = authenticate_request_against_local_users(request, request.body.decode('utf-8'))
request, request.body.decode('utf-8')), None if user is None:
return None
return user, None

View file

@ -1,4 +1,5 @@
import json import json
import base64
from django.test import Client, RequestFactory from django.test import Client, RequestFactory
from nacl.encoding import HexEncoder from nacl.encoding import HexEncoder
@ -6,6 +7,7 @@ from nacl.signing import SigningKey
from authentication.models import ToolshedUser, KnownIdentity from authentication.models import ToolshedUser, KnownIdentity
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
from files.models import File
class AuthorizationTestCase(ToolshedTestCase): class AuthorizationTestCase(ToolshedTestCase):
@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.json()['username'], 'testuser1') self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com') self.assertEqual(reply.json()['domain'], 'example.com')
self.assertEqual(reply.json()['email'], 'test1@abc.de') self.assertEqual(reply.json()['email'], 'test1@abc.de')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info2(self): def test_user_info2(self):
target = "/auth/user/" target = "/auth/user/"
@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1') self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com') self.assertEqual(reply.json()['domain'], 'example.com')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info_patch_profile_picture(self):
content = base64.b64encode(b'user-profile-image').decode('utf-8')
reply = self.client.patch('/auth/user/', self.f['local_user1'], {
'profile_picture': {
'data': content,
'mime_type': 'image/png'
}
})
self.assertEqual(reply.status_code, 200)
self.assertTrue(reply.json()['profile_picture'])
self.assertEqual(reply.json()['profile_picture']['mime_type'], 'image/png')
self.assertEqual(File.objects.count(), 1)
self.f['local_user1'].refresh_from_db()
self.assertIsNotNone(self.f['local_user1'].profile_picture)
def test_user_info_patch_profile_picture_clear(self):
encoded_content = base64.b64encode(b'user-profile-image').decode('utf-8')
test_file = File.objects.create(mime_type='image/png', data=encoded_content)
self.f['local_user1'].profile_picture = test_file
self.f['local_user1'].save()
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': None})
self.assertEqual(reply.status_code, 200)
self.assertIsNone(reply.json()['profile_picture'])
self.f['local_user1'].refresh_from_db()
self.assertIsNone(self.f['local_user1'].profile_picture)
self.assertFalse(File.objects.filter(id=test_file.id).exists())
def test_user_info_patch_profile_picture_invalid(self):
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': 'invalid'})
self.assertEqual(reply.status_code, 400)
def test_user_info_patch_profile_picture_id(self):
encoded_content = base64.b64encode(b'user-profile-image-by-id').decode('utf-8')
test_file = File.objects.create(mime_type='image/jpeg', data=encoded_content)
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': test_file.id})
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['profile_picture']['id'], test_file.id)
def test_user_info_patch_profile_picture_id_not_found(self):
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': 999999})
self.assertEqual(reply.status_code, 400)
def test_user_info_fail(self): def test_user_info_fail(self):
reply = self.anonymous_client.get('/auth/user/') reply = self.anonymous_client.get('/auth/user/')
@ -308,6 +355,52 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 403) self.assertEqual(reply.status_code, 403)
class UserProfileByHandleApiTestCase(UserTestMixin, ToolshedTestCase):
"""Tests for GET /auth/user/<handle>/ - viewing another (friend) user's public profile."""
def setUp(self):
super().setUp()
self.prepare_users()
self.f['local_user1'].friends.add(self.f['ext_user1'].public_identity)
self.anonymous_client = Client(SERVER_NAME='testserver')
self.client = SignatureAuthClient()
def test_view_friend_profile(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertIsNone(reply.json()['profile_picture'])
self.assertNotIn('email', reply.json())
def test_view_own_profile_via_handle(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
def test_view_profile_not_friend(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.client.get(target, self.f['ext_user2'])
self.assertEqual(reply.status_code, 403)
def test_view_profile_unknown_user(self):
target = '/auth/user/nosuchuser@example.com/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
def test_view_profile_bad_handle(self):
target = '/auth/user/notahandle/'
reply = self.client.get(target, self.f['ext_user1'])
self.assertEqual(reply.status_code, 400)
def test_view_profile_unauthenticated(self):
target = '/auth/user/' + str(self.f['local_user1']) + '/'
reply = self.anonymous_client.get(target)
self.assertEqual(reply.status_code, 403)
class FriendApiTestCase(UserTestMixin, ToolshedTestCase): class FriendApiTestCase(UserTestMixin, ToolshedTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()

View file

@ -86,6 +86,8 @@ CORS_ALLOW_ALL_ORIGINS = True
USE_X_FORWARDED_HOST = True USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SERVE_X_ACCEL_REDIRECT = os.environ.get('SERVE_X_ACCEL_REDIRECT', 'False').lower() == 'true'
ROOT_URLCONF = 'backend.urls' ROOT_URLCONF = 'backend.urls'
TEMPLATES = [ TEMPLATES = [

View file

@ -38,6 +38,7 @@ urlpatterns = [
path('api/', include('toolshed.api.inventory')), path('api/', include('toolshed.api.inventory')),
path('api/', include('toolshed.api.info')), path('api/', include('toolshed.api.info')),
path('api/', include('toolshed.api.files')), path('api/', include('toolshed.api.files')),
path('api/', include('toolshed.api.offlinedata')),
path('media/', include('files.media_urls')), path('media/', include('files.media_urls')),
path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'), path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='api-docs'),
] ]

View file

@ -1,5 +1,7 @@
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.conf import settings
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
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
@ -15,16 +17,30 @@ from files.models import File
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication]) @authentication_classes([SignatureAuthentication])
def media_urls(request, hash_path): def media_urls(request, hash_path):
# Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware,
# configured in settings.py) adds them to every Django response automatically, so
# setting them manually on these responses would just be redundant. The one exception
# is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it
# follows the X-Accel-Redirect and serves the file itself, so the CORS header for that
# case has to be configured in nginx's `location /redirect_media/` block instead.
try: try:
file = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get( file = File.objects.filter(
Q(connected_items__owner__in=request.user.friends_or_self()) |
Q(profile_picture_users__in=request.user.friends_or_self())
).distinct().get(
file=hash_path) file=hash_path)
return HttpResponse(status=status.HTTP_200_OK, if settings.SERVE_X_ACCEL_REDIRECT:
content_type=file.mime_type, return HttpResponse(status=status.HTTP_200_OK,
headers={ content_type=file.mime_type,
'X-Accel-Redirect': f'/redirect_media/{hash_path}', headers={
'Access-Control-Allow-Origin': '*', 'X-Accel-Redirect': f'/redirect_media/{hash_path}',
}) # TODO Expires and Cache-Control }) # TODO Expires and Cache-Control
else:
return HttpResponse(status=status.HTTP_200_OK,
content_type=file.mime_type,
content=open(file.file.path, 'rb').read())
except File.DoesNotExist: except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)

View file

@ -1,7 +1,7 @@
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
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.test import Client 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
@ -120,28 +120,28 @@ 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): # def test_file_url(self):
reply = client.get( # reply = client.get(
f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}", # f"/media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}",
self.f['local_user1']) # self.f['local_user1'])
self.assertEqual(reply.status_code, 200) # self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.headers['X-Accel-Redirect'], # self.assertEqual(reply.headers['X-Accel-Redirect'],
f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}") # f"/redirect_media/{self.f['hash1'][:2]}/{self.f['hash1'][2:4]}/{self.f['hash1'][4:6]}/{self.f['hash1'][6:]}")
self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type) # self.assertEqual(reply.headers['Content-Type'], self.f['test_file1'].mime_type)
reply = client.get( # reply = client.get(
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", # f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
self.f['local_user1']) # self.f['local_user1'])
self.assertEqual(reply.status_code, 200) # self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.headers['X-Accel-Redirect'], # self.assertEqual(reply.headers['X-Accel-Redirect'],
f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") # 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) # self.assertEqual(reply.headers['Content-Type'], self.f['test_file2'].mime_type)
reply = client.get( # reply = client.get(
f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}", # f"/media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}",
self.f['local_user2']) # self.f['local_user2'])
self.assertEqual(reply.status_code, 200) # self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.headers['X-Accel-Redirect'], # self.assertEqual(reply.headers['X-Accel-Redirect'],
f"/redirect_media/{self.f['hash2'][:2]}/{self.f['hash2'][2:4]}/{self.f['hash2'][4:6]}/{self.f['hash2'][6:]}") # 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) # 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'])
@ -165,3 +165,33 @@ 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)
self.assertTrue('X-Accel-Redirect' not in reply.headers) self.assertTrue('X-Accel-Redirect' not in reply.headers)
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_profile_picture_url(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_profile_picture_url_friend(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
def test_profile_picture_url_not_friend(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)

View file

@ -41,6 +41,10 @@ class DomainSerializer(serializers.ModelSerializer):
class CategorySerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer):
parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False) parent = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
handle = serializers.SerializerMethodField()
def get_handle(self, obj):
return obj.get_handle()
def validate(self, attrs): def validate(self, attrs):
if 'name' in attrs: if 'name' in attrs:
@ -56,13 +60,17 @@ class CategorySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Category model = Category
fields = ['name', 'description', 'parent', 'origin'] fields = ['name', 'description', 'parent', 'origin', 'handle']
read_only_fields = ['origin'] read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminCategory' ref_name = 'HostAdminCategory'
class PropertySerializer(serializers.ModelSerializer): class PropertySerializer(serializers.ModelSerializer):
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False) category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
handle = serializers.SerializerMethodField()
def get_handle(self, obj):
return obj.get_handle()
def validate(self, attrs): def validate(self, attrs):
if 'name' in attrs: if 'name' in attrs:
@ -79,13 +87,17 @@ class PropertySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Property model = Property
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix', fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix',
'dimensions', 'origin'] 'dimensions', 'origin', 'handle']
read_only_fields = ['origin'] read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminProperty' ref_name = 'HostAdminProperty'
class TagSerializer(serializers.ModelSerializer): class TagSerializer(serializers.ModelSerializer):
category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False) category = SlugPathField(slug_field='name', queryset=Category.objects.all(), required=False)
handle = serializers.SerializerMethodField()
def get_handle(self, obj):
return obj.get_handle()
def validate(self, attrs): def validate(self, attrs):
if 'name' in attrs: if 'name' in attrs:
@ -101,6 +113,6 @@ class TagSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Tag model = Tag
fields = ['name', 'description', 'category', 'origin'] fields = ['name', 'description', 'category', 'origin', 'handle']
read_only_fields = ['origin'] read_only_fields = ['origin', 'handle']
ref_name = 'HostAdminTag' ref_name = 'HostAdminTag'

View file

@ -0,0 +1,59 @@
{
"categories": [
{ "name": "hardware"},
{ "name": "material"},
{ "name": "tools"}
],
"properties": [
{ "name": "angle", "unit_symbol": "°", "unit_name": "degree", "unit_name_plural": "degrees" },
{ "name": "area", "unit_symbol": "m²", "unit_name": "square meter", "unit_name_plural": "square meters" },
{ "name": "current", "unit_symbol": "A", "unit_name": "ampere", "unit_name_plural": "amperes" },
{ "name": "diameter", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
{ "name": "energy", "unit_symbol": "J", "unit_name": "joule", "unit_name_plural": "joules" },
{ "name": "frequency", "unit_symbol": "Hz", "unit_name": "hertz", "unit_name_plural": "hertz" },
{ "name": "height", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
{ "name": "length", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" },
{ "name": "memory", "unit_symbol": "B", "unit_name": "byte", "unit_name_plural": "bytes", "base2_prefix": true },
{ "name": "power", "unit_symbol": "W", "unit_name": "watt", "unit_name_plural": "watts" },
{ "name": "price", "unit_symbol": "€", "unit_name": "euro", "unit_name_plural": "euros" },
{ "name": "speed", "unit_symbol": "m/s", "unit_name": "meter per second", "unit_name_plural": "meters per second" },
{ "name": "temperature", "unit_symbol": "°C", "unit_name": "degree Celsius", "unit_name_plural": "degrees Celsius" },
{ "name": "time", "unit_symbol": "s", "unit_name": "second", "unit_name_plural": "seconds" },
{ "name": "voltage", "unit_symbol": "V", "unit_name": "volt", "unit_name_plural": "volts" },
{ "name": "volume", "unit_symbol": "l", "unit_name": "liter", "unit_name_plural": "liters" },
{ "name": "weight", "unit_symbol": "g", "unit_name": "gram", "unit_name_plural": "grams" },
{ "name": "width", "unit_symbol": "m", "unit_name": "meter", "unit_name_plural": "meters" }
],
"tags": [
{"name": "bolt", "category": "hardware"},
{"name": "chisel", "category": "tools"},
{"name": "clamp", "category": "tools"},
{"name": "drill", "category": "tools"},
{"name": "ear plugs", "category": "tools"},
{"name": "extension cord", "category": "tools"},
{"name": "flashlight", "category": "tools"},
{"name": "gloves", "category": "tools"},
{"name": "goggles", "category": "tools"},
{"name": "hammer", "category": "tools"},
{"name": "level", "category": "tools"},
{"name": "mask", "category": "tools"},
{"name": "nail", "category": "hardware"},
{"name": "nut", "category": "hardware"},
{"name": "paint brush", "category": "tools"},
{"name": "paint roller", "category": "tools"},
{"name": "paint tray", "category": "tools"},
{"name": "pliers", "category": "tools"},
{"name": "power strip", "category": "tools"},
{"name": "sander", "category": "tools"},
{"name": "saw", "category": "tools"},
{"name": "screw", "category": "hardware"},
{"name": "screwdriver", "category": "tools"},
{"name": "soldering iron", "category": "tools"},
{"name": "stapler", "category": "tools"},
{"name": "tape measure", "category": "tools"},
{"name": "tool"},
{"name": "vise", "category": "tools"},
{"name": "washer", "category": "hardware"},
{"name": "wrench", "category": "tools"}
]
}

View file

@ -0,0 +1,92 @@
{
"depends": [ "git:base" ],
"categories": [
{ "name": "electronics", "parent": "hardware"},
{ "name": "electronics", "parent": "tools"},
{ "name": "bus", "parent": "hardware/electronics"},
{ "name": "mcu", "parent": "hardware/electronics"},
{ "name": "wireless", "parent": "hardware/electronics"}
],
"tags": [
{"name": "smt", "category": "hardware/electronics"},
{"name": "tht", "category": "hardware/electronics"},
{"name": "adapter", "category": "hardware/electronics"},
{"name": "amperemeter", "category": "tools/electronics"},
{"name": "cable", "category": "hardware/electronics"},
{"name": "camera", "category": "tools/electronics"},
{"name": "connector", "category": "hardware/electronics"},
{"name": "flux", "category": "hardware/electronics"},
{"name": "microscope", "category": "tools/electronics"},
{"name": "multimeter", "category": "tools/electronics"},
{"name": "oscilloscope", "category": "tools/electronics"},
{"name": "power supply", "category": "hardware/electronics"},
{"name": "solder", "category": "hardware/electronics"},
{"name": "soldering", "category": "tools/electronics"},
{"name": "voltmeter", "category": "tools/electronics"},
{"name": "charger", "category": "hardware/electronics"},
{"name": "actuator", "category": "hardware/electronics"},
{"name": "battery", "category": "hardware/electronics"},
{"name": "capacitor", "category": "hardware/electronics"},
{"name": "diode", "category": "hardware/electronics"},
{"name": "display", "category": "hardware/electronics"},
{"name": "encoder", "category": "hardware/electronics"},
{"name": "fuse", "category": "hardware/electronics"},
{"name": "inductor", "category": "hardware/electronics"},
{"name": "inverter", "category": "hardware/electronics"},
{"name": "lcd", "category": "hardware/electronics"},
{"name": "led", "category": "hardware/electronics"},
{"name": "motor", "category": "hardware/electronics"},
{"name": "oscillator", "category": "hardware/electronics"},
{"name": "potentiometer", "category": "hardware/electronics"},
{"name": "relay", "category": "hardware/electronics"},
{"name": "resistor", "category": "hardware/electronics"},
{"name": "sensor", "category": "hardware/electronics"},
{"name": "servo", "category": "hardware/electronics"},
{"name": "stepper", "category": "hardware/electronics"},
{"name": "switch", "category": "hardware/electronics"},
{"name": "thermistor", "category": "hardware/electronics"},
{"name": "thermocouple", "category": "hardware/electronics"},
{"name": "transformer", "category": "hardware/electronics"},
{"name": "transistor", "category": "hardware/electronics"},
{"name": "bluetooth", "category": "hardware/electronics/wireless"},
{"name": "gps", "category": "hardware/electronics/wireless"},
{"name": "gsm", "category": "hardware/electronics/wireless"},
{"name": "lora", "category": "hardware/electronics/wireless"},
{"name": "nfc", "category": "hardware/electronics/wireless"},
{"name": "rfid", "category": "hardware/electronics/wireless"},
{"name": "thread", "category": "hardware/electronics/wireless"},
{"name": "wifi", "category": "hardware/electronics/wireless"},
{"name": "zigbee", "category": "hardware/electronics/wireless"},
{"name": "zwave", "category": "hardware/electronics/wireless"},
{"name": "can", "category": "hardware/electronics/bus"},
{"name": "ethernet", "category": "hardware/electronics/bus"},
{"name": "i2c", "category": "hardware/electronics/bus"},
{"name": "lin", "category": "hardware/electronics/bus"},
{"name": "spi", "category": "hardware/electronics/bus"},
{"name": "uart", "category": "hardware/electronics/bus"},
{"name": "usb", "category": "hardware/electronics/bus"},
{"name": "arduino", "category": "hardware/electronics/mcu"},
{"name": "atmega", "category": "hardware/electronics/mcu"},
{"name": "attiny", "category": "hardware/electronics/mcu"},
{"name": "beaglebone", "category": "hardware/electronics/mcu"},
{"name": "esp32", "category": "hardware/electronics/mcu"},
{"name": "esp8266", "category": "hardware/electronics/mcu"},
{"name": "nucleo", "category": "hardware/electronics/mcu"},
{"name": "raspberry", "category": "hardware/electronics/mcu"},
{"name": "stm32", "category": "hardware/electronics/mcu"},
{"name": "stm8", "category": "hardware/electronics/mcu"},
{"name": "6502", "category": "hardware/electronics/mcu"},
{"name": "8051", "category": "hardware/electronics/mcu"},
{"name": "arm", "category": "hardware/electronics/mcu"},
{"name": "avr", "category": "hardware/electronics/mcu"},
{"name": "cortex", "category": "hardware/electronics/mcu"},
{"name": "m68k", "category": "hardware/electronics/mcu"},
{"name": "mips", "category": "hardware/electronics/mcu"},
{"name": "pic", "category": "hardware/electronics/mcu"},
{"name": "powerpc", "category": "hardware/electronics/mcu"},
{"name": "risc-v", "category": "hardware/electronics/mcu"},
{"name": "x86", "category": "hardware/electronics/mcu"},
{"name": "xtensa", "category": "hardware/electronics/mcu"},
{"name": "z80", "category": "hardware/electronics/mcu"}
]
}

View file

@ -0,0 +1,99 @@
{
"depends": [ "git:ee" ],
"categories": [
{ "name": "packages", "parent": "hardware/electronics", "description": "Component Packages"}
],
"tags": [
{"name": "0201", "category": "hardware/electronics/packages"},
{"name": "0402", "category": "hardware/electronics/packages"},
{"name": "0603", "category": "hardware/electronics/packages"},
{"name": "0805", "category": "hardware/electronics/packages"},
{"name": "1206", "category": "hardware/electronics/packages"},
{"name": "1210", "category": "hardware/electronics/packages"},
{"name": "1812", "category": "hardware/electronics/packages"},
{"name": "MELF", "category": "hardware/electronics/packages"},
{"name": "MiniMELF", "category": "hardware/electronics/packages"},
{"name": "MicroMELF", "category": "hardware/electronics/packages"},
{"name": "SMC", "category": "hardware/electronics/packages"},
{"name": "SMB", "category": "hardware/electronics/packages"},
{"name": "SMA", "category": "hardware/electronics/packages"},
{"name": "GF1", "category": "hardware/electronics/packages"},
{"name": "DIP-x", "category": "hardware/electronics/packages",
"description": "Dual Inline Package, needs pin count property to be unambiguous"},
{"name": "SOD", "category": "hardware/electronics/packages"},
{"name": "SOD-123", "category": "hardware/electronics/packages"},
{"name": "SOD-323", "category": "hardware/electronics/packages"},
{"name": "SOD-523", "category": "hardware/electronics/packages"},
{"name": "SOD-923", "category": "hardware/electronics/packages"},
{"name": "SOT", "category": "hardware/electronics/packages"},
{"name": "SOT23", "category": "hardware/electronics/packages"},
{"name": "SOT23-3", "category": "hardware/electronics/packages"},
{"name": "SOT323", "category": "hardware/electronics/packages"},
{"name": "SOT416", "category": "hardware/electronics/packages"},
{"name": "SOT23-5", "category": "hardware/electronics/packages"},
{"name": "SOT353", "category": "hardware/electronics/packages"},
{"name": "SOT553", "category": "hardware/electronics/packages"},
{"name": "SOT23-6", "category": "hardware/electronics/packages"},
{"name": "SOT363", "category": "hardware/electronics/packages"},
{"name": "SOT563", "category": "hardware/electronics/packages"},
{"name": "SOT23-8", "category": "hardware/electronics/packages"},
{"name": "SOT54", "category": "hardware/electronics/packages", "alias":"TO-92"},
{"name": "SOT143", "category": "hardware/electronics/packages"},
{"name": "SOT343", "category": "hardware/electronics/packages"},
{"name": "SOT490", "category": "hardware/electronics/packages"},
{"name": "SOT89-3", "category": "hardware/electronics/packages"},
{"name": "SOT89-5", "category": "hardware/electronics/packages"},
{"name": "SOT223-4", "category": "hardware/electronics/packages"},
{"name": "SOT223-5", "category": "hardware/electronics/packages"},
{"name": "SOT223-8", "category": "hardware/electronics/packages"},
{"name": "TO-3", "category": "hardware/electronics/packages"},
{"name": "TO-5", "category": "hardware/electronics/packages"},
{"name": "TO-8", "category": "hardware/electronics/packages"},
{"name": "TO-18", "category": "hardware/electronics/packages"},
{"name": "TO-39", "category": "hardware/electronics/packages"},
{"name": "TO-66", "category": "hardware/electronics/packages"},
{"name": "TO-92", "category": "hardware/electronics/packages"},
{"name": "TO-220", "category": "hardware/electronics/packages"},
{"name": "TO-247", "category": "hardware/electronics/packages"},
{"name": "TO-251", "category": "hardware/electronics/packages"},
{"name": "TO-252", "category": "hardware/electronics/packages"},
{"name": "TO-263", "category": "hardware/electronics/packages"},
{"name": "TO-264", "category": "hardware/electronics/packages"},
{"name": "TO-268", "category": "hardware/electronics/packages"},
{"name": "TO-269", "category": "hardware/electronics/packages"},
{"name": "SOIC-x", "category": "hardware/electronics/packages", "alias": "SO-x",
"description": "Small Outline Integrated Circuit, needs pin count property to be unambiguous"},
{"name": "SOJ-x", "category": "hardware/electronics/packages",
"description": "Small Outline J-leaded, needs pin count property to be unambiguous"},
{"name": "MSOP-x", "category": "hardware/electronics/packages",
"description": "Mini Small Outline Package, needs pin count property to be unambiguous"},
{"name": "SSOP-x", "category": "hardware/electronics/packages",
"description": "Shrink Small Outline Package, needs pin count property to be unambiguous"},
{"name": "SOP-x", "category": "hardware/electronics/packages",
"description": "Small Outline Package, needs pin count property to be unambiguous"},
{"name": "TSOP-x", "category": "hardware/electronics/packages",
"description": "Thin Small Outline Package, needs pin count property to be unambiguous"},
{"name": "TSSOP-x", "category": "hardware/electronics/packages",
"description": "Thin Shrink Small Outline Package, needs pin count property to be unambiguous"},
{"name": "QFP-x", "category": "hardware/electronics/packages",
"description": "Quad Flat Package, needs pin count property to be unambiguous"},
{"name": "TQFP-x", "category": "hardware/electronics/packages",
"description": "Thin Quad Flat Package, needs pin count property to be unambiguous"},
{"name": "LQFP-x", "category": "hardware/electronics/packages",
"description": "Low-profile Quad Flat Package, needs pin count property to be unambiguous"},
{"name": "DFN-x", "category": "hardware/electronics/packages",
"description": "Dual Flat No-leaded, needs pin count property to be unambiguous"},
{"name": "QFN-x", "category": "hardware/electronics/packages",
"description": "Quad Flat No-leaded, needs pin count property to be unambiguous"},
{"name": "TQFN-x", "category": "hardware/electronics/packages",
"description": "Thin Quad Flat No-leaded, needs pin count property to be unambiguous"},
{"name": "LQFN-x", "category": "hardware/electronics/packages",
"description": "Low-profile Quad Flat No-leaded, needs pin count property to be unambiguous"},
{"name": "UQFN-x", "category": "hardware/electronics/packages",
"description": "Ultra-thin Quad Flat No-leaded, needs pin count property to be unambiguous"}
],
"properties": [
{ "name": "pin count", "unit_symbol": "", "unit_name": "", "unit_name_plural": "" }
],
"url": "https://en.wikipedia.org/wiki/List_of_integrated_circuit_packaging_types"
}

View file

@ -0,0 +1,62 @@
{
"depends": ["git:base"],
"categories": [
{ "name": "electrical"},
{ "name": "connectors"},
{ "name": "power", "parent": "connectors" }
],
"tags": [
{ "name": "braker", "category": "electrical" },
{ "name": "cable", "category": "electrical" },
{ "name": "connector", "category": "electrical" },
{ "name": "plug", "category": "connectors" },
{ "name": "socket", "category": "connectors" },
{ "name": "power", "category": "connectors" },
{ "name": "C1", "category": "connectors/power" },
{ "name": "C2", "category": "connectors/power" },
{ "name": "C3", "category": "connectors/power" },
{ "name": "C4", "category": "connectors/power" },
{ "name": "C5", "category": "connectors/power" },
{ "name": "C6", "category": "connectors/power" },
{ "name": "C7", "category": "connectors/power" },
{ "name": "C7P", "category": "connectors/power" },
{ "name": "C8", "category": "connectors/power" },
{ "name": "C8P", "category": "connectors/power" },
{ "name": "C9", "category": "connectors/power" },
{ "name": "C10", "category": "connectors/power" },
{ "name": "C11", "category": "connectors/power" },
{ "name": "C12", "category": "connectors/power" },
{ "name": "C13", "category": "connectors/power" },
{ "name": "C14", "category": "connectors/power" },
{ "name": "C15", "category": "connectors/power" },
{ "name": "C15A", "category": "connectors/power" },
{ "name": "C16", "category": "connectors/power" },
{ "name": "C16A", "category": "connectors/power" },
{ "name": "C17", "category": "connectors/power" },
{ "name": "C18", "category": "connectors/power" },
{ "name": "C19", "category": "connectors/power" },
{ "name": "C20", "category": "connectors/power" },
{ "name": "C21", "category": "connectors/power" },
{ "name": "C22", "category": "connectors/power" },
{ "name": "C23", "category": "connectors/power" },
{ "name": "C24", "category": "connectors/power" },
{ "name": "Type A", "category": "connectors/power", "description": "NEMA 1-15, U.S. 2 pin" },
{ "name": "Type B", "category": "connectors/power", "description": "NEMA 5-15, U.S. 3 pin" },
{ "name": "Type C", "category": "connectors/power", "description": "CEE 7/16, Europlug" },
{ "name": "Type D", "category": "connectors/power", "description": "BS 546, India 5A/15A" },
{ "name": "Type E", "category": "connectors/power", "description": "CEE 7/5, French 2 pin" },
{ "name": "Type F", "category": "connectors/power", "description": "CEE 7/4, Schuko" },
{ "name": "Type E/F", "category": "connectors/power", "description": "CEE 7/7, Schuko/French hybrid" },
{ "name": "Type G", "category": "connectors/power", "description": "BS 1363, U.K. 3 pin" },
{ "name": "Type H", "category": "connectors/power", "description": "SI 32 Israel"},
{ "name": "Type I", "category": "connectors/power", "description": "AS/NZS 3112, Australia 3 pin" },
{ "name": "Type J", "category": "connectors/power", "description": "SEV 1011, Swiss 3 pin" },
{ "name": "Type K", "category": "connectors/power", "description": "DS 60884-2-D1, Danish 3 pin" },
{ "name": "Type L", "category": "connectors/power", "description": "CEI 23-16/VII, Italian 3 pin" }
],
"properties": [
{ "name": "max current", "description": "Current rating"},
{ "name": "max power", "description": "Power rating" },
{ "name": "grid frequency", "description": "Typical frequency" }
]
}

View file

@ -0,0 +1,82 @@
{
"depends": [ "git:electrical" ],
"categories": [
{ "name": "pc", "description": "PC related" },
{ "name": "usb", "parent": "connectors" }
],
"tags": [
{ "name": "case", "category": "pc" },
{ "name": "cooler", "category": "pc" },
{ "name": "cpu" },
{ "name": "drone" },
{ "name": "fan", "category": "pc" },
{ "name": "gpu" },
{ "name": "hdd" },
{ "name": "headset" },
{ "name": "hub" },
{ "name": "iot" },
{ "name": "keyboard" },
{ "name": "laptop" },
{ "name": "memory" },
{ "name": "microphone" },
{ "name": "monitor" },
{ "name": "motherboard" },
{ "name": "mouse" },
{ "name": "pc"},
{ "name": "power supply" },
{ "name": "printer" },
{ "name": "router" },
{ "name": "scanner" },
{ "name": "server" },
{ "name": "smartphone" },
{ "name": "smartwatch" },
{ "name": "speaker" },
{ "name": "ssd" },
{ "name": "switch" },
{ "name": "tablet" },
{ "name": "watercooling" , "category": "pc"},
{ "name": "webcam" },
{ "name": "workstation" },
{ "name": "USB", "category": "connectors" },
{ "name": "Type A", "category": "connectors/usb" },
{ "name": "Type B", "category": "connectors/usb" },
{ "name": "Type C", "category": "connectors/usb" },
{ "name": "Micro", "category": "connectors/usb" },
{ "name": "Mini", "category": "connectors/usb" },
{ "name": "USB 2.0", "category": "connectors/usb" },
{ "name": "USB 3.0", "category": "connectors/usb" },
{ "name": "USB 3.1", "category": "connectors/usb" },
{ "name": "USB 3.2", "category": "connectors/usb" },
{ "name": "OTG", "category": "connectors/usb" },
{ "name": "thunderbolt", "category": "connectors/usb" },
{ "name": "24pin", "category": "connectors" },
{ "name": "8pin", "category": "connectors" },
{ "name": "atx", "category": "connectors" },
{ "name": "chinch", "category": "connectors" },
{ "name": "displayport", "category": "connectors" },
{ "name": "dvi", "category": "connectors" },
{ "name": "eps", "category": "connectors" },
{ "name": "floppy", "category": "connectors" },
{ "name": "hdmi", "category": "connectors" },
{ "name": "ide", "category": "connectors" },
{ "name": "jack", "category": "connectors" },
{ "name": "m.2", "category": "connectors" },
{ "name": "molex", "category": "connectors" },
{ "name": "p4", "category": "connectors" },
{ "name": "pcie", "category": "connectors" },
{ "name": "qsfp", "category": "connectors" },
{ "name": "qsfp+", "category": "connectors" },
{ "name": "qsfp28", "category": "connectors" },
{ "name": "rj11", "category": "connectors" },
{ "name": "rj45", "category": "connectors" },
{ "name": "sas", "category": "connectors" },
{ "name": "sata", "category": "connectors" },
{ "name": "scsi", "category": "connectors" },
{ "name": "sfp", "category": "connectors" },
{ "name": "sfp+", "category": "connectors" },
{ "name": "sfp28", "category": "connectors" },
{ "name": "toslink", "category": "connectors" },
{ "name": "vga", "category": "connectors" },
{ "name": "xlr", "category": "connectors" }
]
}

View file

@ -0,0 +1,30 @@
{
"depends": [ "git:base" ],
"categories": [
{ "name": "screws", "parent": "hardware"}
],
"tags": [
{"name": "m1", "category": "screws"},
{"name": "m2", "category": "screws"},
{"name": "m2.5", "category": "screws"},
{"name": "m3", "category": "screws"},
{"name": "m4", "category": "screws"},
{"name": "m5", "category": "screws"},
{"name": "m6", "category": "screws"},
{"name": "m8", "category": "screws"},
{"name": "m10", "category": "screws"},
{"name": "m12", "category": "screws"},
{"name": "m16", "category": "screws"},
{"name": "torx", "category": "screws"},
{"name": "hex", "category": "screws"},
{"name": "phillips", "category": "screws"},
{"name": "pozidriv", "category": "screws"},
{"name": "slotted", "category": "screws"},
{"name": "socket", "category": "screws"},
{"name": "flat", "category": "screws"},
{"name": "pan", "category": "screws"},
{"name": "button", "category": "screws"},
{"name": "countersunk", "category": "screws"},
{"name": "round", "category": "screws"}
]
}

View file

@ -0,0 +1,68 @@
{
"depends": ["git:base"],
"categories": [
{ "name": "powertools", "parent": "tools" }
],
"tags": [
{ "name": "3d printer"},
{ "name": "air compressor", "category": "powertools"},
{ "name": "air filter"},
{ "name": "automotive", "category": "tools" },
{ "name": "bandsaw"},
{ "name": "belt sander"},
{ "name": "bench grinder"},
{ "name": "circular saw", "category": "powertools"},
{ "name": "concrete", "category": "tools" },
{ "name": "construction", "category": "tools" },
{ "name": "corded", "category": "powertools" },
{ "name": "cordless", "category": "powertools" },
{ "name": "disc sander"},
{ "name": "drill press"},
{ "name": "drywall", "category": "tools" },
{ "name": "dust collector"},
{ "name": "dust mask"},
{ "name": "ear protection"},
{ "name": "electrical", "category": "tools" },
{ "name": "eye protection"},
{ "name": "fire extinguisher"},
{ "name": "first aid"},
{ "name": "generator", "category": "powertools"},
{ "name": "glue gun", "category": "powertools"},
{ "name": "grinder", "category": "powertools"},
{ "name": "hammer drill", "category": "powertools"},
{ "name": "handheld", "category": "tools" },
{ "name": "heat gun", "category": "powertools"},
{ "name": "impact driver", "category": "powertools"},
{ "name": "jigsaw"},
{ "name": "jointer"},
{ "name": "ladder"},
{ "name": "laser cutter"},
{ "name": "lathe"},
{ "name": "masonry", "category": "tools" },
{ "name": "mechanical", "category": "tools" },
{ "name": "metalworking", "category": "tools" },
{ "name": "mill"},
{ "name": "multitool"},
{ "name": "nailgun", "category": "powertools"},
{ "name": "oscillating tool", "category": "powertools"},
{ "name": "painting", "category": "tools" },
{ "name": "planer"},
{ "name": "plasma cutter"},
{ "name": "plumbing", "category": "tools" },
{ "name": "powerplane", "category": "powertools"},
{ "name": "pressure washer", "category": "powertools"},
{ "name": "roofing", "category": "tools" },
{ "name": "router table"},
{ "name": "router", "category": "powertools"},
{ "name": "sawhorse"},
{ "name": "sawzall", "category": "powertools"},
{ "name": "screwgun", "category": "powertools"},
{ "name": "stationary", "category": "tools" },
{ "name": "tablesaw"},
{ "name": "tile", "category": "tools" },
{ "name": "welder", "category": "powertools"},
{ "name": "woodworking", "category": "tools" },
{ "name": "workbench"},
{ "name": "worklight"}
]
}

View file

@ -1,11 +1,39 @@
from django.contrib import admin from django.contrib import admin
from toolshed.models import InventoryItem, Property, Tag, Category from toolshed.models import (
InventoryItem, ItemProperty, ItemTag, Property, Tag, Category, StorageLocation, WorkflowInstance,
)
class ItemTagInline(admin.TabularInline):
model = ItemTag
extra = 0
autocomplete_fields = ('tag',)
class ItemPropertyInline(admin.TabularInline):
model = ItemProperty
extra = 0
autocomplete_fields = ('property',)
class InventoryItemAdmin(admin.ModelAdmin): class InventoryItemAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner') list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner',
search_fields = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner') 'storage_location', 'get_tags', 'get_properties')
search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username',
'storage_location__name', 'tags__name', 'itemproperty__property__name')
inlines = (ItemTagInline, ItemPropertyInline)
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('tags', 'itemproperty_set__property')
@admin.display(description='Tags')
def get_tags(self, obj):
return ', '.join(tag.name for tag in obj.tags.all())
@admin.display(description='Properties')
def get_properties(self, obj):
return ', '.join(f'{ip.property.name}={ip.value}' for ip in obj.itemproperty_set.all())
admin.site.register(InventoryItem, InventoryItemAdmin) admin.site.register(InventoryItem, InventoryItemAdmin)
@ -13,7 +41,7 @@ admin.site.register(InventoryItem, InventoryItemAdmin)
class PropertyAdmin(admin.ModelAdmin): class PropertyAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin') list_display = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin')
search_fields = ('name', 'description', 'category', 'unit_symbol', 'base2_prefix', 'dimensions', 'origin') search_fields = ('name', 'description', 'category__name', 'unit_symbol', 'origin')
admin.site.register(Property, PropertyAdmin) admin.site.register(Property, PropertyAdmin)
@ -21,7 +49,7 @@ admin.site.register(Property, PropertyAdmin)
class TagAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'origin') list_display = ('name', 'description', 'category', 'origin')
search_fields = ('name', 'description', 'category', 'origin') search_fields = ('name', 'description', 'category__name', 'origin')
admin.site.register(Tag, TagAdmin) admin.site.register(Tag, TagAdmin)
@ -29,7 +57,26 @@ admin.site.register(Tag, TagAdmin)
class CategoryAdmin(admin.ModelAdmin): class CategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'parent', 'origin') list_display = ('name', 'description', 'parent', 'origin')
search_fields = ('name', 'description', 'parent', 'origin') search_fields = ('name', 'description', 'parent__name', 'origin')
admin.site.register(Category, CategoryAdmin) admin.site.register(Category, CategoryAdmin)
class StorageLocationAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'parent', 'owner')
search_fields = ('name', 'description', 'category__name', 'parent__name', 'owner__username')
list_filter = ('category', 'owner')
admin.site.register(StorageLocation, StorageLocationAdmin)
class WorkflowInstanceAdmin(admin.ModelAdmin):
list_display = ('name', 'state', 'owner', 'created_at', 'updated_at')
search_fields = ('name', 'owner__username')
list_filter = ('state', 'created_at', 'owner')
readonly_fields = ('created_at', 'updated_at')
admin.site.register(WorkflowInstance, WorkflowInstanceAdmin)

View file

@ -1,14 +1,14 @@
from django.db import transaction from django.db import transaction
from django.urls import path from django.urls import path
from rest_framework import routers, viewsets from rest_framework import routers, viewsets, status
from rest_framework.decorators import authentication_classes, api_view, permission_classes from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response 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 toolshed.models import InventoryItem, StorageLocation from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer from toolshed.serializers import InventoryItemSerializer, StorageLocationSerializer, WorkflowInstanceSerializer
router = routers.SimpleRouter() router = routers.SimpleRouter()
@ -22,7 +22,8 @@ def inventory_items(identity):
except ToolshedUser.DoesNotExist: except ToolshedUser.DoesNotExist:
pass pass
for friend in identity.friends.all(): for friend in identity.friends.all():
if friend_user := friend.user.get(): friend_user = friend.user.first()
if friend_user:
for item in friend_user.inventory_items.all(): for item in friend_user.inventory_items.all():
if item.availability_policy != 'private': if item.availability_policy != 'private':
yield item yield item
@ -52,13 +53,25 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
instance.delete() instance.delete()
def matches_query(item, query):
query = query.lower()
if query in item.name.lower():
return True
if item.description and query in item.description.lower():
return True
if any(query in tag.name.lower() for tag in item.tags.all()):
return True
return False
@api_view(['GET']) @api_view(['GET'])
@authentication_classes([SignatureAuthentication]) @authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def search_inventory_items(request): def search_inventory_items(request):
query = request.query_params.get('query') query = request.query_params.get('query')
if query: if query:
return Response(InventoryItemSerializer(inventory_items(request.user), many=True).data) matching_items = [item for item in inventory_items(request.user) if matches_query(item, query)]
return Response(InventoryItemSerializer(matching_items, many=True).data)
return Response({'error': 'No query provided.'}, status=400) return Response({'error': 'No query provided.'}, status=400)
@ -72,9 +85,47 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
return StorageLocation.objects.filter(owner=self.request.user.user.get()) return StorageLocation.objects.filter(owner=self.request.user.user.get())
return StorageLocation.objects.none() return StorageLocation.objects.none()
def perform_create(self, serializer):
with transaction.atomic():
serializer.save(owner=self.request.user.user.get())
def perform_update(self, serializer):
with transaction.atomic():
if serializer.instance.owner == self.request.user.user.get():
serializer.save()
def perform_destroy(self, instance):
if instance.owner == self.request.user.user.get():
instance.delete()
class WorkflowInstanceViewSet(viewsets.ModelViewSet):
serializer_class = WorkflowInstanceSerializer
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
return WorkflowInstance.objects.filter(owner=self.request.user.user.get())
return WorkflowInstance.objects.none()
def perform_create(self, serializer):
with transaction.atomic():
serializer.save(owner=self.request.user.user.get())
def perform_update(self, serializer):
with transaction.atomic():
if serializer.instance.owner == self.request.user.user.get():
serializer.save()
def perform_destroy(self, instance):
if instance.owner == self.request.user.user.get():
instance.delete()
router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items') router.register(r'inventory_items', InventoryItemViewSet, basename='inventory_items')
router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations') router.register(r'storage_locations', StorageLocationViewSet, basename='storage_locations')
router.register(r'workflows', WorkflowInstanceViewSet, basename='workflows')
urlpatterns = router.urls + [ urlpatterns = router.urls + [
path('search/', search_inventory_items, name='search_inventory_items'), path('search/', search_inventory_items, name='search_inventory_items'),

View file

@ -0,0 +1,253 @@
from django.http import HttpResponse
from django.urls import path
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthentication
from toolshed.offlinedata import (
inventory_rows, friend_rows, location_rows, inventory_files, rows_to_csv,
import_locations, import_friends, import_inventory,
profile_data, profile_picture_files, settings_data, import_profile, import_settings,
delete_user_data, delete_user_account,
)
def local_user_or_none(identity):
"""Resolve the local ToolshedUser associated with an authenticated KnownIdentity, if any.
Returns None if the identity belongs to an external/remote user with no local account.
"""
if identity is None:
return None
return identity.user.first()
def user_data(user):
import io
import json
import zipfile
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
zip_file.writestr('profile.json', json.dumps(profile_data(user)))
zip_file.writestr('settings.json', json.dumps(settings_data(user)))
inventory_csv = b''.join(rows_to_csv(inventory_rows(user)))
zip_file.writestr('inventory.csv', inventory_csv)
friends_csv = b''.join(rows_to_csv(friend_rows(user)))
zip_file.writestr('friends.csv', friends_csv)
locations_csv = b''.join(rows_to_csv(location_rows(user)))
zip_file.writestr('locations.csv', locations_csv)
written_files = set()
for arcname, data in profile_picture_files(user):
zip_file.writestr(arcname, data)
written_files.add(arcname)
for arcname, data in inventory_files(user):
if arcname in written_files:
continue
zip_file.writestr(arcname, data)
written_files.add(arcname)
return zip_buffer.getvalue()
def parse_user_data(data):
import io
import zipfile
with zipfile.ZipFile(io.BytesIO(data), "r") as zip_file:
for file_name in zip_file.namelist():
yield file_name, zip_file.read(file_name)
def import_files(zip_file):
"""Fault-tolerant extraction of the 'files/' subfolder into File objects.
Returns a dict mapping the zip arcname to the created/existing File instance. Entries that
fail to read or save are silently skipped so a single corrupt attachment doesn't abort the import.
"""
from hashlib import sha256
import mimetypes
from django.core.files.base import ContentFile
from django.db import transaction
from files.models import File
result = {}
for name in zip_file.namelist():
if not name.startswith('files/') or name == 'files/':
continue
try:
with transaction.atomic():
data = zip_file.read(name)
content_hash = sha256(data).hexdigest()
mime_type, _ = mimetypes.guess_type(name)
file_obj = File.objects.filter(hash=content_hash).first()
if file_obj is None:
file_obj = File.objects.create(
file=ContentFile(data, content_hash),
mime_type=mime_type or 'application/octet-stream',
hash=content_hash,
)
result[name] = file_obj
except Exception as error:
print(f'Skipping file "{name}" during import: {error}')
return result
def import_user_data(user, data):
"""Fault-tolerant import of an export zip produced by `user_data()`.
Any of 'profile.json', 'settings.json', 'inventory.csv', 'friends.csv', 'locations.csv' or
the 'files/' subfolder may be missing; whatever is present is imported and everything else
is silently skipped.
Each section is imported inside its own transaction savepoint (`transaction.atomic()`), so a
DB-level failure in one section (e.g. a profile.json whose email collides with another
account) can't leave the connection in a broken/aborted-transaction state that would
otherwise silently take down every subsequent section (including the inventory items and
their properties) with an opaque "current transaction is aborted" error.
"""
import io
import zipfile
from django.db import transaction
summary = {'profile': False, 'settings': 0, 'locations': 0, 'friends': 0, 'inventory_items': 0, 'files': 0,
'errors': []}
with zipfile.ZipFile(io.BytesIO(data), 'r') as zip_file:
names = set(zip_file.namelist())
available_files = import_files(zip_file)
summary['files'] = len(available_files)
if 'profile.json' in names:
try:
with transaction.atomic():
summary['profile'] = import_profile(user, zip_file.read('profile.json'), available_files)
except Exception as error:
summary['errors'].append(f'Could not import profile.json: {error}')
if 'settings.json' in names:
try:
with transaction.atomic():
summary['settings'] = import_settings(user, zip_file.read('settings.json'))
except Exception as error:
summary['errors'].append(f'Could not import settings.json: {error}')
if 'locations.csv' in names:
try:
with transaction.atomic():
summary['locations'] = import_locations(user, zip_file.read('locations.csv'))
except Exception as error:
summary['errors'].append(f'Could not import locations.csv: {error}')
if 'friends.csv' in names:
try:
with transaction.atomic():
summary['friends'] = import_friends(user, zip_file.read('friends.csv'))
except Exception as error:
summary['errors'].append(f'Could not import friends.csv: {error}')
if 'inventory.csv' in names:
try:
with transaction.atomic():
summary['inventory_items'], inventory_errors = import_inventory(
user, zip_file.read('inventory.csv'), available_files)
summary['errors'].extend(inventory_errors)
except Exception as error:
summary['errors'].append(f'Could not import inventory.csv: {error}')
return summary
def _extract_zip_bytes(zip_payload):
"""Normalize the incoming 'zip' request payload (uploaded file, base64 string, or raw bytes)."""
import base64
if hasattr(zip_payload, 'read'):
return zip_payload.read()
if isinstance(zip_payload, str):
try:
return base64.b64decode(zip_payload, validate=True)
except Exception:
return zip_payload.encode('utf-8')
return zip_payload
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def import_data(request, format=None):
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
zip_payload = request.data.get('zip')
if not zip_payload:
return Response(status=400)
try:
zip_bytes = _extract_zip_bytes(zip_payload)
summary = import_user_data(local_user, zip_bytes)
except Exception as error:
return Response({'detail': f'Could not read zip file: {error}'}, status=400)
return Response(summary, status=200)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def export_data(request, format=None):
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
return HttpResponse(user_data(local_user), content_type='application/zip', status=200)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def delete_data(request, format=None):
"""Wipe all of the local user's data (everything included in the export), but keep the account.
This is *not* account deletion/closure - the user stays logged in and can keep using the
account (with all their data reset to a blank slate) afterwards.
"""
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
summary = delete_user_data(local_user)
return Response(summary, status=200)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication])
def delete_account(request, format=None):
"""Permanently close the local user's account, after wiping all of its data.
Unlike `delete_data()`, this also removes the account itself - the user can no longer log
in afterwards. Their public identity is kept so remote friends/history referencing it stay
intact, but the local ToolshedUser row is gone.
"""
local_user = local_user_or_none(request.user)
if local_user is None:
return Response({'detail': 'This endpoint is only available to local users'}, status=403)
summary = delete_user_account(local_user)
return Response(summary, status=200)
urlpatterns = [
path('export/', export_data, name='export_data'),
path('import/', import_data, name='import_data'),
path('account_data/', delete_data, name='delete_data'),
path('account/', delete_account, name='delete_account'),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.2 on 2025-09-26 10:20
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('toolshed', '0006_alter_tag_options_alter_category_name_and_more'),
]
operations = [
migrations.CreateModel(
name='WorkflowInstance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('state', models.CharField(max_length=255)),
('payload', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workflows', to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.2.2 on 2026-07-23 02:02
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0007_workflowinstance'),
]
operations = [
migrations.AlterField(
model_name='inventoryitem',
name='storage_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventory_items', to='toolshed.storagelocation'),
),
]

View file

@ -1,5 +1,5 @@
from django.db import models from django.db import models
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django_softdelete.models import SoftDeleteModel from django_softdelete.models import SoftDeleteModel
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
@ -26,6 +26,10 @@ class Category(SoftDeleteModel):
parent = str(self.parent) + "/" if self.parent else "" parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name return parent + self.name
def get_handle(self):
"""Return a fully qualified handle like 'git:base#category:tools'"""
return f"{self.origin}#category:{self.name}"
class Property(models.Model): class Property(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -50,6 +54,10 @@ class Property(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_handle(self):
"""Return a fully qualified handle like 'git:base#property:length'"""
return f"{self.origin}#property:{self.name}"
class Tag(models.Model): class Tag(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -69,6 +77,10 @@ class Tag(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def get_handle(self):
"""Return a fully qualified handle like 'git:tools#tag:drill'"""
return f"{self.origin}#tag:{self.name}"
class InventoryItem(SoftDeleteModel): class InventoryItem(SoftDeleteModel):
AVAILABILITY_POLICY_CHOICES = ( AVAILABILITY_POLICY_CHOICES = (
@ -90,7 +102,7 @@ class InventoryItem(SoftDeleteModel):
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items') tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
properties = models.ManyToManyField(Property, through='ItemProperty') properties = models.ManyToManyField(Property, through='ItemProperty')
files = models.ManyToManyField(File, related_name='connected_items') files = models.ManyToManyField(File, related_name='connected_items')
storage_location = models.ForeignKey('StorageLocation', on_delete=models.CASCADE, null=True, blank=True, storage_location = models.ForeignKey('StorageLocation', on_delete=models.SET_NULL, null=True, blank=True,
related_name='inventory_items') related_name='inventory_items')
def clean(self): def clean(self):
@ -120,3 +132,17 @@ class StorageLocation(models.Model):
def __str__(self): def __str__(self):
parent = str(self.parent) + "/" if self.parent else "" parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name return parent + self.name
class WorkflowInstance(models.Model):
name = models.CharField(max_length=255)
state = models.CharField(max_length=255)
payload = models.TextField(default='', blank=True) # an opaque, frontend-serialized JSON string on the backend.
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='workflows')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.name} ({self.state})"

View file

@ -0,0 +1,670 @@
"""Data helpers for building/importing a user's offline export.
These functions deal with the toolshed models and CSV row shapes only - they know nothing
about the zip container format or how the request is authenticated. See toolshed/api/offlinedata.py
for the zip-building/parsing and the API views themselves.
"""
def inventory_rows(user):
"""Generator that yields the given user's inventory items as flattened dicts, one per row."""
import mimetypes
from toolshed.models import InventoryItem
items = (InventoryItem.objects
.filter(owner=user)
.select_related('category', 'storage_location')
.prefetch_related('tags', 'itemproperty_set__property', 'files'))
for item in items:
file_paths = []
for f in item.files.all():
extension = mimetypes.guess_extension(f.mime_type) or ''
file_paths.append(f'files/{f.hash}{extension}')
yield {
'id': item.id,
'name': item.name or '',
'description': item.description or '',
'category': item.category.get_handle() if item.category else '',
'availability_policy': item.availability_policy,
'owned_quantity': item.owned_quantity,
'storage_location': str(item.storage_location) if item.storage_location else '',
'tags': ', '.join(tag.get_handle() for tag in item.tags.all()),
'properties': _encode_properties_cell(item.itemproperty_set.all()),
'files': ', '.join(file_paths),
'created_at': item.created_at.isoformat() if item.created_at else '',
}
def friend_rows(user):
"""Generator that yields the given user's friends (known identities) as flattened dicts, one per row."""
friends = user.friends.all()
for friend in friends:
yield {
'username': friend.username,
'domain': friend.domain,
'handle': f'{friend.username}@{friend.domain}',
'public_key': friend.public_key,
}
def location_path(location):
"""Recursively build the '/'-joined path of a StorageLocation, following its parent chain."""
if location.parent:
return location_path(location.parent) + '/' + location.name
return location.name
def location_rows(user):
"""Generator that yields the given user's storage locations as flattened dicts, one per row."""
from toolshed.models import StorageLocation
locations = StorageLocation.objects.filter(owner=user).select_related('category', 'parent')
for location in locations:
yield {
'id': location.id,
'name': location.name,
'description': location.description or '',
'category': str(location.category) if location.category else '',
'parent': str(location.parent) if location.parent else '',
'path': location_path(location),
}
def inventory_files(user):
"""Generator that yields (arcname, data) for each unique file attached to the user's inventory items.
Files are deduplicated by hash and placed under a 'files/' subfolder, keeping their original
extension (guessed from mime_type) so attachments and images remain viewable once extracted.
"""
import mimetypes
from toolshed.models import InventoryItem
seen_hashes = set()
items = InventoryItem.objects.filter(owner=user).prefetch_related('files')
for item in items:
for file in item.files.all():
if file.hash in seen_hashes:
continue
seen_hashes.add(file.hash)
extension = mimetypes.guess_extension(file.mime_type) or ''
arcname = f'files/{file.hash}{extension}'
file.file.open('rb')
try:
data = file.file.read()
finally:
file.file.close()
yield arcname, data
def profile_data(user):
"""Return the given user's profile as a dict, matching profile.json in the export zip."""
import mimetypes
data = {
'username': user.username,
'domain': user.domain,
'email': user.email,
'first_name': user.first_name or '',
'last_name': user.last_name or '',
'profile_picture': None,
}
if user.profile_picture:
extension = mimetypes.guess_extension(user.profile_picture.mime_type) or ''
data['profile_picture'] = f'files/{user.profile_picture.hash}{extension}'
return data
def profile_picture_files(user):
"""Generator that yields (arcname, data) for the user's profile picture, if one is set.
Kept separate from `inventory_files()` so the profile picture is included in the export
even for users with no inventory items or whose picture isn't attached to any item.
"""
import mimetypes
if not user.profile_picture:
return
extension = mimetypes.guess_extension(user.profile_picture.mime_type) or ''
arcname = f'files/{user.profile_picture.hash}{extension}'
user.profile_picture.file.open('rb')
try:
data = user.profile_picture.file.read()
finally:
user.profile_picture.file.close()
yield arcname, data
def settings_data(user):
"""Return the given user's account-level preferences as a {key: value} dict for settings.json.
Only account preferences (AccountPreference) are exported; device-level preferences stay
in the browser's local storage since they describe the device, not the account.
"""
return {pref.key: pref.value for pref in user.preferences.all()}
def import_profile(user, data, available_files):
"""Fault-tolerant import of profile.json, updating the user's editable profile fields.
Only 'first_name', 'last_name', 'email' and 'profile_picture' (a 'files/...' path present in
`available_files`) are applied; 'username' and 'domain' are ignored since they identify the
account itself and can't be changed by an import.
"""
import json
try:
profile = json.loads(data.decode('utf-8'))
if not isinstance(profile, dict):
return False
if 'first_name' in profile:
user.first_name = profile.get('first_name') or ''
if 'last_name' in profile:
user.last_name = profile.get('last_name') or ''
if profile.get('email'):
user.email = profile['email']
picture_path = profile.get('profile_picture')
if picture_path and picture_path in available_files:
user.profile_picture = available_files[picture_path]
user.save()
return True
except Exception as error:
print(f'Skipping profile.json: {error}')
return False
def import_settings(user, data):
"""Fault-tolerant import of settings.json into the user's account-level preferences.
Each top-level key/value pair is upserted as an AccountPreference; unreadable data or a
non-object payload is skipped rather than aborting the whole import.
"""
import json
from authentication.models import AccountPreference
try:
settings = json.loads(data.decode('utf-8'))
if not isinstance(settings, dict):
return 0
imported = 0
for key, value in settings.items():
try:
AccountPreference.objects.update_or_create(user=user, key=key, defaults={'value': value})
imported += 1
except Exception as error:
print(f'Skipping setting "{key}": {error}')
return imported
except Exception as error:
print(f'Skipping settings.json: {error}')
return 0
def delete_user_data(user):
"""Permanently delete everything that `user_data()` exports, keeping the account itself intact.
This removes the user's inventory items (hard delete, bypassing soft-delete), storage
locations, account preferences, the friends relation on their public identity, the profile
picture, and any File blobs (profile picture / inventory attachments) that would otherwise be
orphaned - i.e. not referenced by any other inventory item or user. Files still referenced
elsewhere (they're deduplicated by content hash) are left untouched. The ToolshedUser account
(and its underlying KnownIdentity) is *not* deleted - this only wipes the account's data, it
doesn't close the account. See `delete_account()` in toolshed/api/offlinedata.py for that.
Returns a summary dict describing what was removed.
"""
from django.db import transaction
from toolshed.models import InventoryItem, StorageLocation
summary = {'inventory_items': 0, 'locations': 0, 'settings': 0, 'friends': 0, 'files': 0}
with transaction.atomic():
candidate_file_ids = set(
InventoryItem.global_objects.filter(owner=user).values_list('files__id', flat=True))
candidate_file_ids.discard(None)
if user.profile_picture_id:
candidate_file_ids.add(user.profile_picture_id)
for item in InventoryItem.global_objects.filter(owner=user):
item.hard_delete()
summary['inventory_items'] += 1
summary['locations'], _ = StorageLocation.objects.filter(owner=user).delete()
summary['settings'], _ = user.preferences.all().delete()
summary['friends'] = user.public_identity.friends.count()
user.public_identity.friends.clear()
user.profile_picture = None
user.save(update_fields=['profile_picture'])
summary['files'] = _delete_orphaned_files(candidate_file_ids)
return summary
def delete_user_account(user):
"""Permanently delete the local user's account, after wiping all of its data.
This first calls `delete_user_data()` to remove everything covered by the data export
(inventory, locations, settings, friends relation, profile picture and orphaned files),
then deletes the ToolshedUser row itself. The underlying KnownIdentity is kept so remote
friends/history relating to this identity remain intact - only the local account is closed.
Returns a summary dict describing what was removed, with `account` set to True.
"""
from django.db import transaction
with transaction.atomic():
summary = delete_user_data(user)
user.delete()
summary['account'] = True
return summary
def _delete_orphaned_files(file_ids):
"""Delete File rows (and their underlying blobs) in `file_ids` that are no longer referenced.
A File is considered orphaned once no InventoryItem and no ToolshedUser (profile picture)
references it anymore. Returns the number of files deleted.
"""
from files.models import File
deleted = 0
for file_obj in File.objects.filter(id__in=file_ids):
if file_obj.connected_items.exists() or file_obj.profile_picture_users.exists():
continue
file_obj.file.delete(save=False)
file_obj.delete()
deleted += 1
return deleted
def rows_to_csv(rows, fieldnames=None, encoding='utf-8'):
"""Generator that consumes an iterable of dicts and yields encoded CSV data chunk by chunk."""
import csv
class _Echo:
"""A file-like object whose write() just returns what was passed, for streaming csv.writer output."""
def write(self, value):
return value
rows = iter(rows)
try:
first_row = next(rows)
except StopIteration:
return
if fieldnames is None:
fieldnames = list(first_row.keys())
writer = csv.DictWriter(_Echo(), fieldnames=fieldnames)
yield writer.writeheader().encode(encoding)
yield writer.writerow(first_row).encode(encoding)
for row in rows:
yield writer.writerow(row).encode(encoding)
def _read_csv_rows(data, encoding='utf-8'):
"""Decode CSV bytes and yield rows as dicts keyed by the header labels (not column offsets)."""
import csv
import io
reader = csv.DictReader(io.StringIO(data.decode(encoding)))
for row in reader:
yield row
def _field_is_optional(model, field_name):
"""Return True if a field may be omitted: it's a relation, has a default, or allows null/blank."""
try:
field = model._meta.get_field(field_name)
except Exception:
return True
if getattr(field, 'many_to_many', False) or getattr(field, 'one_to_many', False):
return True
return bool(getattr(field, 'null', False) or getattr(field, 'blank', False) or field.has_default())
def get_or_create_category(path):
"""Resolve or create a Category from a '/'-separated path such as 'Electronics/Cables'."""
from toolshed.models import Category
parent = None
category = None
for part in (p for p in path.split('/') if p):
category, _ = Category.objects.get_or_create(name=part, parent=parent, defaults={'origin': 'import'})
parent = category
return category
def import_locations(user, data):
"""Fault-tolerant import of locations.csv into StorageLocations owned by `user`.
Rows are read by header label. Rows missing the required 'name' column are skipped.
Optional columns ('description', 'category') are simply omitted if absent. Locations are
processed in path-depth order so a child's parent already exists by the time it's needed.
Each row runs in its own transaction savepoint so a DB-level failure on one row (e.g. a
constraint violation) can't poison the surrounding transaction and silently break every
subsequent row.
"""
from django.db import transaction
from toolshed.models import StorageLocation
rows = list(_read_csv_rows(data))
rows.sort(key=lambda row: (row.get('path') or row.get('name') or '').count('/'))
resolved_by_path = {}
imported = 0
for row in rows:
try:
with transaction.atomic():
name = (row.get('name') or '').strip()
if not name and not _field_is_optional(StorageLocation, 'name'):
continue # required column missing, skip this row
path = (row.get('path') or name).strip()
parent = None
if '/' in path:
parent = resolved_by_path.get(path.rsplit('/', 1)[0])
category = None
category_path = row.get('category')
if category_path:
category = get_or_create_category(category_path)
location, _ = StorageLocation.objects.update_or_create(
owner=user, name=name, parent=parent,
defaults={
'description': row.get('description', '') or '',
'category': category,
},
)
resolved_by_path[path] = location
imported += 1
except Exception as error:
print(f'Skipping location row {row}: {error}')
return imported
def import_friends(user, data):
"""Fault-tolerant import of friends.csv, adding valid entries to the user's known friends."""
from django.db import transaction
from authentication.models import KnownIdentity
imported = 0
for row in _read_csv_rows(data):
try:
with transaction.atomic():
username = row.get('username')
domain = row.get('domain')
handle = row.get('handle')
if (not username or not domain) and handle and '@' in handle:
username, domain = handle.split('@', 1)
public_key = row.get('public_key')
if not username or not domain or not public_key:
continue # required fields missing, skip this row
identity, _ = KnownIdentity.objects.get_or_create(
username=username, domain=domain,
defaults={'public_key': public_key},
)
user.public_identity.friends.add(identity)
imported += 1
except Exception as error:
print(f'Skipping friend row {row}: {error}')
return imported
class _HandleNotFound(Exception):
"""Raised when a fully qualified handle (e.g. 'git:base#tag:drill') can't be resolved.
This intentionally aborts the whole row (rather than falling back to creating a new
entity), since a handle references a *specific* entity from a *specific* origin - silently
creating a new local entity named after the raw handle string would be incorrect.
"""
def _resolve_handle(value, entity_type, model):
"""Resolve a fully qualified handle (e.g. 'git:base#tag:drill') to an *existing* model instance.
Never creates anything: a handle references a specific entity from a specific origin, so
silently creating a new local entity named after the raw handle string would be incorrect.
Raises `_HandleNotFound` if the handle's entity type doesn't match or no such object exists,
so the caller can skip the row and report a helpful error instead.
"""
origin, rest = value.split('#', 1)
if ':' in rest:
found_type, name = rest.split(':', 1)
if found_type != entity_type:
raise _HandleNotFound(
f"expected a {entity_type} handle but got '{value}' (type '{found_type}')")
else:
name = rest
try:
return model.objects.get(origin=origin, name=name)
except model.DoesNotExist:
raise _HandleNotFound(f"{entity_type} '{value}' does not exist, skipping item")
def _quote_value_if_needed(value):
"""Wrap `value` in double quotes (CSV-style, doubling any embedded quotes) if it contains a
comma or a quote character, so it survives sitting inside a comma-separated "handle=value"
list unambiguously.
"""
if any(ch in value for ch in ',"'):
return '"' + value.replace('"', '""') + '"'
return value
def _encode_properties_cell(item_properties):
"""Encode an item's properties as a comma-separated "handle=value" list for the 'properties'
CSV cell, quoting a value (CSV-style) when it contains a comma or a quote character so it
still round-trips correctly. See `_parse_properties_cell()` for the reader side.
"""
entries = [
f"{ip.property.get_handle()}={_quote_value_if_needed(ip.value or '')}"
for ip in item_properties
]
return ', '.join(entries)
def _split_quoted_comma_list(raw_value):
"""Split a comma-separated list into entries, honouring double-quoted substrings (CSV-style,
wherever they appear in an entry) so a quoted value's own commas aren't mistaken for
separators. Doubled quotes ("") inside a quoted substring are unescaped to a single literal
quote, and the surrounding quotes themselves are stripped from the result.
Only the single space after each ", " separator (as written by `_encode_properties_cell()`)
is dropped - unlike a blanket `.strip()`, this preserves any leading/trailing whitespace that
is genuinely part of a (quoted) value.
"""
entries = []
current = []
in_quotes = False
i = 0
length = len(raw_value)
while i < length:
char = raw_value[i]
if char == '"':
if in_quotes and i + 1 < length and raw_value[i + 1] == '"':
current.append('"')
i += 2
continue
in_quotes = not in_quotes
i += 1
continue
if char == ',' and not in_quotes:
entries.append(''.join(current))
current = []
i += 1
if i < length and raw_value[i] == ' ':
i += 1 # skip the single space of the ", " separator
continue
current.append(char)
i += 1
entries.append(''.join(current))
return [entry for entry in entries if entry]
def _parse_properties_cell(raw_value, resolve_property):
"""Parse the 'properties' CSV cell into a list of (Property, value) tuples.
The cell is a comma-separated list of "handle=value" entries; a value containing a comma or
a quote character is double-quoted (CSV-style) by `_encode_properties_cell()` so it survives
the round trip intact.
"""
raw_value = (raw_value or '').strip()
if not raw_value:
return []
properties = []
for prop_entry in _split_quoted_comma_list(raw_value):
if '=' not in prop_entry:
continue
prop_name, value = prop_entry.split('=', 1)
prop = resolve_property(prop_name.strip())
if prop:
properties.append((prop, value))
return properties
def import_inventory(user, data, available_files):
"""Fault-tolerant import of inventory.csv into InventoryItems owned by `user`.
`available_files` maps the 'files' column's paths to File instances successfully extracted
from the zip; references to missing files are ignored rather than failing the row.
If a row references a fully qualified tag/property/category handle that doesn't exist
locally, the whole item is skipped (rather than creating a bogus local entity named after
the raw handle) and a helpful message is added to the returned `errors` list. Each row runs
in its own transaction savepoint so a DB-level failure on one row can't poison the
surrounding transaction and silently break every subsequent row.
"""
from django.db import transaction
from toolshed.models import Category, InventoryItem, ItemProperty, StorageLocation, Tag, Property
def resolve_category_from_csv(value):
"""Resolve a category from CSV - supports both old format and fully qualified handles"""
if not value:
return None
if '#' in value:
return _resolve_handle(value, 'category', Category)
# Fallback to old path-based lookup
return get_or_create_category(value)
def resolve_tag_from_csv(value):
"""Resolve a tag from CSV - supports both old format and fully qualified handles"""
if not value:
return None
if '#' in value:
return _resolve_handle(value, 'tag', Tag)
# Fallback to old name-only lookup or create
return Tag.objects.get_or_create(name=value, category=None, defaults={'origin': 'import'})[0]
def resolve_property_from_csv(value):
"""Resolve a property from CSV - supports both old format and fully qualified handles"""
if not value:
return None
if '#' in value:
return _resolve_handle(value, 'property', Property)
# Fallback to old name-only lookup or create
return Property.objects.get_or_create(name=value, category=None, defaults={'origin': 'import'})[0]
imported = 0
errors = []
for row in _read_csv_rows(data):
name = (row.get('name') or '').strip()
try:
with transaction.atomic():
file_paths = [p.strip() for p in (row.get('files') or '').split(',') if p.strip()]
files = [available_files[p] for p in file_paths if p in available_files]
if not name and not files:
continue # InventoryItem requires a name or at least one file
category = resolve_category_from_csv((row.get('category') or '').strip())
tags = []
for tag_name in (row.get('tags') or '').split(','):
tag_name = tag_name.strip()
if tag_name:
tag = resolve_tag_from_csv(tag_name)
if tag:
tags.append(tag)
properties = _parse_properties_cell(row.get('properties') or '', resolve_property_from_csv)
storage_location = None
location_path_value = row.get('storage_location')
if location_path_value:
storage_location = StorageLocation.objects.filter(
owner=user, name=location_path_value.rsplit('/', 1)[-1]).first()
try:
owned_quantity = int(row.get('owned_quantity') or 1)
except (TypeError, ValueError):
owned_quantity = 1
item = InventoryItem.objects.create(
owner=user,
name=name or None,
description=row.get('description', '') or '',
category=category,
availability_policy=row.get('availability_policy') or 'private',
owned_quantity=owned_quantity,
storage_location=storage_location,
)
for tag in tags:
item.tags.add(tag, through_defaults={})
for prop, value in properties:
ItemProperty.objects.create(inventory_item=item, property=prop, value=value)
for file in files:
item.files.add(file)
imported += 1
except _HandleNotFound as error:
message = f"Skipping item '{name or 'unnamed'}': {error}"
print(message)
errors.append(message)
except Exception as error:
message = f'Skipping inventory row {row}: {error}'
print(message)
errors.append(message)
return imported, errors

View file

@ -3,7 +3,47 @@ from authentication.models import KnownIdentity, ToolshedUser, FriendRequestInco
from authentication.serializers import OwnerSerializer from authentication.serializers import OwnerSerializer
from files.models import File from files.models import File
from files.serializers import FileSerializer from files.serializers import FileSerializer
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance
def parse_handle(handle):
"""Parse a fully qualified handle like 'git:base#property:length' into (origin, entity_type, name)"""
if '#' not in handle:
# Fallback to old format (just name)
return None, None, handle
origin, rest = handle.split('#', 1)
if ':' not in rest:
return origin, None, rest
entity_type, name = rest.split(':', 1)
return origin, entity_type, name
def resolve_category_handle(handle):
"""Resolve a fully qualified handle to a Category object"""
origin, entity_type, name = parse_handle(handle)
if origin and entity_type == 'category':
return Category.objects.get(origin=origin, name=name)
# Fallback to name-only lookup
return Category.objects.get(name=handle.split('/')[-1])
def resolve_property_handle(handle):
"""Resolve a fully qualified handle to a Property object"""
origin, entity_type, name = parse_handle(handle)
if origin and entity_type == 'property':
return Property.objects.get(origin=origin, name=name)
# Fallback to name-only lookup
return Property.objects.get(name=handle)
def resolve_tag_handle(handle):
"""Resolve a fully qualified handle to a Tag object"""
origin, entity_type, name = parse_handle(handle)
if origin and entity_type == 'tag':
return Tag.objects.get(origin=origin, name=name)
# Fallback to name-only lookup
return Tag.objects.get(name=handle)
class FriendSerializer(serializers.ModelSerializer): class FriendSerializer(serializers.ModelSerializer):
@ -29,33 +69,45 @@ class FriendRequestSerializer(serializers.ModelSerializer):
class PropertySerializer(serializers.ModelSerializer): class PropertySerializer(serializers.ModelSerializer):
category = serializers.SlugRelatedField(queryset=Category.objects.all(), slug_field='name') category = serializers.SerializerMethodField()
handle = serializers.SerializerMethodField()
def get_category(self, obj):
return resolve_category_handle(obj.category.get_handle()) if obj.category else None
def get_handle(self, obj):
return obj.get_handle()
class Meta: class Meta:
model = Property model = Property
fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix'] fields = ['name', 'description', 'category', 'unit_symbol', 'unit_name', 'unit_name_plural', 'base2_prefix', 'handle']
class CategorySerializer(serializers.ModelSerializer): class CategorySerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
class Meta: class Meta:
model = Category model = Category
fields = ['name'] fields = ['name', 'handle']
def get_handle(self, obj):
return obj.get_handle()
def to_representation(self, instance): def to_representation(self, instance):
return str(instance) return instance.name
def to_internal_value(self, data): def to_internal_value(self, data):
return Category.objects.get(name=data.split("/")[-1]) return resolve_category_handle(data.split("/")[-1])
class StorageLocationSerializer(serializers.ModelSerializer): class StorageLocationSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True) owner = OwnerSerializer(read_only=True)
category = CategorySerializer(required=False, allow_null=True) category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
path = serializers.SerializerMethodField() path = serializers.SerializerMethodField()
class Meta: class Meta:
model = StorageLocation model = StorageLocation
fields = ['id', 'name', 'description', 'path', 'category', 'owner'] fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
read_only_fields = ['path'] read_only_fields = ['path']
@staticmethod @staticmethod
@ -67,23 +119,28 @@ class StorageLocationSerializer(serializers.ModelSerializer):
class ItemPropertySerializer(serializers.ModelSerializer): class ItemPropertySerializer(serializers.ModelSerializer):
property = PropertySerializer(read_only=True) property = PropertySerializer(read_only=True)
handle = serializers.SerializerMethodField()
class Meta: class Meta:
model = ItemProperty model = ItemProperty
fields = ['property', 'value'] fields = ['property', 'value', 'handle']
def get_handle(self, obj):
return obj.property.get_handle()
def to_representation(self, instance): def to_representation(self, instance):
return {'value': instance.value, 'name': instance.property.name} return {'value': instance.value, 'name': instance.property.name}
def to_internal_value(self, data): def to_internal_value(self, data):
prop = Property.objects.get(name=data['name']) prop = resolve_property_handle(data.get('name') or data.get('handle'))
value = data['value'] value = data['value']
return {'property': prop, 'value': value} return {'property': prop, 'value': value}
class InventoryItemSerializer(serializers.ModelSerializer): class InventoryItemSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True) owner = OwnerSerializer(read_only=True)
tags = serializers.SlugRelatedField(many=True, required=False, queryset=Tag.objects.all(), slug_field='name') tags = serializers.SerializerMethodField()
tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set') properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
category = CategorySerializer(required=False, allow_null=True) category = CategorySerializer(required=False, allow_null=True)
files = FileSerializer(many=True, read_only=True) files = FileSerializer(many=True, read_only=True)
@ -91,11 +148,16 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner', fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
'tags', 'properties', 'files', 'storage_location'] 'tags', 'tags_input', 'properties', 'files', 'storage_location']
def get_tags(self, obj):
return [tag.name for tag in obj.tags.all()]
def to_internal_value(self, data): def to_internal_value(self, data):
files = data.pop('files', []) files = data.pop('files', [])
tags_input = data.pop('tags_input', data.pop('tags', []))
ret = super().to_internal_value(data) ret = super().to_internal_value(data)
ret['tags'] = [resolve_tag_handle(tag) for tag in tags_input]
ret['files'] = files ret['files'] = files
return ret return ret
@ -138,3 +200,12 @@ class InventoryItemSerializer(serializers.ModelSerializer):
ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value']) ItemProperty.objects.create(inventory_item=item, property=prop['property'], value=prop['value'])
item.save() item.save()
return item return item
class WorkflowInstanceSerializer(serializers.ModelSerializer):
owner = serializers.StringRelatedField(read_only=True)
class Meta:
model = WorkflowInstance
fields = ['id', 'name', 'state', 'payload', 'owner', 'created_at', 'updated_at']
read_only_fields = ['owner', 'created_at', 'updated_at']

View file

@ -1,4 +1,5 @@
from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation from toolshed.models import Category, Tag, Property, InventoryItem, ItemProperty, StorageLocation, WorkflowInstance
import json
class CategoryTestMixin: class CategoryTestMixin:
@ -54,3 +55,33 @@ class LocationTestMixin:
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1']) category=self.f['cat1'])
class WorkflowTestMixin:
def prepare_workflows(self):
# `payload` is an opaque, frontend-serialized JSON string on the backend.
self.f['workflow1'] = WorkflowInstance.objects.create(
name='workflow1',
state='initial',
payload=json.dumps({}),
owner=self.f['local_user1']
)
self.f['workflow2'] = WorkflowInstance.objects.create(
name='workflow1',
state='upload',
payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']}),
owner=self.f['local_user1']
)
self.f['workflow3'] = WorkflowInstance.objects.create(
name='workflow1',
state='describe',
payload=json.dumps({'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2', 'a1b2c3d4e5f60718293a4b5c6d7e8f90', 'b1c2d3e4f5a60718293b4c5d6e7f8090'],
'descriptions': ['file 1 description']}),
owner=self.f['local_user1']
)
self.f['workflow_user2'] = WorkflowInstance.objects.create(
name='workflow2',
state='initial',
payload=json.dumps({}),
owner=self.f['local_user2']
)

View file

@ -1,6 +1,6 @@
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin from files.tests import FilesTestMixin
from toolshed.models import InventoryItem, Category from toolshed.models import InventoryItem, Category, StorageLocation
from toolshed.tests import InventoryTestMixin, LocationTestMixin from toolshed.tests import InventoryTestMixin, LocationTestMixin
client = SignatureAuthClient() client = SignatureAuthClient()
@ -69,3 +69,63 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
self.assertEqual(reply.json()[3]['description'], None) self.assertEqual(reply.json()[3]['description'], None)
self.assertEqual(reply.json()[3]['category'], 'cat1') self.assertEqual(reply.json()[3]['category'], 'cat1')
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4') self.assertEqual(reply.json()[3]['path'], 'loc1/loc4')
def test_post_new_location(self):
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
'name': 'loc5',
'description': 'a new location',
})
self.assertEqual(reply.status_code, 201)
self.assertEqual(StorageLocation.objects.count(), 5)
location = StorageLocation.objects.get(name='loc5')
self.assertEqual(location.description, 'a new location')
self.assertEqual(location.owner, self.f['local_user1'])
self.assertEqual(location.parent, None)
self.assertEqual(reply.json()['path'], 'loc5')
def test_post_new_nested_location(self):
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
'name': 'loc5',
'parent': self.f['loc3'].id,
})
self.assertEqual(reply.status_code, 201)
location = StorageLocation.objects.get(name='loc5')
self.assertEqual(location.parent, self.f['loc3'])
self.assertEqual(reply.json()['path'], 'loc1/loc3/loc5')
def test_patch_location(self):
reply = client.patch('/api/storage_locations/' + str(self.f['loc2'].id) + '/', self.f['local_user1'], {
'name': 'loc2-renamed',
'parent': self.f['loc1'].id,
})
self.assertEqual(reply.status_code, 200)
location = StorageLocation.objects.get(id=self.f['loc2'].id)
self.assertEqual(location.name, 'loc2-renamed')
self.assertEqual(location.parent, self.f['loc1'])
self.assertEqual(reply.json()['path'], 'loc1/loc2-renamed')
def test_delete_location(self):
reply = client.delete('/api/storage_locations/' + str(self.f['loc4'].id) + '/', self.f['local_user1'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(StorageLocation.objects.count(), 3)
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc4'].id).count(), 0)
def test_delete_location_with_items_sets_null(self):
item = InventoryItem.objects.create(
owner=self.f['local_user1'], name='located_item', storage_location=self.f['loc3'])
reply = client.delete('/api/storage_locations/' + str(self.f['loc3'].id) + '/', self.f['local_user1'])
self.assertEqual(reply.status_code, 204)
item.refresh_from_db()
self.assertIsNone(item.storage_location)
self.assertEqual(InventoryItem.objects.filter(id=item.id).count(), 1)
def test_locations_are_owner_scoped(self):
reply = client.get('/api/storage_locations/', self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_cannot_delete_other_users_location(self):
reply = client.delete('/api/storage_locations/' + str(self.f['loc1'].id) + '/', self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1)

View file

@ -0,0 +1,264 @@
from django.core.files.base import ContentFile
from django.test import Client
from authentication.models import AccountPreference, ToolshedUser
from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase
from files.models import File
from toolshed.models import InventoryItem, ItemProperty, StorageLocation
from toolshed.offlinedata import import_inventory, inventory_rows, rows_to_csv
from toolshed.tests import CategoryTestMixin, LocationTestMixin, PropertyTestMixin, TagTestMixin
anonymous_client = Client()
client = SignatureAuthClient()
class _DeleteTestDataMixin(UserTestMixin, CategoryTestMixin, LocationTestMixin):
"""Shared fixture setup for the delete-data and delete-account test cases."""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_categories()
self.prepare_locations()
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
self.f['shared_file'] = File.objects.create(
file=ContentFile(b'shared', 'shared'), mime_type='text/plain', hash='shared')
self.f['orphan_file'] = File.objects.create(
file=ContentFile(b'orphan', 'orphan'), mime_type='text/plain', hash='orphan')
self.f['item1'] = InventoryItem.objects.create(
owner=self.f['local_user1'], owned_quantity=1, name='item1', category=self.f['cat1'])
self.f['item1'].files.add(self.f['orphan_file'])
self.f['item_other_user'] = InventoryItem.objects.create(
owner=self.f['local_user2'], owned_quantity=1, name='item2', category=self.f['cat1'])
self.f['item_other_user'].files.add(self.f['shared_file'])
self.f['item1'].files.add(self.f['shared_file'])
AccountPreference.objects.create(user=self.f['local_user1'], key='theme', value='dark')
self.f['local_user1'].profile_picture = self.f['orphan_file']
self.f['local_user1'].save()
class DeleteDataTestCase(_DeleteTestDataMixin, ToolshedTestCase):
def test_delete_data_anonymous(self):
response = anonymous_client.delete('/api/account_data/')
self.assertEqual(response.status_code, 403)
def test_delete_data_removes_all_owned_data_but_keeps_account(self):
response = client.delete('/api/account_data/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
summary = response.json()
self.assertEqual(summary['inventory_items'], 1)
self.assertEqual(summary['locations'], 4)
self.assertEqual(summary['settings'], 1)
self.assertEqual(summary['friends'], 1)
# the account itself survives - this wipes data, it doesn't close the account
self.f['local_user1'].refresh_from_db()
self.assertTrue(ToolshedUser.objects.filter(username='testuser1').exists())
self.assertIsNone(self.f['local_user1'].profile_picture)
self.assertFalse(InventoryItem.global_objects.filter(owner_id=self.f['local_user1'].id).exists())
self.assertFalse(StorageLocation.objects.filter(owner_id=self.f['local_user1'].id).exists())
self.assertFalse(AccountPreference.objects.filter(user_id=self.f['local_user1'].id).exists())
self.assertEqual(self.f['local_user1'].public_identity.friends.count(), 0)
# orphaned file (only referenced by the deleted user/items) is gone
self.assertFalse(File.objects.filter(hash='orphan').exists())
# file still referenced by the other user's item survives
self.assertTrue(File.objects.filter(hash='shared').exists())
# the other user's data and identity/friend relation to the deleted identity are untouched
self.f['local_user2'].refresh_from_db()
self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
class DeleteAccountTestCase(_DeleteTestDataMixin, ToolshedTestCase):
def test_delete_account_anonymous(self):
response = anonymous_client.delete('/api/account/')
self.assertEqual(response.status_code, 403)
def test_delete_account_removes_data_and_closes_account(self):
user1_id = self.f['local_user1'].id
identity_id = self.f['local_user1'].public_identity_id
response = client.delete('/api/account/', self.f['local_user1'])
self.assertEqual(response.status_code, 200)
summary = response.json()
self.assertEqual(summary['inventory_items'], 1)
self.assertEqual(summary['locations'], 4)
self.assertEqual(summary['settings'], 1)
self.assertEqual(summary['friends'], 1)
self.assertTrue(summary['account'])
# the account itself is gone
self.assertFalse(ToolshedUser.objects.filter(id=user1_id).exists())
self.assertFalse(InventoryItem.global_objects.filter(owner_id=user1_id).exists())
self.assertFalse(StorageLocation.objects.filter(owner_id=user1_id).exists())
self.assertFalse(AccountPreference.objects.filter(user_id=user1_id).exists())
# the underlying identity is kept, so remote friends/history referencing it stay intact
from authentication.models import KnownIdentity
self.assertTrue(KnownIdentity.objects.filter(id=identity_id).exists())
# orphaned file (only referenced by the deleted user/items) is gone
self.assertFalse(File.objects.filter(hash='orphan').exists())
# file still referenced by the other user's item survives
self.assertTrue(File.objects.filter(hash='shared').exists())
# the other user's data and identity/friend relation to the deleted identity are untouched
self.f['local_user2'].refresh_from_db()
self.assertTrue(InventoryItem.objects.filter(owner=self.f['local_user2']).exists())
self.assertEqual(self.f['local_user2'].public_identity.friends.count(), 0)
class ImportInventoryPropertiesTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
ToolshedTestCase):
"""Properties must round-trip through export/import even when their value contains a
comma or an '=' sign - characters that a naive "handle=value, handle2=value2" encoding of
the 'properties' CSV cell would misinterpret as a field/entry separator.
"""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_categories()
self.prepare_tags()
self.prepare_properties()
def test_property_values_with_comma_and_equals_round_trip(self):
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='widget')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='widget')
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})
def test_legacy_comma_equals_format_is_still_importable(self):
handle1 = self.f['prop1'].get_handle()
handle2 = self.f['prop2'].get_handle()
csv_data = (
'name,properties\r\n'
f'legacy widget,"{handle1}=value1, {handle2}=value2"\r\n'
).encode('utf-8')
imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
item = InventoryItem.objects.get(owner=self.f['local_user1'], name='legacy widget')
values = {ip.property.name: ip.value for ip in item.itemproperty_set.select_related('property')}
self.assertEqual(values, {'prop1': 'value1', 'prop2': 'value2'})
def test_item_without_properties_imports_cleanly(self):
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='bare item')
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='bare item')
self.assertEqual(list(new_item.itemproperty_set.all()), [])
def test_category_and_tags_round_trip(self):
item = InventoryItem.objects.create(
owner=self.f['local_user1'], name='cat and tags item', category=self.f['cat1'])
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='cat and tags item')
self.assertEqual(new_item.category, self.f['cat1'])
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
def test_unknown_property_handle_skips_item_with_error(self):
csv_data = (
'name,properties\r\n'
'ghost widget,test#property:doesnotexist=x\r\n'
).encode('utf-8')
imported, errors = import_inventory(self.f['local_user1'], csv_data, available_files={})
self.assertEqual(imported, 0)
self.assertEqual(len(errors), 1)
self.assertIn('doesnotexist', errors[0])
self.assertFalse(InventoryItem.objects.filter(owner=self.f['local_user1'], name='ghost widget').exists())
def test_property_value_with_quote_character_round_trips(self):
item = InventoryItem.objects.create(owner=self.f['local_user1'], name='quoted widget')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='12" screen')
csv_bytes = b''.join(rows_to_csv(list(inventory_rows(self.f['local_user1']))))
imported, errors = import_inventory(self.f['local_user2'], csv_bytes, available_files={})
self.assertEqual(errors, [])
self.assertEqual(imported, 1)
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='quoted widget')
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
self.assertEqual(values, {'prop1': '12" screen'})
class ExportImportApiRoundTripTestCase(UserTestMixin, CategoryTestMixin, TagTestMixin, PropertyTestMixin,
ToolshedTestCase):
"""End-to-end coverage of the /api/export/ + /api/import/ endpoints (as actually used by
clients), rather than calling the internal helper functions directly - this is what a real
export/import round trip between two accounts looks like.
"""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_categories()
self.prepare_tags()
self.prepare_properties()
def test_export_then_import_preserves_category_tags_and_properties(self):
import base64
item = InventoryItem.objects.create(
owner=self.f['local_user1'], name='drill', description='cordless drill',
category=self.f['cat1'], availability_policy='friends', owned_quantity=2)
item.tags.add(self.f['tag1'], self.f['tag2'], through_defaults={})
ItemProperty.objects.create(inventory_item=item, property=self.f['prop1'], value='10cm, 20cm')
ItemProperty.objects.create(inventory_item=item, property=self.f['prop2'], value='a=b')
export_reply = client.get('/api/export/', self.f['local_user1'])
self.assertEqual(export_reply.status_code, 200)
zip_bytes = export_reply.content
import_reply = client.post('/api/import/', self.f['local_user2'],
{'zip': base64.b64encode(zip_bytes).decode('ascii')})
self.assertEqual(import_reply.status_code, 200)
summary = import_reply.json()
self.assertEqual(summary['inventory_items'], 1)
self.assertEqual(summary['errors'], [])
new_item = InventoryItem.objects.get(owner=self.f['local_user2'], name='drill')
self.assertEqual(new_item.category, self.f['cat1'])
self.assertEqual(sorted(t.name for t in new_item.tags.all()), ['tag1', 'tag2'])
values = {ip.property.name: ip.value for ip in new_item.itemproperty_set.select_related('property')}
self.assertEqual(values, {'prop1': '10cm, 20cm', 'prop2': 'a=b'})

View file

@ -0,0 +1,48 @@
import json
from django.test import Client
from django.urls import reverse
from rest_framework import status
from authentication.tests import UserTestMixin, SignatureAuthClient, ToolshedTestCase
from toolshed.tests import WorkflowTestMixin
from toolshed.models import WorkflowInstance
anonymous_client = Client()
client = SignatureAuthClient()
class WorkflowInstanceApiTestCase(UserTestMixin, WorkflowTestMixin, ToolshedTestCase):
"""Comprehensive test cases for the Workflow API"""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_workflows()
def test_get_workflow_instances(self):
reply = client.get('/api/workflows/', self.f['local_user1'])
self.assertEqual(reply.status_code, status.HTTP_200_OK)
self.assertEqual(len(reply.data), 3)
self.assertEqual(reply.data[0]['name'], 'workflow1')
self.assertEqual(reply.data[1]['name'], 'workflow1')
self.assertEqual(reply.data[2]['name'], 'workflow1')
self.assertEqual(reply.data[0]['state'], 'initial')
self.assertEqual(reply.data[1]['state'], 'upload')
self.assertEqual(reply.data[2]['state'], 'describe')
self.assertEqual(json.loads(reply.data[0]['payload']), {})
self.assertEqual(json.loads(reply.data[1]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2']})
self.assertEqual(json.loads(reply.data[2]['payload']), {'files': ['ef35c4a9b2d1c4f1a3e6f7d8c9b0a1b2',
'a1b2c3d4e5f60718293a4b5c6d7e8f90',
'b1c2d3e4f5a60718293b4c5d6e7f8090'],
'descriptions': ['file 1 description']})
def test_get_workflow_instances_user2(self):
reply = client.get('/api/workflows/', self.f['local_user2'])
self.assertEqual(reply.status_code, status.HTTP_200_OK)
self.assertEqual(len(reply.data), 1)
self.assertEqual(reply.data[0]['name'], 'workflow2')
self.assertEqual(reply.data[0]['state'], 'initial')
self.assertEqual(json.loads(reply.data[0]['payload']), {})

83
cli-client/toolshed-client.py Executable file
View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
import argparse
import os
import requests
from nacl.signing import SigningKey
from json import dumps
class ToolshedApi:
user = None
host = None
signing_key = None
def __init__(self, user, host, key):
if host is None:
raise ValueError("TOOLSHED_HOST environment variable not set")
if user is None:
raise ValueError("TOOLSHED_USER environment variable not set")
if key is None:
raise ValueError("TOOLSHED_KEY environment variable not set")
if len(key) != 64:
raise ValueError("TOOLSHED_KEY must be 64 hex characters")
signing_key = SigningKey(bytes.fromhex(key))
self.user = user
self.host = host
self.signing_key = signing_key
def get(self, target):
url = "http://" + self.host + target
signed = self.signing_key.sign(url.encode('utf-8'))
signature = signed.signature.hex()
response = requests.get(url, headers={"Authorization": "Signature " + self.user + ":" + signature})
return response.json()
def post(self, target, data):
url = "http://" + self.host + target
json = dumps(data)
signed = self.signing_key.sign(url.encode('utf-8') + json.encode('utf-8'))
signature = signed.signature.hex()
response = requests.post(url, headers={"Authorization": "Signature " + self.user + ":" + signature}, json=data)
return response.json()
def main():
host = os.environ.get('TOOLSHED_HOST')
user = os.environ.get('TOOLSHED_USER')
key = os.environ.get('TOOLSHED_KEY')
parser = argparse.ArgumentParser(description='Toolshed API client')
parser.add_argument('--host', help='Toolshed host')
parser.add_argument('--user', help='Toolshed user')
parser.add_argument('--key', help='Toolshed key')
parser.add_argument('cmd', help='Command')
args = parser.parse_args()
if args.host is not None:
host = args.host
if args.user is not None:
user = args.user
if args.key is not None:
key = args.key
api = ToolshedApi(user, host, key)
if args.cmd == 'getinventory':
inv = api.get("/api/inventory_items/")
print(inv)
elif args.cmd == 'additem':
inv = api.post("/api/inventory_items/", {"name": "test"})
print(inv)
else:
print("Unknown command: " + args.cmd)
if __name__ == '__main__':
main()

View file

@ -13,4 +13,4 @@ COPY requirements.txt /code/
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# Run the application # Run the application
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--insecure"] CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --insecure"]

View file

@ -8,6 +8,7 @@ WORKDIR /app
# A wildcard is used to ensure both package.json AND package-lock.json are copied # A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package.json ./ COPY package.json ./
COPY extras/ ./extras/
RUN npm install RUN npm install
CMD [ "npm", "run", "dev", "--", "--host"] CMD [ "npm", "run", "dev", "--", "--host"]

View file

@ -2,6 +2,8 @@
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG=True DEBUG=True
SERVE_X_ACCEL_REDIRECT=False
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY='e*lm&*!j0_stqaiod$1zob(vs@aq6+n-i$1%!rek)_v9n^ue$3' SECRET_KEY='e*lm&*!j0_stqaiod$1zob(vs@aq6+n-i$1%!rek)_v9n^ue$3'

View file

@ -14,7 +14,7 @@ http {
} }
upstream dns { upstream dns {
server dns:8053; server toolshed-dns:8053;
} }
server { server {
@ -45,8 +45,34 @@ http {
proxy_pass http://backend; proxy_pass http://backend;
} }
location /media {
proxy_set_header Host $host:$server_port;
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 $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /djangoadmin {
proxy_set_header Host $host:$server_port;
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 $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /docs { location /docs {
proxy_pass http://backend/docs; proxy_set_header Host $host:$server_port;
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 $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
} }
location /static { location /static {
@ -80,17 +106,35 @@ http {
# DoH server # DoH server
server { server {
listen 5353 ssl; listen 5353 ssl;
server_name localhost; server_name localhost 127.0.0.3;
ssl_certificate /etc/nginx/nginx.crt; ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key; ssl_certificate_key /etc/nginx/nginx.key;
location /dns-query { # Ensure CORS headers are present even when nginx generates 5xx responses.
proxy_pass http://dns; add_header 'Access-Control-Allow-Origin' '*' always;
# allow any origin add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS'; add_header 'Access-Control-Expose-Headers' 'Content-Type' always;
error_page 500 502 503 504 = @doh_error;
location /dns-query {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain; charset=utf-8';
return 204;
}
proxy_pass http://dns;
}
location @doh_error {
default_type text/plain;
return 502 'DoH upstream unavailable';
} }
} }
} }

View file

@ -57,7 +57,7 @@ def create_test_data():
if KnownIdentity.objects.filter(username='test_b').exists(): if KnownIdentity.objects.filter(username='test_b').exists():
identity = KnownIdentity.objects.get(username='test_b') identity = KnownIdentity.objects.get(username='test_b')
identity = KnownIdentity.objects.create(username='test_b', domain='b.localhost', identity = KnownIdentity.objects.create(username='test_b', domain='b.localhost',
public_key='4b0dffe21764c591762615ef84cfea7fd4055fab3e9f58ae077fd8c79c34af91') public_key='14c44f03c3a0406934cf3a27b0eeedae8a08a3f436690c7103eca13435172a8c')
# pk '2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40' # pk '2ec1e7d5f5b8d5f87233944970d57f942095fe9e6c4fc49edde61fcd3fb1bf40'
identity.save() identity.save()
print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain, print('Created identity {}@{} with public key {}'.format(identity.username, identity.domain,

View file

@ -1,6 +1,8 @@
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG=True DEBUG=True
SERVE_X_ACCEL_REDIRECT=True
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe' SECRET_KEY='7ccxjje%q@@0*z+r&-$fy3(rj9n)%$!sk-k++-&rb=_u(wpjbe'

View file

@ -35,6 +35,28 @@ http {
proxy_pass http://backend; proxy_pass http://backend;
} }
location /media {
proxy_set_header Host $host:$server_port;
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 $scheme;
proxy_set_header X-Forwarded-Host $host:$server_port;
proxy_set_header X-Forwarded-Port $server_port;
proxy_pass http://backend;
}
location /redirect_media/ {
internal;
alias /var/www/userfiles/;
# This location serves the file directly, bypassing Django (and therefore
# django-cors-headers) entirely - it's the target of the X-Accel-Redirect
# response from files/media_urls.py, used when SERVE_X_ACCEL_REDIRECT=True.
# CORS headers must be added here explicitly since nothing else will.
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Accept, Content-Type, Origin, User-Agent' always;
}
location /docs { location /docs {
proxy_pass http://backend/docs; proxy_pass http://backend/docs;
} }

View file

@ -21,4 +21,4 @@
"target": "127.0.0.2." "target": "127.0.0.2."
} }
} }
] ]

View file

@ -10,6 +10,7 @@ services:
- ../deploy/dev/instance_a/a.env:/code/.env - ../deploy/dev/instance_a/a.env:/code/.env
- ../deploy/dev/instance_a/testdata.py:/code/testdata.py - ../deploy/dev/instance_a/testdata.py:/code/testdata.py
- ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3 - ../deploy/dev/instance_a/a.sqlite3:/code/db.sqlite3
- ../deploy/dev/instance_a/userfiles:/code/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"
@ -23,6 +24,7 @@ services:
- ../deploy/dev/instance_b/b.env:/code/.env - ../deploy/dev/instance_b/b.env:/code/.env
- ../deploy/dev/instance_b/testdata.py:/code/testdata.py - ../deploy/dev/instance_b/testdata.py:/code/testdata.py
- ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3 - ../deploy/dev/instance_b/b.sqlite3:/code/db.sqlite3
- ../deploy/dev/instance_b/userfiles:/code/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"
@ -57,6 +59,7 @@ services:
- ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro - ./dev/instance_a/nginx-a.dev.conf:/etc/nginx/nginx.conf:ro
- ./dev/instance_a/dns.json:/var/www/dns.json:ro - ./dev/instance_a/dns.json:/var/www/dns.json:ro
- ./dev/instance_a/domains.json:/var/www/domains.json:ro - ./dev/instance_a/domains.json:/var/www/domains.json:ro
- ./dev/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"
@ -67,6 +70,7 @@ services:
dockerfile: dev/Dockerfile.proxy dockerfile: dev/Dockerfile.proxy
volumes: volumes:
- ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro - ./dev/instance_b/nginx-b.dev.conf:/etc/nginx/nginx.conf:ro
- ./dev/instance_b/userfiles:/var/www/userfiles:ro
ports: ports:
- "127.0.0.2:8080:8080" - "127.0.0.2:8080:8080"
@ -78,3 +82,7 @@ services:
- ./dev/zone.json:/dns/zone.json - ./dev/zone.json:/dns/zone.json
expose: expose:
- 8053 - 8053
networks:
default:
aliases:
- toolshed-dns

View file

@ -1,43 +0,0 @@
version: '3.3'
services:
# db:
# image: postgres
# container_name: docker-django-vue-db
# environment:
# POSTGRES_USER: user
# POSTGRES_PASSWORD: pass
# POSTGRES_DB: db
# restart: unless-stopped
# ports:
# - "5432:5432"
django:
build:
context: ./backend
dockerfile: ./Dockerfile
command: python backend/manage.py runserver 0.0.0.0:8000
volumes:
- .:/app
ports:
- "8002:8000"
networks:
- internal
# depends_on:
# - db
vue:
build:
context: ./frontend
dockerfile: ./Dockerfile
command: nginx -g 'daemon off;'
volumes:
- .:/app
ports:
- "8001:80"
networks:
- internal
- external
depends_on:
- django
networks:
external:
internal:

View file

@ -97,6 +97,12 @@ Start the fullstack application:
docker-compose -f deploy/docker-compose.override.yml up --build docker-compose -f deploy/docker-compose.override.yml up --build
``` ```
Run backend tests in Docker:
``` bash
docker compose -f deploy/docker-compose.override.yml run --rm backend-a bash -lc "python configure.py && python manage.py test"
```
This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend. This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend.
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
server is used to direct the frontend to the correct backend instance. server is used to direct the frontend to the correct backend instance.

View file

@ -1,20 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDLzCCAhegAwIBAgIUJmAh7fVmxDGKqLmRepxu/JqgEcgwDQYJKoZIhvcNAQEL
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
MzExMDMwMDI4NDNaFw0yNjA4MjMwMDI4NDNaMCcxCzAJBgNVBAYTAlVTMRgwFgYD
VQQDDA9FeGFtcGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQDE/lXJfHolXhbd7gevY0+AurY4iMAUx+FDbdpRaKc4z+hHANKWR5OZBwIj
mI6RNpe/yq+ZE7fDnQ5mq9BuD37i9ZR3TiFQQ0K6yVHJs1xn64Ce4dhz0LxIU+mR
rWYTMDkfjOnKi6ueycgKSzk6GnCgSix0ho4yRj8CPO9HzKf7bgHJZzh//xFk3Cbo
EgHGXxnm5yCfBthaU5Toey8AYlHlH/OVYTSxu2yRu/HdWbVglqdSu2us7U9SG88V
EeJf5fSwmyYS4ab9JZUhM7HgMVQZIx/Gq+VoYZCs6s8/Bm3hnFQDVHDQ9mAL9cZB
EvOgQGcEFXHS6SXkWwWpYietmNdPAgMBAAGjUzBRMB0GA1UdDgQWBBTCPiGSDMoi
nNiPFsW4fYZW3XDPKjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAP
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAQw65jeCi5mNPmw4Uw
BYEmGfzv7bYMrDu8w1A2U2yFa+6BN17mAMLJQxzcrvP4/DK1MaP1ehGfWRH2jzSb
m5CgbZYDVBib2jD1HlMFEBvEF2wduQL9mIipeYXFIS9ttuW7QVqso62Na/3tcp9J
dCfHsbnAvWSt63/7d9wHX3zlBN7v9GeJbXo14p8aA8IEjQFQWnkjhvRVzU/fltKm
dvG/xkXA59dqXMEYxHyGK2tE4WiaLwsQW44LQcuEjy4NSj5t0F4NWYVLUgIgnqk8
RkGEXAxcRfFXSF6VEBTxoAAhTh5zSge4PSwHT+29dzkJqzpFb6cCke4Skk16KiW8
0dg1
-----END CERTIFICATE-----

View file

@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDE/lXJfHolXhbd
7gevY0+AurY4iMAUx+FDbdpRaKc4z+hHANKWR5OZBwIjmI6RNpe/yq+ZE7fDnQ5m
q9BuD37i9ZR3TiFQQ0K6yVHJs1xn64Ce4dhz0LxIU+mRrWYTMDkfjOnKi6ueycgK
Szk6GnCgSix0ho4yRj8CPO9HzKf7bgHJZzh//xFk3CboEgHGXxnm5yCfBthaU5To
ey8AYlHlH/OVYTSxu2yRu/HdWbVglqdSu2us7U9SG88VEeJf5fSwmyYS4ab9JZUh
M7HgMVQZIx/Gq+VoYZCs6s8/Bm3hnFQDVHDQ9mAL9cZBEvOgQGcEFXHS6SXkWwWp
YietmNdPAgMBAAECggEADKTgf77AHk7kO9lv39eJflM9p/JLFepgcJIH/cqwVQqv
N4N7gM7WT++tuPgtSm/NHK/XRoWH+YPGrVtg1Pekps+peGDo7Xxrt459c+tRxo/M
oSYQ3RosQ54zMzkEgL3LJNXIGcqOOPJtpw99/Fc/HGK2aMJMNTjiCXQ8zYh6zb38
himWC1Vysb6tO4bQ/zJeMuh+ny6oAu0x8CAKJfFHwm/enA2ab2VGT9L/S/Mv7bqh
WmRsOrY7nnSSHhYFBr3VcvC4yzsQ+UI9LKllK1M2Lb7JVrGkgYwJFqiwomV5bRDn
QP4gFgDvgghsQgm9Fx7ZTC6vIXpgEZQAiSPkIVB7LQKBgQDiO9Db4Jp1h46Bu+je
JW3MUw8QNHRnlTmJr0bIu1IDq+3LBLJUo7wR2+/JxNx/v4R+qSRv65HEFF5QwuID
o+F2vZ08xHKY1uKuXV26VpRohuQVPz+PhUExYE29RhAUbGq/14wbH8vIpUNk5VKW
9yxAFZjqKyqhsSdL6C23i2AT6wKBgQDe6aDwRP0WXDsdgdiXiEXXZ47RyE6h9/8+
ehx++rMHME2C6fL562MHBiwfZWoy3X/hlCahvOMGSPT93sqgKATYdYWzwQnOIyUG
LIH2ZfUStKnnK+yNMh4sUZZERDy+JSzvIULtxNJ/m4ai0FNTobAqw4WMXKUTKKOL
rSgRPkVFLQKBgQDVlQMpknJAYyn28SB4GX3/WKrv8ZWz9P3NLtz0xWQ4cyiccmKp
5aq4cZuAMYq6UGUV7zmnMA79vv0L4fDe3ukM4RaBODW4daasVxkVwod551+Nb5ji
NjvGEdVSy8KChZiCZUywjuQlGWD28El4nQH5vmoPKZGl9UnUL+55Sev64QKBgAOv
AqzVxwTmk0eCjMy8QMM1f+OxlrMQgRRMOkpS5EB7KNy/9vbwT9ue233NVDrw9BsL
MrQ7hdjlvb6wPtL0tlfJYWM+DmZsqED43VDwEGO1vpUGYctXh64dzwjjw8k0nhB5
kTySoxPewxPPs4DPTDT4tC1Enqm5YzJ8swvN5qElAoGABPP5g+EekI2eo6xPIsVf
FPhto+WKn7Hxx+sQZBg7EuX8QLxrGBy9yeU0BMcyuvQYPHbGtfqlItVchBk7Nxug
s82jJgWYymbroKvCYShAnQujZuyjI62qJq1gfvfcLT4g+k3nZ+KZwCc4iusOELlH
0rnQsA6KSyCpKB51T1iUkZQ=
-----END PRIVATE KEY-----

View file

@ -1,20 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDLzCCAhegAwIBAgIUJmAh7fVmxDGKqLmRepxu/JqgEcgwDQYJKoZIhvcNAQEL
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
MzExMDMwMDI4NDNaFw0yNjA4MjMwMDI4NDNaMCcxCzAJBgNVBAYTAlVTMRgwFgYD
VQQDDA9FeGFtcGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQDE/lXJfHolXhbd7gevY0+AurY4iMAUx+FDbdpRaKc4z+hHANKWR5OZBwIj
mI6RNpe/yq+ZE7fDnQ5mq9BuD37i9ZR3TiFQQ0K6yVHJs1xn64Ce4dhz0LxIU+mR
rWYTMDkfjOnKi6ueycgKSzk6GnCgSix0ho4yRj8CPO9HzKf7bgHJZzh//xFk3Cbo
EgHGXxnm5yCfBthaU5Toey8AYlHlH/OVYTSxu2yRu/HdWbVglqdSu2us7U9SG88V
EeJf5fSwmyYS4ab9JZUhM7HgMVQZIx/Gq+VoYZCs6s8/Bm3hnFQDVHDQ9mAL9cZB
EvOgQGcEFXHS6SXkWwWpYietmNdPAgMBAAGjUzBRMB0GA1UdDgQWBBTCPiGSDMoi
nNiPFsW4fYZW3XDPKjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAP
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAQw65jeCi5mNPmw4Uw
BYEmGfzv7bYMrDu8w1A2U2yFa+6BN17mAMLJQxzcrvP4/DK1MaP1ehGfWRH2jzSb
m5CgbZYDVBib2jD1HlMFEBvEF2wduQL9mIipeYXFIS9ttuW7QVqso62Na/3tcp9J
dCfHsbnAvWSt63/7d9wHX3zlBN7v9GeJbXo14p8aA8IEjQFQWnkjhvRVzU/fltKm
dvG/xkXA59dqXMEYxHyGK2tE4WiaLwsQW44LQcuEjy4NSj5t0F4NWYVLUgIgnqk8
RkGEXAxcRfFXSF6VEBTxoAAhTh5zSge4PSwHT+29dzkJqzpFb6cCke4Skk16KiW8
0dg1
-----END CERTIFICATE-----

View file

@ -1 +0,0 @@
0C2D2D6E0BCB8B8AFD205E5E4A616C11D96DD566

View file

@ -1,8 +0,0 @@
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
#DNS.2 = fake1.local
#DNS.3 = fake2.local

View file

@ -1,42 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDkjCCAnqgAwIBAgIUDC0tbgvLi4r9IF5eSmFsEdlt1WYwDQYJKoZIhvcNAQEL
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
MzExMDMwMDMxMDFaFw0yNjA4MjMwMDMxMDFaMG0xCzAJBgNVBAYTAlVTMRIwEAYD
VQQIDAlZb3VyU3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFt
cGxlLUNlcnRpZmljYXRlczEYMBYGA1UEAwwPbG9jYWxob3N0LmxvY2FsMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwUYH95U11TYvYn9or/cP/oPtiJJm
l1aH5J2AOfno/QW5FjDKeGUYeXrGTOLeDRiHIAHVtg00nfTtm56du5+I972VbEW6
ngrQAGyk/GSvOGK1iG4M4eHpCkZHrdkubOWsO5vDj2juTwjE1fyncLmIbyZN5NKJ
6JID8c/ItFXQzXDLaFAQH3+uFFX9iTeT2cGyFEidooKo4oKj3imQRM6YKl4ygEMc
BTN0p7AqtjKSfva5WsMSXdESLsNdStgs5OgfJUE8x4jhcrVrZ2Mnw8Zmfb2pIoNe
6HphVtU96EMDqZmRJ4c9REqS6qZmHKsAPet/jyNNZjUJIptJE6Cl0IihhwIDAQAB
o3AwbjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAJBgNVHRMEAjAA
MAsGA1UdDwQEAwIE8DAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFIni
f5YpRjm9GEjktq2gr/34isB1MA0GCSqGSIb3DQEBCwUAA4IBAQCAv0FdWz+Ktq6+
I7wOUWx3LHFQSov71ca9HGbD9SMOBbd26MJXnxxjOU62W+Q/Bkx4C3ezzSuLMhZq
Vl3jV2wDkWWCFvR1a2cwmr05B2xs/lqX+eNwrNZNfGxMV4I4dn+bkGe2czRcZaur
et0SylTQvWwrCgewRirS44LcN9EEutu0WzxlUpdty71zlVKdpyE6MrZ10ADcin5B
P/10++aBuQXF1EDFGZ6snexGuSsshAP2tBLww+Z8xPyN3SRMj/Y0z7Rgm4nhEfNP
M1BcUiaT7e8evsWVAyoDI6N1Z4M0p4SXLLeHdhKelbvNdeVZxwDkl8jR+Q5hwt7K
ZurEL1FZ
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDLzCCAhegAwIBAgIUJmAh7fVmxDGKqLmRepxu/JqgEcgwDQYJKoZIhvcNAQEL
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
MzExMDMwMDI4NDNaFw0yNjA4MjMwMDI4NDNaMCcxCzAJBgNVBAYTAlVTMRgwFgYD
VQQDDA9FeGFtcGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQDE/lXJfHolXhbd7gevY0+AurY4iMAUx+FDbdpRaKc4z+hHANKWR5OZBwIj
mI6RNpe/yq+ZE7fDnQ5mq9BuD37i9ZR3TiFQQ0K6yVHJs1xn64Ce4dhz0LxIU+mR
rWYTMDkfjOnKi6ueycgKSzk6GnCgSix0ho4yRj8CPO9HzKf7bgHJZzh//xFk3Cbo
EgHGXxnm5yCfBthaU5Toey8AYlHlH/OVYTSxu2yRu/HdWbVglqdSu2us7U9SG88V
EeJf5fSwmyYS4ab9JZUhM7HgMVQZIx/Gq+VoYZCs6s8/Bm3hnFQDVHDQ9mAL9cZB
EvOgQGcEFXHS6SXkWwWpYietmNdPAgMBAAGjUzBRMB0GA1UdDgQWBBTCPiGSDMoi
nNiPFsW4fYZW3XDPKjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAP
BgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAQw65jeCi5mNPmw4Uw
BYEmGfzv7bYMrDu8w1A2U2yFa+6BN17mAMLJQxzcrvP4/DK1MaP1ehGfWRH2jzSb
m5CgbZYDVBib2jD1HlMFEBvEF2wduQL9mIipeYXFIS9ttuW7QVqso62Na/3tcp9J
dCfHsbnAvWSt63/7d9wHX3zlBN7v9GeJbXo14p8aA8IEjQFQWnkjhvRVzU/fltKm
dvG/xkXA59dqXMEYxHyGK2tE4WiaLwsQW44LQcuEjy4NSj5t0F4NWYVLUgIgnqk8
RkGEXAxcRfFXSF6VEBTxoAAhTh5zSge4PSwHT+29dzkJqzpFb6cCke4Skk16KiW8
0dg1
-----END CERTIFICATE-----

View file

@ -1,22 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDkjCCAnqgAwIBAgIUDC0tbgvLi4r9IF5eSmFsEdlt1WYwDQYJKoZIhvcNAQEL
BQAwJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD0V4YW1wbGUtUm9vdC1DQTAeFw0y
MzExMDMwMDMxMDFaFw0yNjA4MjMwMDMxMDFaMG0xCzAJBgNVBAYTAlVTMRIwEAYD
VQQIDAlZb3VyU3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFt
cGxlLUNlcnRpZmljYXRlczEYMBYGA1UEAwwPbG9jYWxob3N0LmxvY2FsMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwUYH95U11TYvYn9or/cP/oPtiJJm
l1aH5J2AOfno/QW5FjDKeGUYeXrGTOLeDRiHIAHVtg00nfTtm56du5+I972VbEW6
ngrQAGyk/GSvOGK1iG4M4eHpCkZHrdkubOWsO5vDj2juTwjE1fyncLmIbyZN5NKJ
6JID8c/ItFXQzXDLaFAQH3+uFFX9iTeT2cGyFEidooKo4oKj3imQRM6YKl4ygEMc
BTN0p7AqtjKSfva5WsMSXdESLsNdStgs5OgfJUE8x4jhcrVrZ2Mnw8Zmfb2pIoNe
6HphVtU96EMDqZmRJ4c9REqS6qZmHKsAPet/jyNNZjUJIptJE6Cl0IihhwIDAQAB
o3AwbjAfBgNVHSMEGDAWgBTCPiGSDMoinNiPFsW4fYZW3XDPKjAJBgNVHRMEAjAA
MAsGA1UdDwQEAwIE8DAUBgNVHREEDTALgglsb2NhbGhvc3QwHQYDVR0OBBYEFIni
f5YpRjm9GEjktq2gr/34isB1MA0GCSqGSIb3DQEBCwUAA4IBAQCAv0FdWz+Ktq6+
I7wOUWx3LHFQSov71ca9HGbD9SMOBbd26MJXnxxjOU62W+Q/Bkx4C3ezzSuLMhZq
Vl3jV2wDkWWCFvR1a2cwmr05B2xs/lqX+eNwrNZNfGxMV4I4dn+bkGe2czRcZaur
et0SylTQvWwrCgewRirS44LcN9EEutu0WzxlUpdty71zlVKdpyE6MrZ10ADcin5B
P/10++aBuQXF1EDFGZ6snexGuSsshAP2tBLww+Z8xPyN3SRMj/Y0z7Rgm4nhEfNP
M1BcUiaT7e8evsWVAyoDI6N1Z4M0p4SXLLeHdhKelbvNdeVZxwDkl8jR+Q5hwt7K
ZurEL1FZ
-----END CERTIFICATE-----

View file

@ -1,17 +0,0 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICsjCCAZoCAQAwbTELMAkGA1UEBhMCVVMxEjAQBgNVBAgMCVlvdXJTdGF0ZTER
MA8GA1UEBwwIWW91ckNpdHkxHTAbBgNVBAoMFEV4YW1wbGUtQ2VydGlmaWNhdGVz
MRgwFgYDVQQDDA9sb2NhbGhvc3QubG9jYWwwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQDBRgf3lTXVNi9if2iv9w/+g+2IkmaXVofknYA5+ej9BbkWMMp4
ZRh5esZM4t4NGIcgAdW2DTSd9O2bnp27n4j3vZVsRbqeCtAAbKT8ZK84YrWIbgzh
4ekKRket2S5s5aw7m8OPaO5PCMTV/KdwuYhvJk3k0onokgPxz8i0VdDNcMtoUBAf
f64UVf2JN5PZwbIUSJ2igqjigqPeKZBEzpgqXjKAQxwFM3SnsCq2MpJ+9rlawxJd
0RIuw11K2Czk6B8lQTzHiOFytWtnYyfDxmZ9vakig17oemFW1T3oQwOpmZEnhz1E
SpLqpmYcqwA963+PI01mNQkim0kToKXQiKGHAgMBAAGgADANBgkqhkiG9w0BAQsF
AAOCAQEAZUPJaPBXjSsbDoWEREiOrbai0sVRa3Qr/IIz3Z/+Nw9m2E+j9O11Vi8G
G3eqkixw/m4StbKZyMg+PAQlLC5NUmgMC5w9lfODq/CSRY8UVUi/EiIAMI4Rh/Dk
rGVsYArCcsmbWUzB5oUw4oUtgv0fWsuM0Thc7GORvtQvq4xzPcpWa1L88kRA08Mv
P8Yyk3txYQlkk3Bd40UL8TN6XiQC2IF1b/kXizqr2HL+1BiqCDUWtdDZmVZTvUXH
chUn1tCEm0iJSJZsJLVa5CDWkZHat2A3+8SaxYRL6QlWbiISOhs7KL6fPeTxc/S8
OKHnA3FRDzQTp+lpjSJWJEqt5W3i+Q==
-----END CERTIFICATE REQUEST-----

View file

@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDBRgf3lTXVNi9i
f2iv9w/+g+2IkmaXVofknYA5+ej9BbkWMMp4ZRh5esZM4t4NGIcgAdW2DTSd9O2b
np27n4j3vZVsRbqeCtAAbKT8ZK84YrWIbgzh4ekKRket2S5s5aw7m8OPaO5PCMTV
/KdwuYhvJk3k0onokgPxz8i0VdDNcMtoUBAff64UVf2JN5PZwbIUSJ2igqjigqPe
KZBEzpgqXjKAQxwFM3SnsCq2MpJ+9rlawxJd0RIuw11K2Czk6B8lQTzHiOFytWtn
YyfDxmZ9vakig17oemFW1T3oQwOpmZEnhz1ESpLqpmYcqwA963+PI01mNQkim0kT
oKXQiKGHAgMBAAECgf9k4w6I+HUuySeHJ+06PhtsELUINvfCPaxhFaAc2TwWNgGC
XfWjTrZ323ZJaMTsEReFWkdqLDciqKoA8AEVQCQxmY7fVA1mwt/QtzZ1lrq3zx+w
Ci3yrZvCBJzES0kmgXmpo+i0eh8T3waqkcwz1XzON3PP8J1qoOiWoe1Yawd4oMIq
QN0/DE1Zdz23QtWo0OLZw0/tLpB3cTK2XpzpBLh+OLImLmA3mqhbsJKzoVD79MTF
emgjFumsZqf0+jUU6tn2+XP83dxeL4yRQGfI19Qg0g+AF4Ue39/7Zife127h7x8o
iknk0m1WSyXjNNxFk1hceHz0CE1qWh/OxuWK+lkCgYEA9uxcmcSYvqMBQKd/q33r
v9wB5jHOPNGXapawKDTCW6B77bvKiYwP0wGNcIV0gDqFdt1RnS4OC3ERoiuhgZum
C7m2r/WOF+ktpkdMaiOLnrtPcAeZKItALAzTqAsbEDHupuqMvQ9Rlk33K/p+SsMt
YXEU0lbe7nsTVHUwZIEnymMCgYEAyGDOTUTJjWIL6x2n7kIWo+kxvCtL7rSmS6LY
XgyE7YTAbptEPtSG4iZTwp2mV6teeakIJ8he6Qw+DlMI+25tDjODMiX2CTbDUPD7
Koz/7rvX1KvqLGDocik3dzNEgimHamn9edR4he+yOuVNQTeQKyT2kiTS13PDUSyx
c511A40CgYALcE+p3+2jhzGbqSbE7wRo0uxTmaMQNjVpfqmScz89t2WGUdjEHnTt
NXkz6TrIZe2g4AfT3DpSVV6XwKWut2Mtz+CiriFzAh5XQZ5MnHDvXYYEjpWNx0Wl
xMJZWopaabXAjcUPTRJegCYfJ2+KTxhfFzn7tIKHUbRHiVZAkasiOQKBgQC0yGJY
5E7T+Dm4jrEMDSRQDfyJQKbpXqMKaom7kJ/9i1/yarNP3H81Wk8HryR8xGgga5io
C+dVa24xjkj0T9Skk2UywX0RIw9k34Y8BaqCwYcgoVpNX8FX/kfEi8SrirxaDfZb
DI8QiAnNiUQCKxcvVdIAWKjBKfh2LFSNcuo3oQKBgDuFJRHTBOkSTFIImVoxAV8i
duZrLuhgrzyLAuH5kMNGW8hm3dHYRMFm86nrWyMQgY52dar0TNzbPRvy2qU/2L8g
LxFrVO3pw5uZbYrcuhWAqLJCkJTDqawHvLGy5L5YJHewxBUszNHJIaluh0MvlJev
c+jFMCSN9BLo6Fy50vM8
-----END PRIVATE KEY-----

View file

@ -1,8 +0,0 @@
#!/bin/bash
cd $(dirname $0)
rf -rf RootCA.*
openssl req -x509 -nodes -new -sha256 -days 1024 -newkey rsa:2048 -keyout RootCA.key -out RootCA.pem -subj "/C=US/CN=Example-Root-CA"
openssl x509 -outform pem -in RootCA.pem -out RootCA.crt

View file

@ -1,11 +0,0 @@
#!/bin/bash
cd $(dirname $0)
rf -rf localhost.*
openssl req -new -nodes -newkey rsa:2048 -keyout localhost.key -out localhost.csr -subj "/C=US/ST=YourState/L=YourCity/O=Example-Certificates/CN=localhost.local"
openssl x509 -req -sha256 -days 1024 -in localhost.csr -CA RootCA.pem -CAkey RootCA.key -CAcreateserial -extfile domains.ext -out localhost.crt
cat localhost.crt RootCA.crt > fullchain.pem
cat localhost.key > privkey.pem

View file

@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDBRgf3lTXVNi9i
f2iv9w/+g+2IkmaXVofknYA5+ej9BbkWMMp4ZRh5esZM4t4NGIcgAdW2DTSd9O2b
np27n4j3vZVsRbqeCtAAbKT8ZK84YrWIbgzh4ekKRket2S5s5aw7m8OPaO5PCMTV
/KdwuYhvJk3k0onokgPxz8i0VdDNcMtoUBAff64UVf2JN5PZwbIUSJ2igqjigqPe
KZBEzpgqXjKAQxwFM3SnsCq2MpJ+9rlawxJd0RIuw11K2Czk6B8lQTzHiOFytWtn
YyfDxmZ9vakig17oemFW1T3oQwOpmZEnhz1ESpLqpmYcqwA963+PI01mNQkim0kT
oKXQiKGHAgMBAAECgf9k4w6I+HUuySeHJ+06PhtsELUINvfCPaxhFaAc2TwWNgGC
XfWjTrZ323ZJaMTsEReFWkdqLDciqKoA8AEVQCQxmY7fVA1mwt/QtzZ1lrq3zx+w
Ci3yrZvCBJzES0kmgXmpo+i0eh8T3waqkcwz1XzON3PP8J1qoOiWoe1Yawd4oMIq
QN0/DE1Zdz23QtWo0OLZw0/tLpB3cTK2XpzpBLh+OLImLmA3mqhbsJKzoVD79MTF
emgjFumsZqf0+jUU6tn2+XP83dxeL4yRQGfI19Qg0g+AF4Ue39/7Zife127h7x8o
iknk0m1WSyXjNNxFk1hceHz0CE1qWh/OxuWK+lkCgYEA9uxcmcSYvqMBQKd/q33r
v9wB5jHOPNGXapawKDTCW6B77bvKiYwP0wGNcIV0gDqFdt1RnS4OC3ERoiuhgZum
C7m2r/WOF+ktpkdMaiOLnrtPcAeZKItALAzTqAsbEDHupuqMvQ9Rlk33K/p+SsMt
YXEU0lbe7nsTVHUwZIEnymMCgYEAyGDOTUTJjWIL6x2n7kIWo+kxvCtL7rSmS6LY
XgyE7YTAbptEPtSG4iZTwp2mV6teeakIJ8he6Qw+DlMI+25tDjODMiX2CTbDUPD7
Koz/7rvX1KvqLGDocik3dzNEgimHamn9edR4he+yOuVNQTeQKyT2kiTS13PDUSyx
c511A40CgYALcE+p3+2jhzGbqSbE7wRo0uxTmaMQNjVpfqmScz89t2WGUdjEHnTt
NXkz6TrIZe2g4AfT3DpSVV6XwKWut2Mtz+CiriFzAh5XQZ5MnHDvXYYEjpWNx0Wl
xMJZWopaabXAjcUPTRJegCYfJ2+KTxhfFzn7tIKHUbRHiVZAkasiOQKBgQC0yGJY
5E7T+Dm4jrEMDSRQDfyJQKbpXqMKaom7kJ/9i1/yarNP3H81Wk8HryR8xGgga5io
C+dVa24xjkj0T9Skk2UywX0RIw9k34Y8BaqCwYcgoVpNX8FX/kfEi8SrirxaDfZb
DI8QiAnNiUQCKxcvVdIAWKjBKfh2LFSNcuo3oQKBgDuFJRHTBOkSTFIImVoxAV8i
duZrLuhgrzyLAuH5kMNGW8hm3dHYRMFm86nrWyMQgY52dar0TNzbPRvy2qU/2L8g
LxFrVO3pw5uZbYrcuhWAqLJCkJTDqawHvLGy5L5YJHewxBUszNHJIaluh0MvlJev
c+jFMCSN9BLo6Fy50vM8
-----END PRIVATE KEY-----

@ -1 +1 @@
Subproject commit 98d7936a50a148e0c2c718b3d7b8662ffcb3fde8 Subproject commit 21bf28a8b6293872f5f7a42c9ca0bc12ede5aaae

View file

@ -1,87 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIEXTCCA0WgAwIBAgISBEHk0Sh8UrMfT+VPkRhr83mfMA0GCSqGSIb3DQEBCwUA
MDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD
EwJSMzAeFw0yMzA2MDIxNzI1MDVaFw0yMzA4MzExNzI1MDRaMBsxGTAXBgNVBAMT
EHRvb2xzaGVkLmozZDEuZGUwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAARYeMpT
k1DaC8cigL3DivanGrLQahYBEDm5B26VaS3gUmq9T0RNkEUxJIPnZBwdF8p7xAEB
hlTXwgy3eBLAp8lAo4ICTTCCAkkwDgYDVR0PAQH/BAQDAgeAMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTRtqOn
Qht7rD+2IEGzuYt8frTgVjAfBgNVHSMEGDAWgBQULrMXt1hWy65QCUDmH6+dixTC
xjBVBggrBgEFBQcBAQRJMEcwIQYIKwYBBQUHMAGGFWh0dHA6Ly9yMy5vLmxlbmNy
Lm9yZzAiBggrBgEFBQcwAoYWaHR0cDovL3IzLmkubGVuY3Iub3JnLzAbBgNVHREE
FDASghB0b29sc2hlZC5qM2QxLmRlMEwGA1UdIARFMEMwCAYGZ4EMAQIBMDcGCysG
AQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5cHQu
b3JnMIIBBgYKKwYBBAHWeQIEAgSB9wSB9ADyAHcAtz77JN+cTbp18jnFulj0bF38
Qs96nzXEnh0JgSXttJkAAAGIfVskxgAABAMASDBGAiEA+D8rCaCpttJm7w0M4N5N
3cmJSfPNmh/t2ojaDB0iSe0CIQCS2XkwJzUrDZ35fIJ9evwJduk/K2I/tmWs4Uk5
vnPSNQB3AK33vvp8/xDIi509nB4+GGq0Zyldz7EMJMqFhjTr3IKKAAABiH1bJOoA
AAQDAEgwRgIhAPHQQwLf5xSi1VH6BeOpiUKyTMawd36FFU8eCIdB43q6AiEA0KDD
yRssPcmGnyWDGq9Of3mpKjCChFrnxzpeDXCTlsswDQYJKoZIhvcNAQELBQADggEB
AKo8APReSKNTydks9yqASKhUjuLfXS+mpFQSl2tbU8ER6eIiYHx8o+n2QCdT7h91
ZLkGx8ZAmWBvVwXC3QPH5W08ilogi4EU/+HGffkditG5K6/Qn2bzjqnmFIyYqgdT
RVaRcxqS9byAGEw3oU5FSCIOuFSBeOHeTwaj+lSVMZTv6LmoovOpCo8sA5xZ6K6H
XVwNXIwunssaR4MrnWupB/5N+T7zkhanky4GgiLRuTm+mDbK+OIDx47Hv9jTe+tm
s4aixD0eWhzAaiA7HuHJI3Xi64YjK7eNlrwE0ZKdgy8KveDwUBiVcVtz7LR+0v1l
P27Z/OkZlA+42LvIJdISMl4=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw
WhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg
RW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP
R5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx
sxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm
NHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg
Z3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG
/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC
AYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB
Af8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA
FHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw
AoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw
Oi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB
gt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W
PTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl
ikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz
CkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm
lJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4
avAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2
yJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O
yK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids
hCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+
HlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv
MldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX
nLRbwHOoq7hHwg==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB
AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC
ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL
wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D
LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK
4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5
bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y
sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ
Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4
FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc
SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql
PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND
TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1
c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx
+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB
ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu
b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E
U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu
MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC
5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW
9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG
WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O
he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC
Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5
-----END CERTIFICATE-----

View file

@ -1,14 +0,0 @@
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
}
location /api {
proxy_pass http://django:8000;
}
}

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,10 @@
"vue": "^3.2.47", "vue": "^3.2.47",
"vue-multiselect": "^2.1.7", "vue-multiselect": "^2.1.7",
"vue-router": "^4.1.6", "vue-router": "^4.1.6",
"vuex": "^4.1.0" "vuex": "^4.1.0",
"extras": "file:./extras",
"jquery": "^3.6.0",
"popper.js": "^1.16.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^4.0.0", "@vitejs/plugin-vue": "^4.0.0",

View file

@ -1,5 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgD6EmCAUWob1zUw4Q
F+Pf9cSOmSCTODe6u+Gst177IoihRANCAARYeMpTk1DaC8cigL3DivanGrLQahYB
EDm5B26VaS3gUmq9T0RNkEUxJIPnZBwdF8p7xAEBhlTXwgy3eBLAp8lA
-----END PRIVATE KEY-----

View file

@ -3,23 +3,13 @@
<template> <template>
<router-view></router-view> <router-view></router-view>
<!-- TODO UI für Freunde liste, add, remove -->
</template> </template>
<script> <script>
import {mapMutations} from 'vuex';
import store from '@/store';
export default { export default {
name: 'App', name: 'App'
methods: {
...mapMutations(['init']),
},
beforeCreate () {
store.commit('load_local')
}
} }
</script> </script>

Some files were not shown because too many files have changed in this diff Show more