basic file sharing

This commit is contained in:
2025-08-29 15:32:23 +02:00
parent c8b52a5b3b
commit 284d805590
84 changed files with 3969 additions and 375 deletions

View File

@@ -11,12 +11,14 @@
"@nuxt/test-utils": "3.19.2",
"@pinia/nuxt": "^0.11.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/yup": "^4.15.1",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^13.5.0",
"@vueuse/core": "^13.7.0",
"byte-size": "^9.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs-nuxt": "2.1.11",
"eslint": "^9.0.0",
"lucide-vue-next": "^0.525.0",
"nuxt": "^3.17.6",
@@ -489,8 +491,12 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="],
"@tanstack/vue-table": ["@tanstack/vue-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "vue": ">=3.2" } }, "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw=="],
"@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-vhF7kEU9EXWXh+HdAwKJ2m3xaOnTTmgcdXcF2pim8g4GvI7eRrk2YRuV5nUlZnd/NbCIX4/Ja2OZu5EjJL06Ww=="],
"@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="],
@@ -583,11 +589,11 @@
"@vue/shared": ["@vue/shared@3.5.17", "", {}, "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg=="],
"@vueuse/core": ["@vueuse/core@13.5.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.5.0", "@vueuse/shared": "13.5.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g=="],
"@vueuse/core": ["@vueuse/core@13.7.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.7.0", "@vueuse/shared": "13.7.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg=="],
"@vueuse/metadata": ["@vueuse/metadata@13.5.0", "", {}, "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw=="],
"@vueuse/metadata": ["@vueuse/metadata@13.7.0", "", {}, "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg=="],
"@vueuse/shared": ["@vueuse/shared@13.5.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g=="],
"@vueuse/shared": ["@vueuse/shared@13.7.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg=="],
"@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="],
@@ -813,6 +819,10 @@
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
"dayjs-nuxt": ["dayjs-nuxt@2.1.11", "", { "dependencies": { "@nuxt/kit": "^3.7.4", "dayjs": "^1.11.10" } }, "sha512-KDDNiET7KAKf6yzL3RaPWq5aV7ql9QTt5fIDYv+4eOegDmnEQGjwkKYADDystsKtPjt7QZerpVbhC96o3BIyqQ=="],
"db0": ["db0@0.3.2", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-xzWNQ6jk/+NtdfLyXEipbX55dmDSeteLFt/ayF+wZUU5bzKgmrDOxmInUTbyVRp46YwnJdkDA1KhB7WIXFofJw=="],
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],

View File

@@ -2,8 +2,9 @@
import type { DirectoryEntry } from '~/shared/types';
const { entry } = defineProps<{ entry: DirectoryEntry }>();
const warrenStore = useWarrenStore();
const emit = defineEmits<{
back: [];
}>();
const onDrop = onDirectoryEntryDrop(entry, true);
</script>
@@ -12,7 +13,7 @@ const onDrop = onDirectoryEntryDrop(entry, true);
<button
class="bg-accent/30 border-border flex w-52 translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none"
@contextmenu.prevent
@click="() => warrenStore.backCurrentPath()"
@click="() => emit('back')"
@drop="onDrop"
>
<div class="flex flex-row items-center">

View File

