From d5c6f0ac2907e6a26e467346a4aa2838d68da161 Mon Sep 17 00:00:00 2001 From: Varun Raj Date: Sun, 19 Jan 2025 10:09:19 +0530 Subject: [PATCH] feat: added option for link expiry Signed-off-by: Varun Raj --- README.md | 2 +- src/components/shared/ShareAssetsTrigger.tsx | 46 ++++- src/pages/api/share-link/[token]/assets.ts | 171 ++++++++++--------- src/pages/api/share-link/[token]/index.ts | 12 +- src/pages/api/share-link/[token]/people.ts | 101 ++++++----- src/pages/api/share-link/generate.ts | 19 ++- src/pages/s/[token].tsx | 8 +- src/types/shareLink.d.ts | 1 + 8 files changed, 214 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index cbd95d3..92ceefe 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ A unofficial immich client to provide better tools to organize and manage your i If you find this tool useful, please consider supporting me by buying me a coffee. -[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/varunraj){:target="_blank"} +[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/varunraj) ## 💭 Back story diff --git a/src/components/shared/ShareAssetsTrigger.tsx b/src/components/shared/ShareAssetsTrigger.tsx index 846f047..d8fc7ab 100644 --- a/src/components/shared/ShareAssetsTrigger.tsx +++ b/src/components/shared/ShareAssetsTrigger.tsx @@ -5,8 +5,9 @@ import { ShareLinkFilters } from '@/types/shareLink'; import { generateShareLink } from '@/handlers/api/shareLink.handler'; import { Input } from '../ui/input'; import { Label } from '@radix-ui/react-label'; -import { Checkbox } from '../ui/checkbox'; + import { Switch } from '../ui/switch'; +import { Select, SelectValue, SelectContent, SelectItem, SelectTrigger } from '../ui/select'; interface ShareAssetsTriggerProps { @@ -19,10 +20,18 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets const [loading, setLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [copied, setCopied] = useState(false); - const [config, setConfig] = useState>({}); + const [config, setConfig] = useState>({ + expiresIn: "never" + }); + + const handleReset = () => { + setConfig({ expiresIn: "never" }); + setGeneratedLink(null); + } const handleGenerate = async () => { setLoading(true); + setErrorMessage(null); return generateShareLink({ ...filters, ...config }).then(({ link }) => { setGeneratedLink(link); }).catch((err) => { @@ -63,20 +72,41 @@ export default function ShareAssetsTrigger({ filters, buttonProps }: ShareAssets This is a stateless link, it will not work if you leave the page. Which means it cannot be expired.

- - + +
: (
- -

- Show the list of people in the shared photos -

+ +

+ Show the list of people in the shared photos +

setConfig({ ...config, p: !!checked })} />
+
+
+ +

+ Should the link expire after the selected time +

