diff --git a/.env.sample b/.env.sample index 384929e..74e9192 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 7a1d894..8f07c2d 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 0000000..2f4814d 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 0000000..f2dee45 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 0c527e3..0000000 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 e7abe9a..0000000 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 0000000..05c14cc 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 0000000..4f96591 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 0000000..f5bac36 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 418ed56..0000000 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 0000000..0b36d6c 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 0000000..734f546 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 0000000..1564cb3 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 de5efe7..0000000 Binary files a/assets/showcase_image.png and /dev/null differ diff --git a/components/DashboardItem.tsx b/components/DashboardItem.tsx index d891398..3de882e 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 0b32bee..90648e0 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 26982bf..a3c2d72 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 f0e71b2..962e9ee 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 0000000..898d18b --- /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 747a742..a535b30 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 1a6712b..410d3a1 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 8575d6d..993eb40 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 3be1972..0000000 --- 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 0000000..b31190e --- /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 0000000..525daa3 --- /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 842b205..df0b9a4 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 b985f9f..b7b1e27 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 366c28c..78a31a2 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 0ba36b8..cff56c0 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 2a400df..f1ff559 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 60f43db..77e4dce 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 b2c135e..b1bc511 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 0989ceb..7a9a2af 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 792f9fa..03870aa 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 30bb601..f257021 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 8a56656..90adba4 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 83a3ceb..e344ae4 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 41fb54b..7f7fb2e 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 a147814..b93eeef 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 0000000..6c5de3a --- /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 a9a2753..0000000 --- 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 0000000..f4113b6 --- /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 0000000..2e1d87d --- /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 8f7ea48..8e17887 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 e1b007f..85a8d17 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 d9f24f9..f24738b 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 f072e65..c32b1fb 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 3c4e03e..1d08203 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 3d1b2bc..61dc5c5 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 8f55b45..31554fb 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 bbd8887..c86728f 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 01e6fda..64ff8d7 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 491e24c..e332829 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 7383b88..e9f7d3b 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 8b0ccd5..cebba5a 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 b86c173..283733b 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 a77e211..53a75ee 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 1436c04..0000000 --- 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 0000000..74f7738 --- /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 0b4ab7e..4b5dd59 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 f20b9b6..c25ddff 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 5f6b808..04178f8 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 0000000..dd55179 --- /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 0000000..b3e854d --- /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 5126740..f5b66ed 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 b05d0fb..a28b45d 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() { >