Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94b261fa32 | |||
| c684b54aef |
@@ -21,8 +21,6 @@ ARCHIVE_TAKE_COUNT=
|
|||||||
BROWSER_TIMEOUT=
|
BROWSER_TIMEOUT=
|
||||||
IGNORE_UNAUTHORIZED_CA=
|
IGNORE_UNAUTHORIZED_CA=
|
||||||
IGNORE_HTTPS_ERRORS=
|
IGNORE_HTTPS_ERRORS=
|
||||||
IGNORE_URL_SIZE_LIMIT=
|
|
||||||
ADMINISTRATOR=
|
|
||||||
|
|
||||||
# AWS S3 Settings
|
# AWS S3 Settings
|
||||||
SPACES_KEY=
|
SPACES_KEY=
|
||||||
@@ -77,13 +75,6 @@ AUTH0_ISSUER=
|
|||||||
AUTH0_CLIENT_SECRET=
|
AUTH0_CLIENT_SECRET=
|
||||||
AUTH0_CLIENT_ID=
|
AUTH0_CLIENT_ID=
|
||||||
|
|
||||||
# Authelia
|
|
||||||
NEXT_PUBLIC_AUTHELIA_ENABLED=""
|
|
||||||
AUTHELIA_CLIENT_ID=""
|
|
||||||
AUTHELIA_CLIENT_SECRET=""
|
|
||||||
AUTHELIA_WELLKNOWN_URL=""
|
|
||||||
|
|
||||||
|
|
||||||
# Authentik
|
# Authentik
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||||
AUTHENTIK_CUSTOM_NAME=
|
AUTHENTIK_CUSTOM_NAME=
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Architecture
|
|
||||||
|
|
||||||
This is a summary of the architecture of Linkwarden. It's intended as a primer for collaborators to get a high-level understanding of the project.
|
|
||||||
|
|
||||||
When you start Linkwarden, there are mainly two components that run:
|
|
||||||
|
|
||||||
- The NextJS app, This is the main app and it's responsible for serving the frontend and handling the API routes.
|
|
||||||
- [The Background Worker](https://github.com/linkwarden/linkwarden/blob/main/scripts/worker.ts), This is a separate `ts-node` process that runs in the background and is responsible for archiving links.
|
|
||||||
|
|
||||||
## Main Tech Stack
|
|
||||||
|
|
||||||
- [NextJS](https://github.com/vercel/next.js)
|
|
||||||
- [TypeScript](https://github.com/microsoft/TypeScript)
|
|
||||||
- [Tailwind](https://github.com/tailwindlabs/tailwindcss)
|
|
||||||
- [DaisyUI](https://github.com/saadeghi/daisyui)
|
|
||||||
- [Prisma](https://github.com/prisma/prisma)
|
|
||||||
- [Playwright](https://github.com/microsoft/playwright)
|
|
||||||
- [Zustand](https://github.com/pmndrs/zustand)
|
|
||||||
|
|
||||||
## Folder Structure
|
|
||||||
|
|
||||||
Here's a summary of the main files and folders in the project:
|
|
||||||
|
|
||||||
```
|
|
||||||
linkwarden
|
|
||||||
├── components # React components
|
|
||||||
├── hooks # React reusable hooks
|
|
||||||
├── layouts # Layouts for pages
|
|
||||||
├── lib
|
|
||||||
│ ├── api # Server-side functions (controllers, etc.)
|
|
||||||
│ ├── client # Client-side functions
|
|
||||||
│ └── shared # Shared functions between client and server
|
|
||||||
├── pages # Pages and API routes
|
|
||||||
├── prisma # Prisma schema and migrations
|
|
||||||
├── scripts
|
|
||||||
│ ├── migration # Scripts for breaking changes
|
|
||||||
│ └── worker.ts # Background worker for archiving links
|
|
||||||
├── store # Zustand stores
|
|
||||||
├── styles # Styles
|
|
||||||
└── types # TypeScript types
|
|
||||||
```
|
|
||||||
|
|
||||||
## Versioning
|
|
||||||
|
|
||||||
We use semantic versioning for the project. You can track the changes from the [Releases](https://github.com/linkwarden/linkwarden/releases).
|
|
||||||
@@ -8,38 +8,19 @@ type Props = {
|
|||||||
onMount?: (rect: DOMRect) => void;
|
onMount?: (rect: DOMRect) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getZIndex(element: HTMLElement): number {
|
|
||||||
let zIndex = 0;
|
|
||||||
while (element) {
|
|
||||||
const zIndexStyle = window
|
|
||||||
.getComputedStyle(element)
|
|
||||||
.getPropertyValue("z-index");
|
|
||||||
const numericZIndex = Number(zIndexStyle);
|
|
||||||
if (zIndexStyle !== "auto" && !isNaN(numericZIndex)) {
|
|
||||||
zIndex = numericZIndex;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
element = element.parentElement as HTMLElement;
|
|
||||||
}
|
|
||||||
return zIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useOutsideAlerter(
|
function useOutsideAlerter(
|
||||||
ref: RefObject<HTMLElement>,
|
ref: RefObject<HTMLElement>,
|
||||||
onClickOutside: Function
|
onClickOutside: Function
|
||||||
) {
|
) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: Event) {
|
||||||
const clickedElement = event.target as HTMLElement;
|
if (
|
||||||
if (ref.current && !ref.current.contains(clickedElement)) {
|
ref.current &&
|
||||||
const refZIndex = getZIndex(ref.current);
|
!ref.current.contains(event.target as HTMLInputElement)
|
||||||
const clickedZIndex = getZIndex(clickedElement);
|
) {
|
||||||
if (clickedZIndex <= refZIndex) {
|
onClickOutside(event);
|
||||||
onClickOutside(event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
|||||||
import useAccountStore from "@/store/account";
|
import useAccountStore from "@/store/account";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
@@ -54,9 +53,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
|||||||
let shortendURL;
|
let shortendURL;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (link.url) {
|
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||||
shortendURL = new URL(link.url).host.toLowerCase();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
}
|
}
|
||||||
@@ -112,6 +109,7 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
|||||||
editMode &&
|
editMode &&
|
||||||
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
(permissions === true || permissions?.canCreate || permissions?.canDelete);
|
||||||
|
|
||||||
|
// window.open ('www.yourdomain.com', '_ blank');
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -164,7 +162,18 @@ export default function LinkCard({ link, flipDropdown, editMode }: Props) {
|
|||||||
{unescapeString(link.name || link.description) || link.url}
|
{unescapeString(link.name || link.description) || link.url}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<LinkTypeBadge link={link} />
|
<Link
|
||||||
|
href={link.url || ""}
|
||||||
|
target="_blank"
|
||||||
|
title={link.url || ""}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
|
||||||
|
>
|
||||||
|
<i className="bi-link-45deg text-lg mt-[0.10rem] leading-none"></i>
|
||||||
|
<p className="text-sm truncate">{shortendURL}</p>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
<hr className="divider mt-2 mb-1 last:hidden border-t border-neutral-content h-[1px]" />
|
||||||
|
|||||||
@@ -122,20 +122,18 @@ export default function LinkActions({
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{link.type === "url" && (
|
<li>
|
||||||
<li>
|
<div
|
||||||
<div
|
role="button"
|
||||||
role="button"
|
tabIndex={0}
|
||||||
tabIndex={0}
|
onClick={() => {
|
||||||
onClick={() => {
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
(document?.activeElement as HTMLElement)?.blur();
|
setPreservedFormatsModal(true);
|
||||||
setPreservedFormatsModal(true);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
Preserved Formats
|
||||||
Preserved Formats
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{permissions === true || permissions?.canDelete ? (
|
{permissions === true || permissions?.canDelete ? (
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ export default function LinkDate({
|
|||||||
}: {
|
}: {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
}) {
|
}) {
|
||||||
const formattedDate = new Date(
|
const formattedDate = new Date(link.createdAt as string).toLocaleString(
|
||||||
(link.importDate || link.createdAt) as string
|
"en-US",
|
||||||
).toLocaleString("en-US", {
|
{
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 text-neutral">
|
<div className="flex items-center gap-1 text-neutral">
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import React from "react";
|
|||||||
export default function LinkIcon({
|
export default function LinkIcon({
|
||||||
link,
|
link,
|
||||||
width,
|
width,
|
||||||
className,
|
|
||||||
}: {
|
}: {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
width?: string;
|
width?: string;
|
||||||
className?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const url =
|
const url =
|
||||||
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
|
isValidUrl(link.url || "") && link.url ? new URL(link.url) : undefined;
|
||||||
@@ -18,55 +16,33 @@ export default function LinkIcon({
|
|||||||
const iconClasses: string =
|
const iconClasses: string =
|
||||||
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
|
"bg-white shadow rounded-md border-[2px] flex item-center justify-center border-white select-none z-10" +
|
||||||
" " +
|
" " +
|
||||||
(width || "w-12") +
|
(width || "w-12");
|
||||||
" " +
|
|
||||||
(className || "");
|
|
||||||
|
|
||||||
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
|
const [showFavicon, setShowFavicon] = React.useState<boolean>(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{link.type === "url" && url ? (
|
{link.url && url && showFavicon ? (
|
||||||
showFavicon ? (
|
<Image
|
||||||
<Image
|
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
|
||||||
src={`https://t2.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${link.url}&size=32`}
|
width={64}
|
||||||
width={64}
|
height={64}
|
||||||
height={64}
|
alt=""
|
||||||
alt=""
|
className={iconClasses}
|
||||||
className={iconClasses}
|
draggable="false"
|
||||||
draggable="false"
|
onError={() => {
|
||||||
onError={() => {
|
setShowFavicon(false);
|
||||||
setShowFavicon(false);
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : showFavicon === false ? (
|
||||||
) : (
|
<div className={iconClasses}>
|
||||||
<LinkPlaceholderIcon iconClasses={iconClasses} icon="bi-link-45deg" />
|
<i className="bi-link-45deg text-4xl text-black"></i>
|
||||||
)
|
</div>
|
||||||
) : link.type === "pdf" ? (
|
) : link.type === "pdf" ? (
|
||||||
<LinkPlaceholderIcon
|
<i className={`bi-file-earmark-pdf ${iconClasses}`}></i>
|
||||||
iconClasses={iconClasses}
|
|
||||||
icon="bi-file-earmark-pdf"
|
|
||||||
/>
|
|
||||||
) : link.type === "image" ? (
|
) : link.type === "image" ? (
|
||||||
<LinkPlaceholderIcon
|
<i className={`bi-file-earmark-image ${iconClasses}`}></i>
|
||||||
iconClasses={iconClasses}
|
|
||||||
icon="bi-file-earmark-image"
|
|
||||||
/>
|
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LinkPlaceholderIcon = ({
|
|
||||||
iconClasses,
|
|
||||||
icon,
|
|
||||||
}: {
|
|
||||||
iconClasses: string;
|
|
||||||
icon: string;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className={`text-4xl text-black aspect-square ${iconClasses}`}>
|
|
||||||
<i className={`${icon} m-auto`}></i>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
|
||||||
import Link from "next/link";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
export default function LinkTypeBadge({
|
|
||||||
link,
|
|
||||||
}: {
|
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
|
||||||
}) {
|
|
||||||
let shortendURL;
|
|
||||||
|
|
||||||
if (link.type === "url" && link.url) {
|
|
||||||
try {
|
|
||||||
shortendURL = new URL(link.url).host.toLowerCase();
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return link.url && shortendURL ? (
|
|
||||||
<Link
|
|
||||||
href={link.url || ""}
|
|
||||||
target="_blank"
|
|
||||||
title={link.url || ""}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
|
|
||||||
>
|
|
||||||
<i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i>
|
|
||||||
<p className="text-sm truncate">{shortendURL}</p>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div className="badge badge-primary badge-sm my-1 select-none">
|
|
||||||
{link.type}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import { generateLinkHref } from "@/lib/client/generateLinkHref";
|
|||||||
import useAccountStore from "@/store/account";
|
import useAccountStore from "@/store/account";
|
||||||
import usePermissions from "@/hooks/usePermissions";
|
import usePermissions from "@/hooks/usePermissions";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import LinkTypeBadge from "./LinkComponents/LinkTypeBadge";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
link: LinkIncludingShortenedCollectionAndTags;
|
link: LinkIncludingShortenedCollectionAndTags;
|
||||||
@@ -57,6 +56,14 @@ export default function LinkCardCompact({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let shortendURL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
shortendURL = new URL(link.url || "").host.toLowerCase();
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
}
|
||||||
|
|
||||||
const [collection, setCollection] =
|
const [collection, setCollection] =
|
||||||
useState<CollectionIncludingMembersAndLinkCount>(
|
useState<CollectionIncludingMembersAndLinkCount>(
|
||||||
collections.find(
|
collections.find(
|
||||||
@@ -123,11 +130,7 @@ export default function LinkCardCompact({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<LinkIcon
|
<LinkIcon link={link} width="sm:w-12 w-8 mt-1 sm:mt-0" />
|
||||||
link={link}
|
|
||||||
width="sm:w-12 w-8"
|
|
||||||
className="mt-1 sm:mt-0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-[calc(100%-56px)] ml-2">
|
<div className="w-[calc(100%-56px)] ml-2">
|
||||||
@@ -140,7 +143,24 @@ export default function LinkCardCompact({
|
|||||||
{collection ? (
|
{collection ? (
|
||||||
<LinkCollection link={link} collection={collection} />
|
<LinkCollection link={link} collection={collection} />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<LinkTypeBadge link={link} />
|
{link.url ? (
|
||||||
|
<Link
|
||||||
|
href={link.url || ""}
|
||||||
|
target="_blank"
|
||||||
|
title={link.url || ""}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex gap-1 item-center select-none text-neutral mt-1 hover:opacity-70 duration-100"
|
||||||
|
>
|
||||||
|
<i className="bi-link-45deg text-lg mt-[0.1rem] leading-none"></i>
|
||||||
|
<p className="text-sm truncate">{shortendURL}</p>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="badge badge-primary badge-sm my-1 select-none">
|
||||||
|
{link.type}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<LinkDate link={link} />
|
<LinkDate link={link} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import toast from "react-hot-toast";
|
|
||||||
import Modal from "../Modal";
|
|
||||||
import useUserStore from "@/store/admin/users";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onClose: Function;
|
|
||||||
userId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DeleteUserModal({ onClose, userId }: Props) {
|
|
||||||
const { removeUser } = useUserStore();
|
|
||||||
|
|
||||||
const deleteUser = async () => {
|
|
||||||
const load = toast.loading("Deleting...");
|
|
||||||
|
|
||||||
const response = await removeUser(userId);
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
|
||||||
|
|
||||||
response.ok && toast.success(`User Deleted.`);
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal toggleModal={onClose}>
|
|
||||||
<p className="text-xl font-thin text-red-500">Delete User</p>
|
|
||||||
|
|
||||||
<div className="divider mb-3 mt-1"></div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<p>Are you sure you want to remove this user?</p>
|
|
||||||
|
|
||||||
<div role="alert" className="alert alert-warning">
|
|
||||||
<i className="bi-exclamation-triangle text-xl" />
|
|
||||||
<span>
|
|
||||||
<b>Warning:</b> This action is irreversible!
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={`ml-auto btn w-fit text-white flex items-center gap-2 duration-100 bg-red-500 hover:bg-red-400 hover:dark:bg-red-600 cursor-pointer`}
|
|
||||||
onClick={deleteUser}
|
|
||||||
>
|
|
||||||
<i className="bi-trash text-xl" />
|
|
||||||
Delete, I know what I'm doing
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import toast from "react-hot-toast";
|
|
||||||
import Modal from "../Modal";
|
|
||||||
import useUserStore from "@/store/admin/users";
|
|
||||||
import TextInput from "../TextInput";
|
|
||||||
import { FormEvent, useState } from "react";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onClose: Function;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FormData = {
|
|
||||||
name: string;
|
|
||||||
username?: string;
|
|
||||||
email?: string;
|
|
||||||
password: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true";
|
|
||||||
|
|
||||||
export default function NewUserModal({ onClose }: Props) {
|
|
||||||
const { addUser } = useUserStore();
|
|
||||||
|
|
||||||
const [form, setForm] = useState<FormData>({
|
|
||||||
name: "",
|
|
||||||
username: "",
|
|
||||||
email: emailEnabled ? "" : undefined,
|
|
||||||
password: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
|
||||||
|
|
||||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
if (!submitLoader) {
|
|
||||||
const checkFields = () => {
|
|
||||||
if (emailEnabled) {
|
|
||||||
return form.name !== "" && form.email !== "" && form.password !== "";
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
form.name !== "" && form.username !== "" && form.password !== ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (checkFields()) {
|
|
||||||
if (form.password.length < 8)
|
|
||||||
return toast.error("Passwords must be at least 8 characters.");
|
|
||||||
|
|
||||||
setSubmitLoader(true);
|
|
||||||
|
|
||||||
const load = toast.loading("Creating Account...");
|
|
||||||
|
|
||||||
const response = await addUser(form);
|
|
||||||
|
|
||||||
toast.dismiss(load);
|
|
||||||
setSubmitLoader(false);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
toast.success("User Created!");
|
|
||||||
onClose();
|
|
||||||
} else {
|
|
||||||
toast.error(response.data as string);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error("Please fill out all the fields.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal toggleModal={onClose}>
|
|
||||||
<p className="text-xl font-thin">Create New User</p>
|
|
||||||
|
|
||||||
<div className="divider mb-3 mt-1"></div>
|
|
||||||
|
|
||||||
<form onSubmit={submit}>
|
|
||||||
<div className="grid sm:grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="mb-2">Display Name</p>
|
|
||||||
<TextInput
|
|
||||||
placeholder="Johnny"
|
|
||||||
className="bg-base-200"
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
value={form.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{emailEnabled ? (
|
|
||||||
<div>
|
|
||||||
<p className="mb-2">Username</p>
|
|
||||||
<TextInput
|
|
||||||
placeholder="john"
|
|
||||||
className="bg-base-200"
|
|
||||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
|
||||||
value={form.username}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-2">Email</p>
|
|
||||||
<TextInput
|
|
||||||
placeholder="johnny@example.com"
|
|
||||||
className="bg-base-200"
|
|
||||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
|
||||||
value={form.email}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="mb-2">Password</p>
|
|
||||||
<TextInput
|
|
||||||
placeholder="••••••••••••••"
|
|
||||||
className="bg-base-200"
|
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
||||||
value={form.password}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-5">
|
|
||||||
<button
|
|
||||||
className="btn btn-accent dark:border-violet-400 text-white ml-auto"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
Create User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -43,7 +43,7 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
|
|
||||||
const [file, setFile] = useState<File>();
|
const [file, setFile] = useState<File>();
|
||||||
|
|
||||||
const { uploadFile } = useLinkStore();
|
const { addLink } = useLinkStore();
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
|
|
||||||
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
const [optionsExpanded, setOptionsExpanded] = useState(false);
|
||||||
@@ -100,22 +100,56 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!submitLoader && file) {
|
if (!submitLoader && file) {
|
||||||
setSubmitLoader(true);
|
let fileType: ArchivedFormat | null = null;
|
||||||
|
let linkType: "url" | "image" | "pdf" | null = null;
|
||||||
|
|
||||||
const load = toast.loading("Creating...");
|
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
|
||||||
|
fileType = ArchivedFormat.jpeg;
|
||||||
|
linkType = "image";
|
||||||
|
} else if (file.type === "image/png") {
|
||||||
|
fileType = ArchivedFormat.png;
|
||||||
|
linkType = "image";
|
||||||
|
} else if (file.type === "application/pdf") {
|
||||||
|
fileType = ArchivedFormat.pdf;
|
||||||
|
linkType = "pdf";
|
||||||
|
}
|
||||||
|
|
||||||
const response = await uploadFile(link, file);
|
if (fileType !== null && linkType !== null) {
|
||||||
|
setSubmitLoader(true);
|
||||||
|
|
||||||
toast.dismiss(load);
|
let response;
|
||||||
|
|
||||||
if (response.ok) {
|
const load = toast.loading("Creating...");
|
||||||
toast.success(`Created!`);
|
|
||||||
onClose();
|
|
||||||
} else toast.error(response.data as string);
|
|
||||||
|
|
||||||
setSubmitLoader(false);
|
response = await addLink({
|
||||||
|
...link,
|
||||||
|
type: linkType,
|
||||||
|
name: link.name ? link.name : file.name.replace(/\.[^/.]+$/, ""),
|
||||||
|
});
|
||||||
|
|
||||||
return response;
|
toast.dismiss(load);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const formBody = new FormData();
|
||||||
|
file && formBody.append("file", file);
|
||||||
|
|
||||||
|
await fetch(
|
||||||
|
`/api/v1/archives/${
|
||||||
|
(response.data as LinkIncludingShortenedCollectionAndTags).id
|
||||||
|
}?format=${fileType}`,
|
||||||
|
{
|
||||||
|
body: formBody,
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
toast.success(`Created!`);
|
||||||
|
onClose();
|
||||||
|
} else toast.error(response.data as string);
|
||||||
|
|
||||||
|
setSubmitLoader(false);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,7 +238,7 @@ export default function UploadFileModal({ onClose }: Props) {
|
|||||||
className="btn btn-accent dark:border-violet-400 text-white"
|
className="btn btn-accent dark:border-violet-400 text-white"
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
Upload File
|
Create Link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
+68
-4
@@ -1,24 +1,40 @@
|
|||||||
|
import { signOut } from "next-auth/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||||
import Sidebar from "@/components/Sidebar";
|
import Sidebar from "@/components/Sidebar";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import SearchBar from "@/components/SearchBar";
|
import SearchBar from "@/components/SearchBar";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||||
import ToggleDarkMode from "./ToggleDarkMode";
|
import ToggleDarkMode from "./ToggleDarkMode";
|
||||||
|
import useLocalSettingsStore from "@/store/localSettings";
|
||||||
import NewLinkModal from "./ModalContent/NewLinkModal";
|
import NewLinkModal from "./ModalContent/NewLinkModal";
|
||||||
import NewCollectionModal from "./ModalContent/NewCollectionModal";
|
import NewCollectionModal from "./ModalContent/NewCollectionModal";
|
||||||
|
import Link from "next/link";
|
||||||
import UploadFileModal from "./ModalContent/UploadFileModal";
|
import UploadFileModal from "./ModalContent/UploadFileModal";
|
||||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||||
import MobileNavigation from "./MobileNavigation";
|
import MobileNavigation from "./MobileNavigation";
|
||||||
import ProfileDropdown from "./ProfileDropdown";
|
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
const { settings, updateSettings } = useLocalSettingsStore();
|
||||||
|
|
||||||
|
const { account } = useAccountStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [sidebar, setSidebar] = useState(false);
|
const [sidebar, setSidebar] = useState(false);
|
||||||
|
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (settings.theme === "dark") {
|
||||||
|
updateSettings({ theme: "light" });
|
||||||
|
} else {
|
||||||
|
updateSettings({ theme: "dark" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSidebar(false);
|
setSidebar(false);
|
||||||
document.body.style.overflow = "auto";
|
document.body.style.overflow = "auto";
|
||||||
@@ -77,7 +93,7 @@ export default function Navbar() {
|
|||||||
New Link
|
New Link
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
{/* <li>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
(document?.activeElement as HTMLElement)?.blur();
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
@@ -88,7 +104,7 @@ export default function Navbar() {
|
|||||||
>
|
>
|
||||||
Upload File
|
Upload File
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li> */}
|
||||||
<li>
|
<li>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -104,7 +120,55 @@ export default function Navbar() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProfileDropdown />
|
<div className="dropdown dropdown-end">
|
||||||
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
onMouseDown={dropdownTriggerer}
|
||||||
|
className="btn btn-circle btn-ghost"
|
||||||
|
>
|
||||||
|
<ProfilePhoto
|
||||||
|
src={account.image ? account.image : undefined}
|
||||||
|
priority={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/settings/account"
|
||||||
|
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className="block sm:hidden">
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
handleToggle();
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Switch to {settings.theme === "light" ? "Dark" : "Light"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
(document?.activeElement as HTMLElement)?.blur();
|
||||||
|
signOut();
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MobileNavigation />
|
<MobileNavigation />
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import useLocalSettingsStore from "@/store/localSettings";
|
|
||||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
|
||||||
import ProfilePhoto from "./ProfilePhoto";
|
|
||||||
import useAccountStore from "@/store/account";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { signOut } from "next-auth/react";
|
|
||||||
|
|
||||||
export default function ProfileDropdown() {
|
|
||||||
const { settings, updateSettings } = useLocalSettingsStore();
|
|
||||||
const { account } = useAccountStore();
|
|
||||||
|
|
||||||
const handleToggle = () => {
|
|
||||||
if (settings.theme === "dark") {
|
|
||||||
updateSettings({ theme: "light" });
|
|
||||||
} else {
|
|
||||||
updateSettings({ theme: "dark" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="dropdown dropdown-end">
|
|
||||||
<div
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
onMouseDown={dropdownTriggerer}
|
|
||||||
className="btn btn-circle btn-ghost"
|
|
||||||
>
|
|
||||||
<ProfilePhoto
|
|
||||||
src={account.image ? account.image : undefined}
|
|
||||||
priority={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ul className="dropdown-content z-[1] menu shadow bg-base-200 border border-neutral-content rounded-box w-40 mt-1">
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
href="/settings/account"
|
|
||||||
onClick={() => (document?.activeElement as HTMLElement)?.blur()}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
>
|
|
||||||
Settings
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li className="block sm:hidden">
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
(document?.activeElement as HTMLElement)?.blur();
|
|
||||||
handleToggle();
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
>
|
|
||||||
Switch to {settings.theme === "light" ? "Dark" : "Light"}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
(document?.activeElement as HTMLElement)?.blur();
|
|
||||||
signOut();
|
|
||||||
}}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -34,8 +34,6 @@ export default function ReadableView({ link }: Props) {
|
|||||||
const [imageError, setImageError] = useState<boolean>(false);
|
const [imageError, setImageError] = useState<boolean>(false);
|
||||||
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
||||||
|
|
||||||
const [date, setDate] = useState<Date | string>();
|
|
||||||
|
|
||||||
const colorThief = new ColorThief();
|
const colorThief = new ColorThief();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -56,8 +54,6 @@ export default function ReadableView({ link }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchLinkContent();
|
fetchLinkContent();
|
||||||
|
|
||||||
setDate(link.importDate || link.createdAt);
|
|
||||||
}, [link]);
|
}, [link]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -215,8 +211,8 @@ export default function ReadableView({ link }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="min-w-fit text-sm text-neutral">
|
<p className="min-w-fit text-sm text-neutral">
|
||||||
{date
|
{link?.createdAt
|
||||||
? new Date(date).toLocaleString("en-US", {
|
? new Date(link?.createdAt).toLocaleString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||||
const LINKWARDEN_VERSION = process.env.version;
|
const LINKWARDEN_VERSION = "v2.5.1";
|
||||||
|
|
||||||
const { collections } = useCollectionStore();
|
const { collections } = useCollectionStore();
|
||||||
|
|
||||||
|
|||||||
+44
-16
@@ -7,9 +7,9 @@ import { JSDOM } from "jsdom";
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { Collection, Link, User } from "@prisma/client";
|
import { Collection, Link, User } from "@prisma/client";
|
||||||
import validateUrlSize from "./validateUrlSize";
|
import validateUrlSize from "./validateUrlSize";
|
||||||
|
import removeFile from "./storage/removeFile";
|
||||||
|
import Jimp from "jimp";
|
||||||
import createFolder from "./storage/createFolder";
|
import createFolder from "./storage/createFolder";
|
||||||
import generatePreview from "./generatePreview";
|
|
||||||
import { removeFiles } from "./manageLinkFiles";
|
|
||||||
|
|
||||||
type LinksAndCollectionAndOwner = Link & {
|
type LinksAndCollectionAndOwner = Link & {
|
||||||
collection: Collection & {
|
collection: Collection & {
|
||||||
@@ -51,14 +51,6 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
createFolder({
|
|
||||||
filePath: `archives/preview/${link.collectionId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
createFolder({
|
|
||||||
filePath: `archives/${link.collectionId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -66,10 +58,7 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
? await validateUrlSize(link.url)
|
? await validateUrlSize(link.url)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (
|
if (validatedUrl === null)
|
||||||
validatedUrl === null &&
|
|
||||||
process.env.IGNORE_URL_SIZE_LIMIT !== "true"
|
|
||||||
)
|
|
||||||
throw "Something went wrong while retrieving the file size.";
|
throw "Something went wrong while retrieving the file size.";
|
||||||
|
|
||||||
const contentType = validatedUrl?.get("content-type");
|
const contentType = validatedUrl?.get("content-type");
|
||||||
@@ -173,6 +162,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
return metaTag ? (metaTag as any).content : null;
|
return metaTag ? (metaTag as any).content : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
createFolder({
|
||||||
|
filePath: `archives/preview/${link.collectionId}`,
|
||||||
|
});
|
||||||
|
|
||||||
if (ogImageUrl) {
|
if (ogImageUrl) {
|
||||||
console.log("Found og:image URL:", ogImageUrl);
|
console.log("Found og:image URL:", ogImageUrl);
|
||||||
|
|
||||||
@@ -182,7 +175,35 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
// Check if imageResponse is not null
|
// Check if imageResponse is not null
|
||||||
if (imageResponse && !link.preview?.startsWith("archive")) {
|
if (imageResponse && !link.preview?.startsWith("archive")) {
|
||||||
const buffer = await imageResponse.body();
|
const buffer = await imageResponse.body();
|
||||||
await generatePreview(buffer, link.collectionId, link.id);
|
|
||||||
|
// Check if buffer is not null
|
||||||
|
if (buffer) {
|
||||||
|
// Load the image using Jimp
|
||||||
|
Jimp.read(buffer, async (err, image) => {
|
||||||
|
if (image && !err) {
|
||||||
|
image?.resize(1280, Jimp.AUTO).quality(20);
|
||||||
|
const processedBuffer = await image?.getBufferAsync(
|
||||||
|
Jimp.MIME_JPEG
|
||||||
|
);
|
||||||
|
|
||||||
|
createFile({
|
||||||
|
data: processedBuffer,
|
||||||
|
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||||
|
}).then(() => {
|
||||||
|
return prisma.link.update({
|
||||||
|
where: { id: link.id },
|
||||||
|
data: {
|
||||||
|
preview: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("Error processing the image:", err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("No image data found.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.goBack();
|
await page.goBack();
|
||||||
@@ -302,7 +323,14 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
else {
|
else {
|
||||||
await removeFiles(link.id, link.collectionId);
|
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.png` });
|
||||||
|
removeFile({ filePath: `archives/${link.collectionId}/${link.id}.pdf` });
|
||||||
|
removeFile({
|
||||||
|
filePath: `archives/${link.collectionId}/${link.id}_readability.json`,
|
||||||
|
});
|
||||||
|
removeFile({
|
||||||
|
filePath: `archives/preview/${link.collectionId}/${link.id}.jpeg`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { prisma } from "@/lib/api/db";
|
|||||||
import { UsersAndCollections } from "@prisma/client";
|
import { UsersAndCollections } from "@prisma/client";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import removeFile from "@/lib/api/storage/removeFile";
|
import removeFile from "@/lib/api/storage/removeFile";
|
||||||
import { removeFiles } from "@/lib/api/manageLinkFiles";
|
|
||||||
|
|
||||||
export default async function deleteLinksById(
|
export default async function deleteLinksById(
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -44,7 +43,15 @@ export default async function deleteLinksById(
|
|||||||
const linkId = linkIds[i];
|
const linkId = linkIds[i];
|
||||||
const collectionIsAccessible = collectionIsAccessibleArray[i];
|
const collectionIsAccessible = collectionIsAccessibleArray[i];
|
||||||
|
|
||||||
if (collectionIsAccessible) removeFiles(linkId, collectionIsAccessible.id);
|
removeFile({
|
||||||
|
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||||
|
});
|
||||||
|
removeFile({
|
||||||
|
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||||
|
});
|
||||||
|
removeFile({
|
||||||
|
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { response: deletedLinks, status: 200 };
|
return { response: deletedLinks, status: 200 };
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { prisma } from "@/lib/api/db";
|
|||||||
import { Link, UsersAndCollections } from "@prisma/client";
|
import { Link, UsersAndCollections } from "@prisma/client";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import removeFile from "@/lib/api/storage/removeFile";
|
import removeFile from "@/lib/api/storage/removeFile";
|
||||||
import { removeFiles } from "@/lib/api/manageLinkFiles";
|
|
||||||
|
|
||||||
export default async function deleteLink(userId: number, linkId: number) {
|
export default async function deleteLink(userId: number, linkId: number) {
|
||||||
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
|
if (!linkId) return { response: "Please choose a valid link.", status: 401 };
|
||||||
@@ -13,10 +12,7 @@ export default async function deleteLink(userId: number, linkId: number) {
|
|||||||
(e: UsersAndCollections) => e.userId === userId && e.canDelete
|
(e: UsersAndCollections) => e.userId === userId && e.canDelete
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!(collectionIsAccessible?.ownerId === userId || memberHasAccess))
|
||||||
!collectionIsAccessible ||
|
|
||||||
!(collectionIsAccessible?.ownerId === userId || memberHasAccess)
|
|
||||||
)
|
|
||||||
return { response: "Collection is not accessible.", status: 401 };
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
const deleteLink: Link = await prisma.link.delete({
|
const deleteLink: Link = await prisma.link.delete({
|
||||||
@@ -25,7 +21,15 @@ export default async function deleteLink(userId: number, linkId: number) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
removeFiles(linkId, collectionIsAccessible.id);
|
removeFile({
|
||||||
|
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||||
|
});
|
||||||
|
removeFile({
|
||||||
|
filePath: `archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||||
|
});
|
||||||
|
removeFile({
|
||||||
|
filePath: `archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||||
|
});
|
||||||
|
|
||||||
return { response: deleteLink, status: 200 };
|
return { response: deleteLink, status: 200 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { prisma } from "@/lib/api/db";
|
|||||||
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
import { UsersAndCollections } from "@prisma/client";
|
import { UsersAndCollections } from "@prisma/client";
|
||||||
import getPermission from "@/lib/api/getPermission";
|
import getPermission from "@/lib/api/getPermission";
|
||||||
import { moveFiles } from "@/lib/api/manageLinkFiles";
|
import moveFile from "@/lib/api/storage/moveFile";
|
||||||
|
|
||||||
export default async function updateLinkById(
|
export default async function updateLinkById(
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -146,7 +146,20 @@ export default async function updateLinkById(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (collectionIsAccessible?.id !== data.collection.id) {
|
if (collectionIsAccessible?.id !== data.collection.id) {
|
||||||
await moveFiles(linkId, collectionIsAccessible?.id, data.collection.id);
|
await moveFile(
|
||||||
|
`archives/${collectionIsAccessible?.id}/${linkId}.pdf`,
|
||||||
|
`archives/${data.collection.id}/${linkId}.pdf`
|
||||||
|
);
|
||||||
|
|
||||||
|
await moveFile(
|
||||||
|
`archives/${collectionIsAccessible?.id}/${linkId}.png`,
|
||||||
|
`archives/${data.collection.id}/${linkId}.png`
|
||||||
|
);
|
||||||
|
|
||||||
|
await moveFile(
|
||||||
|
`archives/${collectionIsAccessible?.id}/${linkId}_readability.json`,
|
||||||
|
`archives/${data.collection.id}/${linkId}_readability.json`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { response: updatedLink, status: 200 };
|
return { response: updatedLink, status: 200 };
|
||||||
|
|||||||
@@ -12,16 +12,14 @@ export default async function postLink(
|
|||||||
link: LinkIncludingShortenedCollectionAndTags,
|
link: LinkIncludingShortenedCollectionAndTags,
|
||||||
userId: number
|
userId: number
|
||||||
) {
|
) {
|
||||||
if (link.url || link.type === "url") {
|
try {
|
||||||
try {
|
new URL(link.url || "");
|
||||||
new URL(link.url || "");
|
} catch (error) {
|
||||||
} catch (error) {
|
return {
|
||||||
return {
|
response:
|
||||||
response:
|
"Please enter a valid Address for the Link. (It should start with http/https)",
|
||||||
"Please enter a valid Address for the Link. (It should start with http/https)",
|
status: 400,
|
||||||
status: 400,
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!link.collection.id && link.collection.name) {
|
if (!link.collection.id && link.collection.name) {
|
||||||
@@ -50,7 +48,6 @@ export default async function postLink(
|
|||||||
return { response: "Collection is not accessible.", status: 401 };
|
return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
link.collection.id = findCollection.id;
|
link.collection.id = findCollection.id;
|
||||||
link.collection.ownerId = findCollection.ownerId;
|
|
||||||
} else {
|
} else {
|
||||||
const collection = await prisma.collection.create({
|
const collection = await prisma.collection.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -119,24 +116,15 @@ export default async function postLink(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (user?.preventDuplicateLinks) {
|
if (user?.preventDuplicateLinks) {
|
||||||
const url = link.url?.trim().replace(/\/+$/, ""); // trim and remove trailing slashes from the URL
|
|
||||||
const hasWwwPrefix = url?.includes(`://www.`);
|
|
||||||
const urlWithoutWww = hasWwwPrefix ? url?.replace(`://www.`, "://") : url;
|
|
||||||
const urlWithWww = hasWwwPrefix ? url : url?.replace("://", `://www.`);
|
|
||||||
|
|
||||||
console.log(url, urlWithoutWww, urlWithWww);
|
|
||||||
|
|
||||||
const existingLink = await prisma.link.findFirst({
|
const existingLink = await prisma.link.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ url: urlWithWww }, { url: urlWithoutWww }],
|
url: link.url?.trim(),
|
||||||
collection: {
|
collection: {
|
||||||
ownerId: userId,
|
ownerId: userId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(url, urlWithoutWww, urlWithWww, "DONE!");
|
|
||||||
|
|
||||||
if (existingLink)
|
if (existingLink)
|
||||||
return {
|
return {
|
||||||
response: "Link already exists",
|
response: "Link already exists",
|
||||||
@@ -183,7 +171,7 @@ export default async function postLink(
|
|||||||
|
|
||||||
const newLink = await prisma.link.create({
|
const newLink = await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
url: link.url?.trim().replace(/\/+$/, "") || null,
|
url: link.url?.trim(),
|
||||||
name: link.name,
|
name: link.name,
|
||||||
description,
|
description,
|
||||||
type: linkType,
|
type: linkType,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { prisma } from "@/lib/api/db";
|
|||||||
import createFolder from "@/lib/api/storage/createFolder";
|
import createFolder from "@/lib/api/storage/createFolder";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { parse, Node, Element, TextNode } from "himalaya";
|
import { parse, Node, Element, TextNode } from "himalaya";
|
||||||
import { writeFileSync } from "fs";
|
|
||||||
|
|
||||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||||
|
|
||||||
@@ -37,9 +36,7 @@ export default async function importFromHTMLFile(
|
|||||||
|
|
||||||
const jsonData = parse(document.documentElement.outerHTML);
|
const jsonData = parse(document.documentElement.outerHTML);
|
||||||
|
|
||||||
const processedArray = processNodes(jsonData);
|
for (const item of jsonData) {
|
||||||
|
|
||||||
for (const item of processedArray) {
|
|
||||||
console.log(item);
|
console.log(item);
|
||||||
await processBookmarks(userId, item as Element);
|
await processBookmarks(userId, item as Element);
|
||||||
}
|
}
|
||||||
@@ -77,9 +74,7 @@ async function processBookmarks(
|
|||||||
} else if (item.type === "element" && item.tagName === "a") {
|
} else if (item.type === "element" && item.tagName === "a") {
|
||||||
// process link
|
// process link
|
||||||
|
|
||||||
const linkUrl = item?.attributes.find(
|
const linkUrl = item?.attributes.find((e) => e.key === "href")?.value;
|
||||||
(e) => e.key.toLowerCase() === "href"
|
|
||||||
)?.value;
|
|
||||||
const linkName = (
|
const linkName = (
|
||||||
item?.children.find((e) => e.type === "text") as TextNode
|
item?.children.find((e) => e.type === "text") as TextNode
|
||||||
)?.content;
|
)?.content;
|
||||||
@@ -87,33 +82,14 @@ async function processBookmarks(
|
|||||||
.find((e) => e.key === "tags")
|
.find((e) => e.key === "tags")
|
||||||
?.value.split(",");
|
?.value.split(",");
|
||||||
|
|
||||||
// set date if available
|
|
||||||
const linkDateValue = item?.attributes.find(
|
|
||||||
(e) => e.key.toLowerCase() === "add_date"
|
|
||||||
)?.value;
|
|
||||||
|
|
||||||
const linkDate = linkDateValue
|
|
||||||
? new Date(Number(linkDateValue) * 1000)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
let linkDesc =
|
|
||||||
(
|
|
||||||
(
|
|
||||||
item?.children?.find(
|
|
||||||
(e) => e.type === "element" && e.tagName === "dd"
|
|
||||||
) as Element
|
|
||||||
)?.children[0] as TextNode
|
|
||||||
)?.content || "";
|
|
||||||
|
|
||||||
if (linkUrl && parentCollectionId) {
|
if (linkUrl && parentCollectionId) {
|
||||||
await createLink(
|
await createLink(
|
||||||
userId,
|
userId,
|
||||||
linkUrl,
|
linkUrl,
|
||||||
parentCollectionId,
|
parentCollectionId,
|
||||||
linkName,
|
linkName,
|
||||||
linkDesc,
|
"",
|
||||||
linkTags,
|
linkTags
|
||||||
linkDate
|
|
||||||
);
|
);
|
||||||
} else if (linkUrl) {
|
} else if (linkUrl) {
|
||||||
// create a collection named "Imported Bookmarks" and add the link to it
|
// create a collection named "Imported Bookmarks" and add the link to it
|
||||||
@@ -124,9 +100,8 @@ async function processBookmarks(
|
|||||||
linkUrl,
|
linkUrl,
|
||||||
collectionId,
|
collectionId,
|
||||||
linkName,
|
linkName,
|
||||||
linkDesc,
|
"",
|
||||||
linkTags,
|
linkTags
|
||||||
linkDate
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,8 +160,7 @@ const createLink = async (
|
|||||||
collectionId: number,
|
collectionId: number,
|
||||||
name?: string,
|
name?: string,
|
||||||
description?: string,
|
description?: string,
|
||||||
tags?: string[],
|
tags?: string[]
|
||||||
importDate?: Date
|
|
||||||
) => {
|
) => {
|
||||||
await prisma.link.create({
|
await prisma.link.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -219,48 +193,6 @@ const createLink = async (
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
importDate: importDate || undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function processNodes(nodes: Node[]) {
|
|
||||||
const findAndProcessDL = (node: Node) => {
|
|
||||||
if (node.type === "element" && node.tagName === "dl") {
|
|
||||||
processDLChildren(node);
|
|
||||||
} else if (
|
|
||||||
node.type === "element" &&
|
|
||||||
node.children &&
|
|
||||||
node.children.length
|
|
||||||
) {
|
|
||||||
node.children.forEach((child) => findAndProcessDL(child));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const processDLChildren = (dlNode: Element) => {
|
|
||||||
dlNode.children.forEach((child, i) => {
|
|
||||||
if (child.type === "element" && child.tagName === "dt") {
|
|
||||||
const nextSibling = dlNode.children[i + 1];
|
|
||||||
if (
|
|
||||||
nextSibling &&
|
|
||||||
nextSibling.type === "element" &&
|
|
||||||
nextSibling.tagName === "dd"
|
|
||||||
) {
|
|
||||||
const aElement = child.children.find(
|
|
||||||
(el) => el.type === "element" && el.tagName === "a"
|
|
||||||
);
|
|
||||||
if (aElement && aElement.type === "element") {
|
|
||||||
// Add the 'dd' element as a child of the 'a' element
|
|
||||||
aElement.children.push(nextSibling);
|
|
||||||
// Remove the 'dd' from the parent 'dl' to avoid duplicate processing
|
|
||||||
dlNode.children.splice(i + 1, 1);
|
|
||||||
// Adjust the loop counter due to the removal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
nodes.forEach(findAndProcessDL);
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
|
||||||
|
|
||||||
export default async function getUsers() {
|
|
||||||
// Get all users
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
emailVerified: true,
|
|
||||||
subscriptions: {
|
|
||||||
select: {
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { response: users, status: 200 };
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import bcrypt from "bcrypt";
|
import bcrypt from "bcrypt";
|
||||||
import verifyUser from "../../verifyUser";
|
|
||||||
|
|
||||||
const emailEnabled =
|
const emailEnabled =
|
||||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||||
const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false;
|
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
response: string | object;
|
response: string | object;
|
||||||
@@ -22,15 +20,7 @@ export default async function postUser(
|
|||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<Data>
|
res: NextApiResponse<Data>
|
||||||
) {
|
) {
|
||||||
let isServerAdmin = false;
|
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
|
||||||
|
|
||||||
const user = await verifyUser({ req, res });
|
|
||||||
if (process.env.ADMINISTRATOR === user?.username) isServerAdmin = true;
|
|
||||||
|
|
||||||
if (
|
|
||||||
process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true" &&
|
|
||||||
!isServerAdmin
|
|
||||||
) {
|
|
||||||
return res.status(400).json({ response: "Registration is disabled." });
|
return res.status(400).json({ response: "Registration is disabled." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,16 +57,13 @@ export default async function postUser(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const checkIfUserExists = await prisma.user.findFirst({
|
const checkIfUserExists = await prisma.user.findFirst({
|
||||||
where: {
|
where: emailEnabled
|
||||||
OR: [
|
? {
|
||||||
{
|
|
||||||
email: body.email?.toLowerCase().trim(),
|
email: body.email?.toLowerCase().trim(),
|
||||||
},
|
}
|
||||||
{
|
: {
|
||||||
username: (body.username as string).toLowerCase().trim(),
|
username: (body.username as string).toLowerCase().trim(),
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!checkIfUserExists) {
|
if (!checkIfUserExists) {
|
||||||
@@ -84,63 +71,21 @@ export default async function postUser(
|
|||||||
|
|
||||||
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
|
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
|
||||||
|
|
||||||
// Subscription dates
|
await prisma.user.create({
|
||||||
const currentPeriodStart = new Date();
|
data: {
|
||||||
const currentPeriodEnd = new Date();
|
name: body.name,
|
||||||
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
|
username: emailEnabled
|
||||||
|
? undefined
|
||||||
|
: (body.username as string).toLowerCase().trim(),
|
||||||
|
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (isServerAdmin) {
|
return res.status(201).json({ response: "User successfully created." });
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
name: body.name,
|
|
||||||
username: (body.username as string).toLowerCase().trim(),
|
|
||||||
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
|
||||||
password: hashedPassword,
|
|
||||||
emailVerified: new Date(),
|
|
||||||
subscriptions: stripeEnabled
|
|
||||||
? {
|
|
||||||
create: {
|
|
||||||
stripeSubscriptionId:
|
|
||||||
"fake_sub_" + Math.round(Math.random() * 10000000000000),
|
|
||||||
active: true,
|
|
||||||
currentPeriodStart,
|
|
||||||
currentPeriodEnd,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
username: true,
|
|
||||||
email: true,
|
|
||||||
emailVerified: true,
|
|
||||||
subscriptions: {
|
|
||||||
select: {
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createdAt: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(201).json({ response: user });
|
|
||||||
} else {
|
|
||||||
await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
name: body.name,
|
|
||||||
username: emailEnabled
|
|
||||||
? undefined
|
|
||||||
: (body.username as string).toLowerCase().trim(),
|
|
||||||
email: emailEnabled ? body.email?.toLowerCase().trim() : undefined,
|
|
||||||
password: hashedPassword,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(201).json({ response: "User successfully created." });
|
|
||||||
}
|
|
||||||
} else if (checkIfUserExists) {
|
} else if (checkIfUserExists) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
response: `Email or Username already exists.`,
|
response: `${emailEnabled ? "Email" : "Username"} already exists.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET;
|
|||||||
|
|
||||||
export default async function deleteUserById(
|
export default async function deleteUserById(
|
||||||
userId: number,
|
userId: number,
|
||||||
body: DeleteUserBody,
|
body: DeleteUserBody
|
||||||
isServerAdmin?: boolean
|
|
||||||
) {
|
) {
|
||||||
// First, we retrieve the user from the database
|
// First, we retrieve the user from the database
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -26,13 +25,13 @@ export default async function deleteUserById(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
|
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
|
||||||
if (!keycloakEnabled && !authentikEnabled && !isServerAdmin) {
|
if (!keycloakEnabled && !authentikEnabled) {
|
||||||
const isPasswordValid = bcrypt.compareSync(
|
const isPasswordValid = bcrypt.compareSync(
|
||||||
body.password,
|
body.password,
|
||||||
user.password as string
|
user.password as string
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isPasswordValid && !isServerAdmin) {
|
if (!isPasswordValid) {
|
||||||
return {
|
return {
|
||||||
response: "Invalid credentials.",
|
response: "Invalid credentials.",
|
||||||
status: 401, // Unauthorized
|
status: 401, // Unauthorized
|
||||||
@@ -44,11 +43,6 @@ export default async function deleteUserById(
|
|||||||
await prisma
|
await prisma
|
||||||
.$transaction(
|
.$transaction(
|
||||||
async (prisma) => {
|
async (prisma) => {
|
||||||
// Delete Access Tokens
|
|
||||||
await prisma.accessToken.deleteMany({
|
|
||||||
where: { userId },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete whitelisted users
|
// Delete whitelisted users
|
||||||
await prisma.whitelistedUser.deleteMany({
|
await prisma.whitelistedUser.deleteMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -77,10 +71,6 @@ export default async function deleteUserById(
|
|||||||
|
|
||||||
// Delete archive folders
|
// Delete archive folders
|
||||||
removeFolder({ filePath: `archives/${collection.id}` });
|
removeFolder({ filePath: `archives/${collection.id}` });
|
||||||
|
|
||||||
await removeFolder({
|
|
||||||
filePath: `archives/preview/${collection.id}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete collections after cleaning up related data
|
// Delete collections after cleaning up related data
|
||||||
@@ -93,7 +83,6 @@ export default async function deleteUserById(
|
|||||||
await prisma.subscription.delete({
|
await prisma.subscription.delete({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
// .catch((err) => console.log(err));
|
|
||||||
|
|
||||||
await prisma.usersAndCollections.deleteMany({
|
await prisma.usersAndCollections.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import Jimp from "jimp";
|
|
||||||
import { prisma } from "./db";
|
|
||||||
import createFile from "./storage/createFile";
|
|
||||||
import createFolder from "./storage/createFolder";
|
|
||||||
|
|
||||||
const generatePreview = async (
|
|
||||||
buffer: Buffer,
|
|
||||||
collectionId: number,
|
|
||||||
linkId: number
|
|
||||||
) => {
|
|
||||||
if (buffer && collectionId && linkId) {
|
|
||||||
// Load the image using Jimp
|
|
||||||
await Jimp.read(buffer, async (err, image) => {
|
|
||||||
if (image && !err) {
|
|
||||||
image?.resize(1280, Jimp.AUTO).quality(20);
|
|
||||||
const processedBuffer = await image?.getBufferAsync(Jimp.MIME_JPEG);
|
|
||||||
|
|
||||||
createFile({
|
|
||||||
data: processedBuffer,
|
|
||||||
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
|
|
||||||
}).then(() => {
|
|
||||||
return prisma.link.update({
|
|
||||||
where: { id: linkId },
|
|
||||||
data: {
|
|
||||||
preview: `archives/preview/${collectionId}/${linkId}.jpeg`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error("Error processing the image:", err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default generatePreview;
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import moveFile from "./storage/moveFile";
|
|
||||||
import removeFile from "./storage/removeFile";
|
|
||||||
|
|
||||||
const removeFiles = async (linkId: number, collectionId: number) => {
|
|
||||||
// PDF
|
|
||||||
await removeFile({
|
|
||||||
filePath: `archives/${collectionId}/${linkId}.pdf`,
|
|
||||||
});
|
|
||||||
// Images
|
|
||||||
await removeFile({
|
|
||||||
filePath: `archives/${collectionId}/${linkId}.png`,
|
|
||||||
});
|
|
||||||
await removeFile({
|
|
||||||
filePath: `archives/${collectionId}/${linkId}.jpeg`,
|
|
||||||
});
|
|
||||||
await removeFile({
|
|
||||||
filePath: `archives/${collectionId}/${linkId}.jpg`,
|
|
||||||
});
|
|
||||||
// Preview
|
|
||||||
await removeFile({
|
|
||||||
filePath: `archives/preview/${collectionId}/${linkId}.jpeg`,
|
|
||||||
});
|
|
||||||
// Readability
|
|
||||||
await removeFile({
|
|
||||||
filePath: `archives/${collectionId}/${linkId}_readability.json`,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveFiles = async (linkId: number, from: number, to: number) => {
|
|
||||||
await moveFile(
|
|
||||||
`archives/${from}/${linkId}.pdf`,
|
|
||||||
`archives/${to}/${linkId}.pdf`
|
|
||||||
);
|
|
||||||
|
|
||||||
await moveFile(
|
|
||||||
`archives/${from}/${linkId}.png`,
|
|
||||||
`archives/${to}/${linkId}.png`
|
|
||||||
);
|
|
||||||
|
|
||||||
await moveFile(
|
|
||||||
`archives/${from}/${linkId}.jpeg`,
|
|
||||||
`archives/${to}/${linkId}.jpeg`
|
|
||||||
);
|
|
||||||
|
|
||||||
await moveFile(
|
|
||||||
`archives/${from}/${linkId}.jpg`,
|
|
||||||
`archives/${to}/${linkId}.jpg`
|
|
||||||
);
|
|
||||||
|
|
||||||
await moveFile(
|
|
||||||
`archives/preview/${from}/${linkId}.jpeg`,
|
|
||||||
`archives/preview/${to}/${linkId}.jpeg`
|
|
||||||
);
|
|
||||||
|
|
||||||
await moveFile(
|
|
||||||
`archives/${from}/${linkId}_readability.json`,
|
|
||||||
`archives/${to}/${linkId}_readability.json`
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { removeFiles, moveFiles };
|
|
||||||
@@ -16,8 +16,11 @@ export default async function paymentCheckout(
|
|||||||
|
|
||||||
const isExistingCustomer = listByEmail?.data[0]?.id || undefined;
|
const isExistingCustomer = listByEmail?.data[0]?.id || undefined;
|
||||||
|
|
||||||
|
console.log("isExistingCustomer", listByEmail?.data[0]);
|
||||||
|
|
||||||
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
|
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
|
||||||
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
|
Number(process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS) || 14;
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
customer: isExistingCustomer ? isExistingCustomer : undefined,
|
customer: isExistingCustomer ? isExistingCustomer : undefined,
|
||||||
line_items: [
|
line_items: [
|
||||||
@@ -34,9 +37,9 @@ export default async function paymentCheckout(
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
subscription_data: {
|
subscription_data: {
|
||||||
trial_period_days: NEXT_PUBLIC_TRIAL_PERIOD_DAYS
|
trial_period_days: isExistingCustomer
|
||||||
? Number(NEXT_PUBLIC_TRIAL_PERIOD_DAYS)
|
? undefined
|
||||||
: 14,
|
: NEXT_PUBLIC_TRIAL_PERIOD_DAYS,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
import fetch from "node-fetch";
|
import fetch from "node-fetch";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
||||||
|
|
||||||
export default async function validateUrlSize(url: string) {
|
export default async function validateUrlSize(url: string) {
|
||||||
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const httpsAgent = new https.Agent({
|
const httpsAgent = new https.Agent({
|
||||||
rejectUnauthorized:
|
rejectUnauthorized:
|
||||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let fetchOpts = {
|
const response = await fetch(url, {
|
||||||
method: "HEAD",
|
method: "HEAD",
|
||||||
agent: httpsAgent,
|
agent: httpsAgent,
|
||||||
};
|
});
|
||||||
|
|
||||||
if (process.env.PROXY) {
|
|
||||||
let proxy = new URL(process.env.PROXY);
|
|
||||||
if (process.env.PROXY_USERNAME) {
|
|
||||||
proxy.username = process.env.PROXY_USERNAME;
|
|
||||||
proxy.password = process.env.PROXY_PASSWORD || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchOpts = {
|
|
||||||
method: "HEAD",
|
|
||||||
agent: new SocksProxyAgent(proxy.toString()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, fetchOpts);
|
|
||||||
|
|
||||||
const totalSizeMB =
|
const totalSizeMB =
|
||||||
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
||||||
|
|||||||
@@ -16,30 +16,24 @@ export const generateLinkHref = (
|
|||||||
): string => {
|
): string => {
|
||||||
// Return the links href based on the account's preference
|
// Return the links href based on the account's preference
|
||||||
// If the user's preference is not available, return the original link
|
// If the user's preference is not available, return the original link
|
||||||
if (account.linksRouteTo === LinksRouteTo.ORIGINAL && link.type === "url") {
|
switch (account.linksRouteTo) {
|
||||||
return link.url || "";
|
case LinksRouteTo.ORIGINAL:
|
||||||
} else if (account.linksRouteTo === LinksRouteTo.PDF || link.type === "pdf") {
|
return link.url || "";
|
||||||
if (!pdfAvailable(link)) return link.url || "";
|
case LinksRouteTo.PDF:
|
||||||
|
if (!pdfAvailable(link)) return link.url || "";
|
||||||
|
|
||||||
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
|
return `/preserved/${link?.id}?format=${ArchivedFormat.pdf}`;
|
||||||
} else if (
|
case LinksRouteTo.READABLE:
|
||||||
account.linksRouteTo === LinksRouteTo.READABLE &&
|
if (!readabilityAvailable(link)) return link.url || "";
|
||||||
link.type === "url"
|
|
||||||
) {
|
|
||||||
if (!readabilityAvailable(link)) return link.url || "";
|
|
||||||
|
|
||||||
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
|
return `/preserved/${link?.id}?format=${ArchivedFormat.readability}`;
|
||||||
} else if (
|
case LinksRouteTo.SCREENSHOT:
|
||||||
account.linksRouteTo === LinksRouteTo.SCREENSHOT ||
|
if (!screenshotAvailable(link)) return link.url || "";
|
||||||
link.type === "image"
|
|
||||||
) {
|
|
||||||
console.log(link);
|
|
||||||
if (!screenshotAvailable(link)) return link.url || "";
|
|
||||||
|
|
||||||
return `/preserved/${link?.id}?format=${
|
return `/preserved/${link?.id}?format=${
|
||||||
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
|
link?.image?.endsWith("png") ? ArchivedFormat.png : ArchivedFormat.jpeg
|
||||||
}`;
|
}`;
|
||||||
} else {
|
default:
|
||||||
return link.url || "";
|
return link.url || "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-17
@@ -27,25 +27,14 @@ export default async function getTitle(url: string) {
|
|||||||
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
|
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsePromise = fetch(url, fetchOpts);
|
const response = await fetch(url, fetchOpts);
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
reject(new Error("Fetch title timeout"));
|
|
||||||
}, 10 * 1000); // Stop after 10 seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await Promise.race([responsePromise, timeoutPromise]);
|
const text = await response.text();
|
||||||
|
|
||||||
if ((response as any)?.status) {
|
// regular expression to find the <title> tag
|
||||||
const text = await (response as any).text();
|
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
||||||
|
if (match) return match[1];
|
||||||
// regular expression to find the <title> tag
|
else return "";
|
||||||
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
|
||||||
if (match) return match[1];
|
|
||||||
else return "";
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const { version } = require("./package.json");
|
|
||||||
|
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
images: {
|
images: {
|
||||||
domains: ["t2.gstatic.com"],
|
domains: ["t2.gstatic.com"],
|
||||||
minimumCacheTTL: 10,
|
minimumCacheTTL: 10,
|
||||||
},
|
},
|
||||||
env: {
|
|
||||||
version,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkwarden",
|
"name": "linkwarden",
|
||||||
"version": "v2.5.4",
|
"version": "2.5.1",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --",
|
"dev": "concurrently -k -P \"next dev {@}\" \"yarn worker:dev\" --",
|
||||||
"worker:dev": "nodemon --skip-project scripts/worker.ts",
|
"worker:dev": "nodemon --skip-project scripts/worker.ts",
|
||||||
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
"worker:prod": "ts-node --transpile-only --skip-project scripts/worker.ts",
|
||||||
"start": "concurrently -P \"next start {@}\" \"yarn worker:prod\" --",
|
"start": "concurrently -k -P \"next start {@}\" \"yarn worker:prod\" --",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
"format": "prettier --write \"**/*.{ts,tsx,js,json,md}\""
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"nodemon": "^3.0.2",
|
"nodemon": "^3.0.2",
|
||||||
"postcss": "^8.4.26",
|
"postcss": "^8.4.26",
|
||||||
"prettier": "3.1.1",
|
"prettier": "3.1.1",
|
||||||
"prisma": "^4.16.2",
|
"prisma": "^5.1.0",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "4.9.4"
|
"typescript": "4.9.4"
|
||||||
|
|||||||
+2
-27
@@ -5,8 +5,7 @@ import { SessionProvider } from "next-auth/react";
|
|||||||
import type { AppProps } from "next/app";
|
import type { AppProps } from "next/app";
|
||||||
import Head from "next/head";
|
import Head from "next/head";
|
||||||
import AuthRedirect from "@/layouts/AuthRedirect";
|
import AuthRedirect from "@/layouts/AuthRedirect";
|
||||||
import toast from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import { Toaster, ToastBar } from "react-hot-toast";
|
|
||||||
import { Session } from "next-auth";
|
import { Session } from "next-auth";
|
||||||
import { isPWA } from "@/lib/client/utils";
|
import { isPWA } from "@/lib/client/utils";
|
||||||
|
|
||||||
@@ -62,31 +61,7 @@ export default function App({
|
|||||||
className:
|
className:
|
||||||
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
"border border-sky-100 dark:border-neutral-700 dark:bg-neutral-800 dark:text-white",
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
{(t) => (
|
|
||||||
<ToastBar toast={t}>
|
|
||||||
{({ icon, message }) => (
|
|
||||||
<div
|
|
||||||
className="flex flex-row"
|
|
||||||
data-testid="toast-message-container"
|
|
||||||
data-type={t.type}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
<span data-testid="toast-message">{message}</span>
|
|
||||||
{t.type !== "loading" && (
|
|
||||||
<button
|
|
||||||
className="btn btn-xs outline-none btn-circle btn-ghost"
|
|
||||||
data-testid="close-toast-button"
|
|
||||||
onClick={() => toast.dismiss(t.id)}
|
|
||||||
>
|
|
||||||
<i className="bi bi-x"></i>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ToastBar>
|
|
||||||
)}
|
|
||||||
</Toaster>
|
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</AuthRedirect>
|
</AuthRedirect>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
-174
@@ -1,174 +0,0 @@
|
|||||||
import DeleteUserModal from "@/components/ModalContent/DeleteUserModal";
|
|
||||||
import NewUserModal from "@/components/ModalContent/NewUserModal";
|
|
||||||
import useUserStore from "@/store/admin/users";
|
|
||||||
import { User as U } from "@prisma/client";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Fragment, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface User extends U {
|
|
||||||
subscriptions: {
|
|
||||||
active: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type UserModal = {
|
|
||||||
isOpen: boolean;
|
|
||||||
userId: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Admin() {
|
|
||||||
const { users, setUsers } = useUserStore();
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [filteredUsers, setFilteredUsers] = useState<User[]>();
|
|
||||||
|
|
||||||
const [deleteUserModal, setDeleteUserModal] = useState<UserModal>({
|
|
||||||
isOpen: false,
|
|
||||||
userId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [newUserModal, setNewUserModal] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUsers();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto p-5">
|
|
||||||
<div className="flex sm:flex-row flex-col justify-between gap-2">
|
|
||||||
<div className="gap-2 inline-flex items-center">
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="text-neutral btn btn-square btn-sm btn-ghost"
|
|
||||||
>
|
|
||||||
<i className="bi-chevron-left text-xl"></i>
|
|
||||||
</Link>
|
|
||||||
<p className="capitalize text-3xl font-thin inline">
|
|
||||||
User Administration
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center relative justify-between gap-2">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="search-box"
|
|
||||||
className="inline-flex items-center w-fit absolute left-1 pointer-events-none rounded-md p-1 text-primary"
|
|
||||||
>
|
|
||||||
<i className="bi-search"></i>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<input
|
|
||||||
id="search-box"
|
|
||||||
type="text"
|
|
||||||
placeholder={"Search for Users"}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearchQuery(e.target.value);
|
|
||||||
|
|
||||||
if (users) {
|
|
||||||
setFilteredUsers(
|
|
||||||
users.filter((user) =>
|
|
||||||
JSON.stringify(user)
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(e.target.value.toLowerCase())
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="border border-neutral-content bg-base-200 focus:border-primary py-1 rounded-md pl-9 pr-2 w-full max-w-[15rem] md:w-[15rem] md:max-w-full duration-200 outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
onClick={() => setNewUserModal(true)}
|
|
||||||
className="flex items-center btn btn-accent dark:border-violet-400 text-white btn-sm px-2 aspect-square relative"
|
|
||||||
>
|
|
||||||
<i className="bi-plus text-3xl absolute"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divider my-3"></div>
|
|
||||||
|
|
||||||
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
|
|
||||||
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal)
|
|
||||||
) : searchQuery !== "" ? (
|
|
||||||
<p>No users found with the given search query.</p>
|
|
||||||
) : users && users.length > 0 ? (
|
|
||||||
UserListing(users, deleteUserModal, setDeleteUserModal)
|
|
||||||
) : (
|
|
||||||
<p>No users found.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{newUserModal ? (
|
|
||||||
<NewUserModal onClose={() => setNewUserModal(false)} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserListing = (
|
|
||||||
users: User[],
|
|
||||||
deleteUserModal: UserModal,
|
|
||||||
setDeleteUserModal: Function
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<div className="overflow-x-auto whitespace-nowrap w-full">
|
|
||||||
<table className="table w-full">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th></th>
|
|
||||||
<th>Username</th>
|
|
||||||
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
|
||||||
<th>Email</th>
|
|
||||||
)}
|
|
||||||
{process.env.NEXT_PUBLIC_STRIPE === "true" && <th>Subscribed</th>}
|
|
||||||
<th>Created At</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((user, index) => (
|
|
||||||
<tr
|
|
||||||
key={index}
|
|
||||||
className="group hover:bg-neutral-content hover:bg-opacity-30 duration-100"
|
|
||||||
>
|
|
||||||
<td className="text-primary">{index + 1}</td>
|
|
||||||
<td>{user.username ? user.username : <b>N/A</b>}</td>
|
|
||||||
{process.env.NEXT_PUBLIC_EMAIL_PROVIDER === "true" && (
|
|
||||||
<td>{user.email}</td>
|
|
||||||
)}
|
|
||||||
{process.env.NEXT_PUBLIC_STRIPE === "true" && (
|
|
||||||
<td>
|
|
||||||
{user.subscriptions?.active ? (
|
|
||||||
JSON.stringify(user.subscriptions?.active)
|
|
||||||
) : (
|
|
||||||
<b>N/A</b>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td>{new Date(user.createdAt).toLocaleString()}</td>
|
|
||||||
<td className="relative">
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-ghost duration-100 hidden group-hover:block absolute z-20 right-[0.35rem] top-[0.35rem]"
|
|
||||||
onClick={() =>
|
|
||||||
setDeleteUserModal({ isOpen: true, userId: user.id })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<i className="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{deleteUserModal.isOpen && deleteUserModal.userId ? (
|
|
||||||
<DeleteUserModal
|
|
||||||
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
|
|
||||||
userId={deleteUserModal.userId}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -9,8 +9,6 @@ import formidable from "formidable";
|
|||||||
import createFile from "@/lib/api/storage/createFile";
|
import createFile from "@/lib/api/storage/createFile";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import verifyToken from "@/lib/api/verifyToken";
|
import verifyToken from "@/lib/api/verifyToken";
|
||||||
import generatePreview from "@/lib/api/generatePreview";
|
|
||||||
import createFolder from "@/lib/api/storage/createFolder";
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
@@ -75,97 +73,83 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
return res.send(file);
|
return res.send(file);
|
||||||
}
|
}
|
||||||
} else if (req.method === "POST") {
|
|
||||||
const user = await verifyUser({ req, res });
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const collectionPermissions = await getPermission({
|
|
||||||
userId: user.id,
|
|
||||||
linkId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const memberHasAccess = collectionPermissions?.members.some(
|
|
||||||
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
|
|
||||||
return { response: "Collection is not accessible.", status: 401 };
|
|
||||||
|
|
||||||
// await uploadHandler(linkId, )
|
|
||||||
|
|
||||||
const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
|
|
||||||
|
|
||||||
const form = formidable({
|
|
||||||
maxFields: 1,
|
|
||||||
maxFiles: 1,
|
|
||||||
maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
|
|
||||||
});
|
|
||||||
|
|
||||||
form.parse(req, async (err, fields, files) => {
|
|
||||||
const allowedMIMETypes = [
|
|
||||||
"application/pdf",
|
|
||||||
"image/png",
|
|
||||||
"image/jpg",
|
|
||||||
"image/jpeg",
|
|
||||||
];
|
|
||||||
|
|
||||||
if (
|
|
||||||
err ||
|
|
||||||
!files.file ||
|
|
||||||
!files.file[0] ||
|
|
||||||
!allowedMIMETypes.includes(files.file[0].mimetype || "")
|
|
||||||
) {
|
|
||||||
// Handle parsing error
|
|
||||||
return res.status(500).json({
|
|
||||||
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const fileBuffer = fs.readFileSync(files.file[0].filepath);
|
|
||||||
|
|
||||||
const linkStillExists = await prisma.link.findUnique({
|
|
||||||
where: { id: linkId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (linkStillExists && files.file[0].mimetype?.includes("image")) {
|
|
||||||
const collectionId = collectionPermissions?.id as number;
|
|
||||||
createFolder({
|
|
||||||
filePath: `archives/preview/${collectionId}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
generatePreview(fileBuffer, collectionId, linkId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (linkStillExists) {
|
|
||||||
await createFile({
|
|
||||||
filePath: `archives/${collectionPermissions?.id}/${
|
|
||||||
linkId + suffix
|
|
||||||
}`,
|
|
||||||
data: fileBuffer,
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.link.update({
|
|
||||||
where: { id: linkId },
|
|
||||||
data: {
|
|
||||||
preview: files.file[0].mimetype?.includes("pdf")
|
|
||||||
? "unavailable"
|
|
||||||
: undefined,
|
|
||||||
image: files.file[0].mimetype?.includes("image")
|
|
||||||
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
|
|
||||||
: null,
|
|
||||||
pdf: files.file[0].mimetype?.includes("pdf")
|
|
||||||
? `archives/${collectionPermissions?.id}/${linkId + suffix}`
|
|
||||||
: null,
|
|
||||||
lastPreserved: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.unlinkSync(files.file[0].filepath);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
response: files,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
// else if (req.method === "POST") {
|
||||||
|
// const user = await verifyUser({ req, res });
|
||||||
|
// if (!user) return;
|
||||||
|
|
||||||
|
// const collectionPermissions = await getPermission({
|
||||||
|
// userId: user.id,
|
||||||
|
// linkId,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const memberHasAccess = collectionPermissions?.members.some(
|
||||||
|
// (e: UsersAndCollections) => e.userId === user.id && e.canCreate
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (!(collectionPermissions?.ownerId === user.id || memberHasAccess))
|
||||||
|
// return { response: "Collection is not accessible.", status: 401 };
|
||||||
|
|
||||||
|
// // await uploadHandler(linkId, )
|
||||||
|
|
||||||
|
// const MAX_UPLOAD_SIZE = Number(process.env.NEXT_PUBLIC_MAX_FILE_SIZE);
|
||||||
|
|
||||||
|
// const form = formidable({
|
||||||
|
// maxFields: 1,
|
||||||
|
// maxFiles: 1,
|
||||||
|
// maxFileSize: MAX_UPLOAD_SIZE || 30 * 1048576,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// form.parse(req, async (err, fields, files) => {
|
||||||
|
// const allowedMIMETypes = [
|
||||||
|
// "application/pdf",
|
||||||
|
// "image/png",
|
||||||
|
// "image/jpg",
|
||||||
|
// "image/jpeg",
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// if (
|
||||||
|
// err ||
|
||||||
|
// !files.file ||
|
||||||
|
// !files.file[0] ||
|
||||||
|
// !allowedMIMETypes.includes(files.file[0].mimetype || "")
|
||||||
|
// ) {
|
||||||
|
// // Handle parsing error
|
||||||
|
// return res.status(500).json({
|
||||||
|
// response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${MAX_UPLOAD_SIZE}MB.`,
|
||||||
|
// });
|
||||||
|
// } else {
|
||||||
|
// const fileBuffer = fs.readFileSync(files.file[0].filepath);
|
||||||
|
|
||||||
|
// const linkStillExists = await prisma.link.findUnique({
|
||||||
|
// where: { id: linkId },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if (linkStillExists) {
|
||||||
|
// await createFile({
|
||||||
|
// filePath: `archives/${collectionPermissions?.id}/${
|
||||||
|
// linkId + suffix
|
||||||
|
// }`,
|
||||||
|
// data: fileBuffer,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// await prisma.link.update({
|
||||||
|
// where: { id: linkId },
|
||||||
|
// data: {
|
||||||
|
// image: `archives/${collectionPermissions?.id}/${
|
||||||
|
// linkId + suffix
|
||||||
|
// }`,
|
||||||
|
// lastPreserved: new Date().toISOString(),
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// fs.unlinkSync(files.file[0].filepath);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// return res.status(200).json({
|
||||||
|
// response: files,
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,19 +98,19 @@ if (
|
|||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: emailEnabled
|
where: emailEnabled
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
username: username.toLowerCase(),
|
username: username.toLowerCase(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
email: username?.toLowerCase(),
|
email: username?.toLowerCase(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
emailVerified: { not: null },
|
emailVerified: { not: null },
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
username: username.toLowerCase(),
|
username: username.toLowerCase(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let passwordMatches: boolean = false;
|
let passwordMatches: boolean = false;
|
||||||
@@ -240,37 +240,6 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authelia
|
|
||||||
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
|
|
||||||
providers.push(
|
|
||||||
{
|
|
||||||
id: "authelia",
|
|
||||||
name: "Authelia",
|
|
||||||
type: "oauth",
|
|
||||||
clientId: process.env.AUTHELIA_CLIENT_ID!,
|
|
||||||
clientSecret: process.env.AUTHELIA_CLIENT_SECRET!,
|
|
||||||
wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!,
|
|
||||||
authorization: { params: { scope: "openid email profile" } },
|
|
||||||
idToken: true,
|
|
||||||
checks: ["pkce", "state"],
|
|
||||||
profile(profile) {
|
|
||||||
return {
|
|
||||||
id: profile.sub,
|
|
||||||
name: profile.name,
|
|
||||||
email: profile.email,
|
|
||||||
username: profile.preferred_username,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const _linkAccount = adapter.linkAccount;
|
|
||||||
adapter.linkAccount = (account) => {
|
|
||||||
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
|
|
||||||
return _linkAccount ? _linkAccount(data) : undefined;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentik
|
// Authentik
|
||||||
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
|
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
|
||||||
providers.push(
|
providers.push(
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
|||||||
import { prisma } from "@/lib/api/db";
|
import { prisma } from "@/lib/api/db";
|
||||||
import verifyUser from "@/lib/api/verifyUser";
|
import verifyUser from "@/lib/api/verifyUser";
|
||||||
import isValidUrl from "@/lib/shared/isValidUrl";
|
import isValidUrl from "@/lib/shared/isValidUrl";
|
||||||
|
import removeFile from "@/lib/api/storage/removeFile";
|
||||||
import { Collection, Link } from "@prisma/client";
|
import { Collection, Link } from "@prisma/client";
|
||||||
import { removeFiles } from "@/lib/api/manageLinkFiles";
|
|
||||||
|
|
||||||
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
|
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
|
||||||
|
|
||||||
@@ -80,5 +80,16 @@ const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await removeFiles(link.id, link.collection.id);
|
await removeFile({
|
||||||
|
filePath: `archives/${link.collection.id}/${link.id}.pdf`,
|
||||||
|
});
|
||||||
|
await removeFile({
|
||||||
|
filePath: `archives/${link.collection.id}/${link.id}.png`,
|
||||||
|
});
|
||||||
|
await removeFile({
|
||||||
|
filePath: `archives/${link.collection.id}/${link.id}_readability.json`,
|
||||||
|
});
|
||||||
|
await removeFile({
|
||||||
|
filePath: `archives/preview/${link.collection.id}/${link.id}.png`,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -391,17 +391,10 @@ export function getLogins() {
|
|||||||
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
|
name: process.env.ZOOM_CUSTOM_NAME ?? "Zoom",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Authelia
|
|
||||||
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
|
|
||||||
buttonAuths.push({
|
|
||||||
method: "authelia",
|
|
||||||
name: process.env.AUTHELIA_CUSTOM_NAME ?? "Authelia",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
credentialsEnabled:
|
credentialsEnabled:
|
||||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
|
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
|
||||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
|
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
|
||||||
? "true"
|
? "true"
|
||||||
: "false",
|
: "false",
|
||||||
emailEnabled:
|
emailEnabled:
|
||||||
|
|||||||
@@ -16,17 +16,9 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const userId = token?.id;
|
||||||
where: {
|
|
||||||
id: token?.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const isServerAdmin = process.env.ADMINISTRATOR === user?.username;
|
if (userId !== Number(req.query.id))
|
||||||
|
|
||||||
const userId = isServerAdmin ? Number(req.query.id) : token.id;
|
|
||||||
|
|
||||||
if (userId !== Number(req.query.id) && !isServerAdmin)
|
|
||||||
return res.status(401).json({ response: "Permission denied." });
|
return res.status(401).json({ response: "Permission denied." });
|
||||||
|
|
||||||
if (req.method === "GET") {
|
if (req.method === "GET") {
|
||||||
@@ -61,7 +53,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
const updated = await updateUserById(userId, req.body);
|
const updated = await updateUserById(userId, req.body);
|
||||||
return res.status(updated.status).json({ response: updated.response });
|
return res.status(updated.status).json({ response: updated.response });
|
||||||
} else if (req.method === "DELETE") {
|
} else if (req.method === "DELETE") {
|
||||||
const updated = await deleteUserById(userId, req.body, isServerAdmin);
|
const updated = await deleteUserById(userId, req.body);
|
||||||
return res.status(updated.status).json({ response: updated.response });
|
return res.status(updated.status).json({ response: updated.response });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
import postUser from "@/lib/api/controllers/users/postUser";
|
import postUser from "@/lib/api/controllers/users/postUser";
|
||||||
import getUsers from "@/lib/api/controllers/users/getUsers";
|
|
||||||
import verifyUser from "@/lib/api/verifyUser";
|
|
||||||
|
|
||||||
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method === "POST") {
|
if (req.method === "POST") {
|
||||||
const response = await postUser(req, res);
|
const response = await postUser(req, res);
|
||||||
return response;
|
return response;
|
||||||
} else if (req.method === "GET") {
|
|
||||||
const user = await verifyUser({ req, res });
|
|
||||||
if (!user || process.env.ADMINISTRATOR !== user.username)
|
|
||||||
return res.status(401).json({ response: "Unauthorized..." });
|
|
||||||
|
|
||||||
const response = await getUsers();
|
|
||||||
return res.status(response.status).json({ response: response.response });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -5,6 +5,7 @@ import { useRouter } from "next/router";
|
|||||||
import CenteredForm from "@/layouts/CenteredForm";
|
import CenteredForm from "@/layouts/CenteredForm";
|
||||||
import { Plan } from "@/types/global";
|
import { Plan } from "@/types/global";
|
||||||
import AccentSubmitButton from "@/components/AccentSubmitButton";
|
import AccentSubmitButton from "@/components/AccentSubmitButton";
|
||||||
|
import useAccountStore from "@/store/account";
|
||||||
|
|
||||||
export default function Subscribe() {
|
export default function Subscribe() {
|
||||||
const [submitLoader, setSubmitLoader] = useState(false);
|
const [submitLoader, setSubmitLoader] = useState(false);
|
||||||
@@ -12,6 +13,8 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
const [plan, setPlan] = useState<Plan>(1);
|
const [plan, setPlan] = useState<Plan>(1);
|
||||||
|
|
||||||
|
const { account } = useAccountStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
@@ -27,9 +30,13 @@ export default function Subscribe() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredForm
|
<CenteredForm
|
||||||
text={`Start with a ${
|
text={
|
||||||
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
|
account.username
|
||||||
}-day free trial, cancel anytime!`}
|
? ""
|
||||||
|
: `Start with a ${
|
||||||
|
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS || 14
|
||||||
|
}-day free trial, cancel anytime!`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
<div className="p-4 mx-auto flex flex-col gap-3 justify-between max-w-[30rem] min-w-80 w-full bg-base-200 rounded-2xl shadow-md border border-neutral-content">
|
||||||
<p className="sm:text-3xl text-2xl text-center font-extralight">
|
<p className="sm:text-3xl text-2xl text-center font-extralight">
|
||||||
@@ -37,7 +44,6 @@ export default function Subscribe() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="divider my-0"></div>
|
<div className="divider my-0"></div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
You will be redirected to Stripe, feel free to reach out to us at{" "}
|
You will be redirected to Stripe, feel free to reach out to us at{" "}
|
||||||
@@ -47,7 +53,6 @@ export default function Subscribe() {
|
|||||||
in case of any issue.
|
in case of any issue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 border border-solid border-neutral-content w-4/5 mx-auto p-1 rounded-xl relative">
|
<div className="flex gap-3 border border-solid border-neutral-content w-4/5 mx-auto p-1 rounded-xl relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPlan(Plan.monthly)}
|
onClick={() => setPlan(Plan.monthly)}
|
||||||
@@ -74,7 +79,6 @@ export default function Subscribe() {
|
|||||||
25% Off
|
25% Off
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 justify-center items-center">
|
<div className="flex flex-col gap-2 justify-center items-center">
|
||||||
<p className="text-3xl">
|
<p className="text-3xl">
|
||||||
${plan === Plan.monthly ? "4" : "3"}
|
${plan === Plan.monthly ? "4" : "3"}
|
||||||
@@ -89,13 +93,20 @@ export default function Subscribe() {
|
|||||||
</legend>
|
</legend>
|
||||||
|
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then $
|
{account.username
|
||||||
{plan === Plan.monthly ? "4 per month" : "36 annually"}
|
? ""
|
||||||
|
: `${process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS}-day free trial, then `}
|
||||||
|
${plan === Plan.monthly ? "4 per month" : "36 annually"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm">+ VAT if applicable</p>
|
<p className="text-sm">+ VAT if applicable</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<p className="text-sm mb-5">
|
||||||
|
{account.username
|
||||||
|
? "Please note that since your trial has been previously ended, your subscription will start immediately. You can cancel anytime."
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<AccentSubmitButton
|
<AccentSubmitButton
|
||||||
type="button"
|
type="button"
|
||||||
label="Complete Subscription!"
|
label="Complete Subscription!"
|
||||||
@@ -103,7 +114,6 @@ export default function Subscribe() {
|
|||||||
onClick={submit}
|
onClick={submit}
|
||||||
loading={submitLoader}
|
loading={submitLoader}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
|
className="w-fit mx-auto cursor-pointer text-neutral font-semibold "
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Link" ADD COLUMN "importDate" TIMESTAMP(3);
|
|
||||||
@@ -128,7 +128,6 @@ model Link {
|
|||||||
pdf String?
|
pdf String?
|
||||||
readable String?
|
readable String?
|
||||||
lastPreserved DateTime?
|
lastPreserved DateTime?
|
||||||
importDate DateTime?
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 31 KiB |
@@ -1,66 +0,0 @@
|
|||||||
import { User as U } from "@prisma/client";
|
|
||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
interface User extends U {
|
|
||||||
subscriptions: {
|
|
||||||
active: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ResponseObject = {
|
|
||||||
ok: boolean;
|
|
||||||
data: object | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UserStore = {
|
|
||||||
users: User[];
|
|
||||||
setUsers: () => void;
|
|
||||||
addUser: (body: Partial<U>) => Promise<ResponseObject>;
|
|
||||||
removeUser: (userId: number) => Promise<ResponseObject>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useUserStore = create<UserStore>((set) => ({
|
|
||||||
users: [],
|
|
||||||
setUsers: async () => {
|
|
||||||
const response = await fetch("/api/v1/users");
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) set({ users: data.response });
|
|
||||||
else if (response.status === 401) window.location.href = "/dashboard";
|
|
||||||
},
|
|
||||||
addUser: async (body) => {
|
|
||||||
const response = await fetch("/api/v1/users", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok)
|
|
||||||
set((state) => ({
|
|
||||||
users: [...state.users, data.response],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { ok: response.ok, data: data.response };
|
|
||||||
},
|
|
||||||
removeUser: async (userId) => {
|
|
||||||
const response = await fetch(`/api/v1/users/${userId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok)
|
|
||||||
set((state) => ({
|
|
||||||
users: state.users.filter((user) => user.id !== userId),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { ok: response.ok, data: data.response };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default useUserStore;
|
|
||||||
+1
-84
@@ -1,8 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import {
|
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
|
||||||
ArchivedFormat,
|
|
||||||
LinkIncludingShortenedCollectionAndTags,
|
|
||||||
} from "@/types/global";
|
|
||||||
import useTagStore from "./tags";
|
import useTagStore from "./tags";
|
||||||
import useCollectionStore from "./collections";
|
import useCollectionStore from "./collections";
|
||||||
|
|
||||||
@@ -22,10 +19,6 @@ type LinkStore = {
|
|||||||
addLink: (
|
addLink: (
|
||||||
body: LinkIncludingShortenedCollectionAndTags
|
body: LinkIncludingShortenedCollectionAndTags
|
||||||
) => Promise<ResponseObject>;
|
) => Promise<ResponseObject>;
|
||||||
uploadFile: (
|
|
||||||
link: LinkIncludingShortenedCollectionAndTags,
|
|
||||||
file: File
|
|
||||||
) => Promise<ResponseObject>;
|
|
||||||
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
|
getLink: (linkId: number, publicRoute?: boolean) => Promise<ResponseObject>;
|
||||||
updateLink: (
|
updateLink: (
|
||||||
link: LinkIncludingShortenedCollectionAndTags
|
link: LinkIncludingShortenedCollectionAndTags
|
||||||
@@ -86,82 +79,6 @@ const useLinkStore = create<LinkStore>()((set) => ({
|
|||||||
|
|
||||||
return { ok: response.ok, data: data.response };
|
return { ok: response.ok, data: data.response };
|
||||||
},
|
},
|
||||||
uploadFile: async (link, file) => {
|
|
||||||
let fileType: ArchivedFormat | null = null;
|
|
||||||
let linkType: "url" | "image" | "pdf" | null = null;
|
|
||||||
|
|
||||||
if (file?.type === "image/jpg" || file.type === "image/jpeg") {
|
|
||||||
fileType = ArchivedFormat.jpeg;
|
|
||||||
linkType = "image";
|
|
||||||
} else if (file.type === "image/png") {
|
|
||||||
fileType = ArchivedFormat.png;
|
|
||||||
linkType = "image";
|
|
||||||
} else if (file.type === "application/pdf") {
|
|
||||||
fileType = ArchivedFormat.pdf;
|
|
||||||
linkType = "pdf";
|
|
||||||
} else {
|
|
||||||
return { ok: false, data: "Invalid file type." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch("/api/v1/links", {
|
|
||||||
body: JSON.stringify({
|
|
||||||
...link,
|
|
||||||
type: linkType,
|
|
||||||
name: link.name ? link.name : file.name,
|
|
||||||
}),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
const createdLink: LinkIncludingShortenedCollectionAndTags = data.response;
|
|
||||||
|
|
||||||
console.log(data);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const formBody = new FormData();
|
|
||||||
file && formBody.append("file", file);
|
|
||||||
|
|
||||||
await fetch(
|
|
||||||
`/api/v1/archives/${(data as any).response.id}?format=${fileType}`,
|
|
||||||
{
|
|
||||||
body: formBody,
|
|
||||||
method: "POST",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// get file extension
|
|
||||||
const extension = file.name.split(".").pop() || "";
|
|
||||||
|
|
||||||
set((state) => ({
|
|
||||||
links: [
|
|
||||||
{
|
|
||||||
...createdLink,
|
|
||||||
image:
|
|
||||||
linkType === "image"
|
|
||||||
? `archives/${createdLink.collectionId}/${
|
|
||||||
createdLink.id + extension
|
|
||||||
}`
|
|
||||||
: null,
|
|
||||||
pdf:
|
|
||||||
linkType === "pdf"
|
|
||||||
? `archives/${createdLink.collectionId}/${
|
|
||||||
createdLink.id + ".pdf"
|
|
||||||
}`
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
...state.links,
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
useTagStore.getState().setTags();
|
|
||||||
useCollectionStore.getState().setCollections();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ok: response.ok, data: data.response };
|
|
||||||
},
|
|
||||||
getLink: async (linkId, publicRoute) => {
|
getLink: async (linkId, publicRoute) => {
|
||||||
const path = publicRoute
|
const path = publicRoute
|
||||||
? `/api/v1/public/links/${linkId}`
|
? `/api/v1/public/links/${linkId}`
|
||||||
|
|||||||
Vendored
-9
@@ -13,8 +13,6 @@ declare global {
|
|||||||
MAX_LINKS_PER_USER?: string;
|
MAX_LINKS_PER_USER?: string;
|
||||||
ARCHIVE_TAKE_COUNT?: string;
|
ARCHIVE_TAKE_COUNT?: string;
|
||||||
IGNORE_UNAUTHORIZED_CA?: string;
|
IGNORE_UNAUTHORIZED_CA?: string;
|
||||||
IGNORE_URL_SIZE_LIMIT?: string;
|
|
||||||
ADMINISTRATOR?: string;
|
|
||||||
|
|
||||||
SPACES_KEY?: string;
|
SPACES_KEY?: string;
|
||||||
SPACES_SECRET?: string;
|
SPACES_SECRET?: string;
|
||||||
@@ -78,13 +76,6 @@ declare global {
|
|||||||
AUTH0_CLIENT_SECRET?: string;
|
AUTH0_CLIENT_SECRET?: string;
|
||||||
AUTH0_CLIENT_ID?: string;
|
AUTH0_CLIENT_ID?: string;
|
||||||
|
|
||||||
// Authelia
|
|
||||||
NEXT_PUBLIC_AUTHELIA_ENABLED?: string;
|
|
||||||
AUTHELIA_CUSTOM_NAME?: string;
|
|
||||||
AUTHELIA_CLIENT_ID?: string;
|
|
||||||
AUTHELIA_CLIENT_SECRET?: string;
|
|
||||||
AUTHELIA_WELLKNOWN_URL?: string;
|
|
||||||
|
|
||||||
// Authentik
|
// Authentik
|
||||||
NEXT_PUBLIC_AUTHENTIK_ENABLED?: string;
|
NEXT_PUBLIC_AUTHENTIK_ENABLED?: string;
|
||||||
AUTHENTIK_CUSTOM_NAME?: string;
|
AUTHENTIK_CUSTOM_NAME?: string;
|
||||||
|
|||||||
+1
-7
@@ -7,16 +7,10 @@ type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
|
|||||||
export interface LinkIncludingShortenedCollectionAndTags
|
export interface LinkIncludingShortenedCollectionAndTags
|
||||||
extends Omit<
|
extends Omit<
|
||||||
Link,
|
Link,
|
||||||
| "id"
|
"id" | "createdAt" | "collectionId" | "updatedAt" | "lastPreserved"
|
||||||
| "createdAt"
|
|
||||||
| "collectionId"
|
|
||||||
| "updatedAt"
|
|
||||||
| "lastPreserved"
|
|
||||||
| "importDate"
|
|
||||||
> {
|
> {
|
||||||
id?: number;
|
id?: number;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
importDate?: string;
|
|
||||||
collectionId?: number;
|
collectionId?: number;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
pinnedBy?: {
|
pinnedBy?: {
|
||||||
|
|||||||
@@ -1301,10 +1301,10 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14"
|
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-4.16.1-1.4bc8b6e1b66cb932731fb1bdbbc550d1e010de81.tgz#d3b5dcf95b6d220e258cbf6ae19b06d30a7e9f14"
|
||||||
integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==
|
integrity sha512-q617EUWfRIDTriWADZ4YiWRZXCa/WuhNgLTVd+HqWLffjMSPzyM5uOWoauX91wvQClSKZU4pzI4JJLQ9Kl62Qg==
|
||||||
|
|
||||||
"@prisma/engines@4.16.2":
|
"@prisma/engines@5.1.0":
|
||||||
version "4.16.2"
|
version "5.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-4.16.2.tgz#5ec8dd672c2173d597e469194916ad4826ce2e5f"
|
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.1.0.tgz#4ccf7f344eaeee08ca1e4a1bb2dc14e36ff1d5ec"
|
||||||
integrity sha512-vx1nxVvN4QeT/cepQce68deh/Turxy5Mr+4L4zClFuK1GlxN3+ivxfuv+ej/gvidWn1cE1uAhW7ALLNlYbRUAw==
|
integrity sha512-HqaFsnPmZOdMWkPq6tT2eTVTQyaAXEDdKszcZ4yc7DGMBIYRP6j/zAJTtZUG9SsMV8FaucdL5vRyxY/p5Ni28g==
|
||||||
|
|
||||||
"@radix-ui/primitive@1.0.1":
|
"@radix-ui/primitive@1.0.1":
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
@@ -5038,12 +5038,12 @@ pretty-format@^3.8.0:
|
|||||||
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
|
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-3.8.0.tgz#bfbed56d5e9a776645f4b1ff7aa1a3ac4fa3c385"
|
||||||
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
|
integrity sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==
|
||||||
|
|
||||||
prisma@^4.16.2:
|
prisma@^5.1.0:
|
||||||
version "4.16.2"
|
version "5.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-4.16.2.tgz#469e0a0991c6ae5bcde289401726bb012253339e"
|
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.1.0.tgz#29e316b54844f5694a83017a9781a6d6f7cb99ea"
|
||||||
integrity sha512-SYCsBvDf0/7XSJyf2cHTLjLeTLVXYfqp7pG5eEVafFLeT0u/hLFz/9W196nDRGUOo1JfPatAEb+uEnTQImQC1g==
|
integrity sha512-wkXvh+6wxk03G8qwpZMOed4Y3j+EQ+bMTlvbDZHeal6k1E8QuGKzRO7DRXlE1NV0WNgOAas8kwZqcLETQ2+BiQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@prisma/engines" "4.16.2"
|
"@prisma/engines" "5.1.0"
|
||||||
|
|
||||||
process@^0.11.10:
|
process@^0.11.10:
|
||||||
version "0.11.10"
|
version "0.11.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user