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.
-[](https://www.buymeacoffee.com/varunraj){:target="_blank"}
+[](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
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