feat: added option for link expiry

Signed-off-by: Varun Raj <varun@skcript.com>
This commit is contained in:
Varun Raj committed 2025-01-19 10:09:19 +05:30
1 parent fdef491586
commit d5c6f0ac29
8 files changed
+214 -146

No files matched your search

+1 -1
View File
@@ -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
+38 -8
View File
@@ -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<string | null>(null);
const [copied, setCopied] = useState(false);
const [config, setConfig] = useState<Partial<ShareLinkFilters>>({});
const [config, setConfig] = useState<Partial<ShareLinkFilters>>({
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.
</p>
<div className="flex gap-2">
<Button className="w-full" onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</Button>
<Button className="w-full" variant="outline" onClick={handleGenerate}>Generate New Link</Button>
<Button className="w-full" onClick={handleCopy}>{copied ? 'Copied' : 'Copy'}</Button>
<Button className="w-full" variant="outline" onClick={handleReset}>Generate New Link</Button>
</div>
</div> : (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-1">
<div className="flex flex-col gap-1">
<Label className="text-sm m-0">Show People</Label>
<p className="text-xs text-muted-foreground m-0">
Show the list of people in the shared photos
</p>
<Label className="text-sm m-0">Show People</Label>
<p className="text-xs text-muted-foreground m-0">
Show the list of people in the shared photos
</p>
</div>
<Switch checked={config.p} onCheckedChange={(checked) => setConfig({ ...config, p: !!checked })} />
</div>
<div className="flex items-center justify-between gap-1">
<div className="flex flex-col gap-1">
<Label className="text-sm m-0">Link Expires In</Label>
<p className="text-xs text-muted-foreground m-0">
Should the link expire after the selected time
</p>
</div>
<Select onValueChange={(value) => setConfig({ ...config, expiresIn: value })}>
<SelectTrigger className='w-fit'>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">1 Hour</SelectItem>
<SelectItem value="1d">1 Day (24 Hours)</SelectItem>
<SelectItem value="7d">7 Days</SelectItem>
<SelectItem value="30d">30 Days</SelectItem>
<SelectItem value="90d">90 Days</SelectItem>
<SelectItem value="never">Never Expires</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleGenerate} disabled={loading}>Generate Share Link</Button>
</div>
)}
+89 -82
View File
@@ -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 });
}
}
+11 -1
View File
@@ -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 });
}
}
+54 -47
View File
@@ -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 });
}
}
+14 -5
View File
@@ -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}` });
}
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 });
}
}
+6 -2
View File
@@ -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 <Loader />
else if (errorMessage) return <div>{errorMessage}</div>
else if (errorMessage) return <div className="flex flex-col gap-4 items-center justify-center h-full">
<LinkBreak2Icon className="w-10 h-10 text-zinc-500" />
<p className="text-zinc-500">{errorMessage}</p>
</div>
return (
<div className="flex gap-1 max-h-full">
{config.p && (
@@ -132,7 +136,7 @@ export default function AlbumListPage() {
return (
<PageLayout className="!p-0 !mb-0">
<Header
leftComponent={"Assets"}
leftComponent={"Shared Link"}
rightComponent={
!loading && (
<div className="flex items-center gap-2">
+1
View File
@@ -4,4 +4,5 @@ export interface ShareLinkFilters {
startDate?: string;
endDate?: string;
p?: boolean;
expiresIn?: string;
}