stash
|
|
@ -1,3 +1,10 @@
|
|||
# Worker stays unprivileged as the image's default "nginx" user, but gains the "root" group so
|
||||
# it can read /var/www/userfiles - backend-b (running as root) writes those with
|
||||
# FILE_UPLOAD_DIRECTORY_PERMISSIONS=0750, group-readable but not world-readable. Mirrors
|
||||
# deploy/prod/playbook.yml's "Allow nginx to read backend user files" (www-data joins the
|
||||
# backend's file-owning group instead of loosening the mode).
|
||||
user nginx root;
|
||||
|
||||
events {}
|
||||
|
||||
http {
|
||||
|
|
|
|||
192
frontend/package-lock.json
generated
|
|
@ -197,93 +197,6 @@
|
|||
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
|
||||
"wrap-ansi": "^8.1.0",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"string-width-cjs": {
|
||||
"version": "npm:string-width@4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"strip-ansi-cjs": {
|
||||
"version": "npm:strip-ansi@6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"wrap-ansi-cjs": {
|
||||
"version": "npm:wrap-ansi@7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@jridgewell/sourcemap-codec": {
|
||||
|
|
@ -1704,6 +1617,40 @@
|
|||
"strip-ansi": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"string-width-cjs": {
|
||||
"version": "npm:string-width@4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
|
|
@ -1713,6 +1660,23 @@
|
|||
"ansi-regex": "^6.2.2"
|
||||
}
|
||||
},
|
||||
"strip-ansi-cjs": {
|
||||
"version": "npm:strip-ansi@6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"strip-literal": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz",
|
||||
|
|
@ -2010,6 +1974,60 @@
|
|||
"strip-ansi": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"wrap-ansi-cjs": {
|
||||
"version": "npm:wrap-ansi@7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@
|
|||
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
|
||||
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
|
||||
<button v-if="layout === 'grid'" @click="layout = 'masonry'" class="btn">
|
||||
<b-icon-list></b-icon-list>
|
||||
<b-icon-columns-gap></b-icon-columns-gap>
|
||||
</button>
|
||||
<button v-else-if="layout === 'masonry'" @click="layout = 'table'" class="btn">
|
||||
<b-icon-columns-gap></b-icon-columns-gap>
|
||||
<b-icon-list></b-icon-list>
|
||||
</button>
|
||||
<button v-else @click="layout = 'grid'" class="btn">
|
||||
<b-icon-grid></b-icon-grid>
|
||||
|
|
@ -311,6 +311,10 @@ export default {
|
|||
object-fit: cover;
|
||||
}
|
||||
|
||||
.table-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.masonry-grid {
|
||||
column-count: 2;
|
||||
column-gap: 1rem;
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@
|
|||
<div class="btn-group">
|
||||
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
|
||||
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
|
||||
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
|
||||
<button v-if="layout === 'grid'" @click="layout = 'tree'" class="btn">
|
||||
<b-icon-list-nested></b-icon-list-nested>
|
||||
</button>
|
||||
<button v-else-if="layout === 'tree'" @click="layout = 'table'" class="btn">
|
||||
<b-icon-list></b-icon-list>
|
||||
</button>
|
||||
<button v-else @click="layout = 'grid'" class="btn">
|
||||
|
|
@ -73,6 +76,40 @@
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card-body" v-else-if="layout === 'tree'">
|
||||
<TreeView :items="locationTree">
|
||||
<template #default="{item: location}">
|
||||
<div class="tree-location-row">
|
||||
<router-link :to="locationRoute(location)" class="tree-location-name text-truncate">
|
||||
{{ location.name }}
|
||||
</router-link>
|
||||
<span class="tree-location-category">
|
||||
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
|
||||
<span class="text-muted" v-else>-</span>
|
||||
</span>
|
||||
<span class="tree-location-visibility">
|
||||
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
|
||||
</span>
|
||||
<div class="tree-location-actions table-action">
|
||||
<router-link :to="locationEditRoute(location)" class="me-1" title="Edit">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
</router-link>
|
||||
<guarded-button tag="a" class="me-1" title="Delete"
|
||||
:confirm-message='`Are you sure you want to delete "${location.name}"? This cannot be undone.`'
|
||||
@confirm="tryDeleteLocation(location)">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
</guarded-button>
|
||||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)" class="me-1">
|
||||
<b-icon-link></b-icon-link>
|
||||
</router-link>
|
||||
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)" title="Print Label">
|
||||
<b-icon-qr-code></b-icon-qr-code>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TreeView>
|
||||
</div>
|
||||
<div class="card-body" v-else>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-4 col-lg-3 col-xl-2 d-flex" v-for="location in locations"
|
||||
|
|
@ -131,6 +168,7 @@ import {mapActions, mapGetters, mapState} from "vuex";
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import TreeView from "@/components/TreeView.vue";
|
||||
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
|
|
@ -145,6 +183,7 @@ export default {
|
|||
components: {
|
||||
BaseLayout,
|
||||
GuardedButton,
|
||||
TreeView,
|
||||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -178,6 +217,21 @@ export default {
|
|||
locations() {
|
||||
return this.selectedOwner === this.user ? this.storage_locations : this.groupStorageLocations(this.selectedOwner)
|
||||
},
|
||||
// See docs/implementation.md#category-tree-reconstruction-from-flat-path-strings.
|
||||
locationTree() {
|
||||
const nodesById = {}
|
||||
for (const location of this.locations) {
|
||||
nodesById[location.id] = {...location, children: []}
|
||||
}
|
||||
const roots = []
|
||||
for (const location of [...this.locations].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const node = nodesById[location.id]
|
||||
// Falls back to root if the parent isn't in this owner's own location list.
|
||||
const parent = location.parent != null ? nodesById[location.parent] : null
|
||||
;(parent ? parent.children : roots).push(node)
|
||||
}
|
||||
return roots
|
||||
},
|
||||
addLocationRoute() {
|
||||
const params = this.selectedOwner === this.user ? {} : {group: encodeHandleForUrl(this.selectedOwner)}
|
||||
return {name: 'locations-new', params}
|
||||
|
|
@ -240,6 +294,29 @@ export default {
|
|||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tree-location-row {
|
||||
display: grid;
|
||||
/* Category/Visibility match the table's 20% th widths; actions is max-content so it never wraps. */
|
||||
grid-template-columns: minmax(0, 1fr) 20% 20% auto;
|
||||
align-items: center;
|
||||
column-gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tree-location-name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-location-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* The th widths above leave Actions only the leftover 5% - without this it wraps mid-icon. */
|
||||
.table-action {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-group.mt-auto {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
BIN
testdata/avatars/avatar-2.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
testdata/avatars/avatar-3.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/avatars/avatar-4.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
testdata/avatars/avatar-5.png
vendored
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
testdata/avatars/avatar-6.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/avatars/avatar.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
106
testdata/generate_testdata.py
vendored
|
|
@ -17,6 +17,7 @@ from nacl.signing import SigningKey
|
|||
from nacl.encoding import HexEncoder
|
||||
|
||||
IMGS_DIR = os.path.join(os.path.dirname(__file__), 'imgs')
|
||||
AVATARS_DIR = os.path.join(os.path.dirname(__file__), 'avatars')
|
||||
IMAGE_ATTACHMENT_PROBABILITY = 0.9
|
||||
|
||||
|
||||
|
|
@ -254,11 +255,11 @@ def load_shared_data():
|
|||
return all_tags, all_categories, all_properties
|
||||
|
||||
|
||||
def load_available_images():
|
||||
"""Read every file in imgs/ and return a list of (data, mime_type) tuples."""
|
||||
def _load_images_from_dir(directory):
|
||||
"""Read every image file in the given directory and return a list of (data, mime_type) tuples."""
|
||||
images = []
|
||||
for filename in sorted(os.listdir(IMGS_DIR)):
|
||||
filepath = os.path.join(IMGS_DIR, filename)
|
||||
for filename in sorted(os.listdir(directory)):
|
||||
filepath = os.path.join(directory, filename)
|
||||
if not os.path.isfile(filepath):
|
||||
continue
|
||||
mime_type, _ = mimetypes.guess_type(filename)
|
||||
|
|
@ -269,6 +270,16 @@ def load_available_images():
|
|||
return images
|
||||
|
||||
|
||||
def load_available_images():
|
||||
"""Read every file in imgs/ and return a list of (data, mime_type) tuples."""
|
||||
return _load_images_from_dir(IMGS_DIR)
|
||||
|
||||
|
||||
def load_available_avatars():
|
||||
"""Read every file in avatars/ and return a list of (data, mime_type) tuples."""
|
||||
return _load_images_from_dir(AVATARS_DIR)
|
||||
|
||||
|
||||
def generate_keypair():
|
||||
"""Generate a signing key pair and return (hex_private_key, hex_public_key)."""
|
||||
signing_key = SigningKey.generate()
|
||||
|
|
@ -277,6 +288,18 @@ def generate_keypair():
|
|||
return private_hex, public_hex
|
||||
|
||||
|
||||
def load_or_generate_keypair(key_path):
|
||||
"""Return (hex_private_key, hex_public_key), reusing the key at key_path if it already
|
||||
exists instead of generating (and thereby invalidating) a new identity."""
|
||||
if os.path.exists(key_path):
|
||||
with open(key_path, 'r') as f:
|
||||
private_hex = f.read().strip()
|
||||
signing_key = SigningKey(private_hex.encode('utf-8'), encoder=HexEncoder)
|
||||
public_hex = signing_key.verify_key.encode(encoder=HexEncoder).decode('utf-8')
|
||||
return private_hex, public_hex
|
||||
return generate_keypair()
|
||||
|
||||
|
||||
def generate_locations_csv(count):
|
||||
"""Generate a realistic locations.csv with varied storage locations and parent-child relationships."""
|
||||
locations = random.sample(LOCATIONS_DATABASE, min(count, len(LOCATIONS_DATABASE)))
|
||||
|
|
@ -442,6 +465,24 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
|
|||
return output.getvalue().encode('utf-8'), files
|
||||
|
||||
|
||||
def generate_profile_data(username, domain, first_name, last_name, avatar_data, avatar_mime_type):
|
||||
"""Build profile.json content and the (arcname, data) for its picture, matching
|
||||
`toolshed.offlinedata.profile_data()`/`profile_picture_files()`'s 'files/<hash><ext>' convention.
|
||||
"""
|
||||
extension = mimetypes.guess_extension(avatar_mime_type) or ''
|
||||
arcname = f'files/{sha256(avatar_data).hexdigest()}{extension}'
|
||||
|
||||
profile = {
|
||||
'username': username,
|
||||
'domain': domain,
|
||||
'email': f'{username}@{domain}',
|
||||
'first_name': first_name,
|
||||
'last_name': last_name,
|
||||
'profile_picture': arcname,
|
||||
}
|
||||
return json.dumps(profile, indent=2).encode('utf-8'), arcname
|
||||
|
||||
|
||||
def generate_sample_files():
|
||||
"""Generate sample files for the 'files/' folder in the zip."""
|
||||
files = {}
|
||||
|
|
@ -458,7 +499,8 @@ def generate_sample_files():
|
|||
|
||||
|
||||
def generate_export_zip(username, domain, friend_username, friend_domain, friend_public_key,
|
||||
num_locations=10, num_items=150, item_range=None):
|
||||
num_locations=10, num_items=150, item_range=None,
|
||||
first_name='', last_name='', avatar=None):
|
||||
"""Generate a zip file (bytes) like user_data() produces.
|
||||
|
||||
Args:
|
||||
|
|
@ -470,6 +512,9 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
|
|||
num_locations: Number of locations to generate
|
||||
num_items: Number of items to generate
|
||||
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
|
||||
first_name: First name for profile.json
|
||||
last_name: Last name for profile.json
|
||||
avatar: (data, mime_type) tuple for the profile picture, or None to skip profile.json
|
||||
"""
|
||||
num_locations = max(5, min(20, num_locations))
|
||||
num_items = max(100, min(200, num_items))
|
||||
|
|
@ -495,6 +540,13 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
|
|||
for arcname, content in sample_files.items():
|
||||
zip_file.writestr(arcname, content)
|
||||
|
||||
if avatar:
|
||||
avatar_data, avatar_mime_type = avatar
|
||||
profile_json, avatar_arcname = generate_profile_data(
|
||||
username, domain, first_name, last_name, avatar_data, avatar_mime_type)
|
||||
zip_file.writestr('profile.json', profile_json)
|
||||
zip_file.writestr(avatar_arcname, avatar_data)
|
||||
|
||||
return zip_buffer.getvalue()
|
||||
|
||||
|
||||
|
|
@ -506,20 +558,30 @@ def main():
|
|||
all_tags, all_categories, all_properties = load_shared_data()
|
||||
print(f" ✓ Loaded {len(all_tags)} tags, {len(all_categories)} categories, and {len(all_properties)} properties")
|
||||
|
||||
print("\nGenerating keypairs...")
|
||||
user_a_private, user_a_public = generate_keypair()
|
||||
user_b_private, user_b_public = generate_keypair()
|
||||
print("\nLoading/generating keypairs...")
|
||||
user_a_key_path = os.path.join(script_dir, 'user-a.key')
|
||||
user_b_key_path = os.path.join(script_dir, 'user-b.key')
|
||||
user_a_existed = os.path.exists(user_a_key_path)
|
||||
user_b_existed = os.path.exists(user_b_key_path)
|
||||
user_a_private, user_a_public = load_or_generate_keypair(user_a_key_path)
|
||||
user_b_private, user_b_public = load_or_generate_keypair(user_b_key_path)
|
||||
|
||||
print(f" user-a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
|
||||
print(f" user-b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
|
||||
print(f" test_a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
|
||||
print(f" test_b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
|
||||
|
||||
print("\nWriting key files...")
|
||||
with open(os.path.join(script_dir, 'user-a.key'), 'w') as f:
|
||||
if user_a_existed:
|
||||
print(" ✓ user-a.key (reused existing)")
|
||||
else:
|
||||
with open(user_a_key_path, 'w') as f:
|
||||
f.write(user_a_private)
|
||||
with open(os.path.join(script_dir, 'user-b.key'), 'w') as f:
|
||||
print(" ✓ user-a.key (generated)")
|
||||
if user_b_existed:
|
||||
print(" ✓ user-b.key (reused existing)")
|
||||
else:
|
||||
with open(user_b_key_path, 'w') as f:
|
||||
f.write(user_b_private)
|
||||
print(" ✓ user-a.key")
|
||||
print(" ✓ user-b.key")
|
||||
print(" ✓ user-b.key (generated)")
|
||||
|
||||
print("\nGenerating realistic export zips with partitioned items...")
|
||||
# Partition the ITEMS_DATABASE randomly
|
||||
|
|
@ -531,13 +593,21 @@ def main():
|
|||
print(f" User A items: {user_a_item_range[0]}-{user_a_item_range[1]} ({user_a_item_range[1] - user_a_item_range[0] + 1} unique items)")
|
||||
print(f" User B items: {user_b_item_range[0]}-{user_b_item_range[1]} ({user_b_item_range[1] - user_b_item_range[0] + 1} unique items)")
|
||||
|
||||
available_avatars = load_available_avatars()
|
||||
if len(available_avatars) >= 2:
|
||||
user_a_avatar, user_b_avatar = random.sample(available_avatars, 2)
|
||||
else:
|
||||
user_a_avatar = user_b_avatar = available_avatars[0] if available_avatars else None
|
||||
|
||||
user_a_zip = generate_export_zip(
|
||||
'user-a', 'a.localhost', 'user-b', 'b.localhost', user_b_public,
|
||||
num_locations=12, num_items=175, item_range=user_a_item_range
|
||||
'test_a', 'a.localhost', 'test_b', 'b.localhost', user_b_public,
|
||||
num_locations=12, num_items=175, item_range=user_a_item_range,
|
||||
first_name='Alice', last_name='Anderson', avatar=user_a_avatar
|
||||
)
|
||||
user_b_zip = generate_export_zip(
|
||||
'user-b', 'b.localhost', 'user-a', 'a.localhost', user_a_public,
|
||||
num_locations=8, num_items=140, item_range=user_b_item_range
|
||||
'test_b', 'b.localhost', 'test_a', 'a.localhost', user_a_public,
|
||||
num_locations=8, num_items=140, item_range=user_b_item_range,
|
||||
first_name='Bob', last_name='Baker', avatar=user_b_avatar
|
||||
)
|
||||
|
||||
with open(os.path.join(script_dir, 'user-a.zip'), 'wb') as f:
|
||||
|
|
|
|||
BIN
testdata/imgs/unsplash-1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/imgs/unsplash-2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 888 KiB |
BIN
testdata/imgs/unsplash-3.jpg
vendored
Normal file
|
After Width: | Height: | Size: 970 KiB |
2
testdata/user-a.key
vendored
|
|
@ -1 +1 @@
|
|||
982abc6680c1358c9d4d2436c96c00aebe36e9dd50fe184c12f1ac58fc7f8565
|
||||
53da21dc8bf556e45b906bc9f1cd22688b29efb2e575c8fb8353199a93553d4b
|
||||
BIN
testdata/user-a.zip
vendored
2
testdata/user-b.key
vendored
|
|
@ -1 +1 @@
|
|||
2e81e04093250c647cb2c364e39017be5aeac0303e138d6ded2e2cc7c3cdcdde
|
||||
8ed48f39bd054d88124d7b2ae4529a3da692b131194bd26da2bf5ede612baea9
|
||||