@@ -6,11 +6,7 @@ import {
ContextMenuItem,
ContextMenuSeparator,
} from '@/components/ui/context-menu';
import {
deleteWarrenDirectory,
deleteWarrenFile,
fetchFile,
} from '~/lib/api/warrens';
import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens';
import type { DirectoryEntry } from '#shared/types';
import { toast } from 'vue-sonner';
@@ -23,6 +19,11 @@ const { entry, disabled } = defineProps<{
disabled: boolean;
}>();
const emit = defineEmits<{
'entry-click': [entry: DirectoryEntry];
'entry-download': [entry: DirectoryEntry];
}>();
const deleting = ref(false);
const isCopied = computed(
() =>
@@ -63,30 +64,7 @@ async function openRenameDialog() {
}
async function onClick() {
if (warrenStore.loading || warrenStore.current == null) {
return;
}
if (entry.fileType === 'directory') {
warrenStore.addToCurrentWarrenPath(entry.name);
return;
}
if (entry.mimeType == null) {
return;
}
if (entry.mimeType.startsWith('image/')) {
const result = await fetchFile(
warrenStore.current.warrenId,
warrenStore.current.path,
entry.name
);
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
}
}
emit('entry-click', entry);
}
function onDragStart(e: DragEvent) {
@@ -112,38 +90,22 @@ function onCopy() {
);
}
async function onDownload() {
if (warrenStore.current == null) {
return;
}
function onShare() {
useShareDialog().openDialog(entry);
}
if (entry.fileType !== 'file') {
toast.warning('Download', {
description: 'Directory downloads are not supported yet',
});
return;
}
const anchor = document.createElement('a');
anchor.download = entry.name;
anchor.href = getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
);
anchor.rel = 'noopener';
anchor.target = '_blank';
anchor.click();
function onDownload() {
emit('entry-download', entry);
}
</script>
<template>
<ContextMenu>
<ContextMenuTrigger>
<ContextMenuTrigger class="flex sm:w-52">
<button
:disabled="warrenStore.loading || disabled"
:class="[
'bg-accent/30 border-border flex w-52 translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
'bg-accent/30 border-border flex w-full translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
isCopied && 'border-primary/50 border',
]"
draggable="true"
@@ -151,38 +113,7 @@ async function onDownload() {
@drop="onDrop"
@click="onClick"
>
<div class="flex flex-row items-center">
<Icon
v-if="
entry.fileType !== 'file' ||
entry.mimeType == null ||
!entry.mimeType.startsWith('image/')
"
class="size-6"
:name="
entry.fileType === 'file'
? getFileIcon(entry.mimeType)
: 'lucide:folder'
"
/>
<object
v-else
:type="entry.mimeType"
class="size-6 object-cover"
width="24"
height="24"
:data="
getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current!.path, entry.name)}`
)
"
>
<Icon
class="size-6"
:name="getFileIcon(entry.mimeType)"
/>
</object>
</div>
<DirectoryEntryIcon :entry />
<div
class="flex w-full flex-col items-start justify-stretch gap-0 overflow-hidden text-left leading-6"
@@ -198,11 +129,17 @@ async function onDownload() {
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem @select="openRenameDialog">
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="openRenameDialog"
>
<Icon name="lucide:pencil" />
Rename
</ContextMenuItem>
<ContextMenuItem @select="onCopy">
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="onCopy"
>
<Icon name="lucide:copy" />
Copy
</ContextMenuItem>
@@ -213,15 +150,28 @@ async function onDownload() {
<Icon name="lucide:download" />
Download
</ContextMenuItem>
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="onShare"
>
<Icon name="lucide:share" />
Share
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSeparator
:class="[warrenStore.current == null && 'hidden']"
/>
<ContextMenuItem @select="() => submitDelete(false)">
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@select="() => submitDelete(false)"
>
<Icon name="lucide:trash-2" />
Delete
</ContextMenuItem>
<ContextMenuItem
v-if="entry.fileType === 'directory'"
:class="[warrenStore.current == null && 'hidden']"
@select="() => submitDelete(true)"
>
<Icon

View File

@@ -0,0 +1,47 @@
<script lang="ts" setup>
import type { DirectoryEntry } from '~/shared/types';
const { entry } = defineProps<{
entry: DirectoryEntry;
}>();
const route = useRoute();
const warrenStore = useWarrenStore();
</script>
<template>
<div class="flex flex-row items-center">
<Icon
v-if="
entry.fileType !== 'file' ||
entry.mimeType == null ||
!entry.mimeType.startsWith('image/')
"
class="size-6"
:name="
entry.fileType === 'file'
? getFileIcon(entry.mimeType)
: 'lucide:folder'
"
/>
<object
v-else-if="warrenStore.current != null"
:type="entry.mimeType"
class="size-6 object-cover"
width="24"
height="24"
:data="
route.meta.layout === 'share'
? getApiUrl(
`warrens/files/cat_share?shareId=${route.query.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
)
: getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
)
"
>
<Icon class="size-6" :name="getFileIcon(entry.mimeType)" />
</object>
<Icon v-else class="size-6" :name="getFileIcon(entry.mimeType)" />
</div>
</template>

View File

