import { Alert, AlertDescription, AlertIcon, Badge, Box, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, Button, Center, HStack, Image, Select, SelectContent, SelectListbox, SelectOption, SelectOptionText, SelectTrigger, Text, VStack, useColorModeValue, } from "@hope-ui/solid" import { useNavigate, useParams } from "@solidjs/router" import { changeColor } from "seemly" import { TbSelector } from "solid-icons/tb" import lightGallery from "lightgallery" import lgAutoplay from "lightgallery/plugins/autoplay" import lgFullscreen from "lightgallery/plugins/fullscreen" import lgRotate from "lightgallery/plugins/rotate" import lgThumbnail from "lightgallery/plugins/thumbnail" import lgZoom from "lightgallery/plugins/zoom" import { LightGallery } from "lightgallery/lightgallery" import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, on, onCleanup, untrack, } from "solid-js" import { Dynamic } from "solid-js/web" import { Error, FullLoading, LinkWithBase, Paginator, SelectWrapper, } from "~/components" import { Container } from "~/pages/home/Container" import GridLayout from "~/pages/home/folder/Grid" import ImageLayout from "~/pages/home/folder/Images" import ListLayout from "~/pages/home/folder/List" import { OpenWith } from "~/pages/home/file/open-with" import { Layout } from "~/pages/home/header/layout" import { getPreviews, PreviewComponent } from "~/pages/home/previews" import HomePassword from "~/pages/home/Password" import { Readme } from "~/pages/home/Readme" import { setLinkOverride, useLink, useRouter, useT, useTitle } from "~/hooks" import { DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE, ObjStore, State, getPagination, getMainColor, getSetting, layout, objStore, } from "~/store" import { Obj, ObjType, PublicShareGet, PublicShareInfo, PublicShareList, PublicShareObj, StoreObj, } from "~/types" import { authPublicShare, getPublicShare, getPublicShareInfo, listPublicShare, } from "~/utils/api" import { bus, encodePath, ext, handleResp, hoverColor, joinBase } from "~/utils" import "lightgallery/css/lightgallery-bundle.css" const shareVideoExts = new Set([ "mp4", "m4v", "mov", "webm", "mkv", "avi", "flv", "m3u8", ]) const shareAudioExts = new Set([ "mp3", "flac", "wav", "ogg", "m4a", "aac", "opus", ]) const shareImageExts = new Set([ "jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "avif", "heic", "heif", ]) const shareTextExts = new Set([ "txt", "md", "markdown", "json", "js", "jsx", "ts", "tsx", "css", "scss", "less", "html", "htm", "xml", "yaml", "yml", "toml", "ini", "conf", "log", "csv", "srt", "ass", "vtt", "lrc", "url", ]) type ShareLinkObj = Partial & { path?: string preview_url?: string download_url?: string } type ShareStoreObj = StoreObj & PublicShareObj & { selected?: boolean } const tokenStorageKey = (shareId: string) => `share-token-cache:${shareId}` const legacySessionTokenKey = (shareId: string) => `share-token:${shareId}` const shareTokenCacheTTL = 24 * 60 * 60 * 1000 const readCachedShareToken = (shareId: string) => { const storageKey = tokenStorageKey(shareId) try { const raw = localStorage.getItem(storageKey) if (raw) { const parsed = JSON.parse(raw) as { token?: string expires_at?: number } if ( parsed.token && (!parsed.expires_at || parsed.expires_at > Date.now()) ) { return parsed.token } localStorage.removeItem(storageKey) } } catch { localStorage.removeItem(storageKey) } const legacyToken = sessionStorage.getItem(legacySessionTokenKey(shareId)) || "" if (legacyToken) { writeCachedShareToken(shareId, legacyToken) sessionStorage.removeItem(legacySessionTokenKey(shareId)) return legacyToken } return "" } const writeCachedShareToken = (shareId: string, token: string) => { if (!token) return localStorage.setItem( tokenStorageKey(shareId), JSON.stringify({ token, expires_at: Date.now() + shareTokenCacheTTL, }), ) sessionStorage.removeItem(legacySessionTokenKey(shareId)) } const clearCachedShareToken = (shareId: string) => { localStorage.removeItem(tokenStorageKey(shareId)) sessionStorage.removeItem(legacySessionTokenKey(shareId)) } const parseShareRoute = (raw?: string) => { const decoded = raw ? decodeURIComponent(raw) : "" const segments = decoded.split("/").filter(Boolean) const shareId = segments[0] || "" const path = segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/" return { shareId, path: path.replace(/\/{2,}/g, "/"), } } const normalizeSharePreviewType = (item: PublicShareObj): ObjType => { if (item.type !== ObjType.UNKNOWN) { return item.type } const extension = ext(item.name).toLowerCase() if (shareVideoExts.has(extension)) return ObjType.VIDEO if (shareAudioExts.has(extension)) return ObjType.AUDIO if (shareImageExts.has(extension)) return ObjType.IMAGE if (shareTextExts.has(extension)) return ObjType.TEXT return ObjType.UNKNOWN } const toShareStoreObj = (item: PublicShareObj): ShareStoreObj => ({ ...item, type: normalizeSharePreviewType(item), selected: false, }) as ShareStoreObj const getParentSharePath = (path: string) => { const segments = path.split("/").filter(Boolean) if (segments.length <= 1) { return "/" } return `/${segments.slice(0, -1).join("/")}` } const buildSharePageUrl = (shareId: string, path: string, encodeAll = true) => { const encoded = path === "/" ? "" : encodePath(path, encodeAll) return joinBase(`/s/${shareId}${encoded}`) } const buildShareAssetUrl = ( shareId: string, token: string, path: string, type: "direct" | "preview", encodeAll = true, ) => { const encoded = path === "/" ? "" : encodePath(path, encodeAll) const query = new URLSearchParams() if (token) { query.set("auth", token) } if (type === "preview") { query.set("type", "preview") } const base = joinBase(`/sd/${shareId}${encoded}`) const queryString = query.toString() return queryString ? `${base}?${queryString}` : base } const previewAssetUrl = ( shareId: string, token: string, obj: ShareLinkObj, encodeAll = true, ) => { if (obj.preview_url) { return obj.preview_url } return buildShareAssetUrl( shareId, token, obj.path || "/", "preview", encodeAll, ) } const directAssetUrl = ( shareId: string, token: string, obj: ShareLinkObj, encodeAll = true, ) => { if (obj.download_url) { return obj.download_url } if (obj.preview_url) { return obj.preview_url } return buildShareAssetUrl( shareId, token, obj.path || "/", "direct", encodeAll, ) } const resetObjStore = () => { ObjStore.set({ obj: {} as Obj, raw_url: "", related: [], objs: [], total: 0, readme: "", header: "", provider: "", write: false, state: State.Initial, err: "", }) } const ShareHeader = (props: { showLayout: boolean }) => { const logos = getSetting("logo").split("\n") const defaultLogo = logos[0] === "https://cdn.jsdelivr.net/gh/alist-org/logo@main/logo.svg" ? joinBase("/images/new_icon.png") : logos[0] const logo = useColorModeValue( defaultLogo, logos[logos.length - 1] === "https://cdn.jsdelivr.net/gh/alist-org/logo@main/logo.svg" ? joinBase("/images/new_icon.png") : logos[logos.length - 1] || defaultLogo, ) return (
) } const ShareNav = (props: { rootLabel: string path: string onNavigate: (path: string) => void }) => { const items = createMemo(() => { const segments = props.path.split("/").filter(Boolean) const paths = [{ label: props.rootLabel, path: "/" }] let current = "" for (const segment of segments) { current += `/${segment}` paths.push({ label: segment, path: current }) } return paths }) return ( {(item, index) => { const isLast = () => index() === items().length - 1 return ( { if (!isLast()) { props.onNavigate(item.path) } }} > {item.label} ) }} ) } const ShareFolder = (props: { allowPreview: boolean }) => { const { rawLink } = useLink() const images = createMemo(() => objStore.objs.filter((obj) => obj.type === ObjType.IMAGE), ) let dynamicGallery: LightGallery | undefined const initGallery = () => { dynamicGallery = lightGallery(document.createElement("div"), { addClass: "lightgallery-container", dynamic: true, thumbnail: true, plugins: [lgZoom, lgThumbnail, lgRotate, lgAutoplay, lgFullscreen], dynamicEl: images().map((obj) => { const raw = rawLink(obj, true) return { src: raw, thumb: obj.thumb === "" ? raw : obj.thumb, subHtml: `

${obj.name}

`, } }), }) } const openGallery = (name: string) => { if (!props.allowPreview) return if (!dynamicGallery) { initGallery() } dynamicGallery?.openGallery(images().findIndex((obj) => obj.name === name)) } createEffect( on(images, () => { dynamicGallery?.destroy() dynamicGallery = undefined }), ) createEffect(() => { if (!props.allowPreview) return bus.on("gallery", openGallery) onCleanup(() => { bus.off("gallery", openGallery) }) }) onCleanup(() => { dynamicGallery?.destroy() }) return ( ) } const ShareFile = () => { const t = useT() const previews = createMemo(() => { return getPreviews({ ...(objStore.obj as Obj), provider: objStore.provider, } as Obj & { provider: string; download_url?: string }) }) const [currentPreview, setCurrentPreview] = createSignal< PreviewComponent | undefined >(undefined) createEffect(() => { const options = previews() const current = currentPreview() if (!options.length) { setCurrentPreview(undefined) return } if (!current || !options.find((preview) => preview.name === current.name)) { setCurrentPreview(options[0]) } }) return ( {t( "share.no_inline_preview", undefined, "Inline preview is not available for this file type.", )} } > 1}> { setCurrentPreview( previews().find((preview) => preview.name === name), ) }} options={previews().map((preview) => ({ value: preview.name, }))} /> }> }> ) } const ShareDownloadOnly = (props: { item: ShareLinkObj }) => { const t = useT() const previewUrl = props.item.preview_url const downloadUrl = props.item.download_url return ( {t( "share.preview_disabled", undefined, "Preview is disabled for this share.", )} ) } const SharePager = (props: { currentPage: number currentPerPage: number total: number onPageChange: (page: number) => void onPerPageChange: (perPage: number) => void }) => { const pageSizeOptions = [50, 100, 200, 300, 500].filter( (size) => size <= MAX_PAGE_SIZE, ) return ( {/*Per page*/} {() => ( )} ) } const ShareObj = (props: { nodeLoading: boolean nodeError: string allowPreview: boolean }) => { const cardBg = useColorModeValue("white", "$neutral3") return ( ) } const SharePage = () => { const t = useT() const params = useParams() const navigate = useNavigate() const { searchParams, setSearchParams } = useRouter() const [password, setPassword] = createSignal("") const [authLoading, setAuthLoading] = createSignal(false) const [infoLoading, setInfoLoading] = createSignal(false) const [nodeLoading, setNodeLoading] = createSignal(false) const [nodeError, setNodeError] = createSignal("") const [nodeErrorCode, setNodeErrorCode] = createSignal(null) const [info, setInfo] = createSignal(null) const [shareToken, setShareToken] = createSignal("") const route = createMemo(() => parseShareRoute(params.share_path)) const shareId = createMemo(() => route().shareId) const currentPath = createMemo(() => route().path) const currentToken = createMemo(() => shareToken()) const pagination = getPagination() const currentPage = createMemo(() => { const value = parseInt(searchParams["page"], 10) return Number.isFinite(value) && value > 0 ? value : 1 }) const currentPerPage = createMemo(() => { const value = parseInt(searchParams["per_page"], 10) if (Number.isFinite(value) && value > 0) { return Math.min(MAX_PAGE_SIZE, value) } const fallback = pagination.size || DEFAULT_PAGE_SIZE return Math.min(MAX_PAGE_SIZE, Math.max(1, fallback)) }) const currentItem = createMemo(() => objStore.obj as ShareLinkObj) const shareConsumed = createMemo(() => Boolean(info()?.consumed_at)) let lastShareId = "" let shareLoadID = 0 useTitle(() => info()?.name || "Share") createEffect(() => { const id = shareId() if (!id) { setLinkOverride(null) return } const token = currentToken() setLinkOverride({ getLinkByObj: (obj, type, encodeAll) => type === "preview" ? buildSharePageUrl(id, (obj as ShareLinkObj).path || "/", encodeAll) : type === "proxy" ? previewAssetUrl(id, token, obj as ShareLinkObj, encodeAll) : directAssetUrl(id, token, obj as ShareLinkObj, encodeAll), rawLink: (obj, encodeAll) => directAssetUrl(id, token, obj as ShareLinkObj, encodeAll), proxyLink: (obj, encodeAll) => previewAssetUrl(id, token, obj as ShareLinkObj, encodeAll), previewPage: (obj, encodeAll) => buildSharePageUrl(id, (obj as ShareLinkObj).path || "/", encodeAll), currentObjLink: (encodeAll) => directAssetUrl(id, token, objStore.obj as ShareLinkObj, encodeAll), }) }) onCleanup(() => { setLinkOverride(null) }) const clearNodeError = () => { setNodeError("") setNodeErrorCode(null) } const setShareError = (message: string, code?: number) => { setNodeError(message) setNodeErrorCode(code ?? null) } const markShareConsumed = () => { setInfo((prev) => prev && prev.access_limit > 0 ? { ...prev, access_count: Math.min(prev.access_count + 1, prev.access_limit), remaining_accesses: Math.max( 0, prev.access_limit - Math.min(prev.access_count + 1, prev.access_limit), ), consumed_at: prev.access_count + 1 >= prev.access_limit ? prev.consumed_at || new Date().toISOString() : prev.consumed_at, } : prev, ) } const resetShareState = () => { setInfo(null) setPassword("") clearNodeError() resetObjStore() } const unauthShare = (id: string) => { clearCachedShareToken(id) setShareToken("") setInfo((prev) => (prev ? { ...prev, authed: false } : prev)) resetObjStore() } const applyFolderState = ( nodeData: PublicShareGet, listData: PublicShareList, ) => { const currentDir = toShareStoreObj(nodeData.item) const items = listData.content.map(toShareStoreObj) ObjStore.set({ obj: currentDir as Obj, raw_url: "", related: [], objs: items, total: listData.total, readme: "", header: "", provider: nodeData.provider, write: false, state: State.Folder, err: "", }) } const applyFileState = ( id: string, token: string, nodeData: PublicShareGet, siblings: ShareStoreObj[], allowPreview: boolean, ) => { const fileItem = toShareStoreObj(nodeData.item) const items = siblings.length ? siblings : [fileItem] ObjStore.set({ obj: fileItem as Obj, raw_url: allowPreview ? previewAssetUrl(id, token, fileItem) : directAssetUrl(id, token, fileItem), related: [], objs: items, total: items.length, readme: "", header: "", provider: nodeData.provider, write: false, state: State.File, err: "", }) } const loadSiblingsForFile = async ( currentLoadID: number, id: string, path: string, token: string, shareInfo: PublicShareInfo, fileItem: ShareStoreObj, ) => { if (!shareInfo.is_dir) { return [fileItem] } const parentPath = getParentSharePath(path) let siblings: ShareStoreObj[] = [fileItem] let shouldResetAuth = false const resp = await listPublicShare({ share_id: id, path: parentPath, token: token || undefined, page: 1, per_page: 200, }) if (currentLoadID !== shareLoadID) return null handleResp( resp, (data) => { siblings = data.content.map(toShareStoreObj) }, (_, code) => { if (code === 401) { shouldResetAuth = true } }, false, false, ) if (shouldResetAuth) { unauthShare(id) return null } if (!siblings.find((item) => item.path === fileItem.path)) { siblings = [fileItem, ...siblings] } return siblings } const loadShare = async ( id = shareId(), path = currentPath(), token = currentToken(), ) => { if (!id) return null const currentLoadID = ++shareLoadID setInfoLoading(true) setNodeLoading(true) clearNodeError() ObjStore.setState(State.FetchingObj) const infoResp = await getPublicShareInfo(id, token || undefined) if (currentLoadID !== shareLoadID) return null let infoData: PublicShareInfo | null = null handleResp( infoResp, (data) => { infoData = data setInfo(data) }, (message, code) => { setInfo(null) resetObjStore() setShareError(message, code) }, false, false, ) if (!infoData) { setInfoLoading(false) setNodeLoading(false) return null } if (!infoData.authed) { if (token) { clearCachedShareToken(id) setShareToken("") } resetObjStore() setInfoLoading(false) setNodeLoading(false) return infoData } const nodeResp = await getPublicShare({ share_id: id, path, token: token || undefined, }) if (currentLoadID !== shareLoadID) return infoData let nodeData: PublicShareGet | null = null let shouldResetAuth = false handleResp( nodeResp, (data) => { nodeData = data }, (message, code) => { if (code === 401) { shouldResetAuth = true return } resetObjStore() setShareError(message, code) }, false, false, ) if (shouldResetAuth) { unauthShare(id) setInfoLoading(false) setNodeLoading(false) return null } if (!nodeData) { setInfoLoading(false) setNodeLoading(false) return infoData } if (nodeData.item.is_dir) { ObjStore.setState(State.FetchingObjs) let listData: PublicShareList | null = null let listResetAuth = false const listResp = await listPublicShare({ share_id: id, path, token: token || undefined, page: currentPage(), per_page: currentPerPage(), }) if (currentLoadID !== shareLoadID) return infoData handleResp( listResp, (data) => { listData = data }, (message, code) => { if (code === 401) { listResetAuth = true return } setShareError(message, code) }, false, false, ) if (listResetAuth) { unauthShare(id) setInfoLoading(false) setNodeLoading(false) return null } if (!listData) { setInfoLoading(false) setNodeLoading(false) return infoData } applyFolderState(nodeData, listData) if (infoData.access_limit > 0) { markShareConsumed() } } else { const fileItem = toShareStoreObj(nodeData.item) const siblings = await loadSiblingsForFile( currentLoadID, id, path, token, infoData, fileItem, ) if (siblings === null) { setInfoLoading(false) setNodeLoading(false) return null } applyFileState(id, token, nodeData, siblings, infoData.allow_preview) if (infoData.access_limit > 0 && infoData.allow_preview) { markShareConsumed() } } setInfoLoading(false) setNodeLoading(false) return infoData } createEffect(() => { const id = shareId() const path = currentPath() currentPage() currentPerPage() if (!id) return if (lastShareId !== id) { lastShareId = id resetShareState() const cachedToken = readCachedShareToken(id) setShareToken(cachedToken) void loadShare(id, path, cachedToken) return } void loadShare( id, path, untrack(() => currentToken()), ) }) const submitPassword = async () => { if (!shareId() || authLoading() || !password().trim()) return setAuthLoading(true) clearNodeError() const resp = await authPublicShare(shareId(), password()) let nextToken = "" let authed = false handleResp( resp, (data) => { authed = true nextToken = data.token || "" }, (message, code) => setShareError(message, code), false, false, ) if (authed) { writeCachedShareToken(shareId(), nextToken) setShareToken(nextToken) setPassword("") await loadShare(shareId(), currentPath(), nextToken) } setAuthLoading(false) } const navigateToPath = (path: string) => { navigate(buildSharePageUrl(shareId(), path)) } const topBarBg = createMemo(() => changeColor(getMainColor(), { alpha: 0.15, }), ) return ( <> {info()?.name || t("share.title", undefined, "Share")} {shareId()} {t("share.password_required", undefined, "Password")} {t("share.burn_after_read", undefined, "Burn after read")} 1, )} > {t( "share.access_limit_badge", { count: info()?.access_limit, }, `${info()?.access_limit} accesses`, )} {t("share.preview_disabled", undefined, "Preview off")} {t("share.download_disabled", undefined, "Download off")} {t( "share.burn_after_read_warning", undefined, "This share will be disabled after the first successful read. Loading a directory or previewing a file may consume it.", )} 1 && !shareConsumed(), )} > {t( "share.access_limit_warning", { count: info()?.access_limit, }, `This share will be disabled after ${info() ?.access_limit} successful accesses. Loading a directory or previewing a file may consume it.`, )} {t( "share.consumed_notice", undefined, "This burn-after-read share has already been consumed. Refreshing or opening more items may no longer work.", )} {nodeError()} { void submitPassword() }} /> <> { setSearchParams({ page, per_page: currentPerPage(), }) }} onPerPageChange={(perPage) => { setSearchParams({ page: 1, per_page: perPage, }) }} /> ) } export default SharePage