Compare commits

...

44 Commits

Author SHA1 Message Date
daniel31x13 80ad01a2d0 minor fix 2024-05-03 10:51:11 -04:00
daniel31x13 915d08a315 finalized administration panel 2024-05-03 10:22:45 -04:00
daniel31x13 08c2ff278f delete user functionality 2024-05-02 09:17:56 -04:00
daniel31x13 154d0d5fb6 add search to user admin 2024-04-24 09:16:34 -04:00
daniel31x13 7856e76b15 basic user listing 2024-04-22 18:00:59 -04:00
daniel31x13 f37a4b9c9e replace maskable logo 2024-04-21 19:21:30 -04:00
Daniel 389db59b28 Merge pull request #570 from QAComet/qacomet/add-toast-button
Add close button and data-testids to toast messages
2024-04-20 10:49:30 -04:00
daniel31x13 b702aa0401 small improvement 2024-04-20 10:49:06 -04:00
daniel31x13 9a92b4d229 code cleanup 2024-04-19 06:16:11 -04:00
QAComet 8278878673 feat: add close button and data-testids to toast messages 2024-04-18 11:34:29 -06:00
daniel31x13 4640c1c966 hotfix 2024-04-18 06:14:28 -04:00
Daniel 49fbbe966c Merge pull request #568 from linkwarden/hotfix/title-fetching
minor fix
2024-04-17 18:31:40 -04:00
daniel31x13 3610e73d3b minor fix 2024-04-17 18:18:50 -04:00
Daniel 76a5dcb90b Merge pull request #567 from linkwarden/hotfix/title-fetching
Hotfix/title fetching
2024-04-17 18:11:03 -04:00
Daniel e51fba41e7 Merge pull request #563 from linkwarden/hotfix/title-fetching
update version number
2024-04-17 18:07:05 -04:00
daniel31x13 e8edd1c9a0 update version number 2024-04-17 18:06:04 -04:00
Daniel f30c652676 Merge pull request #562 from linkwarden/hotfix/title-fetching
added a new env var + bug fixed
2024-04-17 18:03:36 -04:00
daniel31x13 8cf621bc62 added a new env var + bug fixed 2024-04-17 18:02:54 -04:00
Daniel a89274fc03 Merge pull request #507 from GoodM4ven/missing-duplicate-checks
[Enhancement] Accounting for "www." prefix for duplicates
2024-04-15 08:09:10 +03:30
Daniel baadd6c06b Merge branch 'dev' into missing-duplicate-checks 2024-04-15 08:08:22 +03:30
daniel31x13 4a71af8a67 remove trailing slashes + small improvement 2024-04-15 00:37:18 -04:00
daniel31x13 ece09c6f3b minor change 2024-04-09 04:43:20 -04:00
Daniel 189db27c5b Merge pull request #521 from chrisbsmith/authelia
Adds OIDC support for Authelia
2024-04-09 05:20:45 +03:30
Daniel 68d8d403cf Merge pull request #556 from linkwarden/feat/file-uploads
Feat/file uploads
2024-04-09 03:08:11 +03:30
Daniel 87eb2471ff Merge pull request #543 from linkwarden/dev
make the status of the script independent from the app
2024-03-27 19:39:09 +03:30
daniel31x13 58b6f7339c make the status of the script independent from the app 2024-03-27 12:08:19 -04:00
Daniel 5503483502 Merge pull request #542 from linkwarden/dev
Dev
2024-03-27 10:58:27 +03:30
daniel31x13 a6d018fb53 Merge branch 'dev' of https://github.com/linkwarden/linkwarden into dev 2024-03-27 03:28:02 -04:00
daniel31x13 3929f32e63 minor fix 2024-03-27 03:27:59 -04:00
Daniel c08522386b Merge pull request #541 from linkwarden/dev
Dev
2024-03-27 10:52:31 +03:30
Daniel b51a876904 Merge pull request #537 from paulhovey/import_date
Import pinboard description and date
2024-03-27 10:51:39 +03:30
daniel31x13 2e2d7baee1 fix imports 2024-03-27 03:20:00 -04:00
Paul Hovey 495af0a752 adds description and tags parsing for pinboard html import 2024-03-23 14:57:34 -05:00
Daniel 388b9d9184 Merge pull request #531 from linkwarden/dev
added architecture.md file + renamed license file
2024-03-20 17:27:07 +03:30
Daniel a3d3b353a1 Merge pull request #528 from linkwarden/dev
Dev
2024-03-18 02:41:49 +03:30
Chris Smith cc2d7c863d Add Authelia as a custom oidc source
set a path to browsers outside of /root

Grant root ownership over /data

set umask + perms after yarn build

revert local testing to upstream
2024-03-14 15:01:19 -04:00
Daniel 53a65774f0 Merge pull request #518 from linkwarden/dev
support for arbitrary values in manual installation
2024-03-13 17:26:53 +03:30
Daniel ce2eb8eafb Merge pull request #517 from linkwarden/dev
support for other ports in manual installation
2024-03-13 17:21:07 +03:30
Daniel 4e20d71a41 Merge pull request #509 from linkwarden/dev
improved UX + improved performance
2024-03-10 13:39:04 +03:30
GoodM4ven cac90524ed [Enhancement] Accounting for "www." prefix for duplicates 2024-03-08 14:34:56 +03:00
Daniel 9fce74971f Merge pull request #500 from linkwarden/dev
update announcement version number
2024-03-07 02:31:21 +03:30
Daniel bde7b9aae0 Merge pull request #497 from linkwarden/dev
improved performance
2024-03-06 17:38:45 +03:30
Daniel 7dd254af48 Merge pull request #495 from linkwarden/dev
more efficient logic for the background script
2024-03-06 02:58:42 +03:30
Daniel 3969cc5abd Merge pull request #494 from linkwarden/dev
v2.5.0
2024-03-05 22:05:54 +03:30
33 changed files with 899 additions and 143 deletions
+9
View File
@@ -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
View File
@@ -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
+25 -6
View File
@@ -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&apos;m doing
</button>
</div>
</Modal>
);
}
+133
View File
@@ -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
View File
@@ -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 />
+71
View File
@@ -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>
);
}
+6 -2
View File
@@ -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",
+1 -1
View File
@@ -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();
+4 -1
View File
@@ -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");
+11 -2
View File
@@ -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;
}
+21
View File
@@ -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 };
}
+72 -17
View File
@@ -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: {
+20 -2
View File
@@ -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
View File
@@ -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);
}
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
};
-1
View File
@@ -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";
+43 -12
View File
@@ -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(
+8 -1
View File
@@ -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:
+11 -3
View File
@@ -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 });
}
}
+9
View File
@@ -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);
+1
View File
@@ -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

+66
View File
@@ -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;
+9
View File
@@ -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
View File
@@ -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?: {