@@ -2,10 +2,22 @@
import { ScrollArea } from '@/components/ui/scroll-area';
import type { DirectoryEntry } from '#shared/types';
const { entries, parent, isOverDropZone } = defineProps<{
const {
entries,
parent,
isOverDropZone,
disableEntries = false,
} = defineProps<{
entries: DirectoryEntry[];
parent: DirectoryEntry | null;
isOverDropZone?: boolean;
disableEntries?: boolean;
}>();
const emit = defineEmits<{
'entry-click': [entry: DirectoryEntry];
'entry-download': [entry: DirectoryEntry];
back: [];
}>();
const { isLoading } = useLoadingIndicator();
@@ -13,23 +25,39 @@ const { isLoading } = useLoadingIndicator();
const sortedEntries = computed(() =>
entries.toSorted((a, b) => a.name.localeCompare(b.name))
);
function onEntryClicked(entry: DirectoryEntry) {
emit('entry-click', entry);
}
function onEntryDownload(entry: DirectoryEntry) {
emit('entry-download', entry);
}
</script>
<template>
<ScrollArea class="h-full w-full">
<ScrollArea class="flex h-full w-full flex-col overflow-hidden">
<div
v-if="isOverDropZone"
class="bg-background/50 pointer-events-none absolute flex h-full w-full items-center justify-center"
>
<Icon class="size-16 animate-pulse" name="lucide:upload" />
</div>
<div class="flex flex-row flex-wrap gap-2">
<DirectoryBackEntry v-if="parent != null" :entry="parent" />
<div
class="flex w-full flex-col gap-2 overflow-hidden sm:flex-row sm:flex-wrap"
>
<DirectoryBackEntry
v-if="parent != null"
:entry="parent"
@back="() => emit('back')"
/>
<DirectoryEntry
v-for="entry in sortedEntries"
:key="entry.name"
:entry="entry"
:disabled="isLoading"
:disabled="isLoading || disableEntries"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
/>
</div>
</ScrollArea>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'vue-sonner';
import { deleteShare } from '~/lib/api/shares';
import type { Share } from '~/shared/types/shares';
const { shares } = defineProps<{
shares: Share[];
}>();
function onCopyClicked(share: Share) {
const link = getShareLink(share);
if (copyToClipboard(link)) {
toast.success('Share', {
description: 'Copied the link to the clipboard',
});
} else {
console.log(`Here's the link to ${share.path}: ${link}`);
toast.error('Share', {
description: `Failed to copy the link to the clipboard. Logged it to the console instead.`,
});
}
}
async function onDeleteClicked(share: Share) {
const result = await deleteShare(share.warrenId, share.id);
if (result.success) {
refreshNuxtData('current-file-shares');
toast.success('Share', {
description: `Successfully deleted the share for ${result.share.path}`,
});
} else {
toast.error('Share', {
description: 'Failed to delete share',
});
}
}
</script>
<template>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-[100px]">ID</TableHead>
<TableHead>Password</TableHead>
<TableHead>Expiration</TableHead>
<TableHead>Created</TableHead>
<TableHead class="w-0 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="share in shares" :key="share.id">
<TableCell class="font-medium" :title="share.id">
<span class="hidden sm:block">
{{ share.id }}
</span>
<span class="block sm:hidden"
>{{ share.id.slice(0, 3) }}...{{ share.id.slice(-3) }}
</span>
</TableCell>
<TableCell>{{ share.password ? 'Yes' : 'No' }}</TableCell>
<TableCell>{{
share.expiresAt == null
? 'Never'
: $dayjs(share.expiresAt).format('MMM D, YYYY HH:mm')
}}</TableCell>
<TableCell>{{
$dayjs(share.createdAt).format('MMM D, YYYY HH:mm')
}}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
@click="() => onCopyClicked(share)"
><Icon name="lucide:copy"
/></Button>
<Button
variant="ghost"
size="icon"
@click="() => onDeleteClicked(share)"
><Icon name="lucide:trash-2"
/></Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</template>

View File

@@ -0,0 +1,203 @@
<script setup lang="ts">
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { useShareDialog } from '@/stores';
import { toTypedSchema } from '@vee-validate/yup';
import { useForm } from 'vee-validate';
import { toast } from 'vue-sonner';
import { createShare, listShares } from '~/lib/api/shares';
import { shareSchema } from '~/lib/schemas/share';
import type { Share } from '~/shared/types/shares';
const warrenStore = useWarrenStore();
const dialog = useShareDialog();
const newScreen = ref(false);
const existingShares = ref<Share[]>([]);
useAsyncData('current-file-shares', async () => {
if (warrenStore.current == null || dialog.target == null) {
return [];
}
const result = await listShares(
warrenStore.current.warrenId,
joinPaths(warrenStore.current.path, dialog.target.name)
);
if (!result.success) {
return [];
}
if (result.shares.length < 1) {
newScreen.value = true;
} else {
newScreen.value = false;
}
existingShares.value = result.shares;
});
dialog.$subscribe(async (_, value) => {
if (value.target !== null) {
await refreshNuxtData('current-file-shares');
}
});
function onOpenChange() {
dialog.reset();
}
function onCancel() {
if (newScreen.value && existingShares.value.length > 0) {
newScreen.value = false;
} else {
dialog.reset();
}
}
const form = useForm({
validationSchema: toTypedSchema(shareSchema),
});
const onSubmit = form.handleSubmit(async (values) => {
if (dialog.target == null || warrenStore.current == null) {
return;
}
const result = await createShare(
warrenStore.current.warrenId,
joinPaths(warrenStore.current.path, dialog.target.name),
values.password ?? null,
values.lifetime ?? null
);
if (result.success) {
toast.success('Share', {
description: `Successfully created a share for ${dialog.target.name}`,
});
await refreshNuxtData('current-file-shares');
newScreen.value = false;
} else {
toast.error('Share', {
description: `Failed to create share`,
});
}
});
</script>
<template>
<Dialog :open="dialog.target != null" @update:open="onOpenChange">
<DialogTrigger as-child>
<slot />
</DialogTrigger>
<DialogContent
v-if="dialog.target != null"
class="w-full !max-w-[calc(100vw-8px)] sm:!max-w-[min(98vw,1000px)]"
>
<DialogHeader class="overflow-hidden">
<DialogTitle class="truncate"
>Share {{ dialog.target.name }}</DialogTitle
>
<DialogDescription
>Create a shareable link to this
{{ dialog.target.fileType }}</DialogDescription
>
</DialogHeader>
<SharesTable v-if="!newScreen" :shares="existingShares" />
<form
v-else
id="share-form"
class="grid gap-4"
@submit.prevent="onSubmit"
>
<FormField v-slot="{ componentField }" name="password">
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
v-bind="componentField"
id="password"
type="password"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</FormControl>
<FormMessage />
<FormDescription>
<span
v-if="
form.values.password == null ||
form.values.password.length < 1
"
>
The share will not require a password
</span>
<span v-else>
The share will require the specified password
</span>
</FormDescription>
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="lifetime">
<FormItem>
<FormLabel>Lifetime (seconds)</FormLabel>
<FormControl>
<div
class="flex w-full flex-row justify-between gap-1"
>
<Input
v-bind="componentField"
id="lifetime"
type="number"
class="w-full max-w-full min-w-0"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</div>
</FormControl>
<FormMessage />
<FormDescription>
<span
v-if="
form.values.lifetime != null &&
form.values.lifetime > 0
"
class="w-full"
>
The share will expire in
{{
$dayjs
.duration({
seconds: form.values.lifetime,
})
.humanize()
}}
</span>
<span v-else> The share will be permanent </span>
</FormDescription>
</FormItem>
</FormField>
</form>
<DialogFooter>
<Button variant="ghost" @click="onCancel">Cancel</Button>
<Button v-if="newScreen" type="submit" form="share-form"
>Share</Button
>
<Button v-else @click="() => (newScreen = true)">New</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -13,19 +13,37 @@ import type { UserWarren } from '~/shared/types/warrens';
const props = defineProps<{
userWarren: UserWarren;
}>();
let realUserWarrenState: UserWarren = JSON.parse(
JSON.stringify(props.userWarren)
);
const userWarren = props.userWarren;
const adminStore = useAdminStore();
const updatePermissionsDebounced = useDebounceFn(
async (userWarren: UserWarren) => {
const result = await editUserWarren(userWarren);
async (uw: UserWarren) => {
const result = await editUserWarren(uw);
if (result.success) {
for (const [key, value] of Object.entries(result.data)) {
if (key in userWarren) {
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
userWarren[key] = value;
}
}
realUserWarrenState = JSON.parse(JSON.stringify(result.data));
toast.success('Permissions', {
description: `Successfully updated the user's permissions`,
});
} else {
for (const [key, value] of Object.entries(realUserWarrenState)) {
if (key in userWarren) {
// @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
userWarren[key] = value;
}
}
userWarren.canCreateShares = realUserWarrenState.canCreateShares;
toast.error('Permissions', {
description: `Failed to update the user's permissions`,
});

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</td>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { cn } from "@/lib/utils"
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
>
<slot />
</th>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
>
<slot />
</tr>
</template>

View File

@@ -0,0 +1,9 @@
export { default as Table } from "./Table.vue"
export { default as TableBody } from "./TableBody.vue"
export { default as TableCaption } from "./TableCaption.vue"
export { default as TableCell } from "./TableCell.vue"
export { default as TableEmpty } from "./TableEmpty.vue"
export { default as TableFooter } from "./TableFooter.vue"
export { default as TableHead } from "./TableHead.vue"
export { default as TableHeader } from "./TableHeader.vue"
export { default as TableRow } from "./TableRow.vue"

View File

@@ -0,0 +1,10 @@
import type { Updater } from "@tanstack/vue-table"
import type { Ref } from "vue"
import { isFunction } from "@tanstack/vue-table"
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue
}

View File

@@ -27,16 +27,7 @@ export function setAuthSession(value: {
}) {
useAuthSession().value = value;
let cookie = `authorization=WarrenAuth ${value.id}; path=/; SameSite=Lax; Secure;`;
const config = useRuntimeConfig().public;
console.log('config', config);
const cookieDomain = config.authCookieDomain;
if (cookieDomain != null && cookieDomain.length > 0) {
cookie += ` domain=${cookieDomain}`;
}
console.log(cookie);
const cookie = `authorization=WarrenAuth ${value.id}; path=/; SameSite=Lax; Secure;`;
document.cookie = cookie;
}

View File

@@ -16,6 +16,7 @@ await useAsyncData('warrens', async () => {
<template>
<SidebarProvider>
<ActionsShareDialog />
<ImageViewer />
<AppSidebar />
<SidebarInset class="flex flex-col-reverse md:flex-col">

View File

@@ -0,0 +1,8 @@
<script lang="ts" setup></script>
<template>
<main class="flex h-full w-full items-center justify-center">
<ImageViewer />
<slot />
</main>
</template>

176
frontend/lib/api/shares.ts Normal file
View File

@@ -0,0 +1,176 @@
import type { ApiResponse } from '~/shared/types/api';
import type { Share } from '~/shared/types/shares';
import { getApiHeaders } from '.';
import type { DirectoryEntry } from '~/shared/types';
export async function getShare(
shareId: string
): Promise<
{ success: true; share: Share; file: DirectoryEntry } | { success: false }
> {
const { data } = await useFetch<
ApiResponse<{ share: Share; file: DirectoryEntry }>
>(getApiUrl('warrens/files/get_share'), {
method: 'POST',
headers: getApiHeaders(false),
body: JSON.stringify({
shareId: shareId,
}),
});
if (data.value == null) {
return {
success: false,
};
}
const { share, file } = data.value.data;
return {
success: true,
share,
file,
};
}
export async function createShare(
warrenId: string,
path: string,
password: string | null,
lifetime: number | null
): Promise<{ success: true; share: Share } | { success: false }> {
const { data } = await useFetch<ApiResponse<Share>>(
getApiUrl('warrens/files/create_share'),
{
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
warrenId: warrenId,
path: path,
lifetime: lifetime,
password: password,
}),
}
);
if (data.value == null) {
return {
success: false,
};
}
return { success: true, share: data.value.data };
}
export async function listShares(
warrenId: string,
path: string
): Promise<{ success: true; shares: Share[] } | { success: false }> {
const { data } = await useFetch<ApiResponse<Share[]>>(
getApiUrl('warrens/files/list_shares'),
{
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
warrenId: warrenId,
path: path,
}),
}
);
if (data.value == null) {
return {
success: false,
};
}
return { success: true, shares: data.value.data };
}
export async function deleteShare(
warrenId: string,
shareId: string
): Promise<{ success: true; share: Share } | { success: false }> {
const { data } = await useFetch<ApiResponse<Share>>(
getApiUrl('warrens/files/delete_share'),
{
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
warrenId: warrenId,
shareId: shareId,
}),
}
);
if (data.value == null) {
return {
success: false,
};
}
return { success: true, share: data.value.data };
}
export async function listShareFiles(
shareId: string,
path: string,
password: string | null
): Promise<
| { success: true; files: DirectoryEntry[]; parent: DirectoryEntry | null }
| { success: false }
> {
const { data } = await useFetch<
ApiResponse<{ files: DirectoryEntry[]; parent: DirectoryEntry | null }>
>(getApiUrl('warrens/files/ls_share'), {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
shareId: shareId,
path: path,
password: password,
}),
});
if (data.value == null) {
return {
success: false,
};
}
const { files, parent } = data.value.data;
return { success: true, files, parent };
}
export async function fetchShareFile(
shareId: string,
path: string,
password: string | null
): Promise<{ success: true; data: Blob } | { success: false }> {
const { data } = await useFetch<Blob>(
getApiUrl(`warrens/files/cat_share?shareId=${shareId}&path=${path}`),
{
method: 'GET',
headers:
password != null
? {
'X-Share-Password': password,
}
: {},
responseType: 'blob',
cache: 'default',
}
);
if (data.value == null) {
return {
success: false,
};
}
return {
success: true,
data: data.value,
};
}

