Henrique Dias cbb712484d fix: remove auth query parameter from download and preview links
macOS saves the download URL in the metadata of the downloaded file.
This means that the downloaded file contains a metadata item with the JWT
token of the user. If the user were to share this file with someone else,
they would have access to their account using the JWT in the metadata
during the validity of the JWT.

The JWT has been removed from the URLs. Since the user is logged in, there
is an authentication cookie set. A JWT in the URL is not necessary.
2025-06-21 09:21:39 +02:00

431 lines
12 KiB
Vue

<template>
<div
id="previewer"
@touchmove.prevent.stop
@wheel.prevent.stop
@mousemove="toggleNavigation"
@touchstart="toggleNavigation"
>
<header-bar v-if="isPdf || isEpub || showNav">
<action icon="close" :label="$t('buttons.close')" @action="close()" />
<title>{{ name }}</title>
<action
:disabled="layoutStore.loading"
v-if="isResizeEnabled && fileStore.req?.type === 'image'"
:icon="fullSize ? 'photo_size_select_large' : 'hd'"
@action="toggleSize"
/>
<template #actions>
<action
:disabled="layoutStore.loading"
v-if="authStore.user?.perm.rename"
icon="mode_edit"
:label="$t('buttons.rename')"
show="rename"
/>
<action
:disabled="layoutStore.loading"
v-if="authStore.user?.perm.delete"
icon="delete"
:label="$t('buttons.delete')"
@action="deleteFile"
id="delete-button"
/>
<action
:disabled="layoutStore.loading"
v-if="authStore.user?.perm.download"
icon="file_download"
:label="$t('buttons.download')"
@action="download"
/>
<action
:disabled="layoutStore.loading"
icon="info"
:label="$t('buttons.info')"
show="info"
/>
</template>
</header-bar>
<div class="loading delayed" v-if="layoutStore.loading">
<div class="spinner">
<div class="bounce1"></div>
<div class="bounce2"></div>
<div class="bounce3"></div>
</div>
</div>
<template v-else>
<div class="preview">
<div v-if="isEpub" class="epub-reader">
<vue-reader
:location="location"
:url="raw"
:get-rendition="getRendition"
:epubInitOptions="{
requestCredentials: true,
}"
:epubOptions="{
allowPopups: true,
allowScriptedContent: true,
}"
@update:location="locationChange"
/>
<div class="size">
<button
@click="changeSize(Math.max(100, size - 10))"
class="reader-button"
>
<i class="material-icons">remove</i>
</button>
<button
@click="changeSize(Math.min(150, size + 10))"
class="reader-button"
>
<i class="material-icons">add</i>
</button>
<span>{{ size }}%</span>
</div>
</div>
<ExtendedImage v-else-if="fileStore.req?.type == 'image'" :src="raw" />
<audio
v-else-if="fileStore.req?.type == 'audio'"
ref="player"
:src="raw"
controls
:autoplay="autoPlay"
@play="autoPlay = true"
></audio>
<VideoPlayer
v-else-if="fileStore.req?.type == 'video'"
ref="player"
:source="raw"
:subtitles="subtitles"
:options="videoOptions"
>
</VideoPlayer>
<object v-else-if="isPdf" class="pdf" :data="raw"></object>
<div v-else-if="fileStore.req?.type == 'blob'" class="info">
<div class="title">
<i class="material-icons">feedback</i>
{{ $t("files.noPreview") }}
</div>
<div>
<a target="_blank" :href="downloadUrl" class="button button--flat">
<div>
<i class="material-icons">file_download</i
>{{ $t("buttons.download") }}
</div>
</a>
<a
target="_blank"
:href="raw"
class="button button--flat"
v-if="!fileStore.req?.isDir"
>
<div>
<i class="material-icons">open_in_new</i
>{{ $t("buttons.openFile") }}
</div>
</a>
</div>
</div>
</div>
</template>
<button
@click="prev"
@mouseover="hoverNav = true"
@mouseleave="hoverNav = false"
:class="{ hidden: !hasPrevious || !showNav }"
:aria-label="$t('buttons.previous')"
:title="$t('buttons.previous')"
>
<i class="material-icons">chevron_left</i>
</button>
<button
@click="next"
@mouseover="hoverNav = true"
@mouseleave="hoverNav = false"
:class="{ hidden: !hasNext || !showNav }"
:aria-label="$t('buttons.next')"
:title="$t('buttons.next')"
>
<i class="material-icons">chevron_right</i>
</button>
<link rel="prefetch" :href="previousRaw" />
<link rel="prefetch" :href="nextRaw" />
</div>
</template>
<script setup lang="ts">
import { useStorage } from "@vueuse/core";
import { useAuthStore } from "@/stores/auth";
import { useFileStore } from "@/stores/file";
import { useLayoutStore } from "@/stores/layout";
import { files as api } from "@/api";
import { createURL } from "@/api/utils";
import { resizePreview } from "@/utils/constants";
import url from "@/utils/url";
import { throttle } from "lodash-es";
import HeaderBar from "@/components/header/HeaderBar.vue";
import Action from "@/components/header/Action.vue";
import ExtendedImage from "@/components/files/ExtendedImage.vue";
import VideoPlayer from "@/components/files/VideoPlayer.vue";
import { VueReader } from "vue-reader";
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { Rendition } from "epubjs";
import { getTheme } from "@/utils/theme";
const location = useStorage("book-progress", 0, undefined, {
serializer: {
read: (v) => JSON.parse(v),
write: (v) => JSON.stringify(v),
},
});
const size = useStorage("book-size", 120, undefined, {
serializer: {
read: (v) => JSON.parse(v),
write: (v) => JSON.stringify(v),
},
});
const locationChange = (epubcifi: number) => {
location.value = epubcifi;
};
let rendition: Rendition | null = null;
const changeSize = (val: number) => {
size.value = val;
rendition?.themes.fontSize(`${val}%`);
};
const getRendition = (_rendition: Rendition) => {
rendition = _rendition;
switch (getTheme()) {
case "dark": {
rendition.themes.override("color", "rgba(255, 255, 255, 0.6)");
break;
}
case "light": {
rendition.themes.override("color", "rgb(111, 111, 111)");
break;
}
}
rendition.themes.registerRules("h2Transparent", {
"h1,h2,h3,h4": {
"background-color": "transparent !important",
},
});
rendition?.themes.fontSize(`${size.value}%`);
rendition.themes.select("h2Transparent");
rendition.themes.override("background-color", "transparent", true);
};
const mediaTypes: ResourceType[] = ["image", "video", "audio", "blob"];
const previousLink = ref<string>("");
const nextLink = ref<string>("");
const listing = ref<ResourceItem[] | null>(null);
const name = ref<string>("");
const fullSize = ref<boolean>(false);
const showNav = ref<boolean>(true);
const navTimeout = ref<null | number>(null);
const hoverNav = ref<boolean>(false);
const autoPlay = ref<boolean>(false);
const previousRaw = ref<string>("");
const nextRaw = ref<string>("");
const player = ref<HTMLVideoElement | HTMLAudioElement | null>(null);
const $showError = inject<IToastError>("$showError")!;
const authStore = useAuthStore();
const fileStore = useFileStore();
const layoutStore = useLayoutStore();
const route = useRoute();
const router = useRouter();
const hasPrevious = computed(() => previousLink.value !== "");
const hasNext = computed(() => nextLink.value !== "");
const downloadUrl = computed(() =>
fileStore.req ? api.getDownloadURL(fileStore.req, false) : ""
);
const raw = computed(() => {
if (fileStore.req?.type === "image" && !fullSize.value) {
return api.getPreviewURL(fileStore.req, "big");
}
if (isEpub.value) {
return createURL("api/raw" + fileStore.req?.path, {});
}
return downloadUrl.value;
});
const isPdf = computed(() => fileStore.req?.extension.toLowerCase() == ".pdf");
const isEpub = computed(
() => fileStore.req?.extension.toLowerCase() == ".epub"
);
const isResizeEnabled = computed(() => resizePreview);
const subtitles = computed(() => {
if (fileStore.req?.subtitles) {
return api.getSubtitlesURL(fileStore.req);
}
return [];
});
const videoOptions = computed(() => {
return { autoplay: autoPlay.value };
});
watch(route, () => {
updatePreview();
toggleNavigation();
});
// Specify hooks
onMounted(async () => {
window.addEventListener("keydown", key);
if (fileStore.oldReq) {
listing.value = fileStore.oldReq.items;
updatePreview();
}
});
onBeforeUnmount(() => window.removeEventListener("keydown", key));
// Specify methods
const deleteFile = () => {
layoutStore.showHover({
prompt: "delete",
confirm: () => {
if (listing.value === null) {
return;
}
listing.value = listing.value.filter((item) => item.name !== name.value);
if (hasNext.value) {
next();
} else if (!hasPrevious.value && !hasNext.value) {
close();
} else {
prev();
}
},
});
};
const prev = () => {
hoverNav.value = false;
router.replace({ path: previousLink.value });
};
const next = () => {
hoverNav.value = false;
router.replace({ path: nextLink.value });
};
const key = (event: KeyboardEvent) => {
if (layoutStore.currentPrompt !== null) {
return;
}
if (event.which === 13 || event.which === 39) {
// right arrow
if (hasNext.value) next();
} else if (event.which === 37) {
// left arrow
if (hasPrevious.value) prev();
} else if (event.which === 27) {
// esc
close();
}
};
const updatePreview = async () => {
if (player.value && player.value.paused && !player.value.ended) {
autoPlay.value = false;
}
const dirs = route.fullPath.split("/");
name.value = decodeURIComponent(dirs[dirs.length - 1]);
if (!listing.value) {
try {
const path = url.removeLastDir(route.path);
const res = await api.fetch(path);
listing.value = res.items;
} catch (e: any) {
$showError(e);
}
}
previousLink.value = "";
nextLink.value = "";
if (listing.value) {
for (let i = 0; i < listing.value.length; i++) {
if (listing.value[i].name !== name.value) {
continue;
}
for (let j = i - 1; j >= 0; j--) {
if (mediaTypes.includes(listing.value[j].type)) {
previousLink.value = listing.value[j].url;
previousRaw.value = prefetchUrl(listing.value[j]);
break;
}
}
for (let j = i + 1; j < listing.value.length; j++) {
if (mediaTypes.includes(listing.value[j].type)) {
nextLink.value = listing.value[j].url;
nextRaw.value = prefetchUrl(listing.value[j]);
break;
}
}
return;
}
}
};
const prefetchUrl = (item: ResourceItem) => {
if (item.type !== "image") {
return "";
}
return fullSize.value
? api.getDownloadURL(item, true)
: api.getPreviewURL(item, "big");
};
const toggleSize = () => (fullSize.value = !fullSize.value);
const toggleNavigation = throttle(function () {
showNav.value = true;
if (navTimeout.value) {
clearTimeout(navTimeout.value);
}
navTimeout.value = window.setTimeout(() => {
showNav.value = false || hoverNav.value;
navTimeout.value = null;
}, 1500);
}, 500);
const close = () => {
fileStore.updateRequest(null);
const uri = url.removeLastDir(route.path) + "/";
router.push({ path: uri });
};
const download = () => window.open(downloadUrl.value);
</script>