+
+ +
)} diff --git a/src/pages/api/share-link/[token]/assets.ts b/src/pages/api/share-link/[token]/assets.ts index 28621ad..c6338ae 100644 --- a/src/pages/api/share-link/[token]/assets.ts +++ b/src/pages/api/share-link/[token]/assets.ts @@ -4,7 +4,7 @@ import { desc, gte, lte } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm"; import { assetFaces, assets, exif, person } from "@/schema"; import { and } from "drizzle-orm"; -import { sign, verify } from "jsonwebtoken"; +import { JsonWebTokenError, sign, verify } from "jsonwebtoken"; import { NextApiResponse } from "next"; import { NextApiRequest } from "next"; @@ -19,89 +19,96 @@ interface IQuery extends ShareLinkFilters { } export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { token, ...rest } = req.query as any as IQuery + try { + const { token, ...rest } = req.query as any as IQuery - const filters = { - personIds: rest['personIds[]'] ?? rest.personIds ?? [], - } - - if (!token) { - return res.status(404).json({ message: "Token not found" }); - } - - const decoded = verify(token as string, ENV.JWT_SECRET); - - if (!decoded) { - return res.status(401).json({ message: "Unauthorized" }); - } - - const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { - expiresIn: "2m" - }); - - const { personIds = [], albumIds = [], startDate, endDate } = decoded as ShareLinkFilters; - let filteredPersonIds: string[] = personIds ?? []; - const queryPersonIds = filters.personIds ? (Array.isArray(filters.personIds) ? filters.personIds : [filters.personIds]) : []; - - - if (queryPersonIds.length > 0) { - - if (personIds.length === 0) { - filteredPersonIds = queryPersonIds; - } else { - filteredPersonIds = queryPersonIds.filter((id) => personIds.includes(id)); + const filters = { + personIds: rest['personIds[]'] ?? rest.personIds ?? [], } - } - - const dbAssets = await db.select({ - id: assets.id, - deviceId: assets.deviceId, - type: assets.type, - originalPath: assets.originalPath, - isFavorite: assets.isFavorite, - duration: assets.duration, - encodedVideoPath: assets.encodedVideoPath, - originalFileName: assets.originalFileName, - sidecarPath: assets.sidecarPath, - deletedAt: assets.deletedAt, - localDateTime: assets.localDateTime, - exifImageWidth: exif.exifImageWidth, - exifImageHeight: exif.exifImageHeight, - ownerId: assets.ownerId, - dateTimeOriginal: exif.dateTimeOriginal, - url: assets.id, - previewUrl: assets.id, - videoURL: assets.id, - orientation: exif.orientation, - }).from(assets) - .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) - .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) - .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) - .innerJoin(exif, eq(exif.assetId, assets.id)) - .where(and( - filteredPersonIds?.length > 0 ? inArray(assetFaces.personId, filteredPersonIds) : undefined, - albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, - startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, - endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, - eq(assets.isArchived, false), - eq(assets.isVisible, true), - eq(assets.isOffline, false), - )) - .orderBy(desc(assets.createdAt)); - const cleanedAssets = dbAssets.map((asset) => { - return { - ...asset, - localDateTime: new Date(asset.localDateTime), - duration: asset.duration ? parseInt(asset.duration) : 0, - exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0, - exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0, - dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(), + if (!token) { + return res.status(404).json({ message: "Token not found" }); } - }).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string)); - - return res.status(200).json({ - assets: cleanedAssets, - filters: decoded as ShareLinkFilters - }); + + const decoded = verify(token as string, ENV.JWT_SECRET); + + if (!decoded) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { + expiresIn: "2m" + }); + + const { personIds = [], albumIds = [], startDate, endDate } = decoded as ShareLinkFilters; + let filteredPersonIds: string[] = personIds ?? []; + const queryPersonIds = filters.personIds ? (Array.isArray(filters.personIds) ? filters.personIds : [filters.personIds]) : []; + + + if (queryPersonIds.length > 0) { + + if (personIds.length === 0) { + filteredPersonIds = queryPersonIds; + } else { + filteredPersonIds = queryPersonIds.filter((id) => personIds.includes(id)); + } + } + + const dbAssets = await db.select({ + id: assets.id, + deviceId: assets.deviceId, + type: assets.type, + originalPath: assets.originalPath, + isFavorite: assets.isFavorite, + duration: assets.duration, + encodedVideoPath: assets.encodedVideoPath, + originalFileName: assets.originalFileName, + sidecarPath: assets.sidecarPath, + deletedAt: assets.deletedAt, + localDateTime: assets.localDateTime, + exifImageWidth: exif.exifImageWidth, + exifImageHeight: exif.exifImageHeight, + ownerId: assets.ownerId, + dateTimeOriginal: exif.dateTimeOriginal, + url: assets.id, + previewUrl: assets.id, + videoURL: assets.id, + orientation: exif.orientation, + }).from(assets) + .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) + .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) + .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) + .innerJoin(exif, eq(exif.assetId, assets.id)) + .where(and( + filteredPersonIds?.length > 0 ? inArray(assetFaces.personId, filteredPersonIds) : undefined, + albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, + startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, + endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, + eq(assets.isArchived, false), + eq(assets.isVisible, true), + eq(assets.isOffline, false), + )) + .orderBy(desc(assets.createdAt)); + + const cleanedAssets = dbAssets.map((asset) => { + return { + ...asset, + localDateTime: new Date(asset.localDateTime), + duration: asset.duration ? parseInt(asset.duration) : 0, + exifImageWidth: (isFlipped(asset.orientation) ? asset.exifImageHeight : asset.exifImageWidth) ?? 0, + exifImageHeight: (isFlipped(asset.orientation) ? asset.exifImageWidth : asset.exifImageHeight) ?? 0, + dateTimeOriginal: new Date(asset.dateTimeOriginal || new Date()).toISOString(), + } + }).map((asset) => cleanUpShareAsset(asset, newPreviewToken as string)); + + return res.status(200).json({ + assets: cleanedAssets, + filters: decoded as ShareLinkFilters + }); + } catch (error) { + if (error instanceof JsonWebTokenError) { + return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." }); + } + return res.status(500).json({ message: (error as Error).message }); + } } \ No newline at end of file diff --git a/src/pages/api/share-link/[token]/index.ts b/src/pages/api/share-link/[token]/index.ts index 7ec9be4..fca170d 100644 --- a/src/pages/api/share-link/[token]/index.ts +++ b/src/pages/api/share-link/[token]/index.ts @@ -2,20 +2,30 @@ import { NextApiResponse } from "next"; import { ENV } from "@/config/environment"; import { ShareLinkFilters } from "@/types/shareLink"; -import { verify } from "jsonwebtoken"; +import { JsonWebTokenError, verify } from "jsonwebtoken"; import { NextApiRequest } from "next"; export default async function GET(req: NextApiRequest, res: NextApiResponse) { const { token } = req.query; + try { + if (!token) { return res.status(404).json({ message: "Token not found" }); } const decoded = verify(token as string, ENV.JWT_SECRET); + console.log(decoded); if (!decoded) { return res.status(401).json({ message: "Unauthorized" }); } + return res.status(200).json(decoded); +} catch (error) { + if (error instanceof JsonWebTokenError) { + return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." }); + } + return res.status(500).json({ message: (error as Error).message }); +} } diff --git a/src/pages/api/share-link/[token]/people.ts b/src/pages/api/share-link/[token]/people.ts index 418f9b5..7f8ea77 100644 --- a/src/pages/api/share-link/[token]/people.ts +++ b/src/pages/api/share-link/[token]/people.ts @@ -4,7 +4,7 @@ import { count, desc, gte, isNotNull, lte, ne } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm"; import { assetFaces, assets, exif, person } from "@/schema"; import { and } from "drizzle-orm"; -import { sign, verify } from "jsonwebtoken"; +import { JsonWebTokenError, sign, verify } from "jsonwebtoken"; import { NextApiResponse } from "next"; import { NextApiRequest } from "next"; @@ -21,52 +21,59 @@ interface ShareLinkFilters { export default async function handler(req: NextApiRequest, res: NextApiResponse) { const { token } = req.query; - if (!token) { - return res.status(404).json({ message: "Token not found" }); - } - - const decoded = verify(token as string, ENV.JWT_SECRET); - - if (!decoded) { - return res.status(401).json({ message: "Unauthorized" }); - } - - const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { - expiresIn: "2m" - }); - const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters; - - const dbPeople = await db.select({ - id: person.id, - name: person.name, - thumbnailAssetId: person.faceAssetId, - assetCount: count(assets.id), - }).from(assets) - .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) - .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) - .innerJoin(person, eq(assetFaces.personId, person.id)) - .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) - .innerJoin(exif, eq(exif.assetId, assets.id)) - .where(and( - personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined, - albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, - startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, - endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, - eq(assets.isArchived, false), - eq(assets.isVisible, true), - eq(assets.isOffline, false), - isNotNull(person.id), - ne(person.name, "") - )) - .groupBy(person.id) - .orderBy(desc(count(assets.id))); - - const cleanedPeople = dbPeople.map((person) => { - return { - ...person, - thumbnailPath: person.thumbnailAssetId ? ASSET_SHARE_THUMBNAIL_PATH({ id: person.id, size: "thumbnail", token: newPreviewToken, isPeople: true }) : null + try { + if (!token) { + return res.status(404).json({ message: "Token not found" }); } - }) - return res.status(200).json(cleanedPeople); + const decoded = verify(token as string, ENV.JWT_SECRET); + + if (!decoded) { + return res.status(401).json({ message: "Unauthorized" }); + } + + const newPreviewToken = sign({ token: token }, ENV.JWT_SECRET, { + expiresIn: "2m" + }); + const { personIds, albumIds, startDate, endDate } = decoded as ShareLinkFilters; + + const dbPeople = await db.select({ + id: person.id, + name: person.name, + thumbnailAssetId: person.faceAssetId, + assetCount: count(assets.id), + }).from(assets) + .innerJoin(assetFaces, eq(assets.id, assetFaces.assetId)) + .innerJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetsId)) + .innerJoin(person, eq(assetFaces.personId, person.id)) + .innerJoin(albums, eq(albumsAssetsAssets.albumsId, albums.id)) + .innerJoin(exif, eq(exif.assetId, assets.id)) + .where(and( + personIds?.length > 0 ? inArray(assetFaces.personId, personIds) : undefined, + albumIds?.length > 0 ? inArray(albums.id, albumIds) : undefined, + startDate ? gte(assets.createdAt, new Date(startDate)) : undefined, + endDate ? lte(assets.createdAt, new Date(endDate)) : undefined, + eq(assets.isArchived, false), + eq(assets.isVisible, true), + eq(assets.isOffline, false), + isNotNull(person.id), + ne(person.name, "") + )) + .groupBy(person.id) + .orderBy(desc(count(assets.id))); + + const cleanedPeople = dbPeople.map((person) => { + return { + ...person, + thumbnailPath: person.thumbnailAssetId ? ASSET_SHARE_THUMBNAIL_PATH({ id: person.id, size: "thumbnail", token: newPreviewToken, isPeople: true }) : null + } + }) + + return res.status(200).json(cleanedPeople); + } catch (error) { + if (error instanceof JsonWebTokenError) { + return res.status(401).json({ message: "Please check your link and try again. Looks like it's expired." }); + } + return res.status(500).json({ message: (error as Error).message }); + } } \ No newline at end of file diff --git a/src/pages/api/share-link/generate.ts b/src/pages/api/share-link/generate.ts index ca87aa1..b76b93c 100644 --- a/src/pages/api/share-link/generate.ts +++ b/src/pages/api/share-link/generate.ts @@ -1,6 +1,5 @@ import { ENV } from "@/config/environment"; -import { SHARE_LINK_ASSETS_PATH } from "@/config/routes"; import { getCurrentUser } from "@/handlers/serverUtils/user.utils"; import { sign } from "jsonwebtoken"; import { NextApiRequest, NextApiResponse } from "next"; @@ -8,8 +7,10 @@ import { NextApiRequest, NextApiResponse } from "next"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { const currentUser = await getCurrentUser(req); - const filters = req.body; + const allFilters = req.body; + const { expiresIn, ...filters } = allFilters; + try { if (!currentUser) { return res.status(401).json({ message: "Unauthorized" }); } @@ -18,6 +19,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) return res.status(401).json({ message: "Unauthorized" }); } - const token = sign(filters, ENV.JWT_SECRET); - return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` }); -} \ No newline at end of file + console.log(expiresIn); + + const token = sign(filters, ENV.JWT_SECRET, expiresIn !== "never" ? { + expiresIn: expiresIn + } : {}); + + return res.status(200).json({ link: `${ENV.POWER_TOOLS_ENDPOINT_URL}/s/${token}` }); + } catch (error) { + return res.status(500).json({ message: (error as Error).message }); + } +} diff --git a/src/pages/s/[token].tsx b/src/pages/s/[token].tsx index 410ed41..0abe807 100644 --- a/src/pages/s/[token].tsx +++ b/src/pages/s/[token].tsx @@ -12,6 +12,7 @@ import { IPerson } from '@/types/person' import PeopleList from '@/components/shared/PeopleList' import { ShareLinkFilters } from '@/types/shareLink' import clsx from 'clsx' +import { LinkBreak2Icon } from '@radix-ui/react-icons' export default function AlbumListPage() { const router = useRouter() @@ -107,7 +108,10 @@ export default function AlbumListPage() { const renderContent = () => { if (loading) return - else if (errorMessage) return
{errorMessage}
+ else if (errorMessage) return
+ +

{errorMessage}

+
return (
{config.p && ( @@ -132,7 +136,7 @@ export default function AlbumListPage() { return (
diff --git a/src/types/shareLink.d.ts b/src/types/shareLink.d.ts index 23498d6..bb691ca 100644 --- a/src/types/shareLink.d.ts +++ b/src/types/shareLink.d.ts @@ -4,4 +4,5 @@ export interface ShareLinkFilters { startDate?: string; endDate?: string; p?: boolean; + expiresIn?: string; } \ No newline at end of file