View File

@@ -0,0 +1,12 @@
import { number, object, string } from 'yup';
export const shareSchema = object({
password: string()
.trim()
.transform((s: string) => (s.length > 0 ? s : undefined))
.optional(),
lifetime: number()
.positive()
.transform((n) => (isNaN(n) ? undefined : n))
.optional(),
});

View File

@@ -14,8 +14,13 @@ export default defineNuxtConfig({
'shadcn-nuxt',
'@nuxtjs/color-mode',
'@pinia/nuxt',
'dayjs-nuxt',
],
dayjs: {
plugins: ['duration', 'relativeTime'],
},
css: ['~/assets/css/tailwind.css', '~/assets/css/sonner.css'],
vite: {

View File

@@ -18,12 +18,14 @@
"@nuxt/test-utils": "3.19.2",
"@pinia/nuxt": "^0.11.1",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/yup": "^4.15.1",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^13.5.0",
"@vueuse/core": "^13.7.0",
"byte-size": "^9.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs-nuxt": "2.1.11",
"eslint": "^9.0.0",
"lucide-vue-next": "^0.525.0",
"nuxt": "^3.17.6",

View File

@@ -33,7 +33,6 @@ if (
route.query.state &&
typeof route.query.state === 'string'
) {
console.log('SEND');
loggingIn.value = true;
const { success } = await oidcLoginUser(
route.query.code,

237
frontend/pages/share.vue Normal file
View File

@@ -0,0 +1,237 @@
<script lang="ts" setup>
import { fetchShareFile, getShare, listShareFiles } from '~/lib/api/shares';
import type { DirectoryEntry } from '~/shared/types';
import type { Share } from '~/shared/types/shares';
definePageMeta({
layout: 'share',
});
const warrenStore = useWarrenStore();
const route = useRoute();
const share = await getShareFromQuery();
const entries = ref<DirectoryEntry[] | null>(null);
const parent = ref<DirectoryEntry | null>(null);
const password = ref<string>('');
const loading = ref<boolean>(false);
if (share != null) {
warrenStore.setCurrentWarren(share.data.warrenId, '/');
if (!share.data.password) {
await loadFiles();
}
}
async function getShareFromQuery(): Promise<{
data: Share;
file: DirectoryEntry;
} | null> {
const shareId = route.query.id;
if (shareId == null || typeof shareId !== 'string') {
return null;
}
const result = await getShare(shareId);
if (!result.success) {
return null;
}
return { data: result.share, file: result.file };
}
async function submitPassword() {
loadFiles();
}
async function loadFiles() {
if (loading.value || share == null || warrenStore.current == null) {
return;
}
if (share.file.fileType !== 'directory') {
return;
}
loading.value = true;
const result = await listShareFiles(
share.data.id,
warrenStore.current.path,
password.value.length > 0 ? password.value : null
);
if (result.success) {
let cookie = `X-Share-Password=${password.value}; Path=/; SameSite=Lax; Secure;`;
if (share.data.expiresAt != null) {
const dayjs = useDayjs();
const diff = dayjs(share.data.expiresAt).diff(dayjs()) / 1000;
cookie += `Max-Age=${diff};`;
}
document.cookie = cookie;
entries.value = result.files;
parent.value = result.parent;
}
loading.value = false;
}
async function onEntryClicked(entry: DirectoryEntry) {
if (warrenStore.current == null) {
return;
}
const entryPath = joinPaths(warrenStore.current.path, entry.name);
if (entry.fileType === 'directory') {
warrenStore.setCurrentWarrenPath(entryPath);
await loadFiles();
return;
}
if (entry.mimeType == null) {
return;
}
if (entry.mimeType.startsWith('image/')) {
const result = await fetchShareFile(
share!.data.id,
entryPath,
password.value.length > 0 ? password.value : null
);
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
}
}
}
async function onBack() {
if (warrenStore.backCurrentPath()) {
await loadFiles();
}
}
function onDowloadClicked() {
if (share == null) {
return;
}
downloadFile(
share.file.name,
getApiUrl(`warrens/files/cat_share?shareId=${share.data.id}&path=/`)
);
}
function onEntryDownload(entry: DirectoryEntry) {
if (share == null || warrenStore.current == null) {
return;
}
downloadFile(
entry.name,
getApiUrl(
`warrens/files/cat_share?shareId=${share.data.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
)
);
}
</script>
<template>
<div
v-if="share != null"
class="flex h-full w-full items-center justify-center px-2"
>
<div
:class="[
'w-full rounded-lg border transition-all',
entries == null ? 'max-w-lg' : 'max-w-screen-xl',
]"
>
<div
class="flex flex-row items-center justify-between gap-4 px-6 pt-6"
>
<div class="flex w-full flex-row">
<div class="flex grow flex-col gap-1.5">
<h3 class="leading-none font-semibold">Share</h3>
<p class="text-muted-foreground text-sm">
Created
{{
$dayjs(share.data.createdAt).format(
'MMM D, YYYY HH:mm'
)
}}
</p>
</div>
<div class="flex flex-row items-center justify-end gap-4">
<p>{{ share.file.name }}</p>
<DirectoryEntryIcon
:entry="{ ...share.file, name: '/' }"
/>
</div>
</div>
<div class="flex flex-row items-end">
<Button
:class="
share.file.fileType !== 'file' &&
entries == null &&
'hidden'
"
size="icon"
variant="outline"
@click="onDowloadClicked"
><Icon name="lucide:download"
/></Button>
</div>
</div>
<div class="flex w-full flex-col p-6">
<DirectoryList
v-if="entries != null"
:entries
:parent
:disable-entries="loading"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
@back="onBack"
/>
<div
v-else-if="share.data.password"
class="flex h-full flex-col justify-between gap-2"
>
<div class="flex flex-col gap-1">
<Label for="password">Password</Label>
<Input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="off"
data-1p-ignore
data-protonpass-ignore
data-bwignore
/>
</div>
<div class="flex flex-row-reverse items-end">
<Button
:disabled="loading || password.length < 1"
@click="submitPassword"
>Enter</Button
>
</div>
</div>
</div>
</div>
</div>
<div v-else class="bg-accent/20 rounded-md p-4">
<p class="text-destructive-foreground">Failed to get share</p>
</div>
</template>

View File

@@ -3,7 +3,8 @@ import { useDropZone } from '@vueuse/core';
import { toast } from 'vue-sonner';
import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue';
import RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue';
import { getWarrenDirectory } from '~/lib/api/warrens';
import { fetchFile, getWarrenDirectory } from '~/lib/api/warrens';
import type { DirectoryEntry } from '~/shared/types';
definePageMeta({
middleware: ['authenticated'],
@@ -31,7 +32,10 @@ const dirData = useAsyncData(
'current-directory',
async () => {
if (warrenStore.current == null) {
return [];
return {
files: [],
parent: null,
};
}
loadingIndicator.start();
@@ -74,6 +78,57 @@ function onDrop(files: File[] | null, e: DragEvent) {
uploadStore.dialogOpen = true;
}
}
async function onEntryClicked(entry: DirectoryEntry) {
if (warrenStore.loading || warrenStore.current == null) {
return;
}
if (entry.fileType === 'directory') {
warrenStore.addToCurrentWarrenPath(entry.name);
return;
}
if (entry.mimeType == null) {
return;
}
if (entry.mimeType.startsWith('image/')) {
const result = await fetchFile(
warrenStore.current.warrenId,
warrenStore.current.path,
entry.name
);
if (result.success) {
const url = URL.createObjectURL(result.data);
warrenStore.imageViewer.src = url;
}
}
}
function onEntryDownload(entry: DirectoryEntry) {
if (warrenStore.current == null) {
return;
}
if (entry.fileType !== 'file') {
toast.warning('Download', {
description: 'Directory downloads are not supported yet',
});
return;
}
downloadFile(
entry.name,
getApiUrl(
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
)
);
}
function onBack() {
warrenStore.backCurrentPath();
}
</script>
<template>
@@ -87,6 +142,9 @@ function onDrop(files: File[] | null, e: DragEvent) {
"
:entries="dirData.files"
:parent="dirData.parent"
@entry-click="onEntryClicked"
@entry-download="onEntryDownload"
@back="onBack"
/>
</DirectoryListContextMenu>
<RenameEntryDialog />

View File

@@ -0,0 +1,13 @@
export type Share = {
id: string;
creatorId: string;
warrenId: string;
path: string;
password: boolean;
expiresAt: number | null;
createdAt: number;
};

View File

@@ -10,8 +10,14 @@ export type AdminWarrenData = WarrenData & {
export type UserWarren = {
userId: string;
warrenId: string;
canListFiles: boolean;
canReadFiles: boolean;
canModifyFiles: boolean;
canDeleteFiles: boolean;
canListShares: boolean;
canCreateShares: boolean;
canModifyShares: boolean;
canDeleteShares: boolean;
};

View File

@@ -30,12 +30,14 @@ export const useWarrenStore = defineStore('warrens', {
this.current.path += path;
},
backCurrentPath() {
backCurrentPath(): boolean {
if (this.current == null || this.current.path === '/') {
return;
return false;
}
this.current.path = getParentPath(this.current.path);
return true;
},
setCurrentWarrenPath(path: string) {
if (this.current == null) {
@@ -89,3 +91,20 @@ export const useRenameDirectoryDialog = defineStore('rename_directory_dialog', {
},
},
});
export const useShareDialog = defineStore('share_dialog', {
state: () => ({
target: null as DirectoryEntry | null,
password: '',
}),
actions: {
openDialog(target: DirectoryEntry) {
this.target = target;
this.password = '';
},
reset() {
this.target = null;
this.password = '';
},
},
});

View File

@@ -1,3 +1,5 @@
import type { Share } from '~/shared/types/shares';
export function getApiUrl(path: string): string {
const API_BASE_URL = useRuntimeConfig().public.apiBase;
return `${API_BASE_URL}/${path}`;
@@ -19,3 +21,7 @@ export function routeWithWarrenName(warrenId: string, path: string): string {
return `${warrenName}${path}`;
}
export function getShareLink(share: Share): string {
return `${window.location.origin}/share?id=${share.id}`;
}

View File

@@ -75,3 +75,14 @@ export async function pasteFile(
return success;
}
export function downloadFile(fileName: string, href: string) {
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = fileName;
anchor.rel = 'noopener';
anchor.target = '_blank';
anchor.click();
}

View File

@@ -27,3 +27,13 @@ export function trim(str: string, char: string) {
return start > 0 || end < str.length ? str.substring(start, end) : str;
}
export function copyToClipboard(content: string): boolean {
navigator.clipboard.writeText(content);
return navigator.clipboard != null;
}
export function capitalize(s: string): string {
return s.slice(0, 1).toUpperCase() + s.slice(1);
}

View File

@@ -4,7 +4,12 @@ export type UserWarrenPermissionKey =
| 'canListFiles'
| 'canReadFiles'
| 'canModifyFiles'
| 'canDeleteFiles';
| 'canDeleteFiles'
| 'canListShares'
| 'canListShares'
| 'canCreateShares'
| 'canModifyShares'
| 'canDeleteShares';
export function getUserWarrenPermissions(
userWarren: UserWarren
@@ -14,6 +19,10 @@ export function getUserWarrenPermissions(
['canReadFiles', userWarren.canReadFiles],
['canModifyFiles', userWarren.canModifyFiles],
['canDeleteFiles', userWarren.canDeleteFiles],
['canListShares', userWarren.canListShares],
['canCreateShares', userWarren.canCreateShares],
['canModifyShares', userWarren.canModifyShares],
['canDeleteShares', userWarren.canDeleteShares],
];
}
@@ -22,6 +31,10 @@ const PERMISSION_NAMES: Record<UserWarrenPermissionKey, string> = {
canReadFiles: 'Read files',
canModifyFiles: 'Modify files',
canDeleteFiles: 'Delete files',
canListShares: 'List shares',
canCreateShares: 'Create shares',
canModifyShares: 'Modify shares',
canDeleteShares: 'Delete shares',
};
export function getUserWarrenPermissionName(