This commit is contained in:
j3d1 2026-08-31 13:02:06 +02:00
parent d606de8773
commit 2218cc3543
12 changed files with 393 additions and 2204 deletions

View file

@ -14,7 +14,7 @@ class FileSerializer(serializers.Serializer):
def to_representation(self, instance): def to_representation(self, instance):
return {'id': instance.id, 'name': instance.file.url, 'size': instance.file.size, return {'id': instance.id, 'name': instance.file.url, 'size': instance.file.size,
'mime_type': instance.mime_type} 'mime_type': instance.mime_type, 'hash': instance.hash}
def create(self, validated_data): def create(self, validated_data):
return File.objects.get_or_create(**validated_data)[0] return File.objects.get_or_create(**validated_data)[0]

View file

@ -55,8 +55,8 @@ def post_item_file(request, item_id):
if not request.user.user.exists(): if not request.user.user.exists():
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
try: try:
file = File.objects.get(hash=request.data['file_hash'], file = File.objects.filter(hash=request.data['file_hash'],
staged_by_workflows__owner=request.user.user.get()) staged_by_workflows__owner=request.user.user.get()).distinct().get()
except File.DoesNotExist: except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND) return Response(status=status.HTTP_404_NOT_FOUND)
item.files.add(file) item.files.add(file)
@ -101,6 +101,31 @@ def item_files(request, item_id, format=None):
return post_item_file(request, item_id) return post_item_file(request, item_id)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def delete_file(request, file_id, format=None):
try:
file = File.objects.get(id=file_id)
except File.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
# Only detach from items this identity is actually authorized to act on - a file is
# content-addressed and can be shared by other users'/groups' items via the same hash,
# so it must never be removed from connections this request has no authority over.
authorized_items = file.connected_items.filter(
Q(owner=request.user) | Q(owner_group__in=request.user.public_identity.member_of_groups.all())
)
if not authorized_items.exists():
return Response(status=status.HTTP_404_NOT_FOUND)
for item in authorized_items:
item.files.remove(file)
if file.connected_items.count() == 0 and file.profile_picture_users.count() == 0 \
and file.staged_by_workflows.count() == 0:
file.file.delete(save=False)
file.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['DELETE']) @api_view(['DELETE'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthentication]) @authentication_classes([SignatureAuthentication])
@ -151,6 +176,7 @@ def delete_staged_file(request, workflow_id, file_hash, format=None):
urlpatterns = [ urlpatterns = [
path('files/', list_all_files), path('files/', list_all_files),
path('files/<int:file_id>/', delete_file),
path('item_files/<int:item_id>/', item_files), path('item_files/<int:item_id>/', item_files),
path('item_files/<int:item_id>/<int:file_id>/', delete_item_file), path('item_files/<int:item_id>/<int:file_id>/', delete_item_file),
path('staged_files/<int:workflow_id>/', staged_files), path('staged_files/<int:workflow_id>/', staged_files),

View file

@ -49,15 +49,21 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
if owner_group: if owner_group:
if not owner_group.is_member(identity): if not owner_group.is_member(identity):
return InventoryItem.objects.none() return InventoryItem.objects.none()
return InventoryItem.objects.filter(owner_group=owner_group) queryset = InventoryItem.objects.filter(owner_group=owner_group)
if owner_user: elif owner_user:
if owner_user not in identity.friends_or_self(): if owner_user not in identity.friends_or_self():
return InventoryItem.objects.none() return InventoryItem.objects.none()
queryset = InventoryItem.objects.filter(owner=owner_user) queryset = InventoryItem.objects.filter(owner=owner_user)
if not identity.user.filter(pk=owner_user.pk).exists(): if not identity.user.filter(pk=owner_user.pk).exists():
queryset = queryset.exclude(availability_policy='private') queryset = queryset.exclude(availability_policy='private')
return queryset else:
return InventoryItem.objects.none() return InventoryItem.objects.none()
# InventoryItemSerializer touches owner/owner_group/category/storage_location (FKs) and
# tags/files/itemproperty_set (M2M/reverse-FK) for every row - without this, listing N
# items costs ~5N extra queries (each a full network round trip once every other API
# call also goes through the federation ServerSet layer, not just local Django).
return queryset.select_related('owner', 'owner_group', 'category', 'storage_location').prefetch_related(
'tags', 'files', 'itemproperty_set__property')
def perform_create(self, serializer): def perform_create(self, serializer):
try: try:
@ -209,6 +215,18 @@ class WorkflowInstanceViewSet(viewsets.ModelViewSet):
file.file.delete(save=False) file.file.delete(save=False)
file.delete() file.delete()
@action(detail=True, methods=['post'])
def update_step(self, request, pk=None):
# get_queryset already scopes to the request user's own workflows, so get_object 404s for anyone else's.
instance = self.get_object()
with transaction.atomic():
if 'current_step' in request.data:
instance.current_step = request.data['current_step']
if 'payload' in request.data:
instance.payload = request.data['payload']
instance.save()
return Response(self.get_serializer(instance).data)
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r'inventory_items/(?P<handle>[^/]+)', InventoryItemViewSet, basename='inventory_items') router.register(r'inventory_items/(?P<handle>[^/]+)', InventoryItemViewSet, basename='inventory_items')

View file

@ -1,5 +1,5 @@
from django.test import Client from django.test import Client
from authentication.models import Group from authentication.models import Group, ToolshedUser
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin from files.tests import FilesTestMixin
from toolshed.models import File, InventoryItem from toolshed.models import File, InventoryItem
@ -140,6 +140,49 @@ class FileApiTestCase(UserTestMixin, FilesTestMixin, InventoryTestMixin, Toolshe
self.assertEqual(File.objects.count(), 3) self.assertEqual(File.objects.count(), 3)
self.assertEqual(self.f['item1'].files.count(), 2) self.assertEqual(self.f['item1'].files.count(), 2)
def test_delete_file_top_level(self):
response = client.delete(f"/api/v1/files/{self.f['test_file2'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(File.objects.count(), 2)
self.assertEqual(self.f['item1'].files.count(), 1)
def test_delete_file_top_level_removes_from_all_owned_items(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(File.objects.count(), 2)
self.assertEqual(self.f['item1'].files.count(), 1)
self.assertEqual(self.f['item2'].files.count(), 0)
def test_delete_file_top_level_not_found(self):
response = client.delete(f"/api/v1/files/99999/", self.f['local_user1'])
self.assertEqual(response.status_code, 404)
self.assertEqual(File.objects.count(), 3)
def test_delete_file_top_level_not_owner(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user2'])
self.assertEqual(response.status_code, 404)
self.assertEqual(File.objects.count(), 3)
self.assertEqual(self.f['item1'].files.count(), 2)
def test_delete_file_top_level_anonymous(self):
response = anonymous_client.delete(f"/api/v1/files/{self.f['test_file1'].id}/")
self.assertEqual(response.status_code, 403)
self.assertEqual(File.objects.count(), 3)
def test_delete_file_top_level_only_detaches_own_items_when_shared_by_hash(self):
other_item = InventoryItem.create_for_owner(
owner=self.f['local_user2'], owned_quantity=1, name='other-user-item',
availability_policy='private')
other_item.files.add(self.f['test_file1'])
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user1'])
self.assertEqual(response.status_code, 204)
self.assertEqual(self.f['item1'].files.count(), 1)
self.assertEqual(self.f['item2'].files.count(), 0)
self.assertEqual(other_item.files.count(), 1)
self.assertEqual(File.objects.filter(id=self.f['test_file1'].id).count(), 1)
def test_get_inventory(self): def test_get_inventory(self):
reply = client.get('/api/v1/inventory_items/{}/'.format(self.f['local_user1']), self.f['local_user1']) reply = client.get('/api/v1/inventory_items/{}/'.format(self.f['local_user1']), self.f['local_user1'])
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
@ -215,6 +258,18 @@ class GroupOwnedFileApiTestCase(UserTestMixin, GroupTestMixin, FilesTestMixin, T
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
self.assertEqual(self.f['group_item'].files.count(), 1) self.assertEqual(self.f['group_item'].files.count(), 1)
def test_other_member_can_delete_file_top_level(self):
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", self.f['local_user2'])
self.assertEqual(response.status_code, 204)
self.assertEqual(self.f['group_item'].files.count(), 0)
def test_non_member_cannot_delete_file_top_level(self):
outsider = ToolshedUser.objects.create_user('testuser3', 'test3@abc.de', 'testpassword4',
domain=self.f['example_com'].name)
response = client.delete(f"/api/v1/files/{self.f['test_file1'].id}/", outsider)
self.assertEqual(response.status_code, 404)
self.assertEqual(self.f['group_item'].files.count(), 1)
def test_item_files_when_id_collides_across_two_groups(self): def test_item_files_when_id_collides_across_two_groups(self):
group2 = Group.objects.create(name='group2', domain=self.f['example_com'].name) group2 = Group.objects.create(name='group2', domain=self.f['example_com'].name)
group2.members.add(self.f['local_user2'].public_identity) group2.members.add(self.f['local_user2'].public_identity)

View file

@ -7,6 +7,8 @@
<p class="text-muted">Upload your CSV or Excel file containing item data for bulk import.</p> <p class="text-muted">Upload your CSV or Excel file containing item data for bulk import.</p>
</div> </div>
<div v-if="uploadError" class="alert alert-danger">{{ uploadError }}</div>
<!-- File Upload Area --> <!-- File Upload Area -->
<div class="upload-section mb-4"> <div class="upload-section mb-4">
<div class="card"> <div class="card">
@ -321,6 +323,7 @@ export default {
processingFile: false, processingFile: false,
processingStatus: '', processingStatus: '',
isDragOver: false, isDragOver: false,
uploadError: null,
fileAnalysis: null, fileAnalysis: null,
detectedColumns: [], detectedColumns: [],
parsedRows: [], parsedRows: [],
@ -385,8 +388,9 @@ export default {
}, },
async processFile(file) { async processFile(file) {
this.uploadError = null;
if (!this.isValidFileType(file)) { if (!this.isValidFileType(file)) {
alert('Please upload a CSV or Excel file (.csv, .xlsx, .xls)'); this.uploadError = 'Please upload a CSV or Excel file (.csv, .xlsx, .xls)';
return; return;
} }
@ -406,7 +410,7 @@ export default {
this.updateStep1Payload(); this.updateStep1Payload();
} catch (error) { } catch (error) {
console.error('Error processing file:', error); console.error('Error processing file:', error);
alert('Error processing file. Please try again.'); this.uploadError = 'Error processing file. Please try again.';
this.removeFile(); this.removeFile();
} finally { } finally {
this.processingFile = false; this.processingFile = false;
@ -565,10 +569,11 @@ export default {
proceedFromStep1() { proceedFromStep1() {
if (!this.canProceedFromStep1) { if (!this.canProceedFromStep1) {
alert('Please upload a file and map the required Name column before proceeding.'); this.uploadError = 'Please upload a file and map the required Name column before proceeding.';
return; return;
} }
this.uploadError = null;
this.updateStep1Payload(); this.updateStep1Payload();
this.$emit('next'); this.$emit('next');
}, },

View file

@ -184,7 +184,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}, {path: '/workflows', }, {path: '/workflows',
component: Workflows, component: Workflows,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
}, {path: '/workflows/:id/:phase?', }, {path: '/workflows/:id/:step?',
name: 'workflow-detail', name: 'workflow-detail',
component: WorkflowDetail, component: WorkflowDetail,
meta: {requiresAuth: true}, meta: {requiresAuth: true},

View file

@ -426,9 +426,9 @@ export default createStore({
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
: await dispatch('getHomeServers') : await dispatch('getHomeServers')
const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/' const path = '/api/v1/inventory_items/' + encodeHandleForUrl(item.owner_group || item.owner) + '/' + item.id + '/'
const ret = await servers.delete(getters.signAuth, path) // No self-refetch here, same as createInventoryItem/updateInventoryItem - callers
dispatch('fetchInventoryItems') // refetch whichever owner-scoped list (own/group/friend) is actually on screen.
return ret return await servers.delete(getters.signAuth, path)
}, },
async fetchSearchResults({state, commit, dispatch, getters}, {query}) { async fetchSearchResults({state, commit, dispatch, getters}, {query}) {
const servers = await dispatch('getAllKnownServers') const servers = await dispatch('getAllKnownServers')

View file

@ -8,9 +8,9 @@
<h5 class="card-title">Images</h5> <h5 class="card-title">Images</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<span v-for="files in only_images(files)" :key="files.id"> <span v-for="file in only_images(files)" :key="file.id">
<deletable-wrapper @delete="deleteFile(files)"> <deletable-wrapper @delete="deleteFile(file)">
<authenticated-image :src="files.name" :owner="files.owner" <authenticated-image :src="thumbnailPathForHash(file.hash)" :owner="file.owner"
class="img-thumbnail"/> class="img-thumbnail"/>
</deletable-wrapper> </deletable-wrapper>
</span> </span>
@ -58,6 +58,10 @@ export default {
}, },
whithout_images(files) { whithout_images(files) {
return files.filter(file => !file.mime_type.startsWith("image/")); return files.filter(file => !file.mime_type.startsWith("image/"));
},
// See docs/implementation.md#thumbnail-lookup-by-hash.
thumbnailPathForHash(hash, size = 256) {
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
} }
}, },
mounted() { mounted() {

View file

@ -3,6 +3,10 @@
<main class="content"> <main class="content">
<div class="container-fluid p-0"> <div class="container-fluid p-0">
<h1 class="h3 mb-3">Settings</h1> <h1 class="h3 mb-3">Settings</h1>
<div v-if="deleteError" class="alert alert-danger">{{ deleteError }}</div>
<div v-if="deleteSuccess" class="alert alert-success">
Your account has been permanently deleted. Signing you out...
</div>
<div class="row"> <div class="row">
<div class="col-md-3 col-xl-2"> <div class="col-md-3 col-xl-2">
<div class="card"> <div class="card">
@ -56,6 +60,12 @@ import {mapActions, mapGetters, mapMutations} from "vuex";
export default { export default {
name: 'Settings', name: 'Settings',
components: {BaseLayout}, components: {BaseLayout},
data() {
return {
deleteError: null,
deleteSuccess: false
}
},
computed: { computed: {
...mapGetters(['signAuth']) ...mapGetters(['signAuth'])
}, },
@ -63,6 +73,7 @@ export default {
...mapActions(['getHomeServers']), ...mapActions(['getHomeServers']),
...mapMutations(['logout']), ...mapMutations(['logout']),
async deleteAccount() { async deleteAccount() {
this.deleteError = null;
if (!confirm('Are you sure you want to permanently delete your account? ' + if (!confirm('Are you sure you want to permanently delete your account? ' +
'All your data will be deleted and you will not be able to log back in. This cannot be undone.')) { 'All your data will be deleted and you will not be able to log back in. This cannot be undone.')) {
return; return;
@ -72,14 +83,16 @@ export default {
const response = await servers.delete(this.signAuth, '/api/v1/account/'); const response = await servers.delete(this.signAuth, '/api/v1/account/');
if (!response || !response.ok) { if (!response || !response.ok) {
const errorBody = response ? await response.json().catch(() => ({})) : {}; const errorBody = response ? await response.json().catch(() => ({})) : {};
alert('Account deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error')); this.deleteError = 'Account deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error');
return; return;
} }
alert('Your account has been permanently deleted.'); this.deleteSuccess = true;
this.logout(); // logout() redirects to /login right away - give the user a moment to read the
// confirmation above before that navigation happens.
setTimeout(() => this.logout(), 2000);
} catch (error) { } catch (error) {
console.error('Account deletion failed', error); console.error('Account deletion failed', error);
alert('Account deletion failed: ' + error); this.deleteError = 'Account deletion failed: ' + error;
} }
}, },
} }

View file

@ -43,6 +43,8 @@
<br> <br>
<small>Delete all your data including photos, videos, <small>Delete all your data including photos, videos,
comments, profile information and more</small> comments, profile information and more</small>
<div v-if="deleteError" class="alert alert-danger mt-3 mb-0">{{ deleteError }}</div>
<div v-if="deleteSuccess" class="alert alert-success mt-3 mb-0">{{ deleteSuccess }}</div>
</div> </div>
</form> </form>
</div> </div>
@ -64,6 +66,8 @@
</div> </div>
<br> <br>
<small>Import data from backup or other instances</small> <small>Import data from backup or other instances</small>
<div v-if="importError" class="alert alert-danger mt-3 mb-0">{{ importError }}</div>
<div v-if="importSuccess" class="alert alert-success mt-3 mb-0">{{ importSuccess }}</div>
</div> </div>
</form> </form>
</div> </div>
@ -82,7 +86,11 @@ export default {
components: {}, components: {},
data: () => ({ data: () => ({
selectedFile: null, selectedFile: null,
localUserIdentityRecord: null localUserIdentityRecord: null,
deleteError: null,
deleteSuccess: null,
importError: null,
importSuccess: null
}), }),
computed: { computed: {
...mapGetters(['signAuth']) ...mapGetters(['signAuth'])
@ -96,6 +104,8 @@ export default {
} }
}, },
async deleteData() { async deleteData() {
this.deleteError = null;
this.deleteSuccess = null;
if (!confirm('Are you sure you want to permanently delete all your data (inventory, locations, ' + if (!confirm('Are you sure you want to permanently delete all your data (inventory, locations, ' +
'settings, friends and files)? Your account itself will stay - this cannot be undone.')) { 'settings, friends and files)? Your account itself will stay - this cannot be undone.')) {
return; return;
@ -105,19 +115,19 @@ export default {
const response = await servers.delete(this.signAuth, '/api/v1/account_data/'); const response = await servers.delete(this.signAuth, '/api/v1/account_data/');
if (!response || !response.ok) { if (!response || !response.ok) {
const errorBody = response ? await response.json().catch(() => ({})) : {}; const errorBody = response ? await response.json().catch(() => ({})) : {};
alert('Data deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error')); this.deleteError = 'Data deletion failed: ' + (errorBody.detail || response?.statusText || 'unknown error');
return; return;
} }
const summary = await response.json().catch(() => ({})); const summary = await response.json().catch(() => ({}));
alert('All your data has been deleted: ' + this.deleteSuccess = 'All your data has been deleted: ' +
`${summary.inventory_items || 0} inventory items, ` + `${summary.inventory_items || 0} inventory items, ` +
`${summary.locations || 0} locations, ` + `${summary.locations || 0} locations, ` +
`${summary.settings || 0} settings, ` + `${summary.settings || 0} settings, ` +
`${summary.friends || 0} friends, ` + `${summary.friends || 0} friends, ` +
`${summary.files || 0} files.`); `${summary.files || 0} files.`;
} catch (error) { } catch (error) {
console.error('Data deletion failed', error); console.error('Data deletion failed', error);
alert('Data deletion failed: ' + error); this.deleteError = 'Data deletion failed: ' + error;
} }
}, },
exportKey() { exportKey() {
@ -141,8 +151,10 @@ export default {
}); });
}, },
async importData() { async importData() {
this.importError = null;
this.importSuccess = null;
if (!this.selectedFile) { if (!this.selectedFile) {
alert('Please select a file to import'); this.importError = 'Please select a file to import';
return; return;
} }
try { try {
@ -150,16 +162,16 @@ export default {
const servers = await this.getHomeServers(); const servers = await this.getHomeServers();
const summary = await servers.post(this.signAuth, '/api/v1/import/', {zip: base64}); const summary = await servers.post(this.signAuth, '/api/v1/import/', {zip: base64});
if (summary && summary.detail) { if (summary && summary.detail) {
alert('Data import failed: ' + summary.detail); this.importError = 'Data import failed: ' + summary.detail;
return; return;
} }
alert('Data imported successfully: ' + this.importSuccess = 'Data imported successfully: ' +
`${summary.profile ? 'profile, ' : ''}` + `${summary.profile ? 'profile, ' : ''}` +
`${summary.settings || 0} settings, ` + `${summary.settings || 0} settings, ` +
`${summary.inventory_items || 0} inventory items, ` + `${summary.inventory_items || 0} inventory items, ` +
`${summary.friends || 0} friends, ` + `${summary.friends || 0} friends, ` +
`${summary.locations || 0} locations, ` + `${summary.locations || 0} locations, ` +
`${summary.files || 0} files.`); `${summary.files || 0} files.`;
this.selectedFile = null; this.selectedFile = null;
const fileInput = document.getElementById('inputFile'); const fileInput = document.getElementById('inputFile');
if (fileInput) { if (fileInput) {
@ -167,7 +179,7 @@ export default {
} }
} catch (error) { } catch (error) {
console.error('Data import failed', error); console.error('Data import failed', error);
alert('Data import failed: ' + error); this.importError = 'Data import failed: ' + error;
} }
}, },
async exportData() { async exportData() {