Merge branch 'dev' into missing-duplicate-checks

This commit is contained in:
Daniel 2024-04-15 08:08:22 +03:30 committed by GitHub
commit baadd6c06b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 814 additions and 416 deletions

View File

@ -75,6 +75,13 @@ AUTH0_ISSUER=
AUTH0_CLIENT_SECRET= AUTH0_CLIENT_SECRET=
AUTH0_CLIENT_ID= AUTH0_CLIENT_ID=
# Authelia
NEXT_PUBLIC_AUTHELIA_ENABLED=""
AUTHELIA_CLIENT_ID=""
AUTHELIA_CLIENT_SECRET=""
AUTHELIA_WELLKNOWN_URL=""
# Authentik # Authentik
NEXT_PUBLIC_AUTHENTIK_ENABLED= NEXT_PUBLIC_AUTHENTIK_ENABLED=
AUTHENTIK_CUSTOM_NAME= AUTHENTIK_CUSTOM_NAME=

45
ARCHITECTURE.md Normal file
View File

@ -0,0 +1,45 @@
# Architecture
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
When you start Linkwarden, there are mainly two components that run:
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
## Main Tech Stack
- [NextJS](https://github.com/vercel/next.js)
- [TypeScript](https://github.com/microsoft/TypeScript)
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
- [DaisyUI](https://github.com/saadeghi/daisyui)
- [Prisma](https://github.com/prisma/prisma)
- [Playwright](https://github.com/microsoft/playwright)
- [Zustand](https://github.com/pmndrs/zustand)
## Folder Structure
Here's a summary of the main files and folders in the project:
```
linkwarden
├── components # React components
├── hooks # React reusable hooks
├── layouts # Layouts for pages
├── lib
│   ├── api # Server-side functions (controllers, etc.)
│   ├── client # Client-side functions
│   └── shared # Shared functions between client and server
├── pages # Pages and API routes
├── prisma # Prisma schema and migrations
├── scripts
│   ├── migration # Scripts for breaking changes
│   └── worker.ts # Background worker for archiving links
├── store # Zustand stores
├── styles # Styles
└── types # TypeScript types
```
## Versioning
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).

View File

View File

@ -47,7 +47,10 @@ const CollectionListing = () => {
useEffect(() => { useEffect(() => {
if (account.username) { if (account.username) {
if (!account.collectionOrder || account.collectionOrder.length === 0) if (
(!account.collectionOrder || account.collectionOrder.length === 0) &&
collections.length > 0
)
updateAccount({ updateAccount({
...account, ...account,
collectionOrder: collections collectionOrder: collections

View File

@ -26,7 +26,7 @@ export default function FilterSearchDropdown({
> >
<i className="bi-funnel text-neutral text-2xl"></i> <i className="bi-funnel text-neutral text-2xl"></i>
</div> </div>
<ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-44 mt-1"> <ul className="dropdown-content z-[30] menu shadow bg-base-200 border border-neutral-content rounded-box w-56 mt-1">
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@ -84,27 +84,6 @@ export default function FilterSearchDropdown({
<span className="label-text">Description</span> <span className="label-text">Description</span>
</label> </label>
</li> </li>
<li>
<label
className="label cursor-pointer flex justify-start"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
</label>
</li>
<li> <li>
<label <label
className="label cursor-pointer flex justify-start" className="label cursor-pointer flex justify-start"
@ -126,6 +105,29 @@ export default function FilterSearchDropdown({
<span className="label-text">Tags</span> <span className="label-text">Tags</span>
</label> </label>
</li> </li>
<li>
<label
className="label cursor-pointer flex justify-between"
tabIndex={0}
role="button"
>
<input
type="checkbox"
name="search-filter-checkbox"
className="checkbox checkbox-primary"
checked={searchFilter.textContent}
onChange={() => {
setSearchFilter({
...searchFilter,
textContent: !searchFilter.textContent,
});
}}
/>
<span className="label-text">Full Content</span>
<div className="ml-auto badge badge-sm badge-neutral">Slower</div>
</label>
</li>
</ul> </ul>
</div> </div>
); );

View File

@ -1,14 +1,16 @@
import LinkCard from "@/components/LinkViews/LinkCard"; import LinkCard from "@/components/LinkViews/LinkCard";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { link } from "fs";
import { GridLoader } from "react-spinners";
export default function CardView({ export default function CardView({
links, links,
showCheckbox = true,
editMode, editMode,
isLoading,
}: { }: {
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
showCheckbox?: boolean;
editMode?: boolean; editMode?: boolean;
isLoading?: boolean;
}) { }) {
return ( return (
<div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5"> <div className="grid min-[1900px]:grid-cols-4 xl:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-5">
@ -23,6 +25,15 @@ export default function CardView({
/> />
); );
})} })}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div> </div>
); );
} }

View File

@ -1,12 +1,15 @@
import LinkList from "@/components/LinkViews/LinkList"; import LinkList from "@/components/LinkViews/LinkList";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { GridLoader } from "react-spinners";
export default function ListView({ export default function ListView({
links, links,
editMode, editMode,
isLoading,
}: { }: {
links: LinkIncludingShortenedCollectionAndTags[]; links: LinkIncludingShortenedCollectionAndTags[];
editMode?: boolean; editMode?: boolean;
isLoading?: boolean;
}) { }) {
return ( return (
<div className="flex gap-1 flex-col"> <div className="flex gap-1 flex-col">
@ -21,6 +24,15 @@ export default function ListView({
/> />
); );
})} })}
{isLoading && links.length > 0 && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="fixed top-5 right-5 opacity-50 z-30"
/>
)}
</div> </div>
); );
} }

View File

@ -19,6 +19,7 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -53,7 +54,9 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
let shortendURL; let shortendURL;
try { try {
shortendURL = new URL(link.url || "").host.toLowerCase(); if (link.url) {
shortendURL = new URL(link.url).host.toLowerCase();
}
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
@ -109,7 +112,6 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
editMode && editMode &&
(permissions === true || permissions?.canCreate || permissions?.canDelete); (permissions === true || permissions?.canCreate || permissions?.canDelete);
// window.open ('www.yourdomain.com', '_ blank');
return ( return (
<div <div
ref={ref} ref={ref}
@ -162,12 +164,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
{unescapeString(link.name || link.description) || link.url} {unescapeString(link.name || link.description) || link.url}
</p> </p>
<div title={link.url || ""} className="w-fit"> <LinkTypeBadge link={link} />
<div className="flex gap-1 item-center select-none text-neutral mt-1">
<i className="bi-link-45deg text-lg mt-[0.15rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</div>
</div>
</div> </div>
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" /> <hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />

View File

@ -122,18 +122,20 @@ export default function LinkActions({
</div> </div>
</li> </li>
) : undefined} ) : undefined}
<li> {link.type === "url" && (
<div <li>
role="button" <div
tabIndex={0} role="button"
onClick={() => { tabIndex={0}
(document?.activeElement as HTMLElement)?.blur(); onClick={() => {
setPreservedFormatsModal(true); (document?.activeElement as HTMLElement)?.blur();
}} setPreservedFormatsModal(true);
> }}
Preserved Formats >
</div> Preserved Formats
</li> </div>
</li>
)}
{permissions === true || permissions?.canDelete ? ( {permissions === true || permissions?.canDelete ? (
<li> <li>
<div <div

View File

@ -2,6 +2,7 @@ import {
CollectionIncludingMembersAndLinkCount, CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags, LinkIncludingShortenedCollectionAndTags,
} from "@/types/global"; } from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React from "react"; import React from "react";
@ -15,12 +16,12 @@ export default function LinkCollection({
const router = useRouter(); const router = useRouter();
return ( return (
<div <Link
href={`/collections/${link.collection.id}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.stopPropagation();
router.push(`/collections/${link.collection.id}`);
}} }}
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100" className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
title={collection?.name} title={collection?.name}
> >
<i <i
@ -28,6 +29,6 @@ export default function LinkCollection({
style={{ color: collection?.color }} style={{ color: collection?.color }}
></i> ></i>
<p className="truncate capitalize">{collection?.name}</p> <p className="truncate capitalize">{collection?.name}</p>
</div> </Link>
); );
} }

View File

@ -6,14 +6,13 @@ export default function LinkDate({
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
}) { }) {
const formattedDate = new Date(link.createdAt as string).toLocaleString( const formattedDate = new Date(
"en-US", (link.importDate || link.createdAt) as string
{ ).toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "short", month: "short",
day: "numeric", day: "numeric",
} });
);
return ( return (
<div className="flex items-center gap-1 text-neutral"> <div className="flex items-center gap-1 text-neutral">

View File

@ -6,9 +6,11 @@ import React from "react";
export default function LinkIcon({ export default function LinkIcon({
link, link,
width, width,
className,
}: { }: {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
width?: string; width?: string;
className?: string;
}) { }) {
const url = const url =
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined; isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
@ -16,33 +18,55 @@ export default function LinkIcon({
const iconClasses: string = const iconClasses: string =
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" + "bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
" " + " " +
(width || "w-12"); (width || "w-12") +
" " +
(className || "");
const [showFavicon, setShowFavicon] = React.useState<boolean>(true); const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
return ( return (
<> <>
{link.url && url && showFavicon ? ( {link.type === "url" && url ? (
<Image showFavicon ? (
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`} <Image
width={64} src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
height={64} width={64}
alt="" height={64}
className={iconClasses} alt=""
draggable="false" className={iconClasses}
onError={() => { draggable="false"
setShowFavicon(false); onError={() => {
}} setShowFavicon(false);
/> }}
) : showFavicon === false ? ( />
<div className={iconClasses}> ) : (
<i className="bi-link-45deg text-4xl text-black"></i> <LinkPlaceholderIcon iconClasses={iconClasses} icon="bi-link-45deg" />
</div> )
) : link.type === "pdf" ? ( ) : link.type === "pdf" ? (
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i> <LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-pdf"
/>
) : link.type === "image" ? ( ) : link.type === "image" ? (
<i className={`bi-file-earmark-image ${iconClasses}`}></i> <LinkPlaceholderIcon
iconClasses={iconClasses}
icon="bi-file-earmark-image"
/>
) : undefined} ) : undefined}
</> </>
); );
} }
const LinkPlaceholderIcon = ({
iconClasses,
icon,
}: {
iconClasses: string;
icon: string;
}) => {
return (
<div className={`text-4xl text-black aspect-square ${iconClasses}`}>
<i className={`${icon} m-auto`}></i>
</div>
);
};

View File

@ -0,0 +1,38 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import React from "react";
export default function LinkTypeBadge({
link,
}: {
link: LinkIncludingShortenedCollectionAndTags;
}) {
let shortendURL;
if (link.type === "url" && link.url) {
try {
shortendURL = new URL(link.url).host.toLowerCase();
} catch (error) {
console.log(error);
}
}
return link.url && shortendURL ? (
<Link
href={link.url || ""}
target="_blank"
title={link.url || ""}
onClick={(e) => {
e.stopPropagation();
}}
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
>
<i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i>
<p className="text-sm truncate">{shortendURL}</p>
</Link>
) : (
<div className="badge badge-primary badge-sm my-1 select-none">
{link.type}
</div>
);
}

View File

@ -16,6 +16,7 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref";
import useAccountStore from "@/store/account"; import useAccountStore from "@/store/account";
import usePermissions from "@/hooks/usePermissions"; import usePermissions from "@/hooks/usePermissions";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
type Props = { type Props = {
link: LinkIncludingShortenedCollectionAndTags; link: LinkIncludingShortenedCollectionAndTags;
@ -56,14 +57,6 @@ export default function LinkCardCompact({
} }
}; };
let shortendURL;
try {
shortendURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
const [collection, setCollection] = const [collection, setCollection] =
useState<CollectionIncludingMembersAndLinkCount>( useState<CollectionIncludingMembersAndLinkCount>(
collections.find( collections.find(
@ -130,7 +123,11 @@ export default function LinkCardCompact({
} }
> >
<div className="shrink-0"> <div className="shrink-0">
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" /> <LinkIcon
link={link}
width="sm:w-12 w-8"
className="mt-1 sm:mt-0"
/>
</div> </div>
<div className="w-[calc(100%-56px)] ml-2"> <div className="w-[calc(100%-56px)] ml-2">
@ -143,16 +140,7 @@ export default function LinkCardCompact({
{collection ? ( {collection ? (
<LinkCollection link={link} collection={collection} /> <LinkCollection link={link} collection={collection} />
) : undefined} ) : undefined}
{link.url ? ( <LinkTypeBadge link={link} />
<div className="flex items-center gap-1 w-fit text-neutral truncate">
<i className="bi-link-45deg text-lg" />
<p className="truncate w-full select-none">{shortendURL}</p>
</div>
) : (
<div className="badge badge-primary badge-sm my-1 select-none">
{link.type}
</div>
)}
<LinkDate link={link} /> <LinkDate link={link} />
</div> </div>
</div> </div>

View File

@ -43,7 +43,7 @@ export default function UploadFileModal({ onClose }: Props) {
const [file, setFile] = useState<File>(); const [file, setFile] = useState<File>();
const { addLink } = useLinkStore(); const { uploadFile } = useLinkStore();
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [optionsExpanded, setOptionsExpanded] = useState(false); const [optionsExpanded, setOptionsExpanded] = useState(false);
@ -100,56 +100,22 @@ export default function UploadFileModal({ onClose }: Props) {
const submit = async () => { const submit = async () => {
if (!submitLoader && file) { if (!submitLoader && file) {
let fileType: ArchivedFormat | null = null; setSubmitLoader(true);
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") { const load = toast.loading("Creating...");
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
}
if (fileType !== null && linkType !== null) { const response = await uploadFile(link, file);
setSubmitLoader(true);
let response; toast.dismiss(load);
const load = toast.loading("Creating..."); if (response.ok) {
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
response = await addLink({ setSubmitLoader(false);
...link,
type: linkType,
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
});
toast.dismiss(load); return response;
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${
(response.data as LinkIncludingShortenedCollectionAndTags).id
}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
toast.success(`Created!`);
onClose();
} else toast.error(response.data as string);
setSubmitLoader(false);
return response;
}
} }
}; };
@ -238,7 +204,7 @@ export default function UploadFileModal({ onClose }: Props) {
className="btn btn-accent dark:border-violet-400 text-white" className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit} onClick={submit}
> >
Create Link Upload File
</button> </button>
</div> </div>
</Modal> </Modal>

View File

@ -65,7 +65,7 @@ export default function Navbar() {
<ToggleDarkMode className="hidden sm:inline-grid" /> <ToggleDarkMode className="hidden sm:inline-grid" />
<div className="dropdown dropdown-end sm:inline-block hidden"> <div className="dropdown dropdown-end sm:inline-block hidden">
<div className="tooltip tooltip-bottom z-10" data-tip="Create New..."> <div className="tooltip tooltip-bottom" data-tip="Create New...">
<div <div
tabIndex={0} tabIndex={0}
role="button" role="button"
@ -93,7 +93,7 @@ export default function Navbar() {
New Link New Link
</div> </div>
</li> </li>
{/* <li> <li>
<div <div
onClick={() => { onClick={() => {
(document?.activeElement as HTMLElement)?.blur(); (document?.activeElement as HTMLElement)?.blur();
@ -104,7 +104,7 @@ export default function Navbar() {
> >
Upload File Upload File
</div> </div>
</li> */} </li>
<li> <li>
<div <div
onClick={() => { onClick={() => {

View File

@ -34,6 +34,8 @@ export default function ReadableView({ link }: Props) {
const [imageError, setImageError] = useState<boolean>(false); const [imageError, setImageError] = useState<boolean>(false);
const [colorPalette, setColorPalette] = useState<RGBColor[]>(); const [colorPalette, setColorPalette] = useState<RGBColor[]>();
const [date, setDate] = useState<Date | string>();
const colorThief = new ColorThief(); const colorThief = new ColorThief();
const router = useRouter(); const router = useRouter();
@ -54,6 +56,8 @@ export default function ReadableView({ link }: Props) {
}; };
fetchLinkContent(); fetchLinkContent();
setDate(link.importDate || link.createdAt);
}, [link]); }, [link]);
useEffect(() => { useEffect(() => {
@ -211,8 +215,8 @@ export default function ReadableView({ link }: Props) {
</div> </div>
<p className="min-w-fit text-sm text-neutral"> <p className="min-w-fit text-sm text-neutral">
{link?.createdAt {date
? new Date(link?.createdAt).toLocaleString("en-US", { ? new Date(date).toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",

View File

@ -4,7 +4,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
export default function SettingsSidebar({ className }: { className?: string }) { export default function SettingsSidebar({ className }: { className?: string }) {
const LINKWARDEN_VERSION = "v2.5.0"; const LINKWARDEN_VERSION = "v2.5.1";
const { collections } = useCollectionStore(); const { collections } = useCollectionStore();

View File

@ -1,5 +1,5 @@
import { LinkRequestQuery } from "@/types/global"; import { LinkRequestQuery } from "@/types/global";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import useDetectPageBottom from "./useDetectPageBottom"; import useDetectPageBottom from "./useDetectPageBottom";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
@ -22,6 +22,8 @@ export default function useLinks(
useLinkStore(); useLinkStore();
const router = useRouter(); const router = useRouter();
const [isLoading, setIsLoading] = useState(true);
const { reachedBottom, setReachedBottom } = useDetectPageBottom(); const { reachedBottom, setReachedBottom } = useDetectPageBottom();
const getLinks = async (isInitialCall: boolean, cursor?: number) => { const getLinks = async (isInitialCall: boolean, cursor?: number) => {
@ -61,10 +63,14 @@ export default function useLinks(
basePath = "/api/v1/public/collections/links"; basePath = "/api/v1/public/collections/links";
} else basePath = "/api/v1/links"; } else basePath = "/api/v1/links";
setIsLoading(true);
const response = await fetch(`${basePath}?${queryString}`); const response = await fetch(`${basePath}?${queryString}`);
const data = await response.json(); const data = await response.json();
setIsLoading(false);
if (response.ok) setLinks(data.response, isInitialCall); if (response.ok) setLinks(data.response, isInitialCall);
}; };
@ -92,4 +98,6 @@ export default function useLinks(
setReachedBottom(false); setReachedBottom(false);
}, [reachedBottom]); }, [reachedBottom]);
return { isLoading };
} }

View File

@ -7,9 +7,9 @@ import { JSDOM } from "jsdom";
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { Collection, Link, User } from "@prisma/client"; import { Collection, Link, User } from "@prisma/client";
import validateUrlSize from "./validateUrlSize"; import validateUrlSize from "./validateUrlSize";
import removeFile from "./storage/removeFile";
import Jimp from "jimp";
import createFolder from "./storage/createFolder"; import createFolder from "./storage/createFolder";
import generatePreview from "./generatePreview";
import { removeFiles } from "./manageLinkFiles";
type LinksAndCollectionAndOwner = Link & { type LinksAndCollectionAndOwner = Link & {
collection: Collection & { collection: Collection & {
@ -51,6 +51,14 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
); );
}); });
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
createFolder({
filePath: `archives/${link.collectionId}`,
});
try { try {
await Promise.race([ await Promise.race([
(async () => { (async () => {
@ -162,10 +170,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
return metaTag ? (metaTag as any).content : null; return metaTag ? (metaTag as any).content : null;
}); });
createFolder({
filePath: `archives/preview/${link.collectionId}`,
});
if (ogImageUrl) { if (ogImageUrl) {
console.log("Found og:image URL:", ogImageUrl); console.log("Found og:image URL:", ogImageUrl);
@ -175,35 +179,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
// Check if imageResponse is not null // Check if imageResponse is not null
if (imageResponse && !link.preview?.startsWith("archive")) { if (imageResponse && !link.preview?.startsWith("archive")) {
const buffer = await imageResponse.body(); const buffer = await imageResponse.body();
await generatePreview(buffer, link.collectionId, link.id);
// Check if buffer is not null
if (buffer) {
// Load the image using Jimp
Jimp.read(buffer, async (err, image) => {
if (image && !err) {
image?.resize(1280, Jimp.AUTO).quality(20);
const processedBuffer = await image?.getBufferAsync(
Jimp.MIME_JPEG
);
createFile({
data: processedBuffer,
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
}).then(() => {
return prisma.link.update({
where: { id: link.id },
data: {
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
},
});
});
}
}).catch((err) => {
console.error("Error processing the image:", err);
});
} else {
console.log("No image data found.");
}
} }
await page.goBack(); await page.goBack();
@ -323,14 +299,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
}, },
}); });
else { else {
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` }); await removeFiles(link.id, link.collectionId);
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
removeFile({
filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
});
removeFile({
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
});
} }
await browser.close(); await browser.close();

View File

@ -32,11 +32,12 @@ export default async function checkSubscriptionByEmail(email: string) {
customer.subscriptions?.data.some((subscription) => { customer.subscriptions?.data.some((subscription) => {
subscription.current_period_end; subscription.current_period_end;
active = subscription.items.data.some( active =
(e) => subscription.items.data.some(
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) || (e) =>
(e.price.id === YEARLY_PRICE_ID && e.price.active === true) (e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
); (e.price.id === YEARLY_PRICE_ID && e.price.active === true)
) || false;
stripeSubscriptionId = subscription.id; stripeSubscriptionId = subscription.id;
currentPeriodStart = subscription.current_period_start * 1000; currentPeriodStart = subscription.current_period_start * 1000;
currentPeriodEnd = subscription.current_period_end * 1000; currentPeriodEnd = subscription.current_period_end * 1000;
@ -44,7 +45,7 @@ export default async function checkSubscriptionByEmail(email: string) {
}); });
return { return {
active, active: active || false,
stripeSubscriptionId, stripeSubscriptionId,
currentPeriodStart, currentPeriodStart,
currentPeriodEnd, currentPeriodEnd,

View File

@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
import { UsersAndCollections } from "@prisma/client"; import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
export default async function deleteLinksById( export default async function deleteLinksById(
userId: number, userId: number,
@ -43,15 +44,7 @@ export default async function deleteLinksById(
const linkId = linkIds[i]; const linkId = linkIds[i];
const collectionIsAccessible = collectionIsAccessibleArray[i]; const collectionIsAccessible = collectionIsAccessibleArray[i];
removeFile({ if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id);
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
} }
return { response: deletedLinks, status: 200 }; return { response: deletedLinks, status: 200 };

View File

@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
import { Link, UsersAndCollections } from "@prisma/client"; import { Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
import { removeFiles } from "@/lib/api/manageLinkFiles";
export default async function deleteLink(userId: number, linkId: number) { export default async function deleteLink(userId: number, linkId: number) {
if (!linkId) return { response: "Please choose a valid link.", status: 401 }; if (!linkId) return { response: "Please choose a valid link.", status: 401 };
@ -12,7 +13,10 @@ export default async function deleteLink(userId: number, linkId: number) {
(e: UsersAndCollections) => e.userId === userId && e.canDelete (e: UsersAndCollections) => e.userId === userId && e.canDelete
); );
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess)) if (
!collectionIsAccessible ||
!(collectionIsAccessible?.ownerId === userId || memberHasAccess)
)
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
const deleteLink: Link = await prisma.link.delete({ const deleteLink: Link = await prisma.link.delete({
@ -21,15 +25,7 @@ export default async function deleteLink(userId: number, linkId: number) {
}, },
}); });
removeFile({ removeFiles(linkId, collectionIsAccessible.id);
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
});
removeFile({
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
});
return { response: deleteLink, status: 200 }; return { response: deleteLink, status: 200 };
} }

View File

@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client"; import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import moveFile from "@/lib/api/storage/moveFile"; import { moveFiles } from "@/lib/api/manageLinkFiles";
export default async function updateLinkById( export default async function updateLinkById(
userId: number, userId: number,
@ -146,20 +146,7 @@ export default async function updateLinkById(
}); });
if (collectionIsAccessible?.id !== data.collection.id) { if (collectionIsAccessible?.id !== data.collection.id) {
await moveFile( await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id);
`archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
`archives/${data.collection.id}/${linkId}.pdf`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}.png`,
`archives/${data.collection.id}/${linkId}.png`
);
await moveFile(
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
`archives/${data.collection.id}/${linkId}_readability.json`
);
} }
return { response: updatedLink, status: 200 }; return { response: updatedLink, status: 200 };

View File

@ -12,14 +12,16 @@ export default async function postLink(
link: LinkIncludingShortenedCollectionAndTags, link: LinkIncludingShortenedCollectionAndTags,
userId: number userId: number
) { ) {
try { if (link.url || link.type === "url") {
new URL(link.url || ""); try {
} catch (error) { new URL(link.url || "");
return { } catch (error) {
response: return {
"Please enter a valid Address for the Link. (It should start with http/https)", response:
status: 400, "Please enter a valid Address for the Link. (It should start with http/https)",
}; status: 400,
};
}
} }
if (!link.collection.id && link.collection.name) { if (!link.collection.id && link.collection.name) {
@ -48,6 +50,7 @@ export default async function postLink(
return { response: "Collection is not accessible.", status: 401 }; return { response: "Collection is not accessible.", status: 401 };
link.collection.id = findCollection.id; link.collection.id = findCollection.id;
link.collection.ownerId = findCollection.ownerId;
} else { } else {
const collection = await prisma.collection.create({ const collection = await prisma.collection.create({
data: { data: {
@ -180,7 +183,7 @@ export default async function postLink(
const newLink = await prisma.link.create({ const newLink = await prisma.link.create({
data: { data: {
url: link.url?.trim().replace(/\/+$/, ""), url: link.url?.trim().replace(/\/+$/, "") || null,
name: link.name, name: link.name,
description, description,
type: linkType, type: linkType,

View File

@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
import createFolder from "@/lib/api/storage/createFolder"; import createFolder from "@/lib/api/storage/createFolder";
import { JSDOM } from "jsdom"; import { JSDOM } from "jsdom";
import { parse, Node, Element, TextNode } from "himalaya"; import { parse, Node, Element, TextNode } from "himalaya";
import { writeFileSync } from "fs";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000; const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
@ -36,7 +37,9 @@ export default async function importFromHTMLFile(
const jsonData = parse(document.documentElement.outerHTML); const jsonData = parse(document.documentElement.outerHTML);
for (const item of jsonData) { const processedArray = processNodes(jsonData);
for (const item of processedArray) {
console.log(item); console.log(item);
await processBookmarks(userId, item as Element); await processBookmarks(userId, item as Element);
} }
@ -74,7 +77,9 @@ async function processBookmarks(
} else if (item.type === "element" && item.tagName === "a") { } else if (item.type === "element" && item.tagName === "a") {
// process link // process link
const linkUrl = item?.attributes.find((e) => e.key === "href")?.value; const linkUrl = item?.attributes.find(
(e) => e.key.toLowerCase() === "href"
)?.value;
const linkName = ( const linkName = (
item?.children.find((e) => e.type === "text") as TextNode item?.children.find((e) => e.type === "text") as TextNode
)?.content; )?.content;
@ -82,14 +87,33 @@ async function processBookmarks(
.find((e) => e.key === "tags") .find((e) => e.key === "tags")
?.value.split(","); ?.value.split(",");
// set date if available
const linkDateValue = item?.attributes.find(
(e) => e.key.toLowerCase() === "add_date"
)?.value;
const linkDate = linkDateValue
? new Date(Number(linkDateValue) * 1000)
: undefined;
let linkDesc =
(
(
item?.children?.find(
(e) => e.type === "element" && e.tagName === "dd"
) as Element
)?.children[0] as TextNode
)?.content || "";
if (linkUrl && parentCollectionId) { if (linkUrl && parentCollectionId) {
await createLink( await createLink(
userId, userId,
linkUrl, linkUrl,
parentCollectionId, parentCollectionId,
linkName, linkName,
"", linkDesc,
linkTags linkTags,
linkDate
); );
} else if (linkUrl) { } else if (linkUrl) {
// create a collection named "Imported Bookmarks" and add the link to it // create a collection named "Imported Bookmarks" and add the link to it
@ -100,8 +124,9 @@ async function processBookmarks(
linkUrl, linkUrl,
collectionId, collectionId,
linkName, linkName,
"", linkDesc,
linkTags linkTags,
linkDate
); );
} }
@ -160,7 +185,8 @@ const createLink = async (
collectionId: number, collectionId: number,
name?: string, name?: string,
description?: string, description?: string,
tags?: string[] tags?: string[],
importDate?: Date
) => { ) => {
await prisma.link.create({ await prisma.link.create({
data: { data: {
@ -193,6 +219,48 @@ const createLink = async (
}), }),
} }
: undefined, : undefined,
importDate: importDate || undefined,
}, },
}); });
}; };
function processNodes(nodes: Node[]) {
const findAndProcessDL = (node: Node) => {
if (node.type === "element" && node.tagName === "dl") {
processDLChildren(node);
} else if (
node.type === "element" &&
node.children &&
node.children.length
) {
node.children.forEach((child) => findAndProcessDL(child));
}
};
const processDLChildren = (dlNode: Element) => {
dlNode.children.forEach((child, i) => {
if (child.type === "element" && child.tagName === "dt") {
const nextSibling = dlNode.children[i + 1];
if (
nextSibling &&
nextSibling.type === "element" &&
nextSibling.tagName === "dd"
) {
const aElement = child.children.find(
(el) => el.type === "element" && el.tagName === "a"
);
if (aElement && aElement.type === "element") {
// Add the 'dd' element as a child of the 'a' element
aElement.children.push(nextSibling);
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
dlNode.children.splice(i + 1, 1);
// Adjust the loop counter due to the removal
}
}
}
});
};
nodes.forEach(findAndProcessDL);
return nodes;
}

View File

@ -71,6 +71,10 @@ export default async function deleteUserById(
// Delete archive folders // Delete archive folders
removeFolder({ filePath: `archives/${collection.id}` }); removeFolder({ filePath: `archives/${collection.id}` });
await removeFolder({
filePath: `archives/preview/${collection.id}`,
});
} }
// Delete collections after cleaning up related data // Delete collections after cleaning up related data

View File

@ -0,0 +1,36 @@
import Jimp from "jimp";
import { prisma } from "./db";
import createFile from "./storage/createFile";
import createFolder from "./storage/createFolder";
const generatePreview = async (
buffer: Buffer,
collectionId: number,
linkId: number
) => {
if (buffer && collectionId && linkId) {
// Load the image using Jimp
await Jimp.read(buffer, async (err, image) => {
if (image && !err) {
image?.resize(1280, Jimp.AUTO).quality(20);
const processedBuffer = await image?.getBufferAsync(Jimp.MIME_JPEG);
createFile({
data: processedBuffer,
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
}).then(() => {
return prisma.link.update({
where: { id: linkId },
data: {
preview: `archives/preview/${collectionId}/${linkId}.jpeg`,
},
});
});
}
}).catch((err) => {
console.error("Error processing the image:", err);
});
}
};
export default generatePreview;

View File

@ -0,0 +1,61 @@
import moveFile from "./storage/moveFile";
import removeFile from "./storage/removeFile";
const removeFiles = async (linkId: number, collectionId: number) => {
// PDF
await removeFile({
filePath: `archives/${collectionId}/${linkId}.pdf`,
});
// Images
await removeFile({
filePath: `archives/${collectionId}/${linkId}.png`,
});
await removeFile({
filePath: `archives/${collectionId}/${linkId}.jpeg`,
});
await removeFile({
filePath: `archives/${collectionId}/${linkId}.jpg`,
});
// Preview
await removeFile({
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
});
// Readability
await removeFile({
filePath: `archives/${collectionId}/${linkId}_readability.json`,
});
};
const moveFiles = async (linkId: number, from: number, to: number) => {
await moveFile(
`archives/${from}/${linkId}.pdf`,
`archives/${to}/${linkId}.pdf`
);
await moveFile(
`archives/${from}/${linkId}.png`,
`archives/${to}/${linkId}.png`
);
await moveFile(
`archives/${from}/${linkId}.jpeg`,
`archives/${to}/${linkId}.jpeg`
);
await moveFile(
`archives/${from}/${linkId}.jpg`,
`archives/${to}/${linkId}.jpg`
);
await moveFile(
`archives/preview/${from}/${linkId}.jpeg`,
`archives/preview/${to}/${linkId}.jpeg`
);
await moveFile(
`archives/${from}/${linkId}_readability.json`,
`archives/${to}/${linkId}_readability.json`
);
};
export { removeFiles, moveFiles };

View File

@ -17,15 +17,7 @@ export default async function verifySubscription(
const currentDate = new Date(); const currentDate = new Date();
if ( if (!subscription?.active || currentDate > subscription.currentPeriodEnd) {
subscription &&
currentDate > subscription.currentPeriodEnd &&
!subscription.active
) {
return null;
}
if (!subscription || currentDate > subscription.currentPeriodEnd) {
const { const {
active, active,
stripeSubscriptionId, stripeSubscriptionId,
@ -59,15 +51,21 @@ export default async function verifySubscription(
}, },
}) })
.catch((err) => console.log(err)); .catch((err) => console.log(err));
} } else if (!active) {
const subscription = await prisma.subscription.findFirst({
where: {
userId: user.id,
},
});
if (!active) { if (subscription)
if (user.username) await prisma.subscription.delete({
// await prisma.user.update({ where: {
// where: { id: user.id }, userId: user.id,
// data: { username: null }, },
// }); });
return null;
return null;
} }
} }

View File

@ -52,7 +52,7 @@ export default async function verifyUser({
} }
if (STRIPE_SECRET_KEY) { if (STRIPE_SECRET_KEY) {
const subscribedUser = verifySubscription(user); const subscribedUser = await verifySubscription(user);
if (!subscribedUser) { if (!subscribedUser) {
res.status(401).json({ res.status(401).json({

View File

@ -16,24 +16,30 @@ export const generateLinkHref = (
): string => { ): string => {
// Return the links href based on the account's preference // Return the links href based on the account's preference
// If the user's preference is not available, return the original link // If the user's preference is not available, return the original link
switch (account.linksRouteTo) { if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") {
case LinksRouteTo.ORIGINAL: return link.url || "";
return link.url || ""; } else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") {
case LinksRouteTo.PDF: if (!pdfAvailable(link)) return link.url || "";
if (!pdfAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`; return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
case LinksRouteTo.READABLE: } else if (
if (!readabilityAvailable(link)) return link.url || ""; account.linksRouteTo === LinksRouteTo.READABLE &&
link.type === "url"
) {
if (!readabilityAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`; return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
case LinksRouteTo.SCREENSHOT: } else if (
if (!screenshotAvailable(link)) return link.url || ""; account.linksRouteTo === LinksRouteTo.SCREENSHOT ||
link.type === "image"
) {
console.log(link);
if (!screenshotAvailable(link)) return link.url || "";
return `/preserved/${link?.id}?format=${ return `/preserved/${link?.id}?format=${
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
}`; }`;
default: } else {
return link.url || ""; return link.url || "";
} }
}; };

View File

@ -1,6 +1,6 @@
{ {
"name": "linkwarden", "name": "linkwarden",
"version": "2.5.0", "version": "2.5.1",
"main": "index.js", "main": "index.js",
"repository": "https://github.com/linkwarden/linkwarden.git", "repository": "https://github.com/linkwarden/linkwarden.git",
"author": "Daniel31X13 <daniel31x13@gmail.com>", "author": "Daniel31X13 <daniel31x13@gmail.com>",
@ -10,10 +10,10 @@
"seed": "node ./prisma/seed.js" "seed": "node ./prisma/seed.js"
}, },
"scripts": { "scripts": {
"dev": "concurrently -k \"next dev\" \"yarn worker:dev\"", "dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --",
"worker:dev": "nodemon --skip-project scripts/worker.ts", "worker:dev": "nodemon --skip-project scripts/worker.ts",
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts", "worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
"start": "concurrently \"next start\" \"yarn worker:prod\"", "start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --",
"build": "next build", "build": "next build",
"lint": "next lint", "lint": "next lint",
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\"" "format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
@ -61,6 +61,7 @@
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.4.1",
"react-image-file-resizer": "^0.4.8", "react-image-file-resizer": "^0.4.8",
"react-select": "^5.7.4", "react-select": "^5.7.4",
"react-spinners": "^0.13.8",
"socks-proxy-agent": "^8.0.2", "socks-proxy-agent": "^8.0.2",
"stripe": "^12.13.0", "stripe": "^12.13.0",
"vaul": "^0.8.8", "vaul": "^0.8.8",
@ -78,7 +79,7 @@
"nodemon": "^3.0.2", "nodemon": "^3.0.2",
"postcss": "^8.4.26", "postcss": "^8.4.26",
"prettier": "3.1.1", "prettier": "3.1.1",
"prisma": "^5.1.0", "prisma": "^4.16.2",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "4.9.4" "typescript": "4.9.4"

View File

@ -9,6 +9,8 @@ import formidable from "formidable";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import fs from "fs"; import fs from "fs";
import verifyToken from "@/lib/api/verifyToken"; import verifyToken from "@/lib/api/verifyToken";
import generatePreview from "@/lib/api/generatePreview";
import createFolder from "@/lib/api/storage/createFolder";
export const config = { export const config = {
api: { api: {
@ -73,83 +75,97 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
return res.send(file); return res.send(file);
} }
} else if (req.method === "POST") {
const user = await verifyUser({ req, res });
if (!user) return;
const collectionPermissions = await getPermission({
userId: user.id,
linkId,
});
const memberHasAccess = collectionPermissions?.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
);
if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
return { response: "Collection is not accessible.", status: 401 };
// await uploadHandler(linkId, )
const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
const form = formidable({
maxFields: 1,
maxFiles: 1,
maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
});
form.parse(req, async (err, fields, files) => {
const allowedMIMETypes = [
"application/pdf",
"image/png",
"image/jpg",
"image/jpeg",
];
if (
err ||
!files.file ||
!files.file[0] ||
!allowedMIMETypes.includes(files.file[0].mimetype || "")
) {
// Handle parsing error
return res.status(500).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
});
} else {
const fileBuffer = fs.readFileSync(files.file[0].filepath);
const linkStillExists = await prisma.link.findUnique({
where: { id: linkId },
});
if (linkStillExists && files.file[0].mimetype?.includes("image")) {
const collectionId = collectionPermissions?.id as number;
createFolder({
filePath: `archives/preview/${collectionId}`,
});
generatePreview(fileBuffer, collectionId, linkId);
}
if (linkStillExists) {
await createFile({
filePath: `archives/${collectionPermissions?.id}/${
linkId + suffix
}`,
data: fileBuffer,
});
await prisma.link.update({
where: { id: linkId },
data: {
preview: files.file[0].mimetype?.includes("pdf")
? "unavailable"
: undefined,
image: files.file[0].mimetype?.includes("image")
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
: null,
pdf: files.file[0].mimetype?.includes("pdf")
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
: null,
lastPreserved: new Date().toISOString(),
},
});
}
fs.unlinkSync(files.file[0].filepath);
}
return res.status(200).json({
response: files,
});
});
} }
// else if (req.method === "POST") {
// const user = await verifyUser({ req, res });
// if (!user) return;
// const collectionPermissions = await getPermission({
// userId: user.id,
// linkId,
// });
// const memberHasAccess = collectionPermissions?.members.some(
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate
// );
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
// return { response: "Collection is not accessible.", status: 401 };
// // await uploadHandler(linkId, )
// const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
// const form = formidable({
// maxFields: 1,
// maxFiles: 1,
// maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
// });
// form.parse(req, async (err, fields, files) => {
// const allowedMIMETypes = [
// "application/pdf",
// "image/png",
// "image/jpg",
// "image/jpeg",
// ];
// if (
// err ||
// !files.file ||
// !files.file[0] ||
// !allowedMIMETypes.includes(files.file[0].mimetype || "")
// ) {
// // Handle parsing error
// return res.status(500).json({
// response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
// });
// } else {
// const fileBuffer = fs.readFileSync(files.file[0].filepath);
// const linkStillExists = await prisma.link.findUnique({
// where: { id: linkId },
// });
// if (linkStillExists) {
// await createFile({
// filePath: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// data: fileBuffer,
// });
// await prisma.link.update({
// where: { id: linkId },
// data: {
// image: `archives/${collectionPermissions?.id}/${
// linkId + suffix
// }`,
// lastPreserved: new Date().toISOString(),
// },
// });
// }
// fs.unlinkSync(files.file[0].filepath);
// }
// return res.status(200).json({
// response: files,
// });
// });
// }
} }

View File

@ -98,19 +98,19 @@ if (
const user = await prisma.user.findFirst({ const user = await prisma.user.findFirst({
where: emailEnabled where: emailEnabled
? { ? {
OR: [ OR: [
{ {
username: username.toLowerCase(), username: username.toLowerCase(),
}, },
{ {
email: username?.toLowerCase(), email: username?.toLowerCase(),
}, },
], ],
emailVerified: { not: null }, emailVerified: { not: null },
} }
: { : {
username: username.toLowerCase(), username: username.toLowerCase(),
}, },
}); });
let passwordMatches: boolean = false; let passwordMatches: boolean = false;
@ -240,6 +240,37 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
}; };
} }
// Authelia
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
providers.push(
{
id: "authelia",
name: "Authelia",
type: "oauth",
clientId: process.env.AUTHELIA_CLIENT_ID!,
clientSecret: process.env.AUTHELIA_CLIENT_SECRET!,
wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!,
authorization: { params: { scope: "openid email profile" } },
idToken: true,
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
username: profile.preferred_username,
}
},
}
);
const _linkAccount = adapter.linkAccount;
adapter.linkAccount = (account) => {
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
return _linkAccount ? _linkAccount(data) : undefined;
};
}
// Authentik // Authentik
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") { if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
providers.push( providers.push(

View File

@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser"; import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl"; import isValidUrl from "@/lib/shared/isValidUrl";
import removeFile from "@/lib/api/storage/removeFile";
import { Collection, Link } from "@prisma/client"; import { Collection, Link } from "@prisma/client";
import { removeFiles } from "@/lib/api/manageLinkFiles";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5; const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@ -80,16 +80,5 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
}, },
}); });
await removeFile({ await removeFiles(link.id, link.collection.id);
filePath: `archives/${link.collection.id}/${link.id}.pdf`,
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}.png`,
});
await removeFile({
filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
});
await removeFile({
filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
});
}; };

View File

@ -391,10 +391,17 @@ export function getLogins() {
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom", name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
}); });
} }
// Authelia
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
buttonAuths.push({
method: "authelia",
name: process.env.AUTHELIA_CUSTOM_NAME ?? "Authelia",
});
}
return { return {
credentialsEnabled: credentialsEnabled:
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" || process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
? "true" ? "true"
: "false", : "false",
emailEnabled: emailEnabled:

View File

@ -168,10 +168,7 @@ export default function Dashboard() {
> >
{links[0] ? ( {links[0] ? (
<div className="w-full"> <div className="w-full">
<LinkComponent <LinkComponent links={links.slice(0, showLinks)} />
links={links.slice(0, showLinks)}
showCheckbox={false}
/>
</div> </div>
) : ( ) : (
<div <div
@ -282,7 +279,6 @@ export default function Dashboard() {
{links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? ( {links.some((e) => e.pinnedBy && e.pinnedBy[0]) ? (
<div className="w-full"> <div className="w-full">
<LinkComponent <LinkComponent
showCheckbox={false}
links={links links={links
.filter((e) => e.pinnedBy && e.pinnedBy[0]) .filter((e) => e.pinnedBy && e.pinnedBy[0])
.slice(0, showLinks)} .slice(0, showLinks)}

View File

@ -60,8 +60,8 @@ export default function PublicCollections() {
name: true, name: true,
url: true, url: true,
description: true, description: true,
textContent: true,
tags: true, tags: true,
textContent: false,
}); });
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);

View File

@ -5,12 +5,13 @@ import MainLayout from "@/layouts/MainLayout";
import useLinkStore from "@/store/links"; import useLinkStore from "@/store/links";
import { Sort, ViewMode } from "@/types/global"; import { Sort, ViewMode } from "@/types/global";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import React, { useState } from "react"; import React, { useEffect, useState } from "react";
import ViewDropdown from "@/components/ViewDropdown"; import ViewDropdown from "@/components/ViewDropdown";
import CardView from "@/components/LinkViews/Layouts/CardView"; import CardView from "@/components/LinkViews/Layouts/CardView";
// import GridView from "@/components/LinkViews/Layouts/GridView"; // import GridView from "@/components/LinkViews/Layouts/GridView";
import ListView from "@/components/LinkViews/Layouts/ListView"; import ListView from "@/components/LinkViews/Layouts/ListView";
import PageHeader from "@/components/PageHeader"; import PageHeader from "@/components/PageHeader";
import { GridLoader, PropagateLoader } from "react-spinners";
export default function Search() { export default function Search() {
const { links } = useLinkStore(); const { links } = useLinkStore();
@ -21,8 +22,8 @@ export default function Search() {
name: true, name: true,
url: true, url: true,
description: true, description: true,
textContent: true,
tags: true, tags: true,
textContent: false,
}); });
const [viewMode, setViewMode] = useState<string>( const [viewMode, setViewMode] = useState<string>(
@ -30,7 +31,7 @@ export default function Search() {
); );
const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst); const [sortBy, setSortBy] = useState<Sort>(Sort.DateNewestFirst);
useLinks({ const { isLoading } = useLinks({
sort: sortBy, sort: sortBy,
searchQueryString: decodeURIComponent(router.query.q as string), searchQueryString: decodeURIComponent(router.query.q as string),
searchByName: searchFilter.name, searchByName: searchFilter.name,
@ -40,6 +41,10 @@ export default function Search() {
searchByTags: searchFilter.tags, searchByTags: searchFilter.tags,
}); });
useEffect(() => {
console.log("isLoading", isLoading);
}, [isLoading]);
const linkView = { const linkView = {
[ViewMode.Card]: CardView, [ViewMode.Card]: CardView,
// [ViewMode.Grid]: GridView, // [ViewMode.Grid]: GridView,
@ -51,7 +56,7 @@ export default function Search() {
return ( return (
<MainLayout> <MainLayout>
<div className="p-5 flex flex-col gap-5 w-full"> <div className="p-5 flex flex-col gap-5 w-full h-full">
<div className="flex justify-between"> <div className="flex justify-between">
<PageHeader icon={"bi-search"} title={"Search Results"} /> <PageHeader icon={"bi-search"} title={"Search Results"} />
@ -67,15 +72,24 @@ export default function Search() {
</div> </div>
</div> </div>
{links[0] ? ( {!isLoading && !links[0] ? (
<LinkComponent links={links} />
) : (
<p> <p>
Nothing found.{" "} Nothing found.{" "}
<span className="font-bold text-xl" title="Shruggie"> <span className="font-bold text-xl" title="Shruggie">
¯\_()_/¯ ¯\_()_/¯
</span> </span>
</p> </p>
) : links[0] ? (
<LinkComponent links={links} isLoading={isLoading} />
) : (
isLoading && (
<GridLoader
color="oklch(var(--p))"
loading={true}
size={20}
className="m-auto py-10"
/>
)
)} )}
</div> </div>
</MainLayout> </MainLayout>

View File

@ -0,0 +1,5 @@
-- CreateIndex
CREATE INDEX "Collection_ownerId_idx" ON "Collection"("ownerId");
-- CreateIndex
CREATE INDEX "UsersAndCollections_userId_idx" ON "UsersAndCollections"("userId");

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Tag_ownerId_idx" ON "Tag"("ownerId");

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "importDate" TIMESTAMP(3);

View File

@ -93,6 +93,8 @@ model Collection {
links Link[] links Link[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@index([ownerId])
} }
model UsersAndCollections { model UsersAndCollections {
@ -107,6 +109,7 @@ model UsersAndCollections {
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@id([userId, collectionId]) @@id([userId, collectionId])
@@index([userId])
} }
model Link { model Link {
@ -125,6 +128,7 @@ model Link {
pdf String? pdf String?
readable String? readable String?
lastPreserved DateTime? lastPreserved DateTime?
importDate DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
} }
@ -139,6 +143,7 @@ model Tag {
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@unique([name, ownerId]) @@unique([name, ownerId])
@@index([ownerId])
} }
model Subscription { model Subscription {

View File

@ -1,5 +1,8 @@
import { create } from "zustand"; import { create } from "zustand";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import {
ArchivedFormat,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import useTagStore from "./tags"; import useTagStore from "./tags";
import useCollectionStore from "./collections"; import useCollectionStore from "./collections";
@ -19,6 +22,10 @@ type LinkStore = {
addLink: ( addLink: (
body: LinkIncludingShortenedCollectionAndTags body: LinkIncludingShortenedCollectionAndTags
) => Promise<ResponseObject>; ) => Promise<ResponseObject>;
uploadFile: (
link: LinkIncludingShortenedCollectionAndTags,
file: File
) => Promise<ResponseObject>;
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>; getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
updateLink: ( updateLink: (
link: LinkIncludingShortenedCollectionAndTags link: LinkIncludingShortenedCollectionAndTags
@ -79,6 +86,82 @@ const useLinkStore = create<LinkStore>()((set) => ({
return { ok: response.ok, data: data.response }; return { ok: response.ok, data: data.response };
}, },
uploadFile: async (link, file) => {
let fileType: ArchivedFormat | null = null;
let linkType: "url" | "image" | "pdf" | null = null;
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
fileType = ArchivedFormat.jpeg;
linkType = "image";
} else if (file.type === "image/png") {
fileType = ArchivedFormat.png;
linkType = "image";
} else if (file.type === "application/pdf") {
fileType = ArchivedFormat.pdf;
linkType = "pdf";
} else {
return { ok: false, data: "Invalid file type." };
}
const response = await fetch("/api/v1/links", {
body: JSON.stringify({
...link,
type: linkType,
name: link.name ? link.name : file.name,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
const data = await response.json();
const createdLink: LinkIncludingShortenedCollectionAndTags = data.response;
console.log(data);
if (response.ok) {
const formBody = new FormData();
file && formBody.append("file", file);
await fetch(
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
{
body: formBody,
method: "POST",
}
);
// get file extension
const extension = file.name.split(".").pop() || "";
set((state) => ({
links: [
{
...createdLink,
image:
linkType === "image"
? `archives/${createdLink.collectionId}/${
createdLink.id + extension
}`
: null,
pdf:
linkType === "pdf"
? `archives/${createdLink.collectionId}/${
createdLink.id + ".pdf"
}`
: null,
},
...state.links,
],
}));
useTagStore.getState().setTags();
useCollectionStore.getState().setCollections();
}
return { ok: response.ok, data: data.response };
},
getLink: async (linkId, publicRoute) => { getLink: async (linkId, publicRoute) => {
const path = publicRoute const path = publicRoute
? `/api/v1/public/links/${linkId}` ? `/api/v1/public/links/${linkId}`

View File

@ -76,6 +76,13 @@ declare global {
AUTH0_CLIENT_SECRET?: string; AUTH0_CLIENT_SECRET?: string;
AUTH0_CLIENT_ID?: string; AUTH0_CLIENT_ID?: string;
// Authelia
NEXT_PUBLIC_AUTHELIA_ENABLED?: string;
AUTHELIA_CUSTOM_NAME?: string;
AUTHELIA_CLIENT_ID?: string;
AUTHELIA_CLIENT_SECRET?: string;
AUTHELIA_WELLKNOWN_URL?: string;
// Authentik // Authentik
NEXT_PUBLIC_AUTHENTIK_ENABLED?: string; NEXT_PUBLIC_AUTHENTIK_ENABLED?: string;
AUTHENTIK_CUSTOM_NAME?: string; AUTHENTIK_CUSTOM_NAME?: string;
@ -410,4 +417,4 @@ declare global {
} }
} }
export {}; export { };

View File

@ -7,10 +7,16 @@ type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
export interface LinkIncludingShortenedCollectionAndTags export interface LinkIncludingShortenedCollectionAndTags
extends Omit< extends Omit<
Link, Link,
"id" | "createdAt" | "collectionId" | "updatedAt" | "lastPreserved" | "id"
| "createdAt"
| "collectionId"
| "updatedAt"
| "lastPreserved"
| "importDate"
> { > {
id?: number; id?: number;
createdAt?: string; createdAt?: string;
importDate?: string;
collectionId?: number; collectionId?: number;
tags: Tag[]; tags: Tag[];
pinnedBy?: { pinnedBy?: {

View File

@ -1301,10 +1301,10 @@
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14"
integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg== integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==
"@prisma/engines@5.1.0": "@prisma/engines@4.16.2":
version "5.1.0" version "4.16.2"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.0.tgz#4ccf7f344eaeee08ca1e4a1bb2dc14e36ff1d5ec" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.16.2.tgz#5ec8dd672c2173d597e469194916ad4826ce2e5f"
integrity sha512-HqaFsnPmZOdMWkPq6tT2eTVTQyaAXEDdKszcZ4yc7DGMBIYRP6j/zAJTtZUG9SsMV8FaucdL5vRyxY/p5Ni28g== integrity sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==
"@radix-ui/primitive@1.0.1": "@radix-ui/primitive@1.0.1":
version "1.0.1" version "1.0.1"
@ -5038,12 +5038,12 @@ pretty-format@^3.8.0:
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew== integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
prisma@^5.1.0: prisma@^4.16.2:
version "5.1.0" version "4.16.2"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.1.0.tgz#29e316b54844f5694a83017a9781a6d6f7cb99ea" resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.16.2.tgz#469e0a0991c6ae5bcde289401726bb012253339e"
integrity sha512-wkXvh+6wxk03G8qwpZMOed4Y3j+EQ+bMTlvbDZHeal6k1E8QuGKzRO7DRXlE1NV0WNgOAas8kwZqcLETQ2+BiQ== integrity sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==
dependencies: dependencies:
"@prisma/engines" "5.1.0" "@prisma/engines" "4.16.2"
process@^0.11.10: process@^0.11.10:
version "0.11.10" version "0.11.10"
@ -5211,6 +5211,11 @@ react-select@^5.7.4:
react-transition-group "^4.3.0" react-transition-group "^4.3.0"
use-isomorphic-layout-effect "^1.1.2" use-isomorphic-layout-effect "^1.1.2"
react-spinners@^0.13.8:
version "0.13.8"
resolved "https://registry.yarnpkg.com/react-spinners/-/react-spinners-0.13.8.tgz#5262571be0f745d86bbd49a1e6b49f9f9cb19acc"
integrity sha512-3e+k56lUkPj0vb5NDXPVFAOkPC//XyhKPJjvcGjyMNPWsBKpplfeyialP74G7H7+It7KzhtET+MvGqbKgAqpZA==
react-style-singleton@^2.2.1: react-style-singleton@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4"