diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 816dda9..d31090b 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -66,6 +66,7 @@ declare module 'vue' { SettingsTabTranscription: typeof import('./src/components/settings/SettingsTabTranscription.vue')['default'] SettingsTabUsers: typeof import('./src/components/settings/SettingsTabUsers.vue')['default'] SettingsTabYoutube: typeof import('./src/components/settings/SettingsTabYoutube.vue')['default'] + ShareFolderPanel: typeof import('./src/components/folders/ShareFolderPanel.vue')['default'] ShareProjectPanel: typeof import('./src/components/projects/ShareProjectPanel.vue')['default'] SidebarProjects: typeof import('./src/components/shell/SidebarProjects.vue')['default'] SidePanel: typeof import('./src/components/common/SidePanel.vue')['default'] diff --git a/frontend/src/components/assets/AssetList.vue b/frontend/src/components/assets/AssetList.vue index f109480..874f011 100644 --- a/frontend/src/components/assets/AssetList.vue +++ b/frontend/src/components/assets/AssetList.vue @@ -37,6 +37,7 @@ draggable @rename="emit('rename-folder', $event)" @move="emit('move-folder', $event)" + @share="emit('share-folder', $event)" @delete="emit('delete-folder', $event)" @drop-assets="(names, target) => emit('drop-assets', names, target)" @drop-folder="(name, target) => emit('drop-folder', name, target)" @@ -148,6 +149,7 @@ const emit = defineEmits<{ 'open-folder': [name: string] 'rename-folder': [folder: Folder] 'move-folder': [folder: Folder] + 'share-folder': [folder: Folder] 'delete-folder': [folder: Folder] 'drop-assets': [names: string[], folder: string] 'drop-folder': [name: string, folder: string] diff --git a/frontend/src/components/folders/FolderCard.vue b/frontend/src/components/folders/FolderCard.vue index f226ec9..1e58ea7 100644 --- a/frontend/src/components/folders/FolderCard.vue +++ b/frontend/src/components/folders/FolderCard.vue @@ -82,6 +82,7 @@ const props = defineProps<{ const emit = defineEmits<{ rename: [folder: Folder] move: [folder: Folder] + share: [folder: Folder] delete: [folder: Folder] 'drop-assets': [names: string[], folder: string] 'drop-folder': [name: string, folder: string] @@ -91,6 +92,7 @@ const dragOver = ref(false) const actions = computed(() => [ { label: 'Rename', icon: 'lucide-pencil', onClick: () => emit('rename', props.folder) }, { label: 'Move', icon: 'lucide-folder-input', onClick: () => emit('move', props.folder) }, + { label: 'Share', icon: 'lucide-share-2', onClick: () => emit('share', props.folder) }, { label: 'Delete', icon: 'lucide-trash-2', diff --git a/frontend/src/components/folders/FolderRow.vue b/frontend/src/components/folders/FolderRow.vue index 82842ab..d7afb30 100644 --- a/frontend/src/components/folders/FolderRow.vue +++ b/frontend/src/components/folders/FolderRow.vue @@ -62,6 +62,7 @@ const props = defineProps<{ const emit = defineEmits<{ rename: [folder: Folder] move: [folder: Folder] + share: [folder: Folder] delete: [folder: Folder] 'drop-assets': [names: string[], folder: string] 'drop-folder': [name: string, folder: string] @@ -71,6 +72,7 @@ const dragOver = ref(false) const actions = computed(() => [ { label: 'Rename', icon: 'lucide-pencil', onClick: () => emit('rename', props.folder) }, { label: 'Move', icon: 'lucide-folder-input', onClick: () => emit('move', props.folder) }, + { label: 'Share', icon: 'lucide-share-2', onClick: () => emit('share', props.folder) }, { label: 'Delete', icon: 'lucide-trash-2', diff --git a/frontend/src/components/folders/ShareFolderPanel.vue b/frontend/src/components/folders/ShareFolderPanel.vue new file mode 100644 index 0000000..c51779b --- /dev/null +++ b/frontend/src/components/folders/ShareFolderPanel.vue @@ -0,0 +1,101 @@ + + + + + + Public share link + + Anyone with the link can browse and download the files in this folder. + Subfolders are not included. + + + + + + + + + + + + Revoking the link immediately removes guest access. + + + + + + diff --git a/frontend/src/components/projects/useProjectBrowser.ts b/frontend/src/components/projects/useProjectBrowser.ts index c6da39a..457e8a2 100644 --- a/frontend/src/components/projects/useProjectBrowser.ts +++ b/frontend/src/components/projects/useProjectBrowser.ts @@ -49,7 +49,15 @@ export function useProjectBrowser( const project = useDoc({ doctype: 'VMS Project', name: currentProject }) const folders = useList({ doctype: 'VMS Folder', - fields: ['name', 'folder_name', 'project', 'parent_folder', 'creation', 'modified'], + fields: [ + 'name', + 'folder_name', + 'project', + 'parent_folder', + 'share_token', + 'creation', + 'modified', + ], filters: () => ({ project: currentProject.value, deleted_at: ['is', 'not set'] }), orderBy: 'folder_name asc', limit: 500, diff --git a/frontend/src/components/projects/useProjectPageActions.ts b/frontend/src/components/projects/useProjectPageActions.ts index 358ec65..3cfaf97 100644 --- a/frontend/src/components/projects/useProjectPageActions.ts +++ b/frontend/src/components/projects/useProjectPageActions.ts @@ -19,8 +19,10 @@ export function useProjectPageActions( const moveFolderOpen = ref(false) const moveAssetsOpen = ref(false) const shareOpen = ref(false) + const shareFolderOpen = ref(false) const settingsOpen = ref(false) const folderAction = ref(null) + const shareFolderTarget = ref(null) const plainDescription = computed(() => (browser.project.doc?.description ?? '') @@ -52,6 +54,12 @@ export function useProjectPageActions( onClick: () => browser.currentFolderDoc.value && openMoveFolder(browser.currentFolderDoc.value), }, + { + label: 'Share', + icon: 'lucide-share-2', + onClick: () => + browser.currentFolderDoc.value && openShareFolder(browser.currentFolderDoc.value), + }, { label: 'Delete', icon: 'lucide-trash-2', @@ -92,6 +100,11 @@ export function useProjectPageActions( moveFolderOpen.value = true } + function openShareFolder(folder: Folder) { + shareFolderTarget.value = folder + shareFolderOpen.value = true + } + async function handleFolderMoved(targetProject: string) { if ( folderAction.value?.name === browser.currentFolder.value && @@ -163,8 +176,10 @@ export function useProjectPageActions( moveFolderOpen, moveAssetsOpen, shareOpen, + shareFolderOpen, settingsOpen, folderAction, + shareFolderTarget, plainDescription, projectActions, folderActions, @@ -173,6 +188,7 @@ export function useProjectPageActions( openFolder, openRenameFolder, openMoveFolder, + openShareFolder, handleFolderMoved, handleAssetsMoved, saveProject, diff --git a/frontend/src/pages/ProjectDetailPage.vue b/frontend/src/pages/ProjectDetailPage.vue index 034f76c..c909643 100644 --- a/frontend/src/pages/ProjectDetailPage.vue +++ b/frontend/src/pages/ProjectDetailPage.vue @@ -37,8 +37,16 @@ @drop-folder="moveFolder" /> Project - - + + - - - moveAssets(names, target)" @drop-folder="(name, target) => moveFolder(name, target)" @@ -163,6 +169,7 @@ @changed="reloadAssets" @rename-folder="openRenameFolder" @move-folder="openMoveFolder" + @share-folder="openShareFolder" @delete-folder="deleteFolder" @drop-assets="(names, target) => moveAssets(names, target)" @drop-folder="(name, target) => moveFolder(name, target)" @@ -237,6 +244,12 @@ :project="project.doc" @changed="project.reload" /> + - + - + - + - {{ project.data.project_name }} + {{ title }} {{ total }} {{ total === 1 ? 'file' : 'files' }} shared - + @@ -63,7 +71,7 @@ @@ -185,12 +193,14 @@ import { fileKindStyle } from '@/lib/fileType' import MediaPreviewDialog from '@/components/common/MediaPreviewDialog.vue' import { formatBytes, serverMessage } from '@/lib/format' -interface SharedProject { +interface SharedScope { name: string - project_name: string - status: string + /** Set on a project share. */ + project_name?: string + status?: string description?: string - thumbnail_url?: string + /** Set on a folder share. */ + folder_name?: string } interface SharedAsset { @@ -212,18 +222,22 @@ interface SharedAssetsResponse { total_pages: number } -interface SharedAssetParams { - asset_name: string - project: string +interface ScopeParams { + project?: string + folder?: string token: string } +type SharedAssetParams = ScopeParams & { asset_name: string } const PAGE_SIZE = 20 -const props = defineProps<{ projectId: string }>() +const props = defineProps<{ projectId?: string; folderId?: string }>() const route = useRoute() +const isFolder = computed(() => Boolean(props.folderId)) +const scopeId = computed(() => props.folderId ?? props.projectId ?? '') + /** Share links carry their access token in the query string. */ const token = computed(() => { const value = route.query.token @@ -234,31 +248,42 @@ const page = ref(1) const preview = ref<{ asset: SharedAsset; url: string; downloadUrl?: string } | null>(null) const downloadingAll = ref(false) -const project = useCall({ - url: '/api/v2/method/vms.api.get_shared_project', - method: 'GET', - params: () => ({ project: props.projectId, token: token.value }), - immediate: Boolean(token.value), - cacheKey: ['shared-project', props.projectId], -}) +function scopeParams(): ScopeParams { + return isFolder.value + ? { folder: props.folderId, token: token.value } + : { project: props.projectId, token: token.value } +} -const assetsCall = useCall< - SharedAssetsResponse, - { project: string; token: string; page: number; page_size: number } ->({ - url: '/api/v2/method/vms.api.get_shared_project_assets', +const infoUrl = computed(() => + isFolder.value + ? '/api/v2/method/vms.api.get_shared_folder' + : '/api/v2/method/vms.api.get_shared_project', +) +const assetsUrl = computed(() => + isFolder.value + ? '/api/v2/method/vms.api.get_shared_folder_assets' + : '/api/v2/method/vms.api.get_shared_project_assets', +) + +const info = useCall({ + url: infoUrl, method: 'GET', - params: () => ({ - project: props.projectId, - token: token.value, - page: page.value, - page_size: PAGE_SIZE, - }), + params: scopeParams, immediate: Boolean(token.value), - refetch: true, - cacheKey: ['shared-project-assets', props.projectId], + cacheKey: ['shared-info', scopeId.value], }) +const assetsCall = useCall( + { + url: assetsUrl, + method: 'GET', + params: () => ({ ...scopeParams(), page: page.value, page_size: PAGE_SIZE }), + immediate: Boolean(token.value), + refetch: true, + cacheKey: ['shared-assets', scopeId.value], + }, +) + const viewUrl = useCall({ url: '/api/v2/method/vms.api.get_shared_asset_view_url', method: 'POST', @@ -274,13 +299,12 @@ const downloadUrl = useCall({ const assets = computed(() => assetsCall.data?.assets ?? []) const total = computed(() => assetsCall.data?.total ?? 0) const totalPages = computed(() => assetsCall.data?.total_pages ?? 1) +const title = computed(() => info.data?.folder_name ?? info.data?.project_name ?? '') -usePageMeta(() => ({ - title: project.data ? `${project.data.project_name} · VMS` : 'Shared project · VMS', -})) +usePageMeta(() => ({ title: title.value ? `${title.value} · VMS` : 'Shared · VMS' })) function paramsFor(asset: SharedAsset): SharedAssetParams { - return { asset_name: asset.name, project: props.projectId, token: token.value } + return { ...scopeParams(), asset_name: asset.name } } async function open(asset: SharedAsset) { diff --git a/frontend/src/router.ts b/frontend/src/router.ts index bef239d..4c4685f 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -58,6 +58,13 @@ const routes: RouteRecordRaw[] = [ props: true, meta: { guest: true }, }, + { + path: '/shared/folder/:folderId', + name: 'SharedFolder', + component: () => import('@/pages/SharedProjectPage.vue'), + props: true, + meta: { guest: true }, + }, { path: '/shared/:projectId', name: 'SharedProject', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index da049c4..025fb1c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -46,6 +46,7 @@ export interface Folder { folder_name: string project: string parent_folder?: string | null + share_token?: string | null deleted_at?: string | null deleted_by?: string | null deleter_name?: string diff --git a/plans/folder-scoped-sharing.md b/plans/folder-scoped-sharing.md new file mode 100644 index 0000000..4077f82 --- /dev/null +++ b/plans/folder-scoped-sharing.md @@ -0,0 +1,89 @@ +# Folder-scoped sharing + +## Problem + +Sharing today is project-wide only. `VMS Project` carries a single `share_token`; +`get_shared_project_assets` filters by `{project}` and returns every asset in the +project as a flat list. There is no way to share one folder without exposing the +rest of the project. + +## Goal + +Share a single folder. A guest with a folder link sees the files **directly in +that folder** — not its subfolders, not sibling or parent folders. + +## Decisions + +- **Scope**: the folder's own assets only. Nested subfolders are not shown or + reachable. +- **Independence**: each folder carries its own `share_token`, unrelated to the + project's share or any other folder's. Project + any number of folders can be + shared at once; revoking one touches nothing else. +- **URL**: `/vms/shared/folder/?token=…` — no project segment, so + moving the folder between projects never breaks the link. +- **Lifecycle**: moving the folder keeps the link. Trashing the folder clears its + `share_token` (link dies). Restoring does not bring the link back — the user + re-shares. The link always reflects the folder's current files. +- **Guest page**: reuses the existing shared-page shell — folder name as title, + flat grid, preview, per-file download, "Download all". +- **Entry points**: a "Share" item on each folder card's ⋮ menu (grid and list + view) and in the "Folder actions" dropdown when inside a folder. Opens a side + panel like the project one. + +## Out of scope + +Recursive/subtree sharing · link expiry / passwords · audit-log entries for +share actions (project sharing isn't audited either) · zip download · guest +uploads · extracting sharing out of `api.py`. + +## Backend (`vms/api.py`, `vms_folder`) + +- `VMS Folder` gains `share_section` + `share_token` (Data, hidden, read-only, + unique) — mirrors `VMS Project`. +- `VMSFolder.revoke_share_on_trash()` (called from `validate`): clears + `share_token` whenever `deleted_at` is set. Covers every trash path since they + all `.save()`. +- New whitelisted methods: + | Method | Auth | Returns | + | --- | --- | --- | + | `enable_folder_sharing(folder)` | `require_vms_access()` | `{share_token, share_url}` | + | `disable_folder_sharing(folder)` | `require_vms_access()` | `{status: "ok"}` | + | `get_shared_folder(folder, token)` | guest | `{name, folder_name, project_name}` | + | `get_shared_folder_assets(folder, token, page, page_size)` | guest | same shape as `get_shared_project_assets`, filtered to `{folder}` | +- `_validate_folder_token(folder, token)` — requires a valid token for every + caller (no authenticated-session bypass) and rejects a trashed or unshared + folder. +- `_validate_shared_asset_scope(project, token, folder)` — returns the + `(field, value)` a shared asset must match: `("folder", folder)` when `folder` + is given, else `("project", project)`. +- `get_shared_asset_view_url` / `get_shared_asset_download_url` gain an optional + `folder` param and use `_validate_shared_asset_scope`. `project` becomes + optional. They also reject a trashed or still-uploading asset. Behaviour for + a live asset with no `folder` is unchanged. + +## Frontend + +- `types.ts`: `Folder.share_token?: string | null`. +- `useProjectBrowser`: add `share_token` to the folders `useList` fields. +- `FolderCard.vue` / `FolderRow.vue`: `Share` menu item, `emit('share', folder)`. +- `AssetList.vue`: forward `share-folder`. +- `useProjectPageActions`: `shareFolderOpen` + `shareFolderTarget` state, + `openShareFolder(folder)`, `Share` entry in `folderActions`. +- `ShareFolderPanel.vue` (new, ~90 lines, mirrors `ShareProjectPanel.vue`): + toggle that calls `enable_folder_sharing` / `disable_folder_sharing`, shows and + copies the link. +- `ProjectDetailPage.vue`: wire `@share` / `@share-folder`, mount + ``. +- `router.ts`: `/shared/folder/:folderId` → `SharedProjectPage.vue`, `props: true`, + `meta: { guest: true }`. +- `SharedProjectPage.vue`: optional `folderId` prop. When set, `isFolder` swaps + the info/assets endpoints to the folder ones and passes `folder` (not + `project`) to the asset-URL calls; title shows the folder name; status badge + and description are hidden. `downloadAll` unchanged. + +## Testing + +Deferred — full e2e/unit suite is a follow-up. One security assertion was +verified during implementation: folder A (file a1) with subfolder B (b1) and +sibling C (c1); sharing A, a guest gets a1 only, b1/c1 are rejected; trashing A +clears the token and kills the link. diff --git a/vms/api.py b/vms/api.py index 6e00583..acd1b5c 100644 --- a/vms/api.py +++ b/vms/api.py @@ -1837,23 +1837,138 @@ def get_shared_project_assets(project: str, token: str | None = None, page=1, pa } +# ── Folder Sharing ─────────────────────────────────────────────────────────── + + +@frappe.whitelist() +def enable_folder_sharing(folder: str): + require_vms_access() + + doc = frappe.get_doc("VMS Folder", folder) + if doc.deleted_at: + frappe.throw(_("A folder in the trash cannot be shared")) + if not doc.share_token: + doc.share_token = uuid.uuid4().hex + doc.save(ignore_permissions=True) + + site_url = frappe.utils.get_url() + return { + "share_token": doc.share_token, + "share_url": f"{site_url}/vms/shared/folder/{folder}?token={doc.share_token}", + } + + +@frappe.whitelist() +def disable_folder_sharing(folder: str): + require_vms_access() + + doc = frappe.get_doc("VMS Folder", folder) + doc.share_token = None + doc.save(ignore_permissions=True) + return {"status": "ok"} + + +def _validate_folder_token(folder_name, token): + result = frappe.db.get_value( + "VMS Folder", + folder_name, + ["share_token", "deleted_at"], + as_dict=True, + ) + + if not result or result.deleted_at or not result.share_token or token != result.share_token: + frappe.throw(_("Invalid or expired share link"), frappe.AuthenticationError) + + +def _validate_shared_asset_scope(project, token, folder): + if folder: + _validate_folder_token(folder, token) + return "folder", folder + _validate_project_token(project, token) + return "project", project + + +# nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method +@frappe.whitelist(allow_guest=True, methods=["GET"]) +def get_shared_folder(folder: str, token: str | None = None): + _validate_folder_token(folder, token) + + doc = frappe.db.get_value( + "VMS Folder", + folder, + ["name", "folder_name", "project"], + as_dict=True, + ) + if not doc: + frappe.throw(_("Folder not found"), frappe.DoesNotExistError) + + return { + "name": doc.name, + "folder_name": doc.folder_name, + "project_name": frappe.db.get_value("VMS Project", doc.project, "project_name"), + } + + +# nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method +@frappe.whitelist(allow_guest=True, methods=["GET"]) +def get_shared_folder_assets( + folder: str, token: str | None = None, page: int | str = 1, page_size: int | str = 20 +): + _validate_folder_token(folder, token) + + page = max(1, int(page)) + page_size = min(100, max(1, int(page_size))) + start = (page - 1) * page_size + + filters = {"folder": folder, "status": ["!=", "Uploading"], "deleted_at": ["is", "not set"]} + + total = frappe.db.count("VMS Asset", filters=filters) + + assets = frappe.get_all( + "VMS Asset", + filters=filters, + fields=[ + "name", + "file_name", + "category", + "file_size", + "file_type", + "uploaded_at", + "creation", + "thumbnail_url", + ], + order_by="creation desc", + start=start, + page_length=page_size, + ) + + return { + "assets": assets, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": -(-total // page_size) if total else 0, + } + + # Reviewed: guests reach this only with a share token that is checked against the # project, and the asset must belong to that same project. # nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method @frappe.whitelist(allow_guest=True) -def get_shared_asset_view_url(asset_name: str, project: str, token: str | None = None): - """Get a presigned view URL for an asset in a shared project (guest-accessible).""" - _validate_project_token(project, token) +def get_shared_asset_view_url( + asset_name: str, project: str | None = None, token: str | None = None, folder: str | None = None +): + scope_field, scope_value = _validate_shared_asset_scope(project, token, folder) asset = frappe.db.get_value( "VMS Asset", asset_name, - ["r2_key", "project", "file_type", "file_name", "preview_r2_key"], + ["r2_key", "project", "folder", "file_type", "file_name", "preview_r2_key", "status", "deleted_at"], as_dict=True, ) - if not asset or asset.project != project: - frappe.throw(_("Asset not found in this project"), frappe.DoesNotExistError) + if not asset or asset.deleted_at or asset.status == "Uploading" or asset.get(scope_field) != scope_value: + frappe.throw(_("Asset not found in this share"), frappe.DoesNotExistError) if not asset.r2_key: frappe.throw(_("Asset has no R2 key")) @@ -2097,20 +2212,22 @@ def restore_version(asset_name: str, version_number: int): return {"status": "ok", "asset_name": asset.name, "version": new_version} +# nosemgrep: frappe-semgrep-rules.rules.security.guest-whitelisted-method @frappe.whitelist(allow_guest=True) -def get_shared_asset_download_url(asset_name: str, project: str, token: str | None = None): - """Get a presigned download URL for an asset in a shared project (guest-accessible).""" - _validate_project_token(project, token) +def get_shared_asset_download_url( + asset_name: str, project: str | None = None, token: str | None = None, folder: str | None = None +): + scope_field, scope_value = _validate_shared_asset_scope(project, token, folder) asset = frappe.db.get_value( "VMS Asset", asset_name, - ["r2_key", "file_name", "project"], + ["r2_key", "file_name", "project", "folder", "status", "deleted_at"], as_dict=True, ) - if not asset or asset.project != project: - frappe.throw(_("Asset not found in this project"), frappe.DoesNotExistError) + if not asset or asset.deleted_at or asset.status == "Uploading" or asset.get(scope_field) != scope_value: + frappe.throw(_("Asset not found in this share"), frappe.DoesNotExistError) if not asset.r2_key: frappe.throw(_("Asset has no R2 key")) diff --git a/vms/video_management_solution/doctype/vms_folder/vms_folder.json b/vms/video_management_solution/doctype/vms_folder/vms_folder.json index 7a46f14..aae0877 100644 --- a/vms/video_management_solution/doctype/vms_folder/vms_folder.json +++ b/vms/video_management_solution/doctype/vms_folder/vms_folder.json @@ -11,6 +11,8 @@ "folder_name", "project", "parent_folder", + "share_section", + "share_token", "section_break_trash", "deleted_at", "trashed_with_parent", @@ -47,6 +49,19 @@ "label": "Parent Folder", "options": "VMS Folder" }, + { + "fieldname": "share_section", + "fieldtype": "Section Break", + "hidden": 1 + }, + { + "fieldname": "share_token", + "fieldtype": "Data", + "hidden": 1, + "label": "Share Token", + "read_only": 1, + "unique": 1 + }, { "fieldname": "section_break_trash", "fieldtype": "Section Break", @@ -81,7 +96,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2026-02-25 12:00:00.000000", + "modified": "2026-09-05 12:00:00.000000", "modified_by": "Administrator", "module": "Video Management Solution", "name": "VMS Folder", diff --git a/vms/video_management_solution/doctype/vms_folder/vms_folder.py b/vms/video_management_solution/doctype/vms_folder/vms_folder.py index a164180..d74ba64 100644 --- a/vms/video_management_solution/doctype/vms_folder/vms_folder.py +++ b/vms/video_management_solution/doctype/vms_folder/vms_folder.py @@ -13,6 +13,11 @@ class VMSFolder(Document): def validate(self): self.validate_parent_folder() + self.revoke_share_on_trash() + + def revoke_share_on_trash(self): + if self.deleted_at and self.share_token: + self.share_token = None def validate_parent_folder(self): if not self.parent_folder:
Public share link
+ Anyone with the link can browse and download the files in this folder. + Subfolders are not included. +
{{ total }} {{ total === 1 ? 'file' : 'files' }} shared