diff --git a/.env.sample b/.env.sample index 384929e3..74e91920 100644 --- a/.env.sample +++ b/.env.sample @@ -1,9 +1,13 @@ NEXTAUTH_SECRET=very_sensitive_secret -DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden NEXTAUTH_URL=http://localhost:3000 -# Additional Optional Settings +# Manual installation database settings +DATABASE_URL=postgresql://user:password@localhost:5432/linkwarden +# Docker installation database settings +POSTGRES_PASSWORD=super_secret_password + +# Additional Optional Settings PAGINATION_TAKE_COUNT= STORAGE_FOLDER= AUTOSCROLL_TIMEOUT= @@ -14,12 +18,17 @@ RE_ARCHIVE_LIMIT= SPACES_KEY= SPACES_SECRET= SPACES_ENDPOINT= +SPACES_BUCKET_NAME= SPACES_REGION= +SPACES_FORCE_PATH_STYLE= # SMTP Settings NEXT_PUBLIC_EMAIL_PROVIDER= EMAIL_FROM= EMAIL_SERVER= -# Docker postgres settings -POSTGRES_PASSWORD= +# Keycloak +NEXT_PUBLIC_KEYCLOAK_ENABLED= +KEYCLOAK_ISSUER= +KEYCLOAK_CLIENT_ID= +KEYCLOAK_CLIENT_SECRET= diff --git a/README.md b/README.md index 7a1d8940..8f07c2db 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@
-[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [Support ❤](https://github.com/linkwarden/linkwarden#support-) +[Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/orgs/linkwarden/projects/1) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
@@ -21,17 +21,31 @@ Additionally, Linkwarden is designed with collaboration in mind, sharing links with the public and/or allowing multiple users to work together seamlessly. - - -> **Note** +> [!TIP] > Our official [Cloud](https://linkwarden.app/#pricing) offering provides the simplest way to begin using Linkwarden and it's the preferred choice for many due to its time-saving benefits.
Your subscription supports our hosting infrastructure and ongoing development.
Alternatively, if you prefer [self-hosting](https://docs.linkwarden.app/self-hosting/installation) Linkwarden, no problem! You'll still have access to all the premium features. + + +
+ + + + + + + + + + + +
+
A bit of a "history" Linkwarden has been completely rebuilt and redesigned from ground up, so pretty much the only thing it has in common with its predecessor is the idea behind it - bookmark management. **What happened to the old version?** -We highly recommend that you don't use the old version because it is no longer maintained and has far fewer features. However, if you still want to check it out, we've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old). +We've forked the old version from the current repository into [this repo](https://github.com/linkwarden/linkwarden-old).
@@ -41,8 +55,8 @@ We highly recommend that you don't use the old version because it is no longer m - 🏛️ Send your webpage to Wayback Machine ([archive.org](https://archive.org)) for a snapshot. (Optional) - 📂 Organize links by collection, name, description and multiple tags. - 👥 Collaborate on gathering links in a collection. -- 🔐 Customize the permissions of each member. -- 🌐 Share your collected links with the world. +- 🎛️ Customize the permissions of each member. +- 🌐 Share your collected links and preserved formats with the world. - 📌 Pin your favorite links to dashboard. - 🔍 Full text search, filter and sort for easy retrieval. - 📱 Responsive design and supports most modern browsers. @@ -50,6 +64,8 @@ We highly recommend that you don't use the old version because it is no longer m - 🧩 Browser extension, managed by the community. [Star it here!](https://github.com/linkwarden/browser-extension) - ⬇️ Import your bookmarks from other browsers. - ⚡️ Powerful API. +- 🔐 SSO and Keycloak integration. (Enterprise and Self-hosted users only) +- ✅ And many more features! ## Suggestions @@ -79,16 +95,6 @@ If you want to contribute, Thanks! Start by checking our [public roadmap](https: If you found a security vulnerability, please do **not** create a public issue, instead send an email to [security@linkwarden.app](mailto:security@linkwarden.app) stating the vulnerability. Thanks! -## Screenshots - -
- - - - - -
- ## Support ❤ Other than using our official [Cloud](https://linkwarden.app/#pricing) offering, any [donations](https://opencollective.com/linkwarden) are highly appreciated as well! diff --git a/assets/all_collections.png b/assets/all_collections.png new file mode 100644 index 00000000..2f4814db Binary files /dev/null and b/assets/all_collections.png differ diff --git a/assets/all_links.png b/assets/all_links.png new file mode 100644 index 00000000..f2dee456 Binary files /dev/null and b/assets/all_links.png differ diff --git a/assets/collaborators.png b/assets/collaborators.png deleted file mode 100644 index 0c527e36..00000000 Binary files a/assets/collaborators.png and /dev/null differ diff --git a/assets/collections.png b/assets/collections.png deleted file mode 100644 index e7abe9a6..00000000 Binary files a/assets/collections.png and /dev/null differ diff --git a/assets/dashboard.png b/assets/dashboard.png new file mode 100644 index 00000000..05c14cc7 Binary files /dev/null and b/assets/dashboard.png differ diff --git a/assets/light_dashboard.png b/assets/light_dashboard.png new file mode 100644 index 00000000..4f965911 Binary files /dev/null and b/assets/light_dashboard.png differ diff --git a/assets/light_mode.png b/assets/light_mode.png new file mode 100644 index 00000000..f5bac36d Binary files /dev/null and b/assets/light_mode.png differ diff --git a/assets/link_details.png b/assets/link_details.png deleted file mode 100644 index 418ed569..00000000 Binary files a/assets/link_details.png and /dev/null differ diff --git a/assets/manage_team.png b/assets/manage_team.png new file mode 100644 index 00000000..0b36d6cb Binary files /dev/null and b/assets/manage_team.png differ diff --git a/assets/public_page.png b/assets/public_page.png new file mode 100644 index 00000000..734f5465 Binary files /dev/null and b/assets/public_page.png differ diff --git a/assets/readable_view.png b/assets/readable_view.png new file mode 100644 index 00000000..1564cb3b Binary files /dev/null and b/assets/readable_view.png differ diff --git a/assets/showcase_image.png b/assets/showcase_image.png deleted file mode 100644 index de5efe75..00000000 Binary files a/assets/showcase_image.png and /dev/null differ diff --git a/components/DashboardItem.tsx b/components/DashboardItem.tsx index d8913982..3de882e5 100644 --- a/components/DashboardItem.tsx +++ b/components/DashboardItem.tsx @@ -20,7 +20,7 @@ export default function dashboardItem({ name, value, icon }: Props) {

{name}

-

+

{value}

diff --git a/components/FilterSearchDropdown.tsx b/components/FilterSearchDropdown.tsx index 0b32bee7..90648e05 100644 --- a/components/FilterSearchDropdown.tsx +++ b/components/FilterSearchDropdown.tsx @@ -56,7 +56,7 @@ export default function FilterSearchDropdown({ } /> setSearchFilter({ diff --git a/components/LinkCard.tsx b/components/LinkCard.tsx index 26982bf2..a3c2d724 100644 --- a/components/LinkCard.tsx +++ b/components/LinkCard.tsx @@ -150,7 +150,7 @@ export default function LinkCard({ link, count, className }: Props) { setExpandDropdown({ x: e.clientX, y: e.clientY }); }} id={"expand-dropdown" + link.id} - className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-5 top-5 z-10 duration-100 p-1" + className="text-gray-500 dark:text-gray-300 inline-flex rounded-md cursor-pointer hover:bg-slate-200 dark:hover:bg-neutral-700 absolute right-4 top-4 z-10 duration-100 p-1" > router.push("/links/" + link.id)} - className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-5" + className="flex items-start cursor-pointer gap-5 sm:gap-10 h-full w-full p-4" > {url && account.displayLinkIcons && ( { const target = e.target as HTMLElement; @@ -208,6 +208,27 @@ export default function LinkCard({ link, count, className }: Props) { {collection?.name}

+ + {/* {link.tags[0] ? ( +
+
+ {link.tags.map((e, i) => ( + { + e.stopPropagation(); + }} + className="px-2 bg-sky-200 text-black dark:text-white dark:bg-sky-900 text-xs rounded-3xl cursor-pointer hover:opacity-60 duration-100 truncate max-w-[19rem]" + > + {e.name} + + ))} +
+
+
+ ) : undefined} */} + { if (link) - setLinkCollection(collections.find((e) => e.id === link?.collection.id)); + setLinkCollection(collections.find((e) => e.id === link?.collection?.id)); }, [link]); return ( diff --git a/components/Modal/Collection/CollectionInfo.tsx b/components/Modal/Collection/CollectionInfo.tsx index f0e71b2d..962e9ee7 100644 --- a/components/Modal/Collection/CollectionInfo.tsx +++ b/components/Modal/Collection/CollectionInfo.tsx @@ -18,7 +18,7 @@ type Props = { SetStateAction >; collection: CollectionIncludingMembersAndLinkCount; - method: "CREATE" | "UPDATE"; + method: "CREATE" | "UPDATE" | "VIEW_TEAM"; }; export default function CollectionInfo({ diff --git a/components/Modal/Collection/ViewTeam.tsx b/components/Modal/Collection/ViewTeam.tsx new file mode 100644 index 00000000..898d18bf --- /dev/null +++ b/components/Modal/Collection/ViewTeam.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faCrown } from "@fortawesome/free-solid-svg-icons"; +import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; +import ProfilePhoto from "@/components/ProfilePhoto"; +import getPublicUserData from "@/lib/client/getPublicUserData"; + +type Props = { + collection: CollectionIncludingMembersAndLinkCount; +}; + +export default function ViewTeam({ collection }: Props) { + const [collectionOwner, setCollectionOwner] = useState({ + id: null, + name: "", + username: "", + image: "", + }); + + useEffect(() => { + const fetchOwner = async () => { + const owner = await getPublicUserData(collection.ownerId as number); + setCollectionOwner(owner); + }; + + fetchOwner(); + }, []); + + return ( +
+

Team

+ +

Here are all the members who are collaborating on this collection.

+ +
+
+ +
+
+

+ {collectionOwner.name} +

+
+ + Admin +
+
+

+ @{collectionOwner.username} +

+
+
+
+ + {collection?.members[0]?.user && ( + <> +
+ {collection.members + .sort((a, b) => (a.userId as number) - (b.userId as number)) + .map((e, i) => { + return ( +
+
+ +
+

+ {e.user.name} +

+

+ @{e.user.username} +

+
+
+
+ ); + })} +
+ + )} +
+ ); +} diff --git a/components/Modal/Collection/index.tsx b/components/Modal/Collection/index.tsx index 747a7420..a535b309 100644 --- a/components/Modal/Collection/index.tsx +++ b/components/Modal/Collection/index.tsx @@ -4,6 +4,7 @@ import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import TeamManagement from "./TeamManagement"; import { useState } from "react"; import DeleteCollection from "./DeleteCollection"; +import ViewTeam from "./ViewTeam"; type Props = | { @@ -21,6 +22,14 @@ type Props = isOwner: boolean; className?: string; defaultIndex?: number; + } + | { + toggleCollectionModal: Function; + activeCollection: CollectionIncludingMembersAndLinkCount; + method: "VIEW_TEAM"; + isOwner: boolean; + className?: string; + defaultIndex?: number; }; export default function CollectionModal({ @@ -46,14 +55,25 @@ export default function CollectionModal({
{method === "CREATE" && ( -

+

New Collection

)} - - {method === "UPDATE" && ( - <> - {isOwner && ( + {method !== "VIEW_TEAM" && ( + + {method === "UPDATE" && ( + <> + {isOwner && ( + + selected + ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none" + : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" + } + > + Collection Info + + )} selected @@ -61,30 +81,21 @@ export default function CollectionModal({ : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" } > - Collection Info + {isOwner ? "Share & Collaborate" : "View Team"} - )} - - selected - ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none" - : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" - } - > - {isOwner ? "Share & Collaborate" : "View Team"} - - - selected - ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none" - : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" - } - > - {isOwner ? "Delete Collection" : "Leave Collection"} - - - )} - + + selected + ? "px-2 py-1 bg-sky-200 dark:bg-sky-800 dark:text-white duration-100 rounded-md outline-none" + : "px-2 py-1 hover:bg-slate-200 hover:dark:bg-neutral-700 hover:dark:text-white rounded-md duration-100 outline-none" + } + > + {isOwner ? "Delete Collection" : "Leave Collection"} + + + )} + + )} {(isOwner || method === "CREATE") && ( @@ -115,6 +126,14 @@ export default function CollectionModal({ )} + + {method === "VIEW_TEAM" && ( + <> + + + + + )}
diff --git a/components/Modal/Link/PreservedFormats.tsx b/components/Modal/Link/PreservedFormats.tsx index 1a6712b1..410d3a1e 100644 --- a/components/Modal/Link/PreservedFormats.tsx +++ b/components/Modal/Link/PreservedFormats.tsx @@ -1,4 +1,7 @@ -import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import { + ArchivedFormat, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; import { useEffect, useState } from "react"; import Link from "next/link"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -27,7 +30,14 @@ export default function PreservedFormats() { useEffect(() => { let interval: NodeJS.Timer | undefined; if (link?.screenshotPath === "pending" || link?.pdfPath === "pending") { - interval = setInterval(() => getLink(link.id as number), 5000); + let isPublicRoute = router.pathname.startsWith("/public") + ? true + : undefined; + + interval = setInterval( + () => getLink(link.id as number, isPublicRoute), + 5000 + ); } else { if (interval) { clearInterval(interval); @@ -58,15 +68,16 @@ export default function PreservedFormats() { } else toast.error(data.response); }; - const handleDownload = (format: "png" | "pdf") => { - const path = `/api/v1/archives/${link?.collection.id}/${link?.id}.${format}`; + const handleDownload = (format: ArchivedFormat) => { + const path = `/api/v1/archives/${link?.id}?format=${format}`; fetch(path) .then((response) => { if (response.ok) { // Create a temporary link and click it to trigger the download const link = document.createElement("a"); link.href = path; - link.download = format === "pdf" ? "PDF" : "Screenshot"; + link.download = + format === ArchivedFormat.screenshot ? "Screenshot" : "PDF"; link.click(); } else { console.error("Failed to download file"); @@ -91,7 +102,7 @@ export default function PreservedFormats() {
handleDownload("png")} + onClick={() => handleDownload(ArchivedFormat.screenshot)} className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" > @@ -126,7 +137,7 @@ export default function PreservedFormats() {
handleDownload("pdf")} + onClick={() => handleDownload(ArchivedFormat.pdf)} className="cursor-pointer hover:opacity-60 duration-100 p-2 rounded-md" > @@ -163,7 +174,7 @@ export default function PreservedFormats() { onClick={() => updateArchive()} >

Update Preserved Formats

-

(Refresh Formats)

+

(Refresh Link)

) : undefined}
- +
{ @@ -76,6 +77,9 @@ export default function Navbar() { New Link
+ + +

{account.name}

diff --git a/components/ProfilePhoto.tsx b/components/ProfilePhoto.tsx index 8575d6d8..993eb40d 100644 --- a/components/ProfilePhoto.tsx +++ b/components/ProfilePhoto.tsx @@ -38,6 +38,7 @@ export default function ProfilePhoto({ src, className, priority }: Props) { width={112} priority={priority} draggable={false} + onError={() => setImage("")} className={`h-10 w-10 bg-sky-600 dark:bg-sky-600 shadow rounded-full aspect-square border border-slate-200 dark:border-neutral-700 ${ className || "" }`} diff --git a/components/PublicPage/LinkCard.tsx b/components/PublicPage/LinkCard.tsx deleted file mode 100644 index 3be19727..00000000 --- a/components/PublicPage/LinkCard.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Image from "next/image"; -import { Link as LinkType, Tag } from "@prisma/client"; -import isValidUrl from "@/lib/client/isValidUrl"; -import unescapeString from "@/lib/client/unescapeString"; - -interface LinksIncludingTags extends LinkType { - tags: Tag[]; -} - -type Props = { - link: LinksIncludingTags; - count: number; -}; - -export default function LinkCard({ link, count }: Props) { - const url = isValidUrl(link.url) ? new URL(link.url) : undefined; - - const formattedDate = new Date( - link.createdAt as unknown as string - ).toLocaleString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - }); - - return ( - -
- {url && ( - <> - { - const target = e.target as HTMLElement; - target.style.display = "none"; - }} - /> - { - const target = e.target as HTMLElement; - target.style.display = "none"; - }} - /> - - )} -
-
-
-

{count + 1}

-

- {unescapeString(link.name || link.description)} -

-
- -

- {unescapeString(link.description)} -

-
-
- {link.tags.map((e, i) => ( -

- {e.name} -

- ))} -
-
-
-

{formattedDate}

-
-

{url ? url.host : link.url}

-
-
-
-
- -
-
-
-
- ); -} diff --git a/components/PublicPage/PublicLinkCard.tsx b/components/PublicPage/PublicLinkCard.tsx new file mode 100644 index 00000000..b31190ed --- /dev/null +++ b/components/PublicPage/PublicLinkCard.tsx @@ -0,0 +1,96 @@ +import { faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import Image from "next/image"; +import { Link as LinkType, Tag } from "@prisma/client"; +import isValidUrl from "@/lib/client/isValidUrl"; +import unescapeString from "@/lib/client/unescapeString"; +import { TagIncludingLinkCount } from "@/types/global"; +import Link from "next/link"; + +interface LinksIncludingTags extends LinkType { + tags: TagIncludingLinkCount[]; +} + +type Props = { + link: LinksIncludingTags; + count: number; +}; + +export default function LinkCard({ link, count }: Props) { + const url = isValidUrl(link.url) ? new URL(link.url) : undefined; + + const formattedDate = new Date( + link.createdAt as unknown as string + ).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + + return ( +
+
+
+
+

+ {url && ( + { + const target = e.target as HTMLElement; + target.style.display = "none"; + }} + /> + )} + {unescapeString(link.name || link.description)} +

+
+ +
+
+ {link.tags.map((e, i) => ( + + {e.name} + + ))} +
+
+
+

{formattedDate}

+

·

+ + {url ? url.host : link.url} + +
+
+ {unescapeString(link.description)}{" "} + +

Read

+ + +
+
+
+
+ ); +} diff --git a/components/PublicPage/PublicSearchBar.tsx b/components/PublicPage/PublicSearchBar.tsx new file mode 100644 index 00000000..525daa37 --- /dev/null +++ b/components/PublicPage/PublicSearchBar.tsx @@ -0,0 +1,59 @@ +import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { toast } from "react-hot-toast"; + +type Props = { + placeHolder?: string; +}; + +export default function PublicSearchBar({ placeHolder }: Props) { + const router = useRouter(); + + const [searchQuery, setSearchQuery] = useState(""); + + useEffect(() => { + router.query.q + ? setSearchQuery(decodeURIComponent(router.query.q as string)) + : setSearchQuery(""); + }, [router.query.q]); + + return ( +
+ + + { + e.target.value.includes("%") && + toast.error("The search query should not contain '%'."); + setSearchQuery(e.target.value.replace("%", "")); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (!searchQuery) { + return router.push("/public/collections/" + router.query.id); + } + + return router.push( + "/public/collections/" + + router.query.id + + "?q=" + + encodeURIComponent(searchQuery || "") + ); + } + }} + className="border text-sm border-sky-100 bg-gray-50 dark:border-neutral-700 focus:border-sky-300 dark:focus:border-sky-600 rounded-md pl-8 py-2 pr-2 w-44 sm:w-60 dark:hover:border-neutral-600 md:focus:w-80 hover:border-sky-300 duration-100 outline-none dark:bg-neutral-800" + /> +
+ ); +} diff --git a/components/Search.tsx b/components/SearchBar.tsx similarity index 82% rename from components/Search.tsx rename to components/SearchBar.tsx index 842b205a..df0b9a46 100644 --- a/components/Search.tsx +++ b/components/SearchBar.tsx @@ -4,24 +4,17 @@ import { useState } from "react"; import { useRouter } from "next/router"; import { toast } from "react-hot-toast"; -export default function Search() { +export default function SearchBar() { const router = useRouter(); - const routeQuery = router.query.query; + const routeQuery = router.query.q; const [searchQuery, setSearchQuery] = useState( routeQuery ? decodeURIComponent(routeQuery as string) : "" ); - const [searchBox, setSearchBox] = useState( - router.pathname.startsWith("/search") || false - ); - return ( -
setSearchBox(true)} - > +
diff --git a/components/SettingsSidebar.tsx b/components/SettingsSidebar.tsx index b985f9f5..b7b1e271 100644 --- a/components/SettingsSidebar.tsx +++ b/components/SettingsSidebar.tsx @@ -5,6 +5,7 @@ import { faPalette, faBoxArchive, faKey, + faLock, } from "@fortawesome/free-solid-svg-icons"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -20,7 +21,7 @@ import { } from "@fortawesome/free-brands-svg-icons"; export default function SettingsSidebar({ className }: { className?: string }) { - const LINKWARDEN_VERSION = "v2.2.1"; + const LINKWARDEN_VERSION = "v2.3.0"; const { collections } = useCollectionStore(); @@ -96,6 +97,23 @@ export default function SettingsSidebar({ className }: { className?: string }) {
+ +
+ + +

+ API Keys +

+
+ +
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 366c28c7..78a31a2d 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -176,6 +176,9 @@ export default function Sidebar({ className }: { className?: string }) { className="w-4 h-4 drop-shadow text-gray-500 dark:text-gray-300" /> ) : undefined} +
+ {e._count?.links} +
); @@ -235,6 +238,9 @@ export default function Sidebar({ className }: { className?: string }) {

{e.name}

+
+ {e._count?.links} +
); diff --git a/components/ToggleDarkMode.tsx b/components/ToggleDarkMode.tsx index 0ba36b8a..cff56c07 100644 --- a/components/ToggleDarkMode.tsx +++ b/components/ToggleDarkMode.tsx @@ -2,7 +2,11 @@ import { useTheme } from "next-themes"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons"; -export default function ToggleDarkMode() { +type Props = { + className?: string; +}; + +export default function ToggleDarkMode({ className }: Props) { const { theme, setTheme } = useTheme(); const handleToggle = () => { @@ -15,15 +19,13 @@ export default function ToggleDarkMode() { return (
-
- -
+
); } diff --git a/docker-compose.yml b/docker-compose.yml index 2a400dff..f1ff559d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.5" services: postgres: - image: postgres + image: postgres:16-alpine env_file: .env restart: always volumes: diff --git a/hooks/useLinks.tsx b/hooks/useLinks.tsx index 60f43dbd..77e4dce7 100644 --- a/hooks/useLinks.tsx +++ b/hooks/useLinks.tsx @@ -50,13 +50,17 @@ export default function useLinks( .join("&"); }; - const queryString = buildQueryString(params); + let queryString = buildQueryString(params); - const response = await fetch( - `/api/v1/${ - router.asPath === "/dashboard" ? "dashboard" : "links" - }?${queryString}` - ); + let basePath; + + if (router.pathname === "/dashboard") basePath = "/api/v1/dashboard"; + else if (router.pathname.startsWith("/public/collections/[id]")) { + queryString = queryString + "&collectionId=" + router.query.id; + basePath = "/api/v1/public/collections/links"; + } else basePath = "/api/v1/links"; + + const response = await fetch(`${basePath}?${queryString}`); const data = await response.json(); diff --git a/layouts/LinkLayout.tsx b/layouts/LinkLayout.tsx index b2c135ee..b1bc5118 100644 --- a/layouts/LinkLayout.tsx +++ b/layouts/LinkLayout.tsx @@ -5,8 +5,7 @@ import useModalStore from "@/store/modals"; import { useRouter } from "next/router"; import ClickAwayHandler from "@/components/ClickAwayHandler"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBars, faChevronLeft } from "@fortawesome/free-solid-svg-icons"; -import Link from "next/link"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; import useWindowDimensions from "@/hooks/useWindowDimensions"; import { faPen, @@ -66,22 +65,22 @@ export default function LinkLayout({ children }: Props) { const [link, setLink] = useState(); useEffect(() => { - if (links) setLink(links.find((e) => e.id === Number(router.query.id))); + if (links[0]) setLink(links.find((e) => e.id === Number(router.query.id))); }, [links]); useEffect(() => { if (link) - setLinkCollection(collections.find((e) => e.id === link?.collection.id)); - }, [link]); + setLinkCollection(collections.find((e) => e.id === link?.collection?.id)); + }, [link, collections]); return ( <>
-
+ {/*
-
+
*/}
@@ -93,84 +92,97 @@ export default function LinkLayout({ children }: Props) {
*/}
router.push(`/collections/${linkCollection?.id}`)} + onClick={() => { + if (router.pathname.startsWith("/public")) { + router.push( + `/public/collections/${ + linkCollection?.id || link?.collection.id + }` + ); + } else { + router.push(`/dashboard`); + } + }} className="inline-flex gap-1 hover:opacity-60 items-center select-none cursor-pointer p-2 lg:p-0 lg:px-1 lg:my-2 text-gray-500 dark:text-gray-300 rounded-md duration-100" > Back{" "} - to {linkCollection?.name} + to{" "} + + {router.pathname.startsWith("/public") + ? linkCollection?.name || link?.collection?.name + : "Dashboard"} +
-
-
- {link?.collection.ownerId === userId || - linkCollection?.members.some( - (e) => e.userId === userId && e.canUpdate - ) ? ( -
{ - link - ? setModal({ - modal: "LINK", - state: true, - active: link, - method: "UPDATE", - }) - : undefined; - }} - className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} - > - -
- ) : undefined} - +
+ {link?.collection?.ownerId === userId || + linkCollection?.members.some( + (e) => e.userId === userId && e.canUpdate + ) ? (
{ link ? setModal({ modal: "LINK", state: true, active: link, - method: "FORMATS", + method: "UPDATE", }) : undefined; }} - title="Preserved Formats" className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} >
+ ) : undefined} - {link?.collection.ownerId === userId || - linkCollection?.members.some( - (e) => e.userId === userId && e.canDelete - ) ? ( -
{ - if (link?.id) { - removeLink(link.id); - router.back(); - } - }} - title="Delete" - className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} - > - -
- ) : undefined} +
{ + link + ? setModal({ + modal: "LINK", + state: true, + active: link, + method: "FORMATS", + }) + : undefined; + }} + title="Preserved Formats" + className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + > +
+ + {link?.collection?.ownerId === userId || + linkCollection?.members.some( + (e) => e.userId === userId && e.canDelete + ) ? ( +
{ + if (link?.id) { + removeLink(link.id); + router.back(); + } + }} + title="Delete" + className={`hover:opacity-60 duration-100 py-2 px-2 cursor-pointer flex items-center gap-4 w-full rounded-md h-8`} + > + +
+ ) : undefined}
diff --git a/layouts/SettingsLayout.tsx b/layouts/SettingsLayout.tsx index 0989cebf..7a9a2af2 100644 --- a/layouts/SettingsLayout.tsx +++ b/layouts/SettingsLayout.tsx @@ -49,8 +49,8 @@ export default function SettingsLayout({ children }: Props) {
-
-
+
+
- -

- {router.asPath.split("/").pop()} Settings -

-
- {children} {sidebar ? ( diff --git a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts index 792f9fa6..03870aad 100644 --- a/lib/api/controllers/collections/collectionId/deleteCollectionById.ts +++ b/lib/api/controllers/collections/collectionId/deleteCollectionById.ts @@ -10,14 +10,10 @@ export default async function deleteCollection( if (!collectionId) return { response: "Please choose a valid collection.", status: 401 }; - const collectionIsAccessible = (await getPermission({ + const collectionIsAccessible = await getPermission({ userId, collectionId, - })) as - | (Collection & { - members: UsersAndCollections[]; - }) - | null; + }); const memberHasAccess = collectionIsAccessible?.members.some( (e: UsersAndCollections) => e.userId === userId diff --git a/lib/api/controllers/collections/collectionId/updateCollectionById.ts b/lib/api/controllers/collections/collectionId/updateCollectionById.ts index 30bb6012..f2570219 100644 --- a/lib/api/controllers/collections/collectionId/updateCollectionById.ts +++ b/lib/api/controllers/collections/collectionId/updateCollectionById.ts @@ -11,14 +11,10 @@ export default async function updateCollection( if (!collectionId) return { response: "Please choose a valid collection.", status: 401 }; - const collectionIsAccessible = (await getPermission({ + const collectionIsAccessible = await getPermission({ userId, collectionId, - })) as - | (Collection & { - members: UsersAndCollections[]; - }) - | null; + }); if (!(collectionIsAccessible?.ownerId === userId)) return { response: "Collection is not accessible.", status: 401 }; diff --git a/lib/api/controllers/links/linkId/deleteLinkById.ts b/lib/api/controllers/links/linkId/deleteLinkById.ts index 8a56656f..90adba40 100644 --- a/lib/api/controllers/links/linkId/deleteLinkById.ts +++ b/lib/api/controllers/links/linkId/deleteLinkById.ts @@ -6,11 +6,7 @@ import removeFile from "@/lib/api/storage/removeFile"; export default async function deleteLink(userId: number, linkId: number) { if (!linkId) return { response: "Please choose a valid link.", status: 401 }; - const collectionIsAccessible = (await getPermission({ userId, linkId })) as - | (Collection & { - members: UsersAndCollections[]; - }) - | null; + const collectionIsAccessible = await getPermission({ userId, linkId }); const memberHasAccess = collectionIsAccessible?.members.some( (e: UsersAndCollections) => e.userId === userId && e.canDelete diff --git a/lib/api/controllers/links/linkId/getLinkById.ts b/lib/api/controllers/links/linkId/getLinkById.ts index 83a3cebd..e344ae4f 100644 --- a/lib/api/controllers/links/linkId/getLinkById.ts +++ b/lib/api/controllers/links/linkId/getLinkById.ts @@ -1,5 +1,5 @@ import { prisma } from "@/lib/api/db"; -import { Collection, Link, UsersAndCollections } from "@prisma/client"; +import { Collection, UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; export default async function getLinkById(userId: number, linkId: number) { @@ -9,11 +9,7 @@ export default async function getLinkById(userId: number, linkId: number) { status: 401, }; - const collectionIsAccessible = (await getPermission({ userId, linkId })) as - | (Collection & { - members: UsersAndCollections[]; - }) - | null; + const collectionIsAccessible = await getPermission({ userId, linkId }); const memberHasAccess = collectionIsAccessible?.members.some( (e: UsersAndCollections) => e.userId === userId @@ -27,7 +23,7 @@ export default async function getLinkById(userId: number, linkId: number) { status: 401, }; else { - const updatedLink = await prisma.link.findUnique({ + const link = await prisma.link.findUnique({ where: { id: linkId, }, @@ -43,6 +39,6 @@ export default async function getLinkById(userId: number, linkId: number) { }, }); - return { response: updatedLink, status: 200 }; + return { response: link, status: 200 }; } } diff --git a/lib/api/controllers/links/linkId/updateLinkById.ts b/lib/api/controllers/links/linkId/updateLinkById.ts index 41fb54bc..7f7fb2eb 100644 --- a/lib/api/controllers/links/linkId/updateLinkById.ts +++ b/lib/api/controllers/links/linkId/updateLinkById.ts @@ -15,11 +15,7 @@ export default async function updateLinkById( status: 401, }; - const collectionIsAccessible = (await getPermission({ userId, linkId })) as - | (Collection & { - members: UsersAndCollections[]; - }) - | null; + const collectionIsAccessible = await getPermission({ userId, linkId }); const memberHasAccess = collectionIsAccessible?.members.some( (e: UsersAndCollections) => e.userId === userId && e.canUpdate diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index a147814b..b93eeef7 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import getTitle from "@/lib/api/getTitle"; import archive from "@/lib/api/archive"; -import { Collection, UsersAndCollections } from "@prisma/client"; +import { UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; import createFolder from "@/lib/api/storage/createFolder"; @@ -27,14 +27,10 @@ export default async function postLink( link.collection.name = link.collection.name.trim(); if (link.collection.id) { - const collectionIsAccessible = (await getPermission({ + const collectionIsAccessible = await getPermission({ userId, collectionId: link.collection.id, - })) as - | (Collection & { - members: UsersAndCollections[]; - }) - | null; + }); const memberHasAccess = collectionIsAccessible?.members.some( (e: UsersAndCollections) => e.userId === userId && e.canCreate diff --git a/lib/api/controllers/public/collections/getPublicCollection.ts b/lib/api/controllers/public/collections/getPublicCollection.ts new file mode 100644 index 00000000..6c5de3a6 --- /dev/null +++ b/lib/api/controllers/public/collections/getPublicCollection.ts @@ -0,0 +1,32 @@ +import { prisma } from "@/lib/api/db"; + +export default async function getPublicCollection(id: number) { + const collection = await prisma.collection.findFirst({ + where: { + id, + isPublic: true, + }, + include: { + members: { + include: { + user: { + select: { + username: true, + name: true, + image: true, + }, + }, + }, + }, + _count: { + select: { links: true }, + }, + }, + }); + + if (collection) { + return { response: collection, status: 200 }; + } else { + return { response: "Collection not found.", status: 400 }; + } +} diff --git a/lib/api/controllers/public/getCollection.ts b/lib/api/controllers/public/getCollection.ts deleted file mode 100644 index a9a27532..00000000 --- a/lib/api/controllers/public/getCollection.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { prisma } from "@/lib/api/db"; -import { PublicLinkRequestQuery } from "@/types/global"; - -export default async function getCollection(body: string) { - const query: PublicLinkRequestQuery = JSON.parse(decodeURIComponent(body)); - console.log(query); - - let data; - - const collection = await prisma.collection.findFirst({ - where: { - id: query.collectionId, - isPublic: true, - }, - }); - - if (collection) { - const links = await prisma.link.findMany({ - take: Number(process.env.PAGINATION_TAKE_COUNT) || 20, - skip: query.cursor ? 1 : undefined, - cursor: query.cursor - ? { - id: query.cursor, - } - : undefined, - where: { - collection: { - id: query.collectionId, - }, - }, - include: { - tags: true, - }, - orderBy: { - createdAt: "desc", - }, - }); - - data = { ...collection, links: [...links] }; - - return { response: data, status: 200 }; - } else { - return { response: "Collection not found...", status: 400 }; - } -} diff --git a/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts b/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts new file mode 100644 index 00000000..f4113b67 --- /dev/null +++ b/lib/api/controllers/public/links/getPublicLinksUnderCollection.ts @@ -0,0 +1,88 @@ +import { prisma } from "@/lib/api/db"; +import { LinkRequestQuery, Sort } from "@/types/global"; + +export default async function getLink( + query: Omit +) { + const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql"); + + let order: any; + if (query.sort === Sort.DateNewestFirst) order = { createdAt: "desc" }; + else if (query.sort === Sort.DateOldestFirst) order = { createdAt: "asc" }; + else if (query.sort === Sort.NameAZ) order = { name: "asc" }; + else if (query.sort === Sort.NameZA) order = { name: "desc" }; + else if (query.sort === Sort.DescriptionAZ) order = { description: "asc" }; + else if (query.sort === Sort.DescriptionZA) order = { description: "desc" }; + + const searchConditions = []; + + if (query.searchQueryString) { + if (query.searchByName) { + searchConditions.push({ + name: { + contains: query.searchQueryString, + mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined, + }, + }); + } + + if (query.searchByUrl) { + searchConditions.push({ + url: { + contains: query.searchQueryString, + mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined, + }, + }); + } + + if (query.searchByDescription) { + searchConditions.push({ + description: { + contains: query.searchQueryString, + mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined, + }, + }); + } + + if (query.searchByTextContent) { + searchConditions.push({ + textContent: { + contains: query.searchQueryString, + mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined, + }, + }); + } + + if (query.searchByTags) { + searchConditions.push({ + tags: { + some: { + name: { + contains: query.searchQueryString, + mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined, + }, + }, + }, + }); + } + } + + const links = await prisma.link.findMany({ + take: Number(process.env.PAGINATION_TAKE_COUNT) || 20, + skip: query.cursor ? 1 : undefined, + cursor: query.cursor ? { id: query.cursor } : undefined, + where: { + collection: { + id: query.collectionId, + isPublic: true, + }, + [query.searchQueryString ? "OR" : "AND"]: [...searchConditions], + }, + include: { + tags: true, + }, + orderBy: order || { createdAt: "desc" }, + }); + + return { response: links, status: 200 }; +} diff --git a/lib/api/controllers/public/links/linkId/getLinkById.ts b/lib/api/controllers/public/links/linkId/getLinkById.ts new file mode 100644 index 00000000..2e1d87d6 --- /dev/null +++ b/lib/api/controllers/public/links/linkId/getLinkById.ts @@ -0,0 +1,24 @@ +import { prisma } from "@/lib/api/db"; + +export default async function getLinkById(linkId: number) { + if (!linkId) + return { + response: "Please choose a valid link.", + status: 401, + }; + + const link = await prisma.link.findFirst({ + where: { + id: linkId, + collection: { + isPublic: true, + }, + }, + include: { + tags: true, + collection: true, + }, + }); + + return { response: link, status: 200 }; +} diff --git a/lib/api/controllers/public/users/getPublicUserById.ts b/lib/api/controllers/public/users/getPublicUser.ts similarity index 77% rename from lib/api/controllers/public/users/getPublicUserById.ts rename to lib/api/controllers/public/users/getPublicUser.ts index 8f7ea48c..8e178874 100644 --- a/lib/api/controllers/public/users/getPublicUserById.ts +++ b/lib/api/controllers/public/users/getPublicUser.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/api/db"; -export default async function getPublicUserById( +export default async function getPublicUser( targetId: number | string, isId: boolean, requestingId?: number @@ -31,13 +31,16 @@ export default async function getPublicUserById( if (user?.isPrivate) { if (requestingId) { - const requestingUsername = ( - await prisma.user.findUnique({ where: { id: requestingId } }) - )?.username; + const requestingUser = await prisma.user.findUnique({ + where: { id: requestingId }, + }); if ( - !requestingUsername || - !whitelistedUsernames.includes(requestingUsername?.toLowerCase()) + requestingUser?.id !== requestingId && + (!requestingUser?.username || + !whitelistedUsernames.includes( + requestingUser.username?.toLowerCase() + )) ) { return { response: "User not found or profile is private.", diff --git a/lib/api/controllers/tags/getTags.ts b/lib/api/controllers/tags/getTags.ts index e1b007f2..85a8d179 100644 --- a/lib/api/controllers/tags/getTags.ts +++ b/lib/api/controllers/tags/getTags.ts @@ -30,6 +30,11 @@ export default async function getTags(userId: number) { }, ], }, + include: { + _count: { + select: { links: true }, + }, + }, // orderBy: { // links: { // _count: "desc", diff --git a/lib/api/controllers/users/postUser.ts b/lib/api/controllers/users/postUser.ts index d9f24f94..f24738bb 100644 --- a/lib/api/controllers/users/postUser.ts +++ b/lib/api/controllers/users/postUser.ts @@ -30,6 +30,11 @@ export default async function postUser( ? !body.password || !body.name || !body.email : !body.username || !body.password || !body.name; + if (!body.password || body.password.length < 8) + return res + .status(400) + .json({ response: "Password must be at least 8 characters." }); + if (checkHasEmptyFields) return res .status(400) diff --git a/lib/api/controllers/users/userId/deleteUserById.ts b/lib/api/controllers/users/userId/deleteUserById.ts index f072e65e..c32b1fb3 100644 --- a/lib/api/controllers/users/userId/deleteUserById.ts +++ b/lib/api/controllers/users/userId/deleteUserById.ts @@ -21,14 +21,19 @@ export default async function deleteUserById( }; } - // Then, we check if the provided password matches the one stored in the database - const isPasswordValid = bcrypt.compareSync(body.password, user.password); + // Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration) + if (!process.env.KEYCLOAK_CLIENT_SECRET) { + const isPasswordValid = bcrypt.compareSync( + body.password, + user.password as string + ); - if (!isPasswordValid) { - return { - response: "Invalid credentials.", - status: 401, // Unauthorized - }; + if (!isPasswordValid) { + return { + response: "Invalid credentials.", + status: 401, // Unauthorized + }; + } } // Delete the user and all related data within a transaction diff --git a/lib/api/controllers/users/userId/updateUserById.ts b/lib/api/controllers/users/userId/updateUserById.ts index 3c4e03e1..1d082039 100644 --- a/lib/api/controllers/users/userId/updateUserById.ts +++ b/lib/api/controllers/users/userId/updateUserById.ts @@ -23,6 +23,11 @@ export default async function updateUserById( response: "Username invalid.", status: 400, }; + if (data.newPassword && data.newPassword?.length < 8) + return { + response: "Password must be at least 8 characters.", + status: 400, + }; // Check email (if enabled) const checkEmail = diff --git a/lib/api/getPermission.ts b/lib/api/getPermission.ts index 3d1b2bc8..61dc5c50 100644 --- a/lib/api/getPermission.ts +++ b/lib/api/getPermission.ts @@ -27,10 +27,8 @@ export default async function getPermission({ } else if (collectionId) { const check = await prisma.collection.findFirst({ where: { - AND: { - id: collectionId, - OR: [{ ownerId: userId }, { members: { some: { userId } } }], - }, + id: collectionId, + OR: [{ ownerId: userId }, { members: { some: { userId } } }], }, include: { members: true }, }); diff --git a/lib/api/storage/createFile.ts b/lib/api/storage/createFile.ts index 8f55b451..31554fba 100644 --- a/lib/api/storage/createFile.ts +++ b/lib/api/storage/createFile.ts @@ -14,7 +14,7 @@ export default async function createFile({ }) { if (s3Client) { const bucketParams: PutObjectCommandInput = { - Bucket: process.env.BUCKET_NAME, + Bucket: process.env.SPACES_BUCKET_NAME, Key: filePath, Body: isBase64 ? Buffer.from(data as string, "base64") : data, }; diff --git a/lib/api/storage/moveFile.ts b/lib/api/storage/moveFile.ts index bbd88874..c86728fd 100644 --- a/lib/api/storage/moveFile.ts +++ b/lib/api/storage/moveFile.ts @@ -5,7 +5,7 @@ import removeFile from "./removeFile"; export default async function moveFile(from: string, to: string) { if (s3Client) { - const Bucket = process.env.BUCKET_NAME; + const Bucket = process.env.SPACES_BUCKET_NAME; const copyParams = { Bucket: Bucket, diff --git a/lib/api/storage/readFile.ts b/lib/api/storage/readFile.ts index 01e6fdab..64ff8d7a 100644 --- a/lib/api/storage/readFile.ts +++ b/lib/api/storage/readFile.ts @@ -20,7 +20,7 @@ export default async function readFile(filePath: string) { if (s3Client) { const bucketParams: GetObjectCommandInput = { - Bucket: process.env.BUCKET_NAME, + Bucket: process.env.SPACES_BUCKET_NAME, Key: filePath, }; diff --git a/lib/api/storage/removeFile.ts b/lib/api/storage/removeFile.ts index 491e24c7..e332829c 100644 --- a/lib/api/storage/removeFile.ts +++ b/lib/api/storage/removeFile.ts @@ -6,7 +6,7 @@ import { PutObjectCommandInput, DeleteObjectCommand } from "@aws-sdk/client-s3"; export default async function removeFile({ filePath }: { filePath: string }) { if (s3Client) { const bucketParams: PutObjectCommandInput = { - Bucket: process.env.BUCKET_NAME, + Bucket: process.env.SPACES_BUCKET_NAME, Key: filePath, }; diff --git a/lib/api/storage/removeFolder.ts b/lib/api/storage/removeFolder.ts index 7383b884..e9f7d3b3 100644 --- a/lib/api/storage/removeFolder.ts +++ b/lib/api/storage/removeFolder.ts @@ -40,7 +40,7 @@ async function emptyS3Directory(bucket: string, dir: string) { export default async function removeFolder({ filePath }: { filePath: string }) { if (s3Client) { try { - await emptyS3Directory(process.env.BUCKET_NAME as string, filePath); + await emptyS3Directory(process.env.SPACES_BUCKET_NAME as string, filePath); } catch (err) { console.log("Error", err); } diff --git a/lib/api/storage/s3Client.ts b/lib/api/storage/s3Client.ts index 8b0ccd55..cebba5ae 100644 --- a/lib/api/storage/s3Client.ts +++ b/lib/api/storage/s3Client.ts @@ -6,7 +6,7 @@ const s3Client: S3 | undefined = process.env.SPACES_KEY && process.env.SPACES_SECRET ? new S3({ - forcePathStyle: false, + forcePathStyle: !!process.env.SPACES_FORCE_PATH_STYLE, endpoint: process.env.SPACES_ENDPOINT, region: process.env.SPACES_REGION, credentials: { diff --git a/lib/client/getPublicCollectionData.ts b/lib/client/getPublicCollectionData.ts index b86c173f..283733ba 100644 --- a/lib/client/getPublicCollectionData.ts +++ b/lib/client/getPublicCollectionData.ts @@ -1,33 +1,17 @@ -import { - PublicCollectionIncludingLinks, - PublicLinkRequestQuery, -} from "@/types/global"; +import { CollectionIncludingMembersAndLinkCount } from "@/types/global"; import { Dispatch, SetStateAction } from "react"; const getPublicCollectionData = async ( collectionId: number, - prevData: PublicCollectionIncludingLinks, - setData: Dispatch> + setData: Dispatch< + SetStateAction + > ) => { - const requestBody: PublicLinkRequestQuery = { - cursor: prevData?.links?.at(-1)?.id, - collectionId, - }; - - const encodedData = encodeURIComponent(JSON.stringify(requestBody)); - - const res = await fetch( - "/api/v1/public/collections?body=" + encodeURIComponent(encodedData) - ); + const res = await fetch("/api/v1/public/collections/" + collectionId); const data = await res.json(); - prevData - ? setData({ - ...data.response, - links: [...prevData.links, ...data.response.links], - }) - : setData(data.response); + setData(data.response); return data; }; diff --git a/package.json b/package.json index a77e2113..53a75eef 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "@fortawesome/react-fontawesome": "^0.2.0", "@headlessui/react": "^1.7.15", "@mozilla/readability": "^0.4.4", - "@next/font": "13.4.9", "@prisma/client": "^4.16.2", "@stripe/stripe-js": "^1.54.1", "@types/crypto-js": "^4.1.1", @@ -40,6 +39,7 @@ "eslint-config-next": "13.4.9", "framer-motion": "^10.16.4", "jsdom": "^22.1.0", + "lottie-web": "^5.12.2", "micro": "^10.0.1", "next": "13.4.12", "next-auth": "^4.22.1", @@ -63,6 +63,7 @@ "@types/dompurify": "^3.0.4", "@types/jsdom": "^21.1.3", "autoprefixer": "^10.4.14", + "daisyui": "^4.4.2", "postcss": "^8.4.26", "prisma": "^5.1.0", "tailwindcss": "^3.3.3" diff --git a/pages/api/v1/archives/[...params].ts b/pages/api/v1/archives/[...params].ts deleted file mode 100644 index 1436c04e..00000000 --- a/pages/api/v1/archives/[...params].ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; -import getPermission from "@/lib/api/getPermission"; -import readFile from "@/lib/api/storage/readFile"; -import verifyUser from "@/lib/api/verifyUser"; - -export default async function Index(req: NextApiRequest, res: NextApiResponse) { - if (!req.query.params) - return res.status(401).json({ response: "Invalid parameters." }); - - const user = await verifyUser({ req, res }); - if (!user) return; - - const collectionId = req.query.params[0]; - const linkId = req.query.params[1]; - - const collectionIsAccessible = await getPermission({ - userId: user.id, - collectionId: Number(collectionId), - }); - - if (!collectionIsAccessible) - return res - .status(401) - .json({ response: "You don't have access to this collection." }); - - const { file, contentType, status } = await readFile( - `archives/${collectionId}/${linkId}` - ); - res.setHeader("Content-Type", contentType).status(status as number); - - return res.send(file); -} diff --git a/pages/api/v1/archives/[linkId].ts b/pages/api/v1/archives/[linkId].ts new file mode 100644 index 00000000..74f77383 --- /dev/null +++ b/pages/api/v1/archives/[linkId].ts @@ -0,0 +1,49 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import readFile from "@/lib/api/storage/readFile"; +import { getToken } from "next-auth/jwt"; +import { prisma } from "@/lib/api/db"; +import { ArchivedFormat } from "@/types/global"; + +export default async function Index(req: NextApiRequest, res: NextApiResponse) { + const linkId = Number(req.query.linkId); + const format = Number(req.query.format); + + let suffix; + + if (format === ArchivedFormat.screenshot) suffix = ".png"; + else if (format === ArchivedFormat.pdf) suffix = ".pdf"; + else if (format === ArchivedFormat.readability) suffix = "_readability.json"; + + if (!linkId || !suffix) + return res.status(401).json({ response: "Invalid parameters." }); + + const token = await getToken({ req }); + const userId = token?.id; + + const collectionIsAccessible = await prisma.collection.findFirst({ + where: { + links: { + some: { + id: linkId, + }, + }, + OR: [ + { ownerId: userId || -1 }, + { members: { some: { userId: userId || -1 } } }, + { isPublic: true }, + ], + }, + }); + + if (!collectionIsAccessible) + return res + .status(401) + .json({ response: "You don't have access to this collection." }); + + const { file, contentType, status } = await readFile( + `archives/${collectionIsAccessible.id}/${linkId + suffix}` + ); + res.setHeader("Content-Type", contentType).status(status as number); + + return res.send(file); +} diff --git a/pages/api/v1/auth/[...nextauth].ts b/pages/api/v1/auth/[...nextauth].ts index 0b4ab7eb..4b5dd59b 100644 --- a/pages/api/v1/auth/[...nextauth].ts +++ b/pages/api/v1/auth/[...nextauth].ts @@ -9,10 +9,15 @@ import { Adapter } from "next-auth/adapters"; import sendVerificationRequest from "@/lib/api/sendVerificationRequest"; import { Provider } from "next-auth/providers"; import verifySubscription from "@/lib/api/verifySubscription"; +import KeycloakProvider from "next-auth/providers/keycloak"; const emailEnabled = process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false; +const keycloakEnabled = process.env.NEXT_PUBLIC_KEYCLOAK_ENABLED === "true"; + +const adapter = PrismaAdapter(prisma); + const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const providers: Provider[] = [ @@ -59,7 +64,7 @@ const providers: Provider[] = [ }), ]; -if (emailEnabled) +if (emailEnabled) { providers.push( EmailProvider({ server: process.env.EMAIL_SERVER, @@ -70,9 +75,36 @@ if (emailEnabled) }, }) ); +} + +if (keycloakEnabled) { + providers.push( + KeycloakProvider({ + id: "keycloak", + name: "Keycloak", + clientId: process.env.KEYCLOAK_CLIENT_ID!, + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!, + issuer: process.env.KEYCLOAK_ISSUER, + profile: (profile) => { + return { + id: profile.sub, + username: profile.preferred_username, + name: profile.name ?? profile.preferred_username, + email: profile.email, + image: profile.picture, + }; + }, + }) + ); + const _linkAccount = adapter.linkAccount; + adapter.linkAccount = (account) => { + const { "not-before-policy": _, refresh_expires_in, ...data } = account; + return _linkAccount ? _linkAccount(data) : undefined; + }; +} export const authOptions: AuthOptions = { - adapter: PrismaAdapter(prisma) as Adapter, + adapter: adapter as Adapter, session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60, // 30 days @@ -85,7 +117,8 @@ export const authOptions: AuthOptions = { callbacks: { async jwt({ token, trigger, user }) { token.sub = token.sub ? Number(token.sub) : undefined; - if (trigger === "signIn") token.id = user?.id as number; + if (trigger === "signIn" || trigger === "signUp") + token.id = user?.id as number; return token; }, diff --git a/pages/api/v1/avatar/[id].ts b/pages/api/v1/avatar/[id].ts index f20b9b65..c25ddff5 100644 --- a/pages/api/v1/avatar/[id].ts +++ b/pages/api/v1/avatar/[id].ts @@ -1,21 +1,21 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { prisma } from "@/lib/api/db"; import readFile from "@/lib/api/storage/readFile"; -import verifyUser from "@/lib/api/verifyUser"; +import { getToken } from "next-auth/jwt"; export default async function Index(req: NextApiRequest, res: NextApiResponse) { const queryId = Number(req.query.id); - const user = await verifyUser({ req, res }); - if (!user) return; - if (!queryId) return res .setHeader("Content-Type", "text/plain") .status(401) .send("Invalid parameters."); - if (user.id !== queryId) { + const token = await getToken({ req }); + const userId = token?.id; + + if (req.method === "GET") { const targetUser = await prisma.user.findUnique({ where: { id: queryId, @@ -25,28 +25,49 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { }, }); - const whitelistedUsernames = targetUser?.whitelistedUsers.map( - (whitelistedUsername) => whitelistedUsername.username + if (targetUser?.isPrivate) { + if (!userId) { + return res + .setHeader("Content-Type", "text/plain") + .status(400) + .send("File inaccessible."); + } + + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + include: { + subscriptions: true, + }, + }); + + const whitelistedUsernames = targetUser?.whitelistedUsers.map( + (whitelistedUsername) => whitelistedUsername.username + ); + + if (!user?.username) { + return res + .setHeader("Content-Type", "text/plain") + .status(400) + .send("File inaccessible."); + } + + if (user.username && !whitelistedUsernames?.includes(user.username)) { + return res + .setHeader("Content-Type", "text/plain") + .status(400) + .send("File inaccessible."); + } + } + + const { file, contentType, status } = await readFile( + `uploads/avatar/${queryId}.jpg` ); - if ( - targetUser?.isPrivate && - user.username && - !whitelistedUsernames?.includes(user.username) - ) { - return res - .setHeader("Content-Type", "text/plain") - .status(400) - .send("File not found."); - } + return res + .setHeader("Content-Type", contentType) + .status(status as number) + .send(file); } - - const { file, contentType, status } = await readFile( - `uploads/avatar/${queryId}.jpg` - ); - - return res - .setHeader("Content-Type", contentType) - .status(status as number) - .send(file); } diff --git a/pages/api/v1/public/collections.ts b/pages/api/v1/public/collections/[id].ts similarity index 59% rename from pages/api/v1/public/collections.ts rename to pages/api/v1/public/collections/[id].ts index 5f6b808c..04178f8a 100644 --- a/pages/api/v1/public/collections.ts +++ b/pages/api/v1/public/collections/[id].ts @@ -1,18 +1,18 @@ -import getCollection from "@/lib/api/controllers/public/getCollection"; +import getPublicCollection from "@/lib/api/controllers/public/collections/getPublicCollection"; import type { NextApiRequest, NextApiResponse } from "next"; -export default async function collections( +export default async function collection( req: NextApiRequest, res: NextApiResponse ) { - if (!req?.query?.body) { + if (!req?.query?.id) { return res .status(401) .json({ response: "Please choose a valid collection." }); } if (req.method === "GET") { - const collection = await getCollection(req?.query?.body as string); + const collection = await getPublicCollection(Number(req?.query?.id)); return res .status(collection.status) .json({ response: collection.response }); diff --git a/pages/api/v1/public/collections/links/index.ts b/pages/api/v1/public/collections/links/index.ts new file mode 100644 index 00000000..dd551790 --- /dev/null +++ b/pages/api/v1/public/collections/links/index.ts @@ -0,0 +1,41 @@ +import getPublicLinksUnderCollection from "@/lib/api/controllers/public/links/getPublicLinksUnderCollection"; +import { LinkRequestQuery } from "@/types/global"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function collections( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method === "GET") { + // Convert the type of the request query to "LinkRequestQuery" + const convertedData: Omit = { + sort: Number(req.query.sort as string), + cursor: req.query.cursor ? Number(req.query.cursor as string) : undefined, + collectionId: req.query.collectionId + ? Number(req.query.collectionId as string) + : undefined, + pinnedOnly: req.query.pinnedOnly + ? req.query.pinnedOnly === "true" + : undefined, + searchQueryString: req.query.searchQueryString + ? (req.query.searchQueryString as string) + : undefined, + searchByName: req.query.searchByName === "true" ? true : undefined, + searchByUrl: req.query.searchByUrl === "true" ? true : undefined, + searchByDescription: + req.query.searchByDescription === "true" ? true : undefined, + searchByTextContent: + req.query.searchByTextContent === "true" ? true : undefined, + searchByTags: req.query.searchByTags === "true" ? true : undefined, + }; + + if (!convertedData.collectionId) { + return res + .status(400) + .json({ response: "Please choose a valid collection." }); + } + + const links = await getPublicLinksUnderCollection(convertedData); + return res.status(links.status).json({ response: links.response }); + } +} diff --git a/pages/api/v1/public/links/[id].ts b/pages/api/v1/public/links/[id].ts new file mode 100644 index 00000000..b3e854dd --- /dev/null +++ b/pages/api/v1/public/links/[id].ts @@ -0,0 +1,13 @@ +import getLinkById from "@/lib/api/controllers/public/links/linkId/getLinkById"; +import type { NextApiRequest, NextApiResponse } from "next"; + +export default async function link(req: NextApiRequest, res: NextApiResponse) { + if (!req?.query?.id) { + return res.status(401).json({ response: "Please choose a valid link." }); + } + + if (req.method === "GET") { + const link = await getLinkById(Number(req?.query?.id)); + return res.status(link.status).json({ response: link.response }); + } +} diff --git a/pages/api/v1/public/users/[id].ts b/pages/api/v1/public/users/[id].ts index 5126740d..f5b66ed5 100644 --- a/pages/api/v1/public/users/[id].ts +++ b/pages/api/v1/public/users/[id].ts @@ -1,5 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; -import getPublicUserById from "@/lib/api/controllers/public/users/getPublicUserById"; +import getPublicUser from "@/lib/api/controllers/public/users/getPublicUser"; import { getToken } from "next-auth/jwt"; export default async function users(req: NextApiRequest, res: NextApiResponse) { @@ -12,7 +12,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) { const isId = lookupId.split("").every((e) => Number.isInteger(parseInt(e))); if (req.method === "GET") { - const users = await getPublicUserById(lookupId, isId, requestingId); + const users = await getPublicUser(lookupId, isId, requestingId); return res.status(users.status).json({ response: users.response }); } } diff --git a/pages/links/[id].tsx b/pages/links/[id].tsx index b05d0fb0..a28b45d2 100644 --- a/pages/links/[id].tsx +++ b/pages/links/[id].tsx @@ -3,7 +3,10 @@ import React, { useEffect, useState } from "react"; import Link from "next/link"; import useLinkStore from "@/store/links"; import { useRouter } from "next/router"; -import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; +import { + ArchivedFormat, + LinkIncludingShortenedCollectionAndTags, +} from "@/types/global"; import Image from "next/image"; import ColorThief, { RGBColor } from "colorthief"; import { useTheme } from "next-themes"; @@ -63,7 +66,9 @@ export default function Index() { link?.readabilityPath && link?.readabilityPath !== "pending" ) { - const response = await fetch(`/api/v1/${link?.readabilityPath}`); + const response = await fetch( + `/api/v1/archives/${link?.id}?format=${ArchivedFormat.readability}` + ); const data = await response?.json(); @@ -146,7 +151,7 @@ export default function Index() { >