diff --git a/docs/_static/api.yml b/docs/_static/api.yml index c906c3baa..e9b65613d 100644 --- a/docs/_static/api.yml +++ b/docs/_static/api.yml @@ -718,6 +718,37 @@ paths: description: Unauthorized 404: description: Not Found + post: + tags: + - Frontend + summary: Update user created container metadata. + parameters: + - name: container + in: query + description: The container which metadata will be updated. + schema: + type: string + example: test-container-1 + required: true + requestBody: + description: Bucket metadata as a key-value object. Updates must include all the metadata for the bucket. Omitted keys are removed by the swift backend. + required: true + content: + application/json: + schema: + type: object + properties: + key: + type: string + example: + owner: project-team + responses: + 204: + description: Container metadata was updated. No Content. + 403: + description: Unauthorized + 404: + description: Container was not found /api/bucket/object/meta: get: tags: @@ -750,34 +781,48 @@ paths: post: tags: - Frontend - summary: Update user created container metadata. + summary: Update user created object metadata. parameters: - name: container in: query - description: The container which metadata will be updated. + description: The container which contains the objects to be updated. schema: type: string example: test-container-1 required: true requestBody: - description: Bucket metadata as a key-value object. Updates must include all the metadata for the bucket. Omitted keys are removed by the swift backend. + description: Object metadata as an array of tuples with the object name and a key-value object. Updates must include all the metadata for the object. Omitted keys are removed by the swift backend. required: true content: application/json: schema: - type: object - properties: - key: - type: string + type: array example: - owner: project-team + - - object-name + - key: value + items: + type: array + items: + oneOf: + - type: string + - type: object + minItems: 2 + maxItems: 2 + example: + - object-name + - key: value + example: + - - object-name + - key: value responses: 204: - description: Container metadata was updated. No Content. + description: Object metadata was updated. No Content. + 400: + description: Payload malformed. Bad request. 403: - description: Unauthorized + description: Unauthorized. 404: - description: Container was not found + description: At least one object was not found. /api/shared/objects: get: tags: diff --git a/swift_browser_ui/ui/api.py b/swift_browser_ui/ui/api.py index a5c1328f4..5390caf8b 100644 --- a/swift_browser_ui/ui/api.py +++ b/swift_browser_ui/ui/api.py @@ -6,7 +6,7 @@ import aiohttp.web from swiftclient.exceptions import ClientException -from swiftclient.service import SwiftError +from swiftclient.service import SwiftError, SwiftPostObject from swiftclient.service import SwiftService, get_conn # for type hints from swiftclient.utils import generate_temp_url @@ -492,7 +492,7 @@ async def get_object_metadata( for i in res ] - # Strip unnecessary specifcations from header names and split open s3 + # Strip unnecessary specifications from header names and split open s3 # information so that it doesn't have to be done in the browser for i in res: i[1] = {k.replace("x-object-meta-", ""): v for k, v in i[1].items()} @@ -589,6 +589,47 @@ async def get_metadata_object(request: aiohttp.web.Request) -> aiohttp.web.Respo return aiohttp.web.json_response(await get_object_metadata(conn, meta_cont, meta_obj)) +async def update_metadata_object(request: aiohttp.web.Request) -> aiohttp.web.Response: + """Update metadata for an object.""" + session = api_check(request) + request.app["Log"].info( + "API cal for updating container metadata from " + f"{request.remote}, sess: {session} :: {time.ctime()}" + ) + + # Get required variables from query string + container = request.query.get("container", "") or None + objects = await request.json() + + if not (container or objects): + raise aiohttp.web.HTTPBadRequest + + objects_post = [] + try: + for (name, meta) in objects: + meta = [(key, value) for key, value in meta.items() if value] + objects_post.append( + SwiftPostObject( + object_name=name, + options={ + "meta": meta, + }, + ) + ) + except ValueError as e: + request.app["Log"].error(f"Payload seems to be malformed: {e}") + raise aiohttp.web.HTTPBadRequest + + conn = request.app["Sessions"][session]["ST_conn"] + ret = conn.post(container=container, objects=objects_post) + + for r in ret: + if not r["success"]: + raise aiohttp.web.HTTPNotFound + + return aiohttp.web.HTTPNoContent() + + async def get_project_metadata(request: aiohttp.web.Request) -> aiohttp.web.Response: """Get the bare minimum required project metadata from OS.""" # The project metadata needs to be filtered for sensitive information, as diff --git a/swift_browser_ui/ui/server.py b/swift_browser_ui/ui/server.py index 5bbfab4a8..04c629be7 100644 --- a/swift_browser_ui/ui/server.py +++ b/swift_browser_ui/ui/server.py @@ -45,6 +45,7 @@ swift_check_object_chunk, swift_replicate_container, update_metadata_bucket, + update_metadata_object, ) from swift_browser_ui.ui.health import handle_health_check from swift_browser_ui.ui.settings import setd @@ -161,6 +162,7 @@ async def servinit() -> aiohttp.web.Application: aiohttp.web.get("/api/bucket/meta", get_metadata_bucket), aiohttp.web.post("/api/bucket/meta", update_metadata_bucket), aiohttp.web.get("/api/bucket/object/meta", get_metadata_object), + aiohttp.web.post("/api/bucket/object/meta", update_metadata_object), aiohttp.web.get("/api/project/meta", get_project_metadata), aiohttp.web.get("/api/project/acl", get_access_control_metadata), aiohttp.web.post("/api/access/{container}", add_project_container_acl), diff --git a/swift_browser_ui_frontend/src/common/api.js b/swift_browser_ui_frontend/src/common/api.js index 6229ba535..5a060e809 100644 --- a/swift_browser_ui_frontend/src/common/api.js +++ b/swift_browser_ui_frontend/src/common/api.js @@ -1,6 +1,9 @@ // API fetch functions. -import { getHumanReadableSize } from "@/common/conv"; +import { + getHumanReadableSize, + makeGetObjectsMetaURL, +} from "@/common/conv"; export async function getUser() { // Function to get the username of the currently displayed user. @@ -131,7 +134,42 @@ export async function getObjects(container) { return objects; } -export async function getSharedObjects( +export async function getObjectsMeta ( + container, + objects, + url, +){ + if (url === undefined) { + url = makeGetObjectsMetaURL(container, objects); + } + + let ret = await fetch( + url, {method: "GET", credentials: "same-origin"}, + ); + return ret.json(); +} + +export async function updateObjectMeta ( + container, + objectMeta, +){ + let url = new URL( + "/api/bucket/object/meta?container=".concat(encodeURI(container)), + document.location.origin, + ); + + let ret = await fetch( + url, + { + method: "POST", + credentials: "same-origin", + body: JSON.stringify([objectMeta]), + }, + ); + return ret; +} + +export async function getSharedObjects ( project, container, url, diff --git a/swift_browser_ui_frontend/src/common/conv.js b/swift_browser_ui_frontend/src/common/conv.js index 263bd1662..1f477e025 100644 --- a/swift_browser_ui_frontend/src/common/conv.js +++ b/swift_browser_ui_frontend/src/common/conv.js @@ -1,6 +1,8 @@ + import { getBucketMeta, getAccessControlMeta, + getObjectsMeta, } from "./api"; export default function getLangCookie() { @@ -153,12 +155,32 @@ export function getHumanReadableSize(val) { return ret; } +function extractTags(meta) { + if ("usertags" in meta[1]) { + return meta[1]["usertags"].split(";"); + } + return []; +} + export async function getTagsForContainer(containerName) { - let tags = []; - await getBucketMeta(containerName).then(meta => { - if ("usertags" in meta[1]) { - tags = meta[1]["usertags"].split(";"); - } - }); - return tags; + let meta = await getBucketMeta(containerName); + return extractTags(meta); } + +export async function getTagsForObjects(containerName, objectList, url) { + let meta = await getObjectsMeta(containerName, objectList, url); + meta.map(item => item[1] = extractTags(item)); + return meta; +} + +export function makeGetObjectsMetaURL(container, objects) { + return new URL( + "/api/bucket/object/meta?container=" + .concat(encodeURI(container)) + .concat("&object=") + .concat(encodeURI(objects.join(","))), + document.location.origin, + ); +} + +export const taginputConfirmKeys = [",", ";", ":", ".", " ", "Tab", "Enter"]; diff --git a/swift_browser_ui_frontend/src/common/lang.js b/swift_browser_ui_frontend/src/common/lang.js index 531b994fd..f2869417b 100644 --- a/swift_browser_ui_frontend/src/common/lang.js +++ b/swift_browser_ui_frontend/src/common/lang.js @@ -192,6 +192,8 @@ let default_translations = { copysuccess: "Started copying the bucket in the background", copyfail: "Failed to copy the bucket", renderFolders: "Render as Folders", + tagName: "Tags", + tagMessage: "Press enter to add.", container_ops: { addContainer: "Add a new bucket", editContainer: "Editing bucket: ", @@ -203,10 +205,10 @@ let default_translations = { containerMessage: "The name of the new bucket", fullDelete: "Deleting a bucket with contents requires deleting " + "all objects inside it first.", - tagName: "Tags", - tagMessage: "Press enter to add.", }, objects: { + objectName: "Object", + editObject: "Editing object: ", deleteConfirm: "Delete Objects", deleteObjects: "Delete Object / Objects", deleteSuccess: "Objects deleted", @@ -422,6 +424,8 @@ let default_translations = { copysuccess: "Aloitettiin säiliön kopiointi taustalla", copyfail: "Säiliön kopiointi epäonnistui", renderFolders: "Näytä kansioina", + tagName: "Tägit", + tagMessage: "Paina 'enter' lisätäksesi.", container_ops: { addContainer: "Luo uusi säiliö", editContainer: "Muokataan säiliötä: ", @@ -431,10 +435,10 @@ let default_translations = { containerName: "Säiliö", containerMessage: "Uuden säiliön nimi", fullDelete: "Säiliön sisältö on poistettava ennen säiliön postamista.", - tagName: "Tägit", - tagMessage: "Paina 'enter' lisätäksesi.", }, objects: { + objectName: "Objekti", + editObject: "Muokataan objekti: ", deleteConfirm: "Poista objektit", deleteObjects: "Poista objekti / objektit", deleteSuccess: "Objektit poistettu", diff --git a/swift_browser_ui_frontend/src/common/router.js b/swift_browser_ui_frontend/src/common/router.js index 6329b1dcd..04cb9f94b 100644 --- a/swift_browser_ui_frontend/src/common/router.js +++ b/swift_browser_ui_frontend/src/common/router.js @@ -3,6 +3,7 @@ import Router from "vue-router"; import DashboardView from "@/views/Dashboard.vue"; import ContainersView from "@/views/Containers.vue"; import ObjectsView from "@/views/Objects.vue"; +import EditObjectView from "@/views/EditObject.vue"; import SharedObjects from "@/views/SharedObjects"; import ShareRequests from "@/views/ShareRequests"; import SharedTo from "@/views/SharedTo"; @@ -89,5 +90,10 @@ export default new Router({ name: "ObjectsView", component: ObjectsView, }, + { + path: "/browse/:user/:project/:container/:object/edit", + name: "EditObjectView", + component: EditObjectView, + }, ], }); diff --git a/swift_browser_ui_frontend/src/common/store.js b/swift_browser_ui_frontend/src/common/store.js index 5239d7f19..513f43706 100644 --- a/swift_browser_ui_frontend/src/common/store.js +++ b/swift_browser_ui_frontend/src/common/store.js @@ -9,6 +9,8 @@ import { } from "./api"; import { getTagsForContainer, + getTagsForObjects, + makeGetObjectsMetaURL, } from "./conv"; Vue.use(Vuex); @@ -22,6 +24,7 @@ const store = new Vuex.Store({ isLoading: false, isFullPage: true, objectCache: [], + objectTagsCache: {}, // {"objectName": ["tag1", "tag2"]} containerCache: [], containerTagsCache: {}, // {"containerName": ["tag1", "tag2"]} langs: [ @@ -44,53 +47,23 @@ const store = new Vuex.Store({ // Update container cache with the new container listing. state.containerCache = payload; }, + updateContainerTags(state, payload) { + state.containerTagsCache = { + ...state.containerTagsCache, + [payload.containerName]: payload.tags, + }; + }, updateObjects ( state, payload, ) { // Update object cache with the new object listing. - let container = payload.route.params.container; - state.isLoading = true; - if (payload.route.name == "SharedObjects") { - state.client.getAccessDetails( - payload.route.params.project, - container, - payload.route.params.owner, - ).then( - (ret) => { - return getSharedObjects( - payload.route.params.owner, - container, - ret.address, - ); - }, - ).then( - (ret) => { - state.isLoading = false; - state.objectCache = ret; - }, - ).catch(() => { - state.objectCache = []; - state.isLoading = false; - }); - } - else { - getObjects(container).then((ret) => { - if (ret.status != 200) { - state.isLoading = false; - } - state.objectCache = ret; - state.isLoading = false; - }).catch(() => { - state.objectCache = []; - state.isLoading = false; - }); - } + state.objectCache = payload; }, - updateContainerTags(state, payload) { - state.containerTagsCache = { - ...state.containerTagsCache, - [payload.containerName]: payload.tags, + updateObjectTags (state, payload) { + state.objectTagsCache = { + ...state.objectTagsCache, + [payload.objectName]: payload.tags, }; }, eraseObjects(state) { @@ -176,6 +149,74 @@ const store = new Vuex.Store({ ); }); }, + updateObjects: async function ({ commit, dispatch, client }, route) { + let container = route.params.container; + commit("loading", true); + if (route.name == "SharedObjects") { + await client.getAccessDetails( + route.params.project, + container, + route.params.owner, + ).then( + (ret) => { + return getSharedObjects( + route.params.owner, + container, + ret.address, + ); + }, + ).then( + (ret) => { + commit("loading", false); + commit("updateObjects", ret); + }, + ).catch(() => { + commit("updateObjects", []); + commit("loading", false); + }); + } else { + await getObjects(container).then((ret) => { + if (ret.status != 200) { + commit("loading", false); + } + commit("updateObjects", ret); + commit("loading", false); + }).catch(() => { + commit("updateObjects", []); + commit("loading", false); + }); + } + dispatch("updateObjectTags", route); + }, + updateObjectTags: async function ({ commit, state }, route) { + if (!state.objectCache.length) { + return; + } + let objectList = []; + for (let i = 0; i < state.objectCache.length; i++) { + // Object names end up in the URL, which has hard length limits. + // The aiohttp backend has a limit of 2048. The maximum size + // for object name is 1024. Set it to a safe enough amount. + // We split the requests to prevent reaching said limits. + objectList.push(state.objectCache[i].name); + const url = makeGetObjectsMetaURL(route.params.container, objectList); + if ( + i === state.objectCache.length - 1 + || url.href.length > 2000 + ) { + getTagsForObjects(route.params.container, objectList, url) + .then(tags => + tags.map(item => { + commit( + "updateObjectTags", + {objectName: item[0], tags: item[1]}, + ); + }), + ); + objectList = []; + } + } + }, }, }); diff --git a/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue b/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue index b8cdc6573..5956e958e 100644 --- a/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue +++ b/swift_browser_ui_frontend/src/components/ObjectDeleteButton.vue @@ -53,10 +53,7 @@ export default { this.$route.params.container, to_remove, ).then(() => { - this.$store.commit({ - type: "updateObjects", - route: this.$route, - }); + this.$store.dispatch("updateObjects", this.$route); }); }, }, diff --git a/swift_browser_ui_frontend/src/components/ObjectTable.vue b/swift_browser_ui_frontend/src/components/ObjectTable.vue index 58f23aa12..1e3c534d7 100644 --- a/swift_browser_ui_frontend/src/components/ObjectTable.vue +++ b/swift_browser_ui_frontend/src/components/ObjectTable.vue @@ -42,6 +42,11 @@ {{ $t('message.renderFolders') }} +
+
+