Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ad01a2d0 | |||
| 915d08a315 | |||
| 08c2ff278f | |||
| 154d0d5fb6 | |||
| 7856e76b15 | |||
| f37a4b9c9e | |||
| 389db59b28 | |||
| b702aa0401 | |||
| 9a92b4d229 | |||
| 8278878673 | |||
| 4640c1c966 | |||
| 49fbbe966c | |||
| 3610e73d3b | |||
| 76a5dcb90b | |||
| e51fba41e7 | |||
| e8edd1c9a0 | |||
| f30c652676 | |||
| 8cf621bc62 | |||
| a89274fc03 | |||
| baadd6c06b | |||
| 4a71af8a67 | |||
| ece09c6f3b | |||
| 189db27c5b | |||
| 68d8d403cf | |||
| 87eb2471ff | |||
| 58b6f7339c | |||
| 5503483502 | |||
| a6d018fb53 | |||
| 3929f32e63 | |||
| c08522386b | |||
| b51a876904 | |||
| 2e2d7baee1 | |||
| 495af0a752 | |||
| 388b9d9184 | |||
| a3d3b353a1 | |||
| cc2d7c863d | |||
| 53a65774f0 | |||
| ce2eb8eafb | |||
| 4e20d71a41 | |||
| cac90524ed | |||
| 9fce74971f | |||
| bde7b9aae0 | |||
| 7dd254af48 | |||
| 3969cc5abd |
@@ -21,6 +21,8 @@ ARCHIVE_TAKE_COUNT=
|
||||
BROWSER_TIMEOUT=
|
||||
IGNORE_UNAUTHORIZED_CA=
|
||||
IGNORE_HTTPS_ERRORS=
|
||||
IGNORE_URL_SIZE_LIMIT=
|
||||
ADMINISTRATOR=
|
||||
|
||||
# AWS S3 Settings
|
||||
SPACES_KEY=
|
||||
@@ -75,6 +77,13 @@ AUTH0_ISSUER=
|
||||
AUTH0_CLIENT_SECRET=
|
||||
AUTH0_CLIENT_ID=
|
||||
|
||||
# Authelia
|
||||
NEXT_PUBLIC_AUTHELIA_ENABLED=""
|
||||
AUTHELIA_CLIENT_ID=""
|
||||
AUTHELIA_CLIENT_SECRET=""
|
||||
AUTHELIA_WELLKNOWN_URL=""
|
||||
|
||||
|
||||
# Authentik
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED=
|
||||
AUTHENTIK_CUSTOM_NAME=
|
||||
|
||||
+1
-1
@@ -20,4 +20,4 @@ COPY . .
|
||||
RUN yarn prisma generate && \
|
||||
yarn build
|
||||
|
||||
CMD yarn prisma migrate deploy && yarn start
|
||||
CMD yarn prisma migrate deploy && yarn start
|
||||
@@ -8,19 +8,38 @@ type Props = {
|
||||
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(
|
||||
ref: RefObject<HTMLElement>,
|
||||
onClickOutside: Function
|
||||
) {
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: Event) {
|
||||
if (
|
||||
ref.current &&
|
||||
!ref.current.contains(event.target as HTMLInputElement)
|
||||
) {
|
||||
onClickOutside(event);
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const clickedElement = event.target as HTMLElement;
|
||||
if (ref.current && !ref.current.contains(clickedElement)) {
|
||||
const refZIndex = getZIndex(ref.current);
|
||||
const clickedZIndex = getZIndex(clickedElement);
|
||||
if (clickedZIndex <= refZIndex) {
|
||||
onClickOutside(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
|
||||
@@ -6,14 +6,13 @@ export default function LinkDate({
|
||||
}: {
|
||||
link: LinkIncludingShortenedCollectionAndTags;
|
||||
}) {
|
||||
const formattedDate = new Date(link.createdAt as string).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}
|
||||
);
|
||||
const formattedDate = new Date(
|
||||
(link.importDate || link.createdAt) as string
|
||||
).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-neutral">
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+2
-66
@@ -1,40 +1,24 @@
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ClickAwayHandler from "@/components/ClickAwayHandler";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
import { useRouter } from "next/router";
|
||||
import SearchBar from "@/components/SearchBar";
|
||||
import useAccountStore from "@/store/account";
|
||||
import ProfilePhoto from "@/components/ProfilePhoto";
|
||||
import useWindowDimensions from "@/hooks/useWindowDimensions";
|
||||
import ToggleDarkMode from "./ToggleDarkMode";
|
||||
import useLocalSettingsStore from "@/store/localSettings";
|
||||
import NewLinkModal from "./ModalContent/NewLinkModal";
|
||||
import NewCollectionModal from "./ModalContent/NewCollectionModal";
|
||||
import Link from "next/link";
|
||||
import UploadFileModal from "./ModalContent/UploadFileModal";
|
||||
import { dropdownTriggerer } from "@/lib/client/utils";
|
||||
import MobileNavigation from "./MobileNavigation";
|
||||
import ProfileDropdown from "./ProfileDropdown";
|
||||
|
||||
export default function Navbar() {
|
||||
const { settings, updateSettings } = useLocalSettingsStore();
|
||||
|
||||
const { account } = useAccountStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [sidebar, setSidebar] = useState(false);
|
||||
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
const handleToggle = () => {
|
||||
if (settings.theme === "dark") {
|
||||
updateSettings({ theme: "light" });
|
||||
} else {
|
||||
updateSettings({ theme: "dark" });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSidebar(false);
|
||||
document.body.style.overflow = "auto";
|
||||
@@ -120,55 +104,7 @@ export default function Navbar() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
|
||||
<MobileNavigation />
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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,6 +34,8 @@ export default function ReadableView({ link }: Props) {
|
||||
const [imageError, setImageError] = useState<boolean>(false);
|
||||
const [colorPalette, setColorPalette] = useState<RGBColor[]>();
|
||||
|
||||
const [date, setDate] = useState<Date | string>();
|
||||
|
||||
const colorThief = new ColorThief();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -54,6 +56,8 @@ export default function ReadableView({ link }: Props) {
|
||||
};
|
||||
|
||||
fetchLinkContent();
|
||||
|
||||
setDate(link.importDate || link.createdAt);
|
||||
}, [link]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -211,8 +215,8 @@ export default function ReadableView({ link }: Props) {
|
||||
</div>
|
||||
|
||||
<p className="min-w-fit text-sm text-neutral">
|
||||
{link?.createdAt
|
||||
? new Date(link?.createdAt).toLocaleString("en-US", {
|
||||
{date
|
||||
? new Date(date).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function SettingsSidebar({ className }: { className?: string }) {
|
||||
const LINKWARDEN_VERSION = "v2.5.1";
|
||||
const LINKWARDEN_VERSION = process.env.version;
|
||||
|
||||
const { collections } = useCollectionStore();
|
||||
|
||||
|
||||
@@ -66,7 +66,10 @@ export default async function archiveHandler(link: LinksAndCollectionAndOwner) {
|
||||
? await validateUrlSize(link.url)
|
||||
: undefined;
|
||||
|
||||
if (validatedUrl === null)
|
||||
if (
|
||||
validatedUrl === null &&
|
||||
process.env.IGNORE_URL_SIZE_LIMIT !== "true"
|
||||
)
|
||||
throw "Something went wrong while retrieving the file size.";
|
||||
|
||||
const contentType = validatedUrl?.get("content-type");
|
||||
|
||||
@@ -119,15 +119,24 @@ export default async function postLink(
|
||||
});
|
||||
|
||||
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({
|
||||
where: {
|
||||
url: link.url?.trim(),
|
||||
OR: [{ url: urlWithWww }, { url: urlWithoutWww }],
|
||||
collection: {
|
||||
ownerId: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(url, urlWithoutWww, urlWithWww, "DONE!");
|
||||
|
||||
if (existingLink)
|
||||
return {
|
||||
response: "Link already exists",
|
||||
@@ -174,7 +183,7 @@ export default async function postLink(
|
||||
|
||||
const newLink = await prisma.link.create({
|
||||
data: {
|
||||
url: link.url?.trim() || null,
|
||||
url: link.url?.trim().replace(/\/+$/, "") || null,
|
||||
name: link.name,
|
||||
description,
|
||||
type: linkType,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { parse, Node, Element, TextNode } from "himalaya";
|
||||
import { writeFileSync } from "fs";
|
||||
|
||||
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
|
||||
|
||||
@@ -36,7 +37,9 @@ export default async function importFromHTMLFile(
|
||||
|
||||
const jsonData = parse(document.documentElement.outerHTML);
|
||||
|
||||
for (const item of jsonData) {
|
||||
const processedArray = processNodes(jsonData);
|
||||
|
||||
for (const item of processedArray) {
|
||||
console.log(item);
|
||||
await processBookmarks(userId, item as Element);
|
||||
}
|
||||
@@ -74,7 +77,9 @@ async function processBookmarks(
|
||||
} else if (item.type === "element" && item.tagName === "a") {
|
||||
// process link
|
||||
|
||||
const linkUrl = item?.attributes.find((e) => e.key === "href")?.value;
|
||||
const linkUrl = item?.attributes.find(
|
||||
(e) => e.key.toLowerCase() === "href"
|
||||
)?.value;
|
||||
const linkName = (
|
||||
item?.children.find((e) => e.type === "text") as TextNode
|
||||
)?.content;
|
||||
@@ -82,14 +87,33 @@ async function processBookmarks(
|
||||
.find((e) => e.key === "tags")
|
||||
?.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) {
|
||||
await createLink(
|
||||
userId,
|
||||
linkUrl,
|
||||
parentCollectionId,
|
||||
linkName,
|
||||
"",
|
||||
linkTags
|
||||
linkDesc,
|
||||
linkTags,
|
||||
linkDate
|
||||
);
|
||||
} else if (linkUrl) {
|
||||
// create a collection named "Imported Bookmarks" and add the link to it
|
||||
@@ -100,8 +124,9 @@ async function processBookmarks(
|
||||
linkUrl,
|
||||
collectionId,
|
||||
linkName,
|
||||
"",
|
||||
linkTags
|
||||
linkDesc,
|
||||
linkTags,
|
||||
linkDate
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,7 +185,8 @@ const createLink = async (
|
||||
collectionId: number,
|
||||
name?: string,
|
||||
description?: string,
|
||||
tags?: string[]
|
||||
tags?: string[],
|
||||
importDate?: Date
|
||||
) => {
|
||||
await prisma.link.create({
|
||||
data: {
|
||||
@@ -193,6 +219,48 @@ const createLink = async (
|
||||
}),
|
||||
}
|
||||
: 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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,9 +1,11 @@
|
||||
import { prisma } from "@/lib/api/db";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import bcrypt from "bcrypt";
|
||||
import verifyUser from "../../verifyUser";
|
||||
|
||||
const emailEnabled =
|
||||
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
|
||||
const stripeEnabled = process.env.STRIPE_SECRET_KEY ? true : false;
|
||||
|
||||
interface Data {
|
||||
response: string | object;
|
||||
@@ -20,7 +22,15 @@ export default async function postUser(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>
|
||||
) {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_REGISTRATION === "true") {
|
||||
let isServerAdmin = false;
|
||||
|
||||
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." });
|
||||
}
|
||||
|
||||
@@ -57,13 +67,16 @@ export default async function postUser(
|
||||
});
|
||||
|
||||
const checkIfUserExists = await prisma.user.findFirst({
|
||||
where: emailEnabled
|
||||
? {
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
email: body.email?.toLowerCase().trim(),
|
||||
}
|
||||
: {
|
||||
},
|
||||
{
|
||||
username: (body.username as string).toLowerCase().trim(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!checkIfUserExists) {
|
||||
@@ -71,21 +84,63 @@ export default async function postUser(
|
||||
|
||||
const hashedPassword = bcrypt.hashSync(body.password, saltRounds);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
// Subscription dates
|
||||
const currentPeriodStart = new Date();
|
||||
const currentPeriodEnd = new Date();
|
||||
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1000); // end date is in 1000 years...
|
||||
|
||||
return res.status(201).json({ response: "User successfully created." });
|
||||
if (isServerAdmin) {
|
||||
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) {
|
||||
return res.status(400).json({
|
||||
response: `${emailEnabled ? "Email" : "Username"} already exists.`,
|
||||
response: `Email or Username already exists.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ const authentikEnabled = process.env.AUTHENTIK_CLIENT_SECRET;
|
||||
|
||||
export default async function deleteUserById(
|
||||
userId: number,
|
||||
body: DeleteUserBody
|
||||
body: DeleteUserBody,
|
||||
isServerAdmin?: boolean
|
||||
) {
|
||||
// First, we retrieve the user from the database
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -25,13 +26,13 @@ export default async function deleteUserById(
|
||||
}
|
||||
|
||||
// Then, we check if the provided password matches the one stored in the database (disabled in Keycloak integration)
|
||||
if (!keycloakEnabled && !authentikEnabled) {
|
||||
if (!keycloakEnabled && !authentikEnabled && !isServerAdmin) {
|
||||
const isPasswordValid = bcrypt.compareSync(
|
||||
body.password,
|
||||
user.password as string
|
||||
);
|
||||
|
||||
if (!isPasswordValid) {
|
||||
if (!isPasswordValid && !isServerAdmin) {
|
||||
return {
|
||||
response: "Invalid credentials.",
|
||||
status: 401, // Unauthorized
|
||||
@@ -43,6 +44,11 @@ export default async function deleteUserById(
|
||||
await prisma
|
||||
.$transaction(
|
||||
async (prisma) => {
|
||||
// Delete Access Tokens
|
||||
await prisma.accessToken.deleteMany({
|
||||
where: { userId },
|
||||
});
|
||||
|
||||
// Delete whitelisted users
|
||||
await prisma.whitelistedUser.deleteMany({
|
||||
where: { userId },
|
||||
@@ -87,6 +93,7 @@ export default async function deleteUserById(
|
||||
await prisma.subscription.delete({
|
||||
where: { userId },
|
||||
});
|
||||
// .catch((err) => console.log(err));
|
||||
|
||||
await prisma.usersAndCollections.deleteMany({
|
||||
where: {
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import fetch from "node-fetch";
|
||||
import https from "https";
|
||||
import { SocksProxyAgent } from "socks-proxy-agent";
|
||||
|
||||
export default async function validateUrlSize(url: string) {
|
||||
if (process.env.IGNORE_URL_SIZE_LIMIT === "true") return null;
|
||||
|
||||
try {
|
||||
const httpsAgent = new https.Agent({
|
||||
rejectUnauthorized:
|
||||
process.env.IGNORE_UNAUTHORIZED_CA === "true" ? false : true,
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
let fetchOpts = {
|
||||
method: "HEAD",
|
||||
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 =
|
||||
Number(response.headers.get("content-length")) / Math.pow(1024, 2);
|
||||
|
||||
+17
-6
@@ -27,14 +27,25 @@ export default async function getTitle(url: string) {
|
||||
fetchOpts = { agent: new SocksProxyAgent(proxy.toString()) }; //TODO: add support for http/https proxies
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOpts);
|
||||
const responsePromise = fetch(url, fetchOpts);
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error("Fetch title timeout"));
|
||||
}, 10 * 1000); // Stop after 10 seconds
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const response = await Promise.race([responsePromise, timeoutPromise]);
|
||||
|
||||
// regular expression to find the <title> tag
|
||||
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
||||
if (match) return match[1];
|
||||
else return "";
|
||||
if ((response as any)?.status) {
|
||||
const text = await (response as any).text();
|
||||
|
||||
// regular expression to find the <title> tag
|
||||
let match = text.match(/<title.*>([^<]*)<\/title>/);
|
||||
if (match) return match[1];
|
||||
else return "";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const { version } = require("./package.json");
|
||||
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
domains: ["t2.gstatic.com"],
|
||||
minimumCacheTTL: 10,
|
||||
},
|
||||
env: {
|
||||
version,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkwarden",
|
||||
"version": "2.5.1",
|
||||
"version": "v2.5.4",
|
||||
"main": "index.js",
|
||||
"repository": "https://github.com/linkwarden/linkwarden.git",
|
||||
"author": "Daniel31X13 <daniel31x13@gmail.com>",
|
||||
|
||||
+27
-2
@@ -5,7 +5,8 @@ import { SessionProvider } from "next-auth/react";
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import AuthRedirect from "@/layouts/AuthRedirect";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import toast from "react-hot-toast";
|
||||
import { Toaster, ToastBar } from "react-hot-toast";
|
||||
import { Session } from "next-auth";
|
||||
import { isPWA } from "@/lib/client/utils";
|
||||
|
||||
@@ -61,7 +62,31 @@ export default function App({
|
||||
className:
|
||||
"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} />
|
||||
</AuthRedirect>
|
||||
</SessionProvider>
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
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,7 +9,6 @@ import formidable from "formidable";
|
||||
import createFile from "@/lib/api/storage/createFile";
|
||||
import fs from "fs";
|
||||
import verifyToken from "@/lib/api/verifyToken";
|
||||
import Jimp from "jimp";
|
||||
import generatePreview from "@/lib/api/generatePreview";
|
||||
import createFolder from "@/lib/api/storage/createFolder";
|
||||
|
||||
|
||||
@@ -98,19 +98,19 @@ if (
|
||||
const user = await prisma.user.findFirst({
|
||||
where: emailEnabled
|
||||
? {
|
||||
OR: [
|
||||
{
|
||||
username: username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: username?.toLowerCase(),
|
||||
},
|
||||
],
|
||||
emailVerified: { not: null },
|
||||
}
|
||||
OR: [
|
||||
{
|
||||
username: username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
email: username?.toLowerCase(),
|
||||
},
|
||||
],
|
||||
emailVerified: { not: null },
|
||||
}
|
||||
: {
|
||||
username: username.toLowerCase(),
|
||||
},
|
||||
username: username.toLowerCase(),
|
||||
},
|
||||
});
|
||||
|
||||
let passwordMatches: boolean = false;
|
||||
@@ -240,6 +240,37 @@ if (process.env.NEXT_PUBLIC_AUTH0_ENABLED === "true") {
|
||||
};
|
||||
}
|
||||
|
||||
// Authelia
|
||||
if (process.env.NEXT_PUBLIC_AUTHELIA_ENABLED === "true") {
|
||||
providers.push(
|
||||
{
|
||||
id: "authelia",
|
||||
name: "Authelia",
|
||||
type: "oauth",
|
||||
clientId: process.env.AUTHELIA_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTHELIA_CLIENT_SECRET!,
|
||||
wellKnown: process.env.AUTHELIA_WELLKNOWN_URL!,
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
idToken: true,
|
||||
checks: ["pkce", "state"],
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
username: profile.preferred_username,
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const _linkAccount = adapter.linkAccount;
|
||||
adapter.linkAccount = (account) => {
|
||||
const { "not-before-policy": _, refresh_expires_in, ...data } = account;
|
||||
return _linkAccount ? _linkAccount(data) : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
// Authentik
|
||||
if (process.env.NEXT_PUBLIC_AUTHENTIK_ENABLED === "true") {
|
||||
providers.push(
|
||||
|
||||
@@ -391,10 +391,17 @@ export function getLogins() {
|
||||
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 {
|
||||
credentialsEnabled:
|
||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === "true" ||
|
||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
|
||||
process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED === undefined
|
||||
? "true"
|
||||
: "false",
|
||||
emailEnabled:
|
||||
|
||||
@@ -16,9 +16,17 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = token?.id;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: token?.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (userId !== Number(req.query.id))
|
||||
const isServerAdmin = process.env.ADMINISTRATOR === user?.username;
|
||||
|
||||
const userId = isServerAdmin ? Number(req.query.id) : token.id;
|
||||
|
||||
if (userId !== Number(req.query.id) && !isServerAdmin)
|
||||
return res.status(401).json({ response: "Permission denied." });
|
||||
|
||||
if (req.method === "GET") {
|
||||
@@ -53,7 +61,7 @@ export default async function users(req: NextApiRequest, res: NextApiResponse) {
|
||||
const updated = await updateUserById(userId, req.body);
|
||||
return res.status(updated.status).json({ response: updated.response });
|
||||
} else if (req.method === "DELETE") {
|
||||
const updated = await deleteUserById(userId, req.body);
|
||||
const updated = await deleteUserById(userId, req.body, isServerAdmin);
|
||||
return res.status(updated.status).json({ response: updated.response });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
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) {
|
||||
if (req.method === "POST") {
|
||||
const response = await postUser(req, res);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Link" ADD COLUMN "importDate" TIMESTAMP(3);
|
||||
@@ -128,6 +128,7 @@ model Link {
|
||||
pdf String?
|
||||
readable String?
|
||||
lastPreserved DateTime?
|
||||
importDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 87 KiB |
@@ -0,0 +1,66 @@
|
||||
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;
|
||||
Vendored
+9
@@ -13,6 +13,8 @@ declare global {
|
||||
MAX_LINKS_PER_USER?: string;
|
||||
ARCHIVE_TAKE_COUNT?: string;
|
||||
IGNORE_UNAUTHORIZED_CA?: string;
|
||||
IGNORE_URL_SIZE_LIMIT?: string;
|
||||
ADMINISTRATOR?: string;
|
||||
|
||||
SPACES_KEY?: string;
|
||||
SPACES_SECRET?: string;
|
||||
@@ -76,6 +78,13 @@ declare global {
|
||||
AUTH0_CLIENT_SECRET?: 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
|
||||
NEXT_PUBLIC_AUTHENTIK_ENABLED?: string;
|
||||
AUTHENTIK_CUSTOM_NAME?: string;
|
||||
|
||||
+7
-1
@@ -7,10 +7,16 @@ type OptionalExcluding<T, TRequired extends keyof T> = Partial<T> &
|
||||
export interface LinkIncludingShortenedCollectionAndTags
|
||||
extends Omit<
|
||||
Link,
|
||||
"id" | "createdAt" | "collectionId" | "updatedAt" | "lastPreserved"
|
||||
| "id"
|
||||
| "createdAt"
|
||||
| "collectionId"
|
||||
| "updatedAt"
|
||||
| "lastPreserved"
|
||||
| "importDate"
|
||||
> {
|
||||
id?: number;
|
||||
createdAt?: string;
|
||||
importDate?: string;
|
||||
collectionId?: number;
|
||||
tags: Tag[];
|
||||
pinnedBy?: {
|
||||
|
||||
Reference in New Issue
Block a user