Compare commits

...

4 Commits

Author SHA1 Message Date
daniel31x13
4e14149dfe Accepted incoming changes 2024-11-12 10:09:02 -05:00
daniel31x13
dbd096ab76 Revert "undo commit"
This reverts commit 9103f67db5.
2024-11-03 03:27:52 -05:00
daniel31x13
e37702aa14 Merge branch 'main' of https://github.com/linkwarden/linkwarden 2024-11-03 03:25:06 -05:00
daniel31x13
9103f67db5 undo commit 2024-11-03 03:25:01 -05:00
133 changed files with 1411 additions and 3315 deletions

View File

@@ -59,10 +59,10 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v3
with:
node-version: "18"
cache: 'yarn'
@@ -135,7 +135,7 @@ jobs:
- name: Run Tests
run: npx playwright test --grep ${{ matrix.test_case }}
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report

View File

@@ -27,7 +27,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -40,7 +40,7 @@ jobs:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v3
with:
context: .
push: true

View File

@@ -1,16 +1,4 @@
# Stage: monolith-builder
# Purpose: Uses the Rust image to build monolith
# Notes:
# - Fine to leave extra here, as only the resulting binary is copied out
FROM docker.io/rust:1.80-bullseye AS monolith-builder
RUN set -eux && cargo install --locked monolith
# Stage: main-app
# Purpose: Compiles the frontend and
# Notes:
# - Nothing extra should be left here. All commands should cleanup
FROM node:18.18-bullseye-slim AS main-app
FROM node:18.18-bullseye-slim
ARG DEBIAN_FRONTEND=noninteractive
@@ -20,23 +8,33 @@ WORKDIR /data
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn \
set -eux && \
yarn install --network-timeout 10000000
RUN --mount=type=cache,sharing=locked,target=/usr/local/share/.cache/yarn yarn install --network-timeout 10000000
# Copy the compiled monolith binary from the builder stage
COPY --from=monolith-builder /usr/local/cargo/bin/monolith /usr/local/bin/monolith
RUN apt-get update
RUN set -eux && \
npx playwright install --with-deps chromium && \
RUN apt-get install -y \
build-essential \
curl \
libssl-dev \
pkg-config
RUN apt-get update
RUN curl https://sh.rustup.rs -sSf | bash -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
RUN cargo install monolith
RUN npx playwright install-deps && \
apt-get clean && \
yarn cache clean
RUN yarn playwright install
COPY . .
RUN yarn prisma generate && \
yarn build
EXPOSE 3000
CMD yarn prisma migrate deploy && yarn start
CMD yarn prisma migrate deploy && yarn start

View File

@@ -1,8 +1,5 @@
import Link from "next/link";
import {
AccountSettings,
CollectionIncludingMembersAndLinkCount,
} from "@/types/global";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import React, { useEffect, useState } from "react";
import ProfilePhoto from "./ProfilePhoto";
import usePermissions from "@/hooks/usePermissions";
@@ -15,11 +12,12 @@ import { dropdownTriggerer } from "@/lib/client/utils";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
export default function CollectionCard({
collection,
}: {
type Props = {
collection: CollectionIncludingMembersAndLinkCount;
}) {
className?: string;
};
export default function CollectionCard({ collection, className }: Props) {
const { t } = useTranslation();
const { settings } = useLocalSettingsStore();
const { data: user = {} } = useUser();
@@ -35,9 +33,15 @@ export default function CollectionCard({
const permissions = usePermissions(collection.id as number);
const [collectionOwner, setCollectionOwner] = useState<
Partial<AccountSettings>
>({});
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
@@ -128,12 +132,12 @@ export default function CollectionCard({
className="flex items-center absolute bottom-3 left-3 z-10 btn px-2 btn-ghost rounded-full"
onClick={() => setEditCollectionSharingModal(true)}
>
{collectionOwner.id && (
{collectionOwner.id ? (
<ProfilePhoto
src={collectionOwner.image || undefined}
name={collectionOwner.name}
/>
)}
) : undefined}
{collection.members
.sort((a, b) => (a.userId as number) - (b.userId as number))
.map((e, i) => {
@@ -147,13 +151,13 @@ export default function CollectionCard({
);
})
.slice(0, 3)}
{collection.members.length - 3 > 0 && (
{collection.members.length - 3 > 0 ? (
<div className={`avatar drop-shadow-md placeholder -ml-3`}>
<div className="bg-base-100 text-neutral rounded-full w-8 h-8 ring-2 ring-neutral-content">
<span>+{collection.members.length - 3}</span>
</div>
</div>
)}
) : null}
</div>
<Link
href={`/collections/${collection.id}`}
@@ -177,12 +181,12 @@ export default function CollectionCard({
<div className="flex justify-end items-center">
<div className="text-right">
<div className="font-bold text-sm flex justify-end gap-1 items-center">
{collection.isPublic && (
{collection.isPublic ? (
<i
className="bi-globe2 drop-shadow text-neutral"
title="This collection is being shared publicly."
></i>
)}
) : undefined}
<i
className="bi-link-45deg text-lg text-neutral"
title="This collection is being shared publicly."
@@ -202,24 +206,24 @@ export default function CollectionCard({
</div>
</div>
</Link>
{editCollectionModal && (
{editCollectionModal ? (
<EditCollectionModal
onClose={() => setEditCollectionModal(false)}
activeCollection={collection}
/>
)}
{editCollectionSharingModal && (
) : undefined}
{editCollectionSharingModal ? (
<EditCollectionSharingModal
onClose={() => setEditCollectionSharingModal(false)}
activeCollection={collection}
/>
)}
{deleteCollectionModal && (
) : undefined}
{deleteCollectionModal ? (
<DeleteCollectionModal
onClose={() => setDeleteCollectionModal(false)}
activeCollection={collection}
/>
)}
) : undefined}
</div>
);
}

View File

@@ -17,8 +17,6 @@ import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useCollections, useUpdateCollection } from "@/hooks/store/collections";
import { useUpdateUser, useUser } from "@/hooks/store/user";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
interface ExtendedTreeItem extends TreeItem {
data: Collection;
@@ -42,7 +40,6 @@ const CollectionListing = () => {
return buildTreeFromCollections(
collections,
router,
tree,
user.collectionOrder
);
} else return undefined;
@@ -252,7 +249,7 @@ const renderItem = (
: "hover:bg-neutral/20"
} duration-100 flex gap-1 items-center pr-2 pl-1 rounded-md`}
>
{Dropdown(item as ExtendedTreeItem, onExpand, onCollapse)}
{Icon(item as ExtendedTreeItem, onExpand, onCollapse)}
<Link
href={`/collections/${collection.id}`}
@@ -262,29 +259,18 @@ const renderItem = (
<div
className={`py-1 cursor-pointer flex items-center gap-2 w-full rounded-md h-8 capitalize`}
>
{collection.icon ? (
<Icon
icon={collection.icon}
size={30}
weight={(collection.iconWeight || "regular") as IconWeight}
color={collection.color}
className="-mr-[0.15rem]"
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: collection.color }}
></i>
)}
<i
className="bi-folder-fill text-2xl drop-shadow"
style={{ color: collection.color }}
></i>
<p className="truncate w-full">{collection.name}</p>
{collection.isPublic && (
{collection.isPublic ? (
<i
className="bi-globe2 text-sm text-black/50 dark:text-white/50 drop-shadow"
title="This collection is being shared publicly."
></i>
)}
) : undefined}
<div className="drop-shadow text-neutral text-xs">
{collection._count?.links}
</div>
@@ -295,7 +281,7 @@ const renderItem = (
);
};
const Dropdown = (
const Icon = (
item: ExtendedTreeItem,
onExpand: (id: ItemId) => void,
onCollapse: (id: ItemId) => void
@@ -318,7 +304,6 @@ const Dropdown = (
const buildTreeFromCollections = (
collections: CollectionIncludingMembersAndLinkCount[],
router: ReturnType<typeof useRouter>,
tree?: TreeData,
order?: number[]
): TreeData => {
if (order) {
@@ -333,15 +318,13 @@ const buildTreeFromCollections = (
id: collection.id,
children: [],
hasChildren: false,
isExpanded: tree?.items[collection.id as number]?.isExpanded || false,
isExpanded: false,
data: {
id: collection.id,
parentId: collection.parentId,
name: collection.name,
description: collection.description,
color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
isPublic: collection.isPublic,
ownerId: collection.ownerId,
createdAt: collection.createdAt,

View File

@@ -1,32 +0,0 @@
import React, { useState } from "react";
type Props = {
text: string;
};
const CopyButton: React.FC<Props> = ({ text }) => {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (err) {
console.log(err);
}
};
return (
<div
className={`text-xl text-neutral btn btn-sm btn-square btn-ghost ${
copied ? "bi-check2 text-success" : "bi-copy"
}`}
onClick={handleCopy}
></div>
);
};
export default CopyButton;

View File

@@ -60,49 +60,47 @@ export default function Dropdown({
}
}, [points, dropdownHeight]);
return (
(!points || pos) && (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${
className || ""
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="select-none">{e.name}</p>
</div>
return !points || pos ? (
<ClickAwayHandler
onMount={(e) => {
setDropdownHeight(e.height);
setDropdownWidth(e.width);
}}
style={
points
? {
position: "fixed",
top: `${pos?.y}px`,
left: `${pos?.x}px`,
}
: undefined
}
onClickOutside={onClickOutside}
className={`${
className || ""
} py-1 shadow-md border border-neutral-content bg-base-200 rounded-md flex flex-col z-20`}
>
{items.map((e, i) => {
const inner = e && (
<div className="cursor-pointer rounded-md">
<div className="flex items-center gap-2 py-1 px-2 hover:bg-base-100 duration-100">
<p className="select-none">{e.name}</p>
</div>
);
</div>
);
return e && e.href ? (
<Link key={i} href={e.href}>
return e && e.href ? (
<Link key={i} href={e.href}>
{inner}
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
{inner}
</Link>
) : (
e && (
<div key={i} onClick={e.onClick}>
{inner}
</div>
)
);
})}
</ClickAwayHandler>
)
);
</div>
)
);
})}
</ClickAwayHandler>
) : null;
}

View File

@@ -1,18 +0,0 @@
import React, { forwardRef } from "react";
import * as Icons from "@phosphor-icons/react";
type Props = {
icon: string;
} & Icons.IconProps;
const Icon = forwardRef<SVGSVGElement, Props>(({ icon, ...rest }, ref) => {
const IconComponent: any = Icons[icon as keyof typeof Icons];
if (!IconComponent) {
return null;
} else return <IconComponent ref={ref} {...rest} />;
});
Icon.displayName = "Icon";
export default Icon;

View File

@@ -1,83 +0,0 @@
import React, { useState } from "react";
import TextInput from "./TextInput";
import Popover from "./Popover";
import { HexColorPicker } from "react-colorful";
import { useTranslation } from "next-i18next";
import Icon from "./Icon";
import { IconWeight } from "@phosphor-icons/react";
import IconGrid from "./IconGrid";
import IconPopover from "./IconPopover";
import clsx from "clsx";
type Props = {
alignment?: string;
color: string;
setColor: Function;
iconName?: string;
setIconName: Function;
weight: "light" | "regular" | "bold" | "fill" | "duotone" | "thin";
setWeight: Function;
hideDefaultIcon?: boolean;
reset: Function;
className?: string;
};
const IconPicker = ({
alignment,
color,
setColor,
iconName,
setIconName,
weight,
setWeight,
hideDefaultIcon,
className,
reset,
}: Props) => {
const { t } = useTranslation();
const [iconPicker, setIconPicker] = useState(false);
return (
<div className="relative">
<div
onClick={() => setIconPicker(!iconPicker)}
className="btn btn-square w-20 h-20"
>
{iconName ? (
<Icon
icon={iconName}
size={60}
weight={(weight || "regular") as IconWeight}
color={color || "#0ea5e9"}
/>
) : !iconName && hideDefaultIcon ? (
<p className="p-1">{t("set_custom_icon")}</p>
) : (
<i
className="bi-folder-fill text-6xl"
style={{ color: color || "#0ea5e9" }}
></i>
)}
</div>
{iconPicker && (
<IconPopover
alignment={alignment}
color={color}
setColor={setColor}
iconName={iconName}
setIconName={setIconName}
weight={weight}
setWeight={setWeight}
reset={reset}
onClose={() => setIconPicker(false)}
className={clsx(
className,
alignment || "lg:-translate-x-1/3 top-20 left-0"
)}
/>
)}
</div>
);
};
export default IconPicker;

View File

@@ -16,8 +16,6 @@ type Props = {
}
| undefined;
creatable?: boolean;
autoFocus?: boolean;
onBlur?: any;
};
export default function CollectionSelection({
@@ -25,8 +23,6 @@ export default function CollectionSelection({
defaultValue,
showDefaultValue = true,
creatable = true,
autoFocus,
onBlur,
}: Props) {
const { data: collections = [] } = useCollections();
@@ -80,7 +76,7 @@ export default function CollectionSelection({
return (
<div
{...innerProps}
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content duration-100 cursor-pointer"
className="px-2 py-2 last:border-0 border-b border-neutral-content hover:bg-neutral-content cursor-pointer"
>
<div className="flex w-full justify-between items-center">
<span>{data.label}</span>
@@ -108,8 +104,6 @@ export default function CollectionSelection({
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
onBlur={onBlur}
defaultValue={showDefaultValue ? defaultValue : null}
components={{
Option: customOption,
@@ -126,9 +120,7 @@ export default function CollectionSelection({
onChange={onChange}
options={options}
styles={styles}
autoFocus={autoFocus}
defaultValue={showDefaultValue ? defaultValue : null}
onBlur={onBlur}
components={{
Option: customOption,
}}

View File

@@ -7,19 +7,12 @@ import { useTags } from "@/hooks/store/tags";
type Props = {
onChange: any;
defaultValue?: {
value?: number;
value: number;
label: string;
}[];
autoFocus?: boolean;
onBlur?: any;
};
export default function TagSelection({
onChange,
defaultValue,
autoFocus,
onBlur,
}: Props) {
export default function TagSelection({ onChange, defaultValue }: Props) {
const { data: tags = [] } = useTags();
const [options, setOptions] = useState<Options[]>([]);
@@ -41,9 +34,8 @@ export default function TagSelection({
options={options}
styles={styles}
defaultValue={defaultValue}
// menuPosition="fixed"
isMulti
autoFocus={autoFocus}
onBlur={onBlur}
/>
);
}

View File

@@ -14,7 +14,7 @@ export const styles: StylesConfig = {
? "oklch(var(--p))"
: "oklch(var(--nc))",
},
transition: "all 100ms",
transition: "all 50ms",
}),
menu: (styles) => ({
...styles,
@@ -54,28 +54,19 @@ export const styles: StylesConfig = {
multiValue: (styles) => {
return {
...styles,
backgroundColor: "oklch(var(--b2))",
color: "oklch(var(--bc))",
display: "flex",
alignItems: "center",
gap: "0.1rem",
marginRight: "0.4rem",
backgroundColor: "#0ea5e9",
color: "white",
};
},
multiValueLabel: (styles) => ({
...styles,
color: "oklch(var(--bc))",
color: "white",
}),
multiValueRemove: (styles) => ({
...styles,
height: "1.2rem",
width: "1.2rem",
borderRadius: "100px",
transition: "all 100ms",
color: "oklch(var(--w))",
":hover": {
color: "red",
backgroundColor: "oklch(var(--nc))",
color: "white",
backgroundColor: "#38bdf8",
},
}),
menuPortal: (base) => ({ ...base, zIndex: 9999 }),

View File

@@ -1,9 +1,7 @@
import Icon from "@/components/Icon";
import {
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import { IconWeight } from "@phosphor-icons/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";
@@ -29,19 +27,10 @@ export default function LinkCollection({
className="flex items-center gap-1 max-w-full w-fit hover:opacity-70 duration-100 select-none"
title={collection?.name}
>
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={20}
weight={(link.collection.iconWeight || "regular") as IconWeight}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-lg"
style={{ color: link.collection.color }}
></i>
)}
<i
className="bi-folder-fill text-lg drop-shadow"
style={{ color: collection?.color }}
></i>
<p className="truncate capitalize">{collection?.name}</p>
</Link>
</>

View File

@@ -1,34 +0,0 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useRouter } from "next/router";
import clsx from "clsx";
import usePinLink from "@/lib/client/pinLink";
type Props = {
link: LinkIncludingShortenedCollectionAndTags;
btnStyle?: string;
};
export default function LinkPin({ link, btnStyle }: Props) {
const pinLink = usePinLink();
const router = useRouter();
const isPublicRoute = router.pathname.startsWith("/public") ? true : false;
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
return (
<div
className="absolute top-3 right-[3.25rem] group-hover:opacity-100 group-focus-within:opacity-100 opacity-0 duration-100"
onClick={() => pinLink(link)}
>
<div className={clsx("btn btn-sm btn-square text-neutral", btnStyle)}>
<i
title="Pin"
className={clsx(
"text-xl",
isAlreadyPinned ? "bi-pin-fill" : "bi-pin"
)}
/>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import {
LinkIncludingShortenedCollectionAndTags,
ViewMode,
} from "@/types/global";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import LinkMasonry from "@/components/LinkViews/LinkComponents/LinkMasonry";
import Masonry from "react-masonry-css";
@@ -11,7 +11,6 @@ import resolveConfig from "tailwindcss/resolveConfig";
import tailwindConfig from "../../tailwind.config.js";
import { useMemo } from "react";
import LinkList from "@/components/LinkViews/LinkComponents/LinkList";
import useLocalSettingsStore from "@/store/localSettings";
export function CardView({
links,
@@ -28,68 +27,16 @@ export function CardView({
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
return (
<div className={`${gridColClass} grid gap-5 pb-5`}>
<div className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5">
{links?.map((e, i) => {
return (
<LinkCard
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
columns={columnCount}
/>
);
})}
@@ -129,58 +76,6 @@ export function MasonryView({
hasNextPage?: boolean;
placeHolderRef?: any;
}) {
const settings = useLocalSettingsStore((state) => state.settings);
const gridMap = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
5: "grid-cols-5",
6: "grid-cols-6",
7: "grid-cols-7",
8: "grid-cols-8",
};
const getColumnCount = () => {
const width = window.innerWidth;
if (width >= 1901) return 5;
if (width >= 1501) return 4;
if (width >= 881) return 3;
if (width >= 551) return 2;
return 1;
};
const [columnCount, setColumnCount] = useState(
settings.columns || getColumnCount()
);
const gridColClass = useMemo(
() => gridMap[columnCount as keyof typeof gridMap],
[columnCount]
);
useEffect(() => {
const handleResize = () => {
if (settings.columns === 0) {
// Only recalculate if zustandColumns is zero
setColumnCount(getColumnCount());
}
};
if (settings.columns === 0) {
window.addEventListener("resize", handleResize);
}
setColumnCount(settings.columns || getColumnCount());
return () => {
if (settings.columns === 0) {
window.removeEventListener("resize", handleResize);
}
};
}, [settings.columns]);
const fullConfig = resolveConfig(tailwindConfig as any);
const breakpointColumnsObj = useMemo(() => {
@@ -195,19 +90,18 @@ export function MasonryView({
return (
<Masonry
breakpointCols={
settings.columns === 0 ? breakpointColumnsObj : columnCount
}
breakpointCols={breakpointColumnsObj}
columnClassName="flex flex-col gap-5 !w-full"
className={`${gridColClass} grid gap-5 pb-5`}
className="grid min-[1901px]:grid-cols-5 min-[1501px]:grid-cols-4 min-[881px]:grid-cols-3 min-[551px]:grid-cols-2 grid-cols-1 gap-5 pb-5"
>
{links?.map((e, i) => {
return (
<LinkMasonry
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
columns={columnCount}
/>
);
})}
@@ -248,9 +142,17 @@ export function ListView({
placeHolderRef?: any;
}) {
return (
<div className="flex flex-col">
<div className="flex gap-1 flex-col">
{links?.map((e, i) => {
return <LinkList key={i} link={e} count={i} editMode={editMode} />;
return (
<LinkList
key={i}
link={e}
count={i}
flipDropdown={i === links.length - 1}
editMode={editMode}
/>
);
})}
{(hasNextPage || isLoading) &&
@@ -259,13 +161,13 @@ export function ListView({
<div
ref={e === 1 ? placeHolderRef : undefined}
key={i}
className="flex gap-2 py-2 px-1"
className="flex gap-4 p-4"
>
<div className="skeleton h-12 w-12"></div>
<div className="flex flex-col gap-3 w-full">
<div className="skeleton h-2 w-2/3"></div>
<div className="skeleton h-2 w-full"></div>
<div className="skeleton h-2 w-1/3"></div>
<div className="skeleton h-16 w-16"></div>
<div className="flex flex-col gap-4 w-full">
<div className="skeleton h-3 w-2/3"></div>
<div className="skeleton h-3 w-full"></div>
<div className="skeleton h-3 w-1/3"></div>
</div>
</div>
);

View File

@@ -87,13 +87,15 @@ export default function MobileNavigation({}: Props) {
<MobileNavigationButton href={`/collections`} icon={"bi-folder"} />
</div>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}
{uploadFileModal && (
) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
)}
) : undefined}
</>
);
}

View File

@@ -46,7 +46,6 @@ export default function BulkEditLinksModal({ onClose }: Props) {
},
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -59,6 +58,8 @@ export default function BulkEditLinksModal({ onClose }: Props) {
},
}
);
setSubmitLoader(false);
}
};

View File

@@ -44,7 +44,6 @@ export default function DeleteCollectionModal({
deleteCollection.mutateAsync(collection.id as number, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -56,6 +55,8 @@ export default function DeleteCollectionModal({
}
},
});
setSubmitLoader(false);
}
};

View File

@@ -3,7 +3,6 @@ import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useDeleteUser } from "@/hooks/store/admin/users";
import { useState } from "react";
import { useSession } from "next-auth/react";
type Props = {
onClose: Function;
@@ -24,40 +23,31 @@ export default function DeleteUserModal({ onClose, userId }: Props) {
onSuccess: () => {
onClose();
},
onSettled: (data, error) => {
setSubmitLoader(false);
},
});
setSubmitLoader(false);
}
};
const { data } = useSession();
const isAdmin = data?.user?.id === Number(process.env.NEXT_PUBLIC_ADMIN);
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin text-red-500">
{isAdmin ? t("delete_user") : t("remove_user")}
</p>
<p className="text-xl font-thin text-red-500">{t("delete_user")}</p>
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<p>{t("confirm_user_deletion")}</p>
<p>{t("confirm_user_removal_desc")}</p>
{isAdmin && (
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
</span>
</div>
)}
<div role="alert" className="alert alert-warning">
<i className="bi-exclamation-triangle text-xl" />
<span>
<b>{t("warning")}:</b> {t("irreversible_action_warning")}
</span>
</div>
<Button className="ml-auto" intent="destructive" onClick={submit}>
<i className="bi-trash text-xl" />
{isAdmin ? t("delete_confirmation") : t("confirm")}
{t("delete_confirmation")}
</Button>
</div>
</Modal>

View File

@@ -1,12 +1,11 @@
import React, { useState } from "react";
import TextInput from "@/components/TextInput";
import { HexColorPicker } from "react-colorful";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
type Props = {
onClose: Function;
@@ -35,7 +34,6 @@ export default function EditCollectionModal({
await updateCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -46,6 +44,8 @@ export default function EditCollectionModal({
}
},
});
setSubmitLoader(false);
}
};
@@ -56,32 +56,10 @@ export default function EditCollectionModal({
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<div className="flex gap-3 items-end">
<IconPicker
color={collection.color}
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: "#0ea5e9",
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-3">
<TextInput
className="bg-base-200"
value={collection.name}
@@ -90,13 +68,38 @@ export default function EditCollectionModal({
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">{t("color")}</p>
<div className="color-picker flex justify-between items-center">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32">
<i
className="bi-folder-fill text-5xl"
style={{ color: collection.color }}
></i>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
{t("reset")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">{t("description")}</p>
<textarea
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>

View File

@@ -0,0 +1,154 @@
import React, { useEffect, useState } from "react";
import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import Link from "next/link";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useUpdateLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
type Props = {
onClose: Function;
activeLink: LinkIncludingShortenedCollectionAndTags;
};
export default function EditLinkModal({ onClose, activeLink }: Props) {
const { t } = useTranslation();
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(activeLink);
let shortenedURL;
try {
shortenedURL = new URL(link.url || "").host.toLowerCase();
} catch (error) {
console.log(error);
}
const [submitLoader, setSubmitLoader] = useState(false);
const updateLink = useUpdateLink();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
const setTags = (e: any) => {
const tagNames = e.map((e: any) => ({ name: e.label }));
setLink({ ...link, tags: tagNames });
};
useEffect(() => {
setLink(activeLink);
}, []);
const submit = async () => {
if (!submitLoader) {
setSubmitLoader(true);
const load = toast.loading(t("updating"));
await updateLink.mutateAsync(link, {
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
onClose();
toast.success(t("updated"));
}
},
});
setSubmitLoader(false);
}
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("edit_link")}</p>
<div className="divider mb-3 mt-1"></div>
{link.url ? (
<Link
href={link.url}
className="truncate text-neutral flex gap-2 mb-5 w-fit max-w-full"
title={link.url}
target="_blank"
>
<i className="bi-link-45deg text-xl" />
<p>{shortenedURL}</p>
</Link>
) : undefined}
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<TextInput
value={link.name}
onChange={(e) => setLink({ ...link, name: e.target.value })}
placeholder={t("placeholder_example_link")}
className="bg-base-200"
/>
</div>
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<p className="mb-2">{t("collection")}</p>
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={
link.collection.id
? { value: link.collection.id, label: link.collection.name }
: { value: null as unknown as number, label: "Unorganized" }
}
creatable={false}
/>
) : null}
</div>
<div>
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
<div className="flex justify-end items-center mt-5">
<button
className="btn btn-accent dark:border-violet-400 text-white"
onClick={submit}
>
{t("save_changes")}
</button>
</div>
</Modal>
);
}

View File

@@ -1,13 +1,12 @@
import React, { useEffect, useState } from "react";
import TextInput from "@/components/TextInput";
import { HexColorPicker } from "react-colorful";
import { Collection } from "@prisma/client";
import Modal from "../Modal";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import { useTranslation } from "next-i18next";
import { useCreateCollection } from "@/hooks/store/collections";
import toast from "react-hot-toast";
import IconPicker from "../IconPicker";
import { IconWeight } from "@phosphor-icons/react";
type Props = {
onClose: Function;
@@ -43,7 +42,6 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
await createCollection.mutateAsync(collection, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -54,6 +52,8 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
}
},
});
setSubmitLoader(false);
};
return (
@@ -72,32 +72,10 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
<div className="divider mb-3 mt-1"></div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3">
<div className="flex gap-3 items-end">
<IconPicker
color={collection.color || "#0ea5e9"}
setColor={(color: string) =>
setCollection({ ...collection, color })
}
weight={(collection.iconWeight || "regular") as IconWeight}
setWeight={(iconWeight: string) =>
setCollection({ ...collection, iconWeight })
}
iconName={collection.icon as string}
setIconName={(icon: string) =>
setCollection({ ...collection, icon })
}
reset={() =>
setCollection({
...collection,
color: "#0ea5e9",
icon: "",
iconWeight: "",
})
}
/>
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col sm:flex-row gap-3">
<div className="w-full">
<p className="mb-2">{t("name")}</p>
<div className="flex flex-col gap-2">
<TextInput
className="bg-base-200"
value={collection.name}
@@ -106,13 +84,38 @@ export default function NewCollectionModal({ onClose, parent }: Props) {
setCollection({ ...collection, name: e.target.value })
}
/>
<div>
<p className="w-full mb-2">{t("color")}</p>
<div className="color-picker flex justify-between items-center">
<HexColorPicker
color={collection.color}
onChange={(color) =>
setCollection({ ...collection, color })
}
/>
<div className="flex flex-col gap-2 items-center w-32">
<i
className={"bi-folder-fill text-5xl"}
style={{ color: collection.color }}
></i>
<div
className="btn btn-ghost btn-xs"
onClick={() =>
setCollection({ ...collection, color: "#0ea5e9" })
}
>
{t("reset")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="w-full">
<p className="mb-2">{t("description")}</p>
<textarea
className="w-full h-32 resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
className="w-full h-[13rem] resize-none border rounded-md duration-100 bg-base-200 p-2 outline-none border-neutral-content focus:border-primary"
placeholder={t("collection_description_placeholder")}
value={collection.description}
onChange={(e) =>

View File

@@ -3,13 +3,14 @@ import CollectionSelection from "@/components/InputSelect/CollectionSelection";
import TagSelection from "@/components/InputSelect/TagSelection";
import TextInput from "@/components/TextInput";
import unescapeString from "@/lib/client/unescapeString";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useSession } from "next-auth/react";
import { useRouter } from "next/router";
import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useAddLink } from "@/hooks/store/links";
import toast from "react-hot-toast";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
type Props = {
onClose: Function;
@@ -17,19 +18,27 @@ type Props = {
export default function NewLinkModal({ onClose }: Props) {
const { t } = useTranslation();
const { data } = useSession();
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
preview: "",
image: "",
pdf: "",
readable: "",
monolith: "",
textContent: "",
collection: {
id: undefined,
name: "",
ownerId: data?.user.id as number,
},
} as PostLinkSchemaType;
} as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const addLink = useAddLink();
@@ -39,10 +48,10 @@ export default function NewLinkModal({ onClose }: Props) {
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = undefined;
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label },
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
@@ -52,23 +61,27 @@ export default function NewLinkModal({ onClose }: Props) {
};
useEffect(() => {
if (router.pathname.startsWith("/collections/") && router.query.id) {
if (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (currentCollection && currentCollection.ownerId)
if (
currentCollection &&
currentCollection.ownerId &&
router.asPath.startsWith("/collections/")
)
setLink({
...initial,
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...initial,
collection: { name: "Unorganized" },
collection: { name: "Unorganized", ownerId: data?.user.id as number },
});
}, []);
@@ -80,17 +93,18 @@ export default function NewLinkModal({ onClose }: Props) {
await addLink.mutateAsync(link, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
toast.error(t(error.message));
toast.error(error.message);
} else {
onClose();
toast.success(t("link_created"));
}
},
});
setSubmitLoader(false);
}
};
@@ -110,19 +124,19 @@ export default function NewLinkModal({ onClose }: Props) {
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p>
{link.collection?.name && (
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={{
value: link.collection?.id,
label: link.collection?.name || "Unorganized",
label: link.collection.name,
value: link.collection.id,
}}
/>
)}
) : null}
</div>
</div>
<div className={"mt-2"}>
{optionsExpanded && (
{optionsExpanded ? (
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
@@ -138,7 +152,7 @@ export default function NewLinkModal({ onClose }: Props) {
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags?.map((e) => ({
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
@@ -147,17 +161,17 @@ export default function NewLinkModal({ onClose }: Props) {
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description || "") || ""}
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("link_description_placeholder")}
className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
)}
) : undefined}
</div>
<div className="flex justify-between items-center mt-5">
<div

View File

@@ -7,7 +7,6 @@ import { dropdownTriggerer } from "@/lib/client/utils";
import Button from "../ui/Button";
import { useTranslation } from "next-i18next";
import { useAddToken } from "@/hooks/store/tokens";
import CopyButton from "../CopyButton";
type Props = {
onClose: Function;
@@ -34,7 +33,6 @@ export default function NewTokenModal({ onClose }: Props) {
await addToken.mutateAsync(token, {
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -44,6 +42,8 @@ export default function NewTokenModal({ onClose }: Props) {
}
},
});
setSubmitLoader(false);
}
};
@@ -68,14 +68,21 @@ export default function NewTokenModal({ onClose }: Props) {
<div className="flex flex-col justify-center space-y-4">
<p className="text-xl font-thin">{t("access_token_created")}</p>
<p>{t("token_creation_notice")}</p>
<div className="relative">
<div className="w-full hide-scrollbar overflow-x-auto whitespace-nowrap rounded-md p-2 bg-base-200 border-neutral-content border-solid border flex items-center gap-2 justify-between pr-14">
{newToken}
<div className="absolute right-0 px-2 border-neutral-content border-solid border-r bg-base-200">
<CopyButton text={newToken} />
</div>
</div>
</div>
<TextInput
spellCheck={false}
value={newToken}
onChange={() => {}}
className="w-full"
/>
<button
onClick={() => {
navigator.clipboard.writeText(newToken);
toast.success(t("copied_to_clipboard"));
}}
className="btn btn-primary w-fit mx-auto"
>
{t("copy_to_clipboard")}
</button>
</div>
) : (
<>

View File

@@ -35,9 +35,6 @@ export default function NewUserModal({ onClose }: Props) {
event.preventDefault();
if (!submitLoader) {
if (form.password.length < 8)
return toast.error(t("password_length_error"));
const checkFields = () => {
if (emailEnabled) {
return form.name !== "" && form.email !== "" && form.password !== "";
@@ -55,10 +52,9 @@ export default function NewUserModal({ onClose }: Props) {
onSuccess: () => {
onClose();
},
onSettled: () => {
setSubmitLoader(false);
},
});
setSubmitLoader(false);
} else {
toast.error(t("fill_all_fields_error"));
}
@@ -83,7 +79,7 @@ export default function NewUserModal({ onClose }: Props) {
/>
</div>
{emailEnabled && (
{emailEnabled ? (
<div>
<p className="mb-2">{t("email")}</p>
<TextInput
@@ -93,7 +89,7 @@ export default function NewUserModal({ onClose }: Props) {
value={form.email}
/>
</div>
)}
) : undefined}
<div>
<p className="mb-2">

View File

@@ -0,0 +1,248 @@
import React, { useEffect, useState } from "react";
import {
LinkIncludingShortenedCollectionAndTags,
ArchivedFormat,
} from "@/types/global";
import toast from "react-hot-toast";
import Link from "next/link";
import Modal from "../Modal";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import {
pdfAvailable,
readabilityAvailable,
monolithAvailable,
screenshotAvailable,
} from "@/lib/shared/getArchiveValidity";
import PreservedFormatRow from "@/components/PreserverdFormatRow";
import getPublicUserData from "@/lib/client/getPublicUserData";
import { useTranslation } from "next-i18next";
import { BeatLoader } from "react-spinners";
import { useUser } from "@/hooks/store/user";
import { useGetLink } from "@/hooks/store/links";
type Props = {
onClose: Function;
link: LinkIncludingShortenedCollectionAndTags;
};
export default function PreservedFormatsModal({ onClose, link }: Props) {
const { t } = useTranslation();
const session = useSession();
const getLink = useGetLink();
const { data: user = {} } = useUser();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
const [collectionOwner, setCollectionOwner] = useState({
id: null as unknown as number,
name: "",
username: "",
image: "",
archiveAsScreenshot: undefined as unknown as boolean,
archiveAsMonolith: undefined as unknown as boolean,
archiveAsPDF: undefined as unknown as boolean,
});
useEffect(() => {
const fetchOwner = async () => {
if (link.collection.ownerId !== user.id) {
const owner = await getPublicUserData(
link.collection.ownerId as number
);
setCollectionOwner(owner);
} else if (link.collection.ownerId === user.id) {
setCollectionOwner({
id: user.id as number,
name: user.name,
username: user.username as string,
image: user.image as string,
archiveAsScreenshot: user.archiveAsScreenshot as boolean,
archiveAsMonolith: user.archiveAsScreenshot as boolean,
archiveAsPDF: user.archiveAsPDF as boolean,
});
}
};
fetchOwner();
}, [link.collection.ownerId]);
const isReady = () => {
return (
link &&
(collectionOwner.archiveAsScreenshot === true
? link.pdf && link.pdf !== "pending"
: true) &&
(collectionOwner.archiveAsMonolith === true
? link.monolith && link.monolith !== "pending"
: true) &&
(collectionOwner.archiveAsPDF === true
? link.pdf && link.pdf !== "pending"
: true) &&
link.readable &&
link.readable !== "pending"
);
};
const atLeastOneFormatAvailable = () => {
return (
screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link)
);
};
useEffect(() => {
(async () => {
await getLink.mutateAsync(link.id as number);
})();
let interval: any;
if (!isReady()) {
interval = setInterval(async () => {
await getLink.mutateAsync(link.id as number);
}, 5000);
} else {
if (interval) {
clearInterval(interval);
}
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [link?.monolith]);
const updateArchive = async () => {
const load = toast.loading(t("sending_request"));
const response = await fetch(`/api/v1/links/${link?.id}/archive`, {
method: "PUT",
});
const data = await response.json();
toast.dismiss(load);
if (response.ok) {
await getLink.mutateAsync(link?.id as number);
toast.success(t("link_being_archived"));
} else toast.error(data.response);
};
return (
<Modal toggleModal={onClose}>
<p className="text-xl font-thin">{t("preserved_formats")}</p>
<div className="divider mb-2 mt-1"></div>
{screenshotAvailable(link) ||
pdfAvailable(link) ||
readabilityAvailable(link) ||
monolithAvailable(link) ? (
<p className="mb-3">{t("available_formats")}</p>
) : (
""
)}
<div className={`flex flex-col gap-3`}>
{monolithAvailable(link) ? (
<PreservedFormatRow
name={t("webpage")}
icon={"bi-filetype-html"}
format={ArchivedFormat.monolith}
link={link}
downloadable={true}
/>
) : undefined}
{screenshotAvailable(link) ? (
<PreservedFormatRow
name={t("screenshot")}
icon={"bi-file-earmark-image"}
format={
link?.image?.endsWith("png")
? ArchivedFormat.png
: ArchivedFormat.jpeg
}
link={link}
downloadable={true}
/>
) : undefined}
{pdfAvailable(link) ? (
<PreservedFormatRow
name={t("pdf")}
icon={"bi-file-earmark-pdf"}
format={ArchivedFormat.pdf}
link={link}
downloadable={true}
/>
) : undefined}
{readabilityAvailable(link) ? (
<PreservedFormatRow
name={t("readable")}
icon={"bi-file-earmark-text"}
format={ArchivedFormat.readability}
link={link}
/>
) : undefined}
{!isReady() && !atLeastOneFormatAvailable() ? (
<div className={`w-full h-full flex flex-col justify-center p-10`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={30}
/>
<p className="text-center text-2xl">{t("preservation_in_queue")}</p>
<p className="text-center text-lg">{t("check_back_later")}</p>
</div>
) : !isReady() && atLeastOneFormatAvailable() ? (
<div className={`w-full h-full flex flex-col justify-center p-5`}>
<BeatLoader
color="oklch(var(--p))"
className="mx-auto mb-3"
size={20}
/>
<p className="text-center">{t("there_are_more_formats")}</p>
<p className="text-center text-sm">{t("check_back_later")}</p>
</div>
) : undefined}
<div
className={`flex flex-col sm:flex-row gap-3 items-center justify-center ${
isReady() ? "sm:mt " : ""
}`}
>
<Link
href={`https://web.archive.org/web/${link?.url?.replace(
/(^\w+:|^)\/\//,
""
)}`}
target="_blank"
className="text-neutral duration-100 hover:opacity-60 flex gap-2 w-1/2 justify-center items-center text-sm"
>
<p className="whitespace-nowrap">{t("view_latest_snapshot")}</p>
<i className="bi-box-arrow-up-right" />
</Link>
{link?.collection.ownerId === session.data?.user.id && (
<div className="btn btn-outline" onClick={updateArchive}>
<div>
<p>{t("refresh_preserved_formats")}</p>
<p className="text-xs">
{t("this_deletes_current_preservations")}
</p>
</div>
</div>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -14,7 +14,6 @@ import Modal from "../Modal";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useUploadFile } from "@/hooks/store/links";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
type Props = {
onClose: Function;
@@ -26,16 +25,24 @@ export default function UploadFileModal({ onClose }: Props) {
const initial = {
name: "",
url: "",
description: "",
type: "url",
tags: [],
preview: "",
image: "",
pdf: "",
readable: "",
monolith: "",
textContent: "",
collection: {
id: undefined,
name: "",
ownerId: data?.user.id as number,
},
} as PostLinkSchemaType;
} as LinkIncludingShortenedCollectionAndTags;
const [link, setLink] = useState<PostLinkSchemaType>(initial);
const [link, setLink] =
useState<LinkIncludingShortenedCollectionAndTags>(initial);
const [file, setFile] = useState<File>();
const uploadFile = useUploadFile();
@@ -45,11 +52,11 @@ export default function UploadFileModal({ onClose }: Props) {
const { data: collections = [] } = useCollections();
const setCollection = (e: any) => {
if (e?.__isNew__) e.value = undefined;
if (e?.__isNew__) e.value = null;
setLink({
...link,
collection: { id: e?.value, name: e?.label },
collection: { id: e?.value, name: e?.label, ownerId: e?.ownerId },
});
};
@@ -63,11 +70,10 @@ export default function UploadFileModal({ onClose }: Props) {
useEffect(() => {
setOptionsExpanded(false);
if (router.pathname.startsWith("/collections/") && router.query.id) {
if (router.query.id) {
const currentCollection = collections.find(
(e) => e.id == Number(router.query.id)
);
if (
currentCollection &&
currentCollection.ownerId &&
@@ -78,12 +84,13 @@ export default function UploadFileModal({ onClose }: Props) {
collection: {
id: currentCollection.id,
name: currentCollection.name,
ownerId: currentCollection.ownerId,
},
});
} else
setLink({
...initial,
collection: { name: "Unorganized" },
collection: { name: "Unorganized", ownerId: data?.user.id as number },
});
}, [router, collections]);
@@ -115,7 +122,6 @@ export default function UploadFileModal({ onClose }: Props) {
{ link, file },
{
onSettled: (data, error) => {
setSubmitLoader(false);
toast.dismiss(load);
if (error) {
@@ -127,6 +133,8 @@ export default function UploadFileModal({ onClose }: Props) {
},
}
);
setSubmitLoader(false);
}
};
@@ -142,7 +150,7 @@ export default function UploadFileModal({ onClose }: Props) {
<label className="btn h-10 btn-sm w-full border border-neutral-content hover:border-neutral-content flex justify-between">
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg"
accept=".pdf,.png,.jpg,.jpeg,.html"
className="cursor-pointer custom-file-input"
onChange={(e) => e.target.files && setFile(e.target.files[0])}
/>
@@ -155,18 +163,18 @@ export default function UploadFileModal({ onClose }: Props) {
</div>
<div className="sm:col-span-2 col-span-5">
<p className="mb-2">{t("collection")}</p>
{link.collection?.name && (
{link.collection.name ? (
<CollectionSelection
onChange={setCollection}
defaultValue={{
value: link.collection?.id,
label: link.collection?.name || "Unorganized",
label: link.collection.name,
value: link.collection.id,
}}
/>
)}
) : null}
</div>
</div>
{optionsExpanded && (
{optionsExpanded ? (
<div className="mt-5">
<div className="grid sm:grid-cols-2 gap-3">
<div>
@@ -182,26 +190,26 @@ export default function UploadFileModal({ onClose }: Props) {
<p className="mb-2">{t("tags")}</p>
<TagSelection
onChange={setTags}
defaultValue={link.tags?.map((e) => ({
value: e.id,
defaultValue={link.tags.map((e) => ({
label: e.name,
value: e.id,
}))}
/>
</div>
<div className="sm:col-span-2">
<p className="mb-2">{t("description")}</p>
<textarea
value={unescapeString(link.description || "") || ""}
value={unescapeString(link.description) as string}
onChange={(e) =>
setLink({ ...link, description: e.target.value })
}
placeholder={t("description_placeholder")}
className="resize-none w-full h-32 rounded-md p-2 border-neutral-content bg-base-200 focus:border-primary border-solid border outline-none duration-100"
className="resize-none w-full rounded-md p-2 border-neutral-content bg-base-200 focus:border-sky-300 dark:focus:border-sky-600 border-solid border outline-none duration-100"
/>
</div>
</div>
</div>
)}
) : undefined}
<div className="flex justify-between items-center mt-5">
<div
onClick={() => setOptionsExpanded(!optionsExpanded)}

View File

@@ -114,7 +114,7 @@ export default function Navbar() {
<MobileNavigation />
{sidebar && (
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-40">
<ClickAwayHandler className="h-full" onClickOutside={toggleSidebar}>
<div className="slide-right h-full shadow-lg">
@@ -122,14 +122,16 @@ export default function Navbar() {
</div>
</ClickAwayHandler>
</div>
)}
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newCollectionModal && (
) : null}
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
{newCollectionModal ? (
<NewCollectionModal onClose={() => setNewCollectionModal(false)} />
)}
{uploadFileModal && (
) : undefined}
{uploadFileModal ? (
<UploadFileModal onClose={() => setUploadFileModal(false)} />
)}
) : undefined}
</div>
);
}

View File

@@ -39,7 +39,9 @@ export default function NoLinksFound({ text }: Props) {
</span>
</div>
</div>
{newLinkModal && <NewLinkModal onClose={() => setNewLinkModal(false)} />}
{newLinkModal ? (
<NewLinkModal onClose={() => setNewLinkModal(false)} />
) : undefined}
</div>
);
}

View File

@@ -1,21 +0,0 @@
import React from "react";
import ClickAwayHandler from "./ClickAwayHandler";
type Props = {
children: React.ReactNode;
onClose: Function;
className?: string;
};
const Popover = ({ children, className, onClose }: Props) => {
return (
<ClickAwayHandler
onClickOutside={() => onClose()}
className={`absolute z-50 ${className || ""}`}
>
{children}
</ClickAwayHandler>
);
};
export default Popover;

View File

@@ -4,6 +4,7 @@ import {
} from "@/types/global";
import Link from "next/link";
import { useRouter } from "next/router";
import { useGetLink } from "@/hooks/store/links";
type Props = {
name: string;
@@ -20,6 +21,8 @@ export default function PreservedFormatRow({
link,
downloadable,
}: Props) {
const getLink = useGetLink();
const router = useRouter();
let isPublic = router.pathname.startsWith("/public") ? true : undefined;
@@ -49,9 +52,11 @@ export default function PreservedFormatRow({
};
return (
<div className="flex justify-between items-center">
<div className="flex justify-between items-center pr-1 border border-neutral-content rounded-md">
<div className="flex gap-2 items-center">
<i className={`${icon} text-2xl text-primary`} />
<div className="bg-primary text-primary-content p-2 rounded-l-md">
<i className={`${icon} text-2xl`} />
</div>
<p>{name}</p>
</div>
@@ -59,7 +64,7 @@ export default function PreservedFormatRow({
{downloadable || false ? (
<div
onClick={() => handleDownload()}
className="btn btn-sm btn-square btn-ghost"
className="btn btn-sm btn-square"
>
<i className="bi-cloud-arrow-down text-xl text-neutral" />
</div>
@@ -70,9 +75,9 @@ export default function PreservedFormatRow({
isPublic ? "/public" : ""
}/preserved/${link?.id}?format=${format}`}
target="_blank"
className="btn btn-sm btn-square btn-ghost"
className="btn btn-sm btn-square"
>
<i className="bi-box-arrow-up-right text-lg text-neutral" />
<i className="bi-box-arrow-up-right text-xl text-neutral" />
</Link>
</div>
</div>

View File

@@ -5,7 +5,7 @@ type Props = {
src?: string;
className?: string;
priority?: boolean;
name?: string | null;
name?: string;
large?: boolean;
};

View File

@@ -3,6 +3,7 @@ import { readabilityAvailable } from "@/lib/shared/getArchiveValidity";
import isValidUrl from "@/lib/shared/isValidUrl";
import {
ArchivedFormat,
CollectionIncludingMembersAndLinkCount,
LinkIncludingShortenedCollectionAndTags,
} from "@/types/global";
import ColorThief, { RGBColor } from "colorthief";
@@ -10,11 +11,11 @@ import DOMPurify from "dompurify";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import LinkActions from "./LinkViews/LinkComponents/LinkActions";
import { useTranslation } from "next-i18next";
import { useCollections } from "@/hooks/store/collections";
import { useGetLink } from "@/hooks/store/links";
import { IconWeight } from "@phosphor-icons/react";
import Icon from "./Icon";
type LinkContent = {
title: string;
@@ -45,6 +46,13 @@ export default function ReadableView({ link }: Props) {
const router = useRouter();
const getLink = useGetLink();
const { data: collections = [] } = useCollections();
const collection = useMemo(() => {
return collections.find(
(e) => e.id === link.collection.id
) as CollectionIncludingMembersAndLinkCount;
}, [collections, link]);
useEffect(() => {
const fetchLinkContent = async () => {
@@ -65,9 +73,9 @@ export default function ReadableView({ link }: Props) {
}, [link]);
useEffect(() => {
if (link) getLink.mutateAsync({ id: link.id as number });
if (link) getLink.mutateAsync(link?.id as number);
let interval: NodeJS.Timeout | null = null;
let interval: any;
if (
link &&
(link?.image === "pending" ||
@@ -80,10 +88,7 @@ export default function ReadableView({ link }: Props) {
!link?.monolith)
) {
interval = setInterval(
() =>
getLink.mutateAsync({
id: link.id as number,
}),
() => getLink.mutateAsync(link.id as number),
5000
);
} else {
@@ -181,7 +186,7 @@ export default function ReadableView({ link }: Props) {
link?.name || link?.description || link?.url || ""
)}
</p>
{link?.url && (
{link?.url ? (
<Link
href={link?.url || ""}
title={link?.url}
@@ -190,10 +195,11 @@ export default function ReadableView({ link }: Props) {
>
<i className="bi-link-45deg"></i>
{isValidUrl(link?.url || "") &&
new URL(link?.url as string).host}
{isValidUrl(link?.url || "")
? new URL(link?.url as string).host
: undefined}
</Link>
)}
) : undefined}
</div>
</div>
@@ -202,21 +208,10 @@ export default function ReadableView({ link }: Props) {
href={`/collections/${link?.collection.id}`}
className="flex items-center gap-1 cursor-pointer hover:opacity-60 duration-100 mr-2 z-10"
>
{link.collection.icon ? (
<Icon
icon={link.collection.icon}
size={30}
weight={
(link.collection.iconWeight || "regular") as IconWeight
}
color={link.collection.color}
/>
) : (
<i
className="bi-folder-fill text-2xl"
style={{ color: link.collection.color }}
></i>
)}
<i
className="bi-folder-fill drop-shadow text-2xl"
style={{ color: link?.collection.color }}
></i>
<p
title={link?.collection.name}
className="text-lg truncate max-w-[12rem]"
@@ -248,6 +243,13 @@ export default function ReadableView({ link }: Props) {
{link?.name ? <p>{unescapeString(link?.description)}</p> : undefined}
</div>
<LinkActions
link={link}
collection={collection}
position="top-3 right-3"
alignToTop
/>
</div>
<div className="flex flex-col gap-5 h-full">

View File

@@ -2,14 +2,11 @@ import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
export default function SettingsSidebar({ className }: { className?: string }) {
const { t } = useTranslation();
const LINKWARDEN_VERSION = process.env.version;
const { data: user } = useUser();
const router = useRouter();
const [active, setActive] = useState("");
@@ -76,7 +73,7 @@ export default function SettingsSidebar({ className }: { className?: string }) {
</div>
</Link>
{process.env.NEXT_PUBLIC_STRIPE && !user.parentSubscriptionId && (
{process.env.NEXT_PUBLIC_STRIPE && (
<Link href="/settings/billing">
<div
className={`${

View File

@@ -1,34 +1,28 @@
import useLocalSettingsStore from "@/store/localSettings";
import { useEffect, useState, ChangeEvent } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "next-i18next";
import clsx from "clsx";
type Props = {
className?: string;
align?: "left" | "right";
};
export default function ToggleDarkMode({ className, align }: Props) {
export default function ToggleDarkMode({ className }: Props) {
const { t } = useTranslation();
const { settings, updateSettings } = useLocalSettingsStore();
const [theme, setTheme] = useState<string | null>(
localStorage.getItem("theme")
);
const [theme, setTheme] = useState(localStorage.getItem("theme"));
const handleToggle = (e: ChangeEvent<HTMLInputElement>) => {
const handleToggle = (e: any) => {
setTheme(e.target.checked ? "dark" : "light");
};
useEffect(() => {
if (theme) {
updateSettings({ theme });
}
updateSettings({ theme: theme as string });
}, [theme]);
return (
<div
className={clsx("tooltip", align ? `tooltip-${align}` : "tooltip-bottom")}
className="tooltip tooltip-bottom"
data-tip={t("switch_to", {
theme: settings.theme === "light" ? "Dark" : "Light",
})}
@@ -40,7 +34,7 @@ export default function ToggleDarkMode({ className, align }: Props) {
type="checkbox"
onChange={handleToggle}
className="theme-controller"
checked={theme === "dark"}
checked={localStorage.getItem("theme") === "light" ? false : true}
/>
<i className="bi-sun-fill text-xl swap-on"></i>
<i className="bi-moon-fill text-xl swap-off"></i>

View File

@@ -74,12 +74,12 @@ const UserListing = (
</tbody>
</table>
{deleteUserModal.isOpen && deleteUserModal.userId && (
{deleteUserModal.isOpen && deleteUserModal.userId ? (
<DeleteUserModal
onClose={() => setDeleteUserModal({ isOpen: false, userId: null })}
userId={deleteUserModal.userId}
/>
)}
) : null}
</div>
);
};

View File

@@ -2,17 +2,19 @@ import axios, { AxiosError } from "axios"
axios.defaults.baseURL = "http://localhost:3000"
export async function seedUser(username?: string, password?: string, name?: string) {
export async function seedUser (username?: string, password?: string, name?: string) {
try {
return await axios.post("/api/v1/users", {
username: username || "test",
password: password || "password",
name: name || "Test User",
})
} catch (error) {
const axiosError = error as AxiosError;
if (axiosError && axiosError.response?.status === 400) return
throw error
} catch (e: any) {
if (e instanceof AxiosError) {
if (e.response?.status === 400) {
return
}
}
throw e
}
}

View File

@@ -11,6 +11,9 @@ const useUsers = () => {
queryFn: async () => {
const response = await fetch("/api/v1/users");
if (!response.ok) {
if (response.status === 401) {
window.location.href = "/dashboard";
}
throw new Error("Failed to fetch users.");
}
@@ -27,6 +30,8 @@ const useAddUser = () => {
return useMutation({
mutationFn: async (body: any) => {
if (body.password.length < 8) throw new Error(t("password_length_error"));
const load = toast.loading(t("creating_account"));
const response = await fetch("/api/v1/users", {

View File

@@ -1,3 +1,4 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { useQuery } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
@@ -6,11 +7,11 @@ const useDashboardData = () => {
return useQuery({
queryKey: ["dashboardData"],
queryFn: async () => {
const response = await fetch("/api/v2/dashboard");
queryFn: async (): Promise<LinkIncludingShortenedCollectionAndTags[]> => {
const response = await fetch("/api/v1/dashboard");
const data = await response.json();
return data.data;
return data.response;
},
enabled: status === "authenticated",
});

View File

@@ -13,7 +13,6 @@ import {
} from "@/types/global";
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
import { PostLinkSchemaType } from "@/lib/shared/schemaValidation";
const useLinks = (params: LinkRequestQuery = {}) => {
const router = useRouter();
@@ -104,15 +103,7 @@ const useAddLink = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (link: PostLinkSchemaType) => {
if (link.url || link.type === "url") {
try {
new URL(link.url || "");
} catch (error) {
throw new Error("invalid_url_guide");
}
}
mutationFn: async (link: LinkIncludingShortenedCollectionAndTags) => {
const response = await fetch("/api/v1/links", {
method: "POST",
headers: {
@@ -129,11 +120,8 @@ const useAddLink = () => {
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return undefined;
return {
...oldData,
links: [data, ...oldData?.links],
};
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@@ -172,8 +160,8 @@ const useUpdateLink = () => {
},
onSuccess: (data) => {
// queryClient.setQueryData(["dashboardData"], (oldData: any) => {
// if (!oldData?.links) return undefined;
// return oldData.links.map((e: any) => (e.id === data.id ? data : e));
// if (!oldData) return undefined;
// return oldData.map((e: any) => (e.id === data.id ? data : e));
// });
// queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@@ -213,11 +201,8 @@ const useDeleteLink = () => {
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return undefined;
return {
...oldData,
links: oldData.links.filter((e: any) => e.id !== data.id),
};
if (!oldData) return undefined;
return oldData.filter((e: any) => e.id !== data.id);
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@@ -240,21 +225,9 @@ const useDeleteLink = () => {
const useGetLink = () => {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: async ({
id,
isPublicRoute = router.pathname.startsWith("/public") ? true : undefined,
}: {
id: number;
isPublicRoute?: boolean;
}) => {
const path = isPublicRoute
? `/api/v1/public/links/${id}`
: `/api/v1/links/${id}`;
const response = await fetch(path);
mutationFn: async (id: number) => {
const response = await fetch(`/api/v1/links/${id}`);
const data = await response.json();
if (!response.ok) throw new Error(data.response);
@@ -263,11 +236,8 @@ const useGetLink = () => {
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return undefined;
return {
...oldData,
links: oldData.links.map((e: any) => (e.id === data.id ? data : e)),
};
if (!oldData) return undefined;
return oldData.map((e: any) => (e.id === data.id ? data : e));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@@ -280,20 +250,7 @@ const useGetLink = () => {
};
});
queryClient.setQueriesData(
{ queryKey: ["publicLinks"] },
(oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) => (item.id === data.id ? data : item))
),
pageParams: oldData.pageParams,
};
}
);
// queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
queryClient.invalidateQueries({ queryKey: ["publicLinks"] });
},
});
};
@@ -319,8 +276,8 @@ const useBulkDeleteLinks = () => {
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return undefined;
return oldData.links.filter((e: any) => !data.includes(e.id));
if (!oldData) return undefined;
return oldData.filter((e: any) => !data.includes(e.id));
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@@ -394,11 +351,8 @@ const useUploadFile = () => {
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return undefined;
return {
...oldData,
links: [data, ...oldData?.links],
};
if (!oldData) return undefined;
return [data, ...oldData];
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
@@ -416,67 +370,6 @@ const useUploadFile = () => {
});
};
const useUpdatePreview = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ linkId, file }: { linkId: number; file: File }) => {
const formBody = new FormData();
if (!linkId || !file)
throw new Error("Error generating preview: Invalid parameters");
formBody.append("file", file);
const res = await fetch(
`/api/v1/archives/${linkId}?format=` + ArchivedFormat.jpeg,
{
body: formBody,
method: "PUT",
}
);
const data = res.json();
return data;
},
onSuccess: (data) => {
queryClient.setQueryData(["dashboardData"], (oldData: any) => {
if (!oldData?.links) return undefined;
return {
...oldData,
links: oldData.links.map((e: any) =>
e.id === data.response.id
? {
...e,
preview: `archives/preview/${e.collectionId}/${e.id}.jpeg`,
}
: e
),
};
});
queryClient.setQueriesData({ queryKey: ["links"] }, (oldData: any) => {
if (!oldData) return undefined;
return {
pages: oldData.pages.map((page: any) =>
page.map((item: any) =>
item.id === data.response.id
? {
...item,
preview: `archives/preview/${item.collectionId}/${item.id}.jpeg`,
updatedAt: new Date().toISOString(),
}
: item
)
),
pageParams: oldData.pageParams,
};
});
},
});
};
const useBulkEditLinks = () => {
const queryClient = useQueryClient();
@@ -510,8 +403,8 @@ const useBulkEditLinks = () => {
onSuccess: (data, { links, newData, removePreviousTags }) => {
// TODO: Fix these
// queryClient.setQueryData(["dashboardData"], (oldData: any) => {
// if (!oldData?.links) return undefined;
// return oldData.links.map((e: any) =>
// if (!oldData) return undefined;
// return oldData.map((e: any) =>
// data.find((d: any) => d.id === e.id) ? data : e
// );
// });
@@ -561,5 +454,4 @@ export {
useGetLink,
useBulkEditLinks,
resetInfiniteQueryPagination,
useUpdatePreview,
};

View File

@@ -27,9 +27,6 @@ const useAddToken = () => {
const response = await fetch("/api/v1/tokens", {
body: JSON.stringify(body),
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();

View File

@@ -23,10 +23,7 @@ export default function AuthRedirect({ children }: Props) {
const isUnauthenticated = status === "unauthenticated";
const isPublicPage = router.pathname.startsWith("/public");
const hasInactiveSubscription =
user.id &&
!user.subscription?.active &&
!user.parentSubscription?.active &&
stripeEnabled;
user.id && !user.subscription?.active && stripeEnabled;
// There are better ways of doing this... but this one works for now
const routes = [
@@ -52,8 +49,6 @@ export default function AuthRedirect({ children }: Props) {
} else {
if (isLoggedIn && hasInactiveSubscription) {
redirectTo("/subscribe");
} else if (isLoggedIn && !user.name && user.parentSubscriptionId) {
redirectTo("/member-onboarding");
} else if (
isLoggedIn &&
!routes.some((e) => router.pathname.startsWith(e.path) && e.isProtected)

View File

@@ -23,7 +23,7 @@ export default function CenteredForm({
data-testid={dataTestId}
>
<div className="m-auto flex flex-col gap-2 w-full">
{settings.theme && (
{settings.theme ? (
<Image
src={`/linkwarden_${
settings.theme === "dark" ? "dark" : "light"
@@ -33,12 +33,12 @@ export default function CenteredForm({
alt="Linkwarden"
className="h-12 w-fit mx-auto"
/>
)}
{text && (
) : undefined}
{text ? (
<p className="text-lg max-w-[30rem] min-w-80 w-full mx-auto font-semibold px-2 text-center">
{text}
</p>
)}
) : undefined}
{children}
<p className="text-center text-xs text-neutral mb-5">
<Trans

View File

@@ -34,9 +34,9 @@ export default function MainLayout({ children }: Props) {
return (
<div className="flex" data-testid="dashboard-wrapper">
{showAnnouncement && (
{showAnnouncement ? (
<Announcement toggleAnnouncementBar={toggleAnnouncementBar} />
)}
) : undefined}
<div className="hidden lg:block">
<Sidebar className={`fixed top-0`} />
</div>

View File

@@ -54,7 +54,7 @@ export default function SettingsLayout({ children }: Props) {
{children}
{sidebar && (
{sidebar ? (
<div className="fixed top-0 bottom-0 right-0 left-0 bg-black bg-opacity-10 backdrop-blur-sm flex items-center fade-in z-30">
<ClickAwayHandler
className="h-full"
@@ -65,7 +65,7 @@ export default function SettingsLayout({ children }: Props) {
</div>
</ClickAwayHandler>
</div>
)}
) : null}
</div>
</div>
</>

View File

@@ -0,0 +1,53 @@
import Stripe from "stripe";
const MONTHLY_PRICE_ID = process.env.MONTHLY_PRICE_ID;
const YEARLY_PRICE_ID = process.env.YEARLY_PRICE_ID;
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function checkSubscriptionByEmail(email: string) {
let active: boolean | undefined,
stripeSubscriptionId: string | undefined,
currentPeriodStart: number | undefined,
currentPeriodEnd: number | undefined;
if (!STRIPE_SECRET_KEY)
return {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
};
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
console.log("Request made to Stripe by:", email);
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
});
listByEmail.data.some((customer) => {
customer.subscriptions?.data.some((subscription) => {
subscription.current_period_end;
active =
subscription.items.data.some(
(e) =>
(e.price.id === MONTHLY_PRICE_ID && e.price.active === true) ||
(e.price.id === YEARLY_PRICE_ID && e.price.active === true)
) || false;
stripeSubscriptionId = subscription.id;
currentPeriodStart = subscription.current_period_start * 1000;
currentPeriodEnd = subscription.current_period_end * 1000;
});
});
return {
active: active || false,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
};
}

View File

@@ -1,31 +1,15 @@
import { prisma } from "@/lib/api/db";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import getPermission from "@/lib/api/getPermission";
import {
UpdateCollectionSchema,
UpdateCollectionSchemaType,
} from "@/lib/shared/schemaValidation";
export default async function updateCollection(
userId: number,
collectionId: number,
body: UpdateCollectionSchemaType
data: CollectionIncludingMembersAndLinkCount
) {
if (!collectionId)
return { response: "Please choose a valid collection.", status: 401 };
const dataValidation = UpdateCollectionSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const data = dataValidation.data;
const collectionIsAccessible = await getPermission({
userId,
collectionId,
@@ -34,8 +18,10 @@ export default async function updateCollection(
if (!(collectionIsAccessible?.ownerId === userId))
return { response: "Collection is not accessible.", status: 401 };
console.log(data);
if (data.parentId) {
if (data.parentId !== "root") {
if (data.parentId !== ("root" as any)) {
const findParentCollection = await prisma.collection.findUnique({
where: {
id: data.parentId,
@@ -58,12 +44,6 @@ export default async function updateCollection(
}
}
const uniqueMembers = data.members.filter(
(e, i, a) =>
a.findIndex((el) => el.userId === e.userId) === i &&
e.userId !== collectionIsAccessible.ownerId
);
const updatedCollection = await prisma.$transaction(async () => {
await prisma.usersAndCollections.deleteMany({
where: {
@@ -81,24 +61,22 @@ export default async function updateCollection(
name: data.name.trim(),
description: data.description,
color: data.color,
icon: data.icon,
iconWeight: data.iconWeight,
isPublic: data.isPublic,
parent:
data.parentId && data.parentId !== "root"
data.parentId && data.parentId !== ("root" as any)
? {
connect: {
id: data.parentId,
},
}
: data.parentId === "root"
: data.parentId === ("root" as any)
? {
disconnect: true,
}
: undefined,
members: {
create: uniqueMembers.map((e) => ({
user: { connect: { id: e.userId } },
create: data.members.map((e) => ({
user: { connect: { id: e.user.id || e.userId } },
canCreate: e.canCreate,
canUpdate: e.canUpdate,
canDelete: e.canDelete,

View File

@@ -1,26 +1,16 @@
import { prisma } from "@/lib/api/db";
import { CollectionIncludingMembersAndLinkCount } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder";
import {
PostCollectionSchema,
PostCollectionSchemaType,
} from "@/lib/shared/schemaValidation";
export default async function postCollection(
body: PostCollectionSchemaType,
collection: CollectionIncludingMembersAndLinkCount,
userId: number
) {
const dataValidation = PostCollectionSchema.safeParse(body);
if (!dataValidation.success) {
if (!collection || collection.name.trim() === "")
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
response: "Please enter a valid collection.",
status: 400,
};
}
const collection = dataValidation.data;
if (collection.parentId) {
const findParentCollection = await prisma.collection.findUnique({
@@ -44,11 +34,14 @@ export default async function postCollection(
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: collection.name.trim(),
description: collection.description,
color: collection.color,
icon: collection.icon,
iconWeight: collection.iconWeight,
parent: collection.parentId
? {
connect: {
@@ -56,16 +49,6 @@ export default async function postCollection(
},
}
: undefined,
owner: {
connect: {
id: userId,
},
},
createdBy: {
connect: {
id: userId,
},
},
},
include: {
_count: {

View File

@@ -1,11 +1,11 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Order, Sort } from "@/types/global";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getDashboardData(
userId: number,
query: LinkRequestQuery
) {
let order: Order = { id: "desc" };
let order: any = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };

View File

@@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Order, Sort } from "@/types/global";
import { LinkRequestQuery, Sort } from "@/types/global";
type Response<D> =
| {
@@ -17,7 +17,7 @@ export default async function getDashboardData(
userId: number,
query: LinkRequestQuery
): Promise<Response<any>> {
let order: Order = { id: "desc" };
let order: any;
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };
@@ -48,7 +48,7 @@ export default async function getDashboardData(
});
const pinnedLinks = await prisma.link.findMany({
take: 16,
take: 10,
where: {
AND: [
{
@@ -80,7 +80,7 @@ export default async function getDashboardData(
});
const recentlyAddedLinks = await prisma.link.findMany({
take: 16,
take: 10,
where: {
collection: {
OR: [
@@ -105,17 +105,12 @@ export default async function getDashboardData(
});
const links = [...recentlyAddedLinks, ...pinnedLinks].sort(
(a, b) => new Date(b.id).getTime() - new Date(a.id).getTime()
);
// Make sure links are unique
const uniqueLinks = links.filter(
(link, index, self) => index === self.findIndex((t) => t.id === link.id)
(a, b) => (new Date(b.id) as any) - (new Date(a.id) as any)
);
return {
data: {
links: uniqueLinks,
links,
numberOfPinnedLinks,
},
message: "Dashboard data fetched successfully.",

View File

@@ -1,10 +1,9 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import updateLinkById from "../linkId/updateLinkById";
import { UpdateLinkSchemaType } from "@/lib/shared/schemaValidation";
export default async function updateLinks(
userId: number,
links: UpdateLinkSchemaType[],
links: LinkIncludingShortenedCollectionAndTags[],
removePreviousTags: boolean,
newData: Pick<
LinkIncludingShortenedCollectionAndTags,
@@ -23,7 +22,7 @@ export default async function updateLinks(
updatedTags = [...(newData.tags ?? [])];
}
const updatedData: UpdateLinkSchemaType = {
const updatedData: LinkIncludingShortenedCollectionAndTags = {
...link,
tags: updatedTags,
collection: {

View File

@@ -1,11 +1,11 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Order, Sort } from "@/types/global";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(userId: number, query: LinkRequestQuery) {
const POSTGRES_IS_ENABLED =
process.env.DATABASE_URL?.startsWith("postgresql");
let order: Order = { id: "desc" };
let order: any = { id: "desc" };
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };

View File

@@ -1,30 +1,19 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import { moveFiles, removeFiles } from "@/lib/api/manageLinkFiles";
import isValidUrl from "@/lib/shared/isValidUrl";
import {
UpdateLinkSchema,
UpdateLinkSchemaType,
} from "@/lib/shared/schemaValidation";
import { moveFiles } from "@/lib/api/manageLinkFiles";
export default async function updateLinkById(
userId: number,
linkId: number,
body: UpdateLinkSchemaType
data: LinkIncludingShortenedCollectionAndTags
) {
const dataValidation = UpdateLinkSchema.safeParse(body);
if (!dataValidation.success) {
if (!data || !data.collection.id)
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
response: "Please choose a valid link and collection.",
status: 401,
};
}
const data = dataValidation.data;
const collectionIsAccessible = await getPermission({ userId, linkId });
@@ -36,18 +25,17 @@ export default async function updateLinkById(
(e: UsersAndCollections) => e.userId === userId
);
// If the user is part of a collection, they can pin it to their dashboard
if (canPinPermission && data.pinnedBy && data.pinnedBy[0]) {
// If the user is able to create a link, they can pin it to their dashboard only.
if (canPinPermission) {
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
pinnedBy: data?.pinnedBy
? data.pinnedBy[0]?.id === userId
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
? { connect: { id: userId } }
: { disconnect: { id: userId } }
: undefined,
: { disconnect: { id: userId } },
},
include: {
collection: true,
@@ -60,7 +48,7 @@ export default async function updateLinkById(
},
});
return { response: updatedLink, status: 200 };
// return { response: updatedLink, status: 200 };
}
const targetCollectionIsAccessible = await getPermission({
@@ -74,9 +62,11 @@ export default async function updateLinkById(
const targetCollectionMatchesData = data.collection.id
? data.collection.id === targetCollectionIsAccessible?.id
: true && data.collection.ownerId
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
: true;
: true && data.collection.name
? data.collection.name === targetCollectionIsAccessible?.name
: true && data.collection.ownerId
? data.collection.ownerId === targetCollectionIsAccessible?.ownerId
: true;
if (!targetCollectionMatchesData)
return {
@@ -99,41 +89,13 @@ export default async function updateLinkById(
status: 401,
};
else {
const oldLink = await prisma.link.findUnique({
where: {
id: linkId,
},
});
if (
data.url &&
oldLink &&
oldLink?.url !== data.url &&
isValidUrl(data.url)
) {
await removeFiles(oldLink.id, oldLink.collectionId);
} else if (oldLink?.url !== data.url)
return {
response: "Invalid URL.",
status: 401,
};
const updatedLink = await prisma.link.update({
where: {
id: linkId,
},
data: {
name: data.name,
url: data.url,
description: data.description,
icon: data.icon,
iconWeight: data.iconWeight,
color: data.color,
image: oldLink?.url !== data.url ? null : undefined,
pdf: oldLink?.url !== data.url ? null : undefined,
readable: oldLink?.url !== data.url ? null : undefined,
monolith: oldLink?.url !== data.url ? null : undefined,
preview: oldLink?.url !== data.url ? null : undefined,
collection: {
connect: {
id: data.collection.id,
@@ -158,11 +120,10 @@ export default async function updateLinkById(
},
})),
},
pinnedBy: data?.pinnedBy
? data.pinnedBy[0]?.id === userId
pinnedBy:
data?.pinnedBy && data.pinnedBy[0]
? { connect: { id: userId } }
: { disconnect: { id: userId } }
: undefined,
: { disconnect: { id: userId } },
},
include: {
tags: true,

View File

@@ -1,30 +1,27 @@
import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import fetchTitleAndHeaders from "@/lib/shared/fetchTitleAndHeaders";
import createFolder from "@/lib/api/storage/createFolder";
import setLinkCollection from "../../setLinkCollection";
import {
PostLinkSchema,
PostLinkSchemaType,
} from "@/lib/shared/schemaValidation";
import { hasPassedLimit } from "../../verifyCapacity";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
export default async function postLink(
body: PostLinkSchemaType,
link: LinkIncludingShortenedCollectionAndTags,
userId: number
) {
const dataValidation = PostLinkSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
if (link.url || link.type === "url") {
try {
new URL(link.url || "");
} catch (error) {
return {
response:
"Please enter a valid Address for the Link. (It should start with http/https)",
status: 400,
};
}
}
const link = dataValidation.data;
const linkCollection = await setLinkCollection(link, userId);
if (!linkCollection)
@@ -58,14 +55,19 @@ export default async function postLink(
};
}
const hasTooManyLinks = await hasPassedLimit(userId, 1);
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: linkCollection.ownerId,
},
},
});
if (hasTooManyLinks) {
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Your subscription have reached the maximum number of links allowed.`,
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
}
const { title, headers } = await fetchTitleAndHeaders(link.url || "");
@@ -92,11 +94,6 @@ export default async function postLink(
name,
description: link.description,
type: linkType,
createdBy: {
connect: {
id: userId,
},
},
collection: {
connect: {
id: linkCollection.id,

View File

@@ -22,5 +22,18 @@ export default async function exportData(userId: number) {
const { password, id, ...userData } = user;
function redactIds(obj: any) {
if (Array.isArray(obj)) {
obj.forEach((o) => redactIds(o));
} else if (obj !== null && typeof obj === "object") {
delete obj.id;
for (let key in obj) {
redactIds(obj[key]);
}
}
}
redactIds(userData);
return { response: userData, status: 200 };
}

View File

@@ -2,7 +2,9 @@ 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 { hasPassedLimit } from "../../verifyCapacity";
import { writeFileSync } from "fs";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
export default async function importFromHTMLFile(
userId: number,
@@ -19,14 +21,19 @@ export default async function importFromHTMLFile(
const bookmarks = document.querySelectorAll("A");
const totalImports = bookmarks.length;
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: userId,
},
},
});
if (hasTooManyLinks) {
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Your subscription have reached the maximum number of links allowed.`,
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
}
const jsonData = parse(document.documentElement.outerHTML);
@@ -148,8 +155,6 @@ const createCollection = async (
collectionName: string,
parentId?: number
) => {
collectionName = collectionName.trim().slice(0, 254);
const findCollection = await prisma.collection.findFirst({
where: {
parentId,
@@ -177,11 +182,6 @@ const createCollection = async (
id: userId,
},
},
createdBy: {
connect: {
id: userId,
},
},
},
});
@@ -199,49 +199,34 @@ const createLink = async (
tags?: string[],
importDate?: Date
) => {
url = url.trim().slice(0, 254);
try {
new URL(url);
} catch (e) {
return;
}
tags = tags?.map((tag) => tag.trim().slice(0, 49));
name = name?.trim().slice(0, 254);
description = description?.trim().slice(0, 254);
if (importDate) {
const dateString = importDate.toISOString();
if (dateString.length > 50) {
importDate = undefined;
}
}
await prisma.link.create({
data: {
name: name || "",
url,
description,
collectionId,
createdById: userId,
tags:
tags && tags[0]
? {
connectOrCreate: tags.map((tag: string) => {
return {
where: {
name_ownerId: {
name: tag.trim(),
ownerId: userId,
},
},
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
return (
{
where: {
name_ownerId: {
name: tag.trim(),
ownerId: userId,
},
},
},
};
create: {
name: tag.trim(),
owner: {
connect: {
id: userId,
},
},
},
} || undefined
);
}),
}
: undefined,

View File

@@ -1,7 +1,8 @@
import { prisma } from "@/lib/api/db";
import { Backup } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder";
import { hasPassedLimit } from "../../verifyCapacity";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
export default async function importFromLinkwarden(
userId: number,
@@ -15,14 +16,19 @@ export default async function importFromLinkwarden(
totalImports += collection.links.length;
});
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: userId,
},
},
});
if (hasTooManyLinks) {
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Your subscription have reached the maximum number of links allowed.`,
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
}
await prisma
.$transaction(
@@ -38,14 +44,9 @@ export default async function importFromLinkwarden(
id: userId,
},
},
name: e.name?.trim().slice(0, 254),
description: e.description?.trim().slice(0, 254),
color: e.color?.trim().slice(0, 50),
createdBy: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
@@ -53,40 +54,27 @@ export default async function importFromLinkwarden(
// Import Links
for (const link of e.links) {
if (link.url) {
try {
new URL(link.url.trim());
} catch (err) {
continue;
}
}
await prisma.link.create({
data: {
url: link.url?.trim().slice(0, 254),
name: link.name?.trim().slice(0, 254),
description: link.description?.trim().slice(0, 254),
url: link.url,
name: link.name,
description: link.description,
collection: {
connect: {
id: newCollection.id,
},
},
createdBy: {
connect: {
id: userId,
},
},
// Import Tags
tags: {
connectOrCreate: link.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name?.slice(0, 49),
name: tag.name.trim(),
ownerId: userId,
},
},
create: {
name: tag.name?.trim().slice(0, 49),
name: tag.name.trim(),
owner: {
connect: {
id: userId,

View File

@@ -1,6 +1,8 @@
import { prisma } from "@/lib/api/db";
import { Backup } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder";
import { hasPassedLimit } from "../../verifyCapacity";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
type WallabagBackup = {
is_archived: number;
@@ -35,14 +37,19 @@ export default async function importFromWallabag(
let totalImports = backup.length;
const hasTooManyLinks = await hasPassedLimit(userId, totalImports);
const numberOfLinksTheUserHas = await prisma.link.count({
where: {
collection: {
ownerId: userId,
},
},
});
if (hasTooManyLinks) {
if (totalImports + numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return {
response: `Your subscription have reached the maximum number of links allowed.`,
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
status: 400,
};
}
await prisma
.$transaction(
@@ -55,56 +62,38 @@ export default async function importFromWallabag(
},
},
name: "Imports",
createdBy: {
connect: {
id: userId,
},
},
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
for (const link of backup) {
if (link.url) {
try {
new URL(link.url.trim());
} catch (err) {
continue;
}
}
await prisma.link.create({
data: {
pinnedBy: link.is_starred
? { connect: { id: userId } }
: undefined,
url: link.url?.trim().slice(0, 254),
name: link.title?.trim().slice(0, 254) || "",
textContent: link.content?.trim() || "",
url: link.url,
name: link.title || "",
textContent: link.content || "",
importDate: link.created_at || null,
collection: {
connect: {
id: newCollection.id,
},
},
createdBy: {
connect: {
id: userId,
},
},
tags:
link.tags && link.tags[0]
? {
connectOrCreate: link.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag?.trim().slice(0, 49),
name: tag.trim(),
ownerId: userId,
},
},
create: {
name: tag?.trim().slice(0, 49),
name: tag.trim(),
owner: {
connect: {
id: userId,

View File

@@ -1,5 +1,5 @@
import { prisma } from "@/lib/api/db";
import { LinkRequestQuery, Order, Sort } from "@/types/global";
import { LinkRequestQuery, Sort } from "@/types/global";
export default async function getLink(
query: Omit<LinkRequestQuery, "tagId" | "pinnedOnly">
@@ -7,7 +7,7 @@ export default async function getLink(
const POSTGRES_IS_ENABLED =
process.env.DATABASE_URL?.startsWith("postgresql");
let order: Order = { id: "desc" };
let order: any;
if (query.sort === Sort.DateNewestFirst) order = { id: "desc" };
else if (query.sort === Sort.DateOldestFirst) order = { id: "asc" };
else if (query.sort === Sort.NameAZ) order = { name: "asc" };

View File

@@ -5,20 +5,13 @@ export default async function getPublicUser(
isId: boolean,
requestingId?: number
) {
const user = await prisma.user.findFirst({
const user = await prisma.user.findUnique({
where: isId
? {
id: Number(targetId) as number,
}
: {
OR: [
{
username: targetId as string,
},
{
email: targetId as string,
},
],
username: targetId as string,
},
include: {
whitelistedUsers: {
@@ -29,7 +22,7 @@ export default async function getPublicUser(
},
});
if (!user || !user.id)
if (!user)
return { response: "User not found or profile is private.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(
@@ -38,7 +31,7 @@ export default async function getPublicUser(
const isInAPublicCollection = await prisma.collection.findFirst({
where: {
OR: [
["OR"]: [
{ ownerId: user.id },
{
members: {
@@ -80,7 +73,6 @@ export default async function getPublicUser(
id: lessSensitiveInfo.id,
name: lessSensitiveInfo.name,
username: lessSensitiveInfo.username,
email: lessSensitiveInfo.email,
image: lessSensitiveInfo.image,
archiveAsScreenshot: lessSensitiveInfo.archiveAsScreenshot,
archiveAsMonolith: lessSensitiveInfo.archiveAsMonolith,

View File

@@ -29,7 +29,7 @@ export default async function createSession(
secret: process.env.NEXTAUTH_SECRET as string,
});
await prisma.accessToken.create({
const createToken = await prisma.accessToken.create({
data: {
name: sessionName || "Unknown Device",
userId,

View File

@@ -1,31 +1,18 @@
import { prisma } from "@/lib/api/db";
import {
UpdateTagSchema,
UpdateTagSchemaType,
} from "@/lib/shared/schemaValidation";
import { Tag } from "@prisma/client";
export default async function updeteTagById(
userId: number,
tagId: number,
body: UpdateTagSchemaType
data: Tag
) {
const dataValidation = UpdateTagSchema.safeParse(body);
if (!dataValidation.success) {
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
status: 400,
};
}
const { name } = dataValidation.data;
if (!tagId || !data.name)
return { response: "Please choose a valid name for the tag.", status: 401 };
const tagNameIsTaken = await prisma.tag.findFirst({
where: {
ownerId: userId,
name: name,
name: data.name,
},
});
@@ -52,7 +39,7 @@ export default async function updeteTagById(
id: tagId,
},
data: {
name: name,
name: data.name,
},
});

View File

@@ -1,32 +1,28 @@
import { prisma } from "@/lib/api/db";
import {
PostTokenSchemaType,
PostTokenSchema,
} from "@/lib/shared/schemaValidation";
import { TokenExpiry } from "@/types/global";
import crypto from "crypto";
import { decode, encode } from "next-auth/jwt";
export default async function postToken(
body: PostTokenSchemaType,
body: {
name: string;
expires: TokenExpiry;
},
userId: number
) {
const dataValidation = PostTokenSchema.safeParse(body);
console.log(body);
if (!dataValidation.success) {
const checkHasEmptyFields = !body.name || body.expires === undefined;
if (checkHasEmptyFields)
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
response: "Please fill out all the fields.",
status: 400,
};
}
const { name, expires } = dataValidation.data;
const checkIfTokenExists = await prisma.accessToken.findFirst({
where: {
name: name,
name: body.name,
revoked: false,
userId,
},
@@ -44,16 +40,16 @@ export default async function postToken(
const oneDayInSeconds = 86400;
let expiryDateSecond = 7 * oneDayInSeconds;
if (expires === TokenExpiry.oneMonth) {
if (body.expires === TokenExpiry.oneMonth) {
expiryDate.setDate(expiryDate.getDate() + 30);
expiryDateSecond = 30 * oneDayInSeconds;
} else if (expires === TokenExpiry.twoMonths) {
} else if (body.expires === TokenExpiry.twoMonths) {
expiryDate.setDate(expiryDate.getDate() + 60);
expiryDateSecond = 60 * oneDayInSeconds;
} else if (expires === TokenExpiry.threeMonths) {
} else if (body.expires === TokenExpiry.threeMonths) {
expiryDate.setDate(expiryDate.getDate() + 90);
expiryDateSecond = 90 * oneDayInSeconds;
} else if (expires === TokenExpiry.never) {
} else if (body.expires === TokenExpiry.never) {
expiryDate.setDate(expiryDate.getDate() + 73000); // 200 years (not really never)
expiryDateSecond = 73050 * oneDayInSeconds;
} else {
@@ -79,7 +75,7 @@ export default async function postToken(
const createToken = await prisma.accessToken.create({
data: {
name: name,
name: body.name,
userId,
token: tokenBody?.jti as string,
expires: expiryDate,

View File

@@ -1,71 +1,21 @@
import { prisma } from "@/lib/api/db";
import { User } from "@prisma/client";
export default async function getUsers(user: User) {
if (user.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
const users = await prisma.user.findMany({
select: {
id: true,
username: true,
email: true,
emailVerified: true,
subscriptions: {
select: {
active: true,
},
},
createdAt: true,
},
});
return {
response: users.sort((a: any, b: any) => a.id - b.id),
status: 200,
};
} else {
let subscriptionId = (
await prisma.subscription.findFirst({
where: {
userId: user.id,
},
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: {
id: true,
active: true,
},
})
)?.id;
if (!subscriptionId)
return {
response: "Subscription not found.",
status: 404,
};
const users = await prisma.user.findMany({
where: {
OR: [
{
parentSubscriptionId: subscriptionId,
},
{
subscriptions: {
id: subscriptionId,
},
},
],
},
select: {
id: true,
name: true,
username: true,
email: true,
emailVerified: true,
createdAt: true,
},
});
createdAt: true,
},
});
return {
response: users.sort((a: any, b: any) => a.id - b.id),
status: 200,
};
}
return { response: users, status: 200 };
}

View File

@@ -12,11 +12,6 @@ export default async function getUserById(userId: number) {
},
},
subscriptions: true,
parentSubscription: {
include: {
user: true,
},
},
},
});
@@ -27,21 +22,13 @@ export default async function getUserById(userId: number) {
(usernames) => usernames.username
);
const { password, subscriptions, parentSubscription, ...lessSensitiveInfo } =
user;
const { password, subscriptions, ...lessSensitiveInfo } = user;
const data = {
...lessSensitiveInfo,
whitelistedUsers: whitelistedUsernames,
subscription: {
active: subscriptions?.active,
quantity: subscriptions?.quantity,
},
parentSubscription: {
active: parentSubscription?.active,
user: {
email: parentSubscription?.user.email,
},
},
};

View File

@@ -6,27 +6,42 @@ import createFile from "@/lib/api/storage/createFile";
import createFolder from "@/lib/api/storage/createFolder";
import sendChangeEmailVerificationRequest from "@/lib/api/sendChangeEmailVerificationRequest";
import { i18n } from "next-i18next.config";
import { UpdateUserSchema } from "@/lib/shared/schemaValidation";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
export default async function updateUserById(
userId: number,
body: AccountSettings
data: AccountSettings
) {
const dataValidation = UpdateUserSchema().safeParse(body);
if (!dataValidation.success) {
if (emailEnabled && !data.email)
return {
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
response: "Email invalid.",
status: 400,
};
else if (!data.username)
return {
response: "Username invalid.",
status: 400,
};
}
const data = dataValidation.data;
// Check email (if enabled)
const checkEmail =
/^(([^<>()[\]\.,;:\s@\"]+(\.[^<>()[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()[\]\.,;:\s@\"]+\.)+[^<>()[\]\.,;:\s@\"]{2,})$/i;
if (emailEnabled && !checkEmail.test(data.email?.toLowerCase() || ""))
return {
response: "Please enter a valid email.",
status: 400,
};
const checkUsername = RegExp("^[a-z0-9_-]{3,31}$");
if (!checkUsername.test(data.username.toLowerCase()))
return {
response:
"Username has to be between 3-30 characters, no spaces and special characters are allowed.",
status: 400,
};
const userIsTaken = await prisma.user.findFirst({
where: {
@@ -101,6 +116,7 @@ export default async function updateUserById(
const user = await prisma.user.findUnique({
where: { id: userId },
select: { email: true, password: true },
});
if (user && user.email && data.email && data.email !== user.email) {
@@ -132,7 +148,7 @@ export default async function updateUserById(
sendChangeEmailVerificationRequest(
user.email,
data.email,
data.name?.trim() || user.name || "Linkwarden User"
data.name.trim()
);
}
@@ -169,28 +185,16 @@ export default async function updateUserById(
// Other settings / Apply changes
const isInvited =
user?.name === null && user.parentSubscriptionId && !user.password;
if (isInvited && data.password === "")
return {
response: "Password is required.",
status: 400,
};
const saltRounds = 10;
const newHashedPassword = bcrypt.hashSync(
data.newPassword || data.password || "",
saltRounds
);
const newHashedPassword = bcrypt.hashSync(data.newPassword || "", saltRounds);
const updatedUser = await prisma.user.update({
where: {
id: userId,
},
data: {
name: data.name,
username: data.username,
name: data.name.trim(),
username: data.username?.toLowerCase().trim(),
isPrivate: data.isPrivate,
image:
data.image && data.image.startsWith("http")
@@ -198,10 +202,10 @@ export default async function updateUserById(
: data.image
? `uploads/avatar/${userId}.jpg`
: "",
collectionOrder: data.collectionOrder?.filter(
collectionOrder: data.collectionOrder.filter(
(value, index, self) => self.indexOf(value) === index
),
locale: i18n.locales.includes(data.locale || "") ? data.locale : "en",
locale: i18n.locales.includes(data.locale) ? data.locale : "en",
archiveAsScreenshot: data.archiveAsScreenshot,
archiveAsMonolith: data.archiveAsMonolith,
archiveAsPDF: data.archiveAsPDF,
@@ -211,28 +215,18 @@ export default async function updateUserById(
referredBy:
!user?.referredBy && data.referredBy ? data.referredBy : undefined,
password:
isInvited || (data.newPassword && data.newPassword !== "")
data.newPassword && data.newPassword !== ""
? newHashedPassword
: undefined,
},
include: {
whitelistedUsers: true,
subscriptions: true,
parentSubscription: {
include: {
user: true,
},
},
},
});
const {
whitelistedUsers,
password,
subscriptions,
parentSubscription,
...userInfo
} = updatedUser;
const { whitelistedUsers, password, subscriptions, ...userInfo } =
updatedUser;
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
const newWhitelistedUsernames: string[] = data.whitelistedUsers || [];
@@ -273,20 +267,11 @@ export default async function updateUserById(
});
}
const response = {
const response: Omit<AccountSettings, "password"> = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
image: userInfo.image ? `${userInfo.image}?${Date.now()}` : "",
subscription: {
active: subscriptions?.active,
quantity: subscriptions?.quantity,
},
parentSubscription: {
active: parentSubscription?.active,
user: {
email: parentSubscription?.user.email,
},
},
subscription: { active: subscriptions?.active },
};
return { response, status: 200 };

View File

@@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.DEBUG === "true" ? ["query", "info", "warn", "error"] : ["warn", "error"]
log: ["query"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -16,7 +16,7 @@ const generatePreview = async (
return;
}
image.resize(1000, Jimp.AUTO).quality(20);
image.resize(1280, Jimp.AUTO).quality(20);
const processedBuffer = await image.getBufferAsync(Jimp.MIME_JPEG);
if (

View File

@@ -6,16 +6,16 @@ type Props = {
req: NextApiRequest;
};
export default async function isAuthenticatedRequest({ req }: Props) {
export default async function isServerAdmin({ req }: Props): Promise<boolean> {
const token = await getToken({ req });
const userId = token?.id;
if (!userId) {
return null;
return false;
}
if (token.exp < Date.now() / 1000) {
return null;
return false;
}
// check if token is revoked
@@ -27,21 +27,18 @@ export default async function isAuthenticatedRequest({ req }: Props) {
});
if (revoked) {
return null;
return false;
}
const findUser = await prisma.user.findFirst({
where: {
id: userId,
},
include: {
subscriptions: true,
},
});
if (findUser && !findUser?.subscriptions) {
return null;
if (findUser?.id === Number(process.env.NEXT_PUBLIC_ADMIN || 1)) {
return true;
} else {
return false;
}
return findUser;
}

View File

@@ -1,6 +1,4 @@
import Stripe from "stripe";
import verifySubscription from "./stripe/verifySubscription";
import { prisma } from "./db";
export default async function paymentCheckout(
stripeSecretKey: string,
@@ -11,23 +9,6 @@ export default async function paymentCheckout(
apiVersion: "2022-11-15",
});
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
include: {
subscriptions: true,
parentSubscription: true,
},
});
const subscription = await verifySubscription(user);
if (subscription) {
// To prevent users from creating multiple subscriptions
return { response: "/dashboard", status: 200 };
}
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
@@ -37,7 +18,6 @@ export default async function paymentCheckout(
const NEXT_PUBLIC_TRIAL_PERIOD_DAYS =
process.env.NEXT_PUBLIC_TRIAL_PERIOD_DAYS;
const session = await stripe.checkout.sessions.create({
customer: isExistingCustomer ? isExistingCustomer : undefined,
line_items: [
@@ -48,7 +28,7 @@ export default async function paymentCheckout(
],
mode: "subscription",
customer_email: isExistingCustomer ? undefined : email.toLowerCase(),
success_url: `${process.env.BASE_URL}/dashboard`,
success_url: `${process.env.BASE_URL}?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.BASE_URL}/login`,
automatic_tax: {
enabled: true,

View File

@@ -1,56 +0,0 @@
import { readFileSync } from "fs";
import path from "path";
import Handlebars from "handlebars";
import transporter from "./transporter";
type Params = {
parentSubscriptionEmail: string;
identifier: string;
url: string;
from: string;
token: string;
};
export default async function sendInvitationRequest({
parentSubscriptionEmail,
identifier,
url,
from,
token,
}: Params) {
const emailsDir = path.resolve(process.cwd(), "templates");
const templateFile = readFileSync(
path.join(emailsDir, "acceptInvitation.html"),
"utf8"
);
const emailTemplate = Handlebars.compile(templateFile);
const { host } = new URL(url);
const result = await transporter.sendMail({
to: identifier,
from: {
name: "Linkwarden",
address: from as string,
},
subject: `You have been invited to join Linkwarden`,
text: text({ url, host }),
html: emailTemplate({
parentSubscriptionEmail,
identifier,
url: `${
process.env.NEXTAUTH_URL
}/callback/email?token=${token}&email=${encodeURIComponent(identifier)}`,
}),
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
throw new Error(`Email (${failed.join(", ")}) could not be sent`);
}
}
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url, host }: { url: string; host: string }) {
return `Sign in to ${host}\n${url}\n\n`;
}

View File

@@ -1,10 +1,13 @@
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { prisma } from "./db";
import getPermission from "./getPermission";
import { UsersAndCollections } from "@prisma/client";
import { PostLinkSchemaType } from "../shared/schemaValidation";
const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
if (link.collection?.id && typeof link.collection?.id === "number") {
const setLinkCollection = async (
link: LinkIncludingShortenedCollectionAndTags,
userId: number
) => {
if (link?.collection?.id && typeof link?.collection?.id === "number") {
const existingCollection = await prisma.collection.findUnique({
where: {
id: link.collection.id,
@@ -26,7 +29,7 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
return null;
return existingCollection;
} else if (link.collection?.name) {
} else if (link?.collection?.name) {
if (link.collection.name === "Unorganized") {
const firstTopLevelUnorganizedCollection =
await prisma.collection.findFirst({
@@ -45,7 +48,6 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
data: {
name: link.collection.name.trim(),
ownerId: userId,
createdById: userId,
},
});
@@ -79,7 +81,6 @@ const setLinkCollection = async (link: PostLinkSchemaType, userId: number) => {
name: "Unorganized",
ownerId: userId,
parentId: null,
createdById: userId,
},
});
}

View File

@@ -14,7 +14,7 @@ export default async function moveFile(from: string, to: string) {
};
try {
s3Client.copyObject(copyParams, async (err: unknown) => {
s3Client.copyObject(copyParams, async (err: any) => {
if (err) {
console.error("Error copying the object:", err);
} else {

View File

@@ -1,31 +0,0 @@
import Stripe from "stripe";
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
export default async function checkSubscriptionByEmail(email: string) {
if (!STRIPE_SECRET_KEY) return null;
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
console.log("Request made to Stripe by:", email);
const listByEmail = await stripe.customers.list({
email: email.toLowerCase(),
expand: ["data.subscriptions"],
});
if (listByEmail?.data[0]?.subscriptions?.data[0]) {
return {
active: (listByEmail.data[0].subscriptions?.data[0] as any).plan.active,
stripeSubscriptionId: listByEmail.data[0].subscriptions?.data[0].id,
currentPeriodStart:
listByEmail.data[0].subscriptions?.data[0].current_period_start * 1000,
currentPeriodEnd:
listByEmail.data[0].subscriptions?.data[0].current_period_end * 1000,
quantity: (listByEmail?.data[0]?.subscriptions?.data[0] as any).quantity,
};
} else {
return null;
}
}

View File

@@ -1,91 +0,0 @@
import Stripe from "stripe";
import { prisma } from "../db";
type Data = {
id: string;
active: boolean;
quantity: number;
periodStart: number;
periodEnd: number;
};
export default async function handleSubscription({
id,
active,
quantity,
periodStart,
periodEnd,
}: Data) {
const subscription = await prisma.subscription.findUnique({
where: {
stripeSubscriptionId: id,
},
});
if (subscription) {
await prisma.subscription.update({
where: {
stripeSubscriptionId: id,
},
data: {
active,
quantity,
currentPeriodStart: new Date(periodStart * 1000),
currentPeriodEnd: new Date(periodEnd * 1000),
},
});
return;
} else {
if (!process.env.STRIPE_SECRET_KEY)
throw new Error("Missing Stripe secret key");
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
const subscription = await stripe.subscriptions.retrieve(id);
const customerId = subscription.customer;
const customer = await stripe.customers.retrieve(customerId.toString());
const email = (customer as Stripe.Customer).email;
if (!email) throw new Error("Email not found");
const user = await prisma.user.findUnique({
where: {
email,
},
});
if (!user) throw new Error("User not found");
const userId = user.id;
await prisma.subscription
.upsert({
where: {
userId,
},
create: {
active,
stripeSubscriptionId: id,
quantity,
currentPeriodStart: new Date(periodStart * 1000),
currentPeriodEnd: new Date(periodEnd * 1000),
user: {
connect: {
id: userId,
},
},
},
update: {
active,
stripeSubscriptionId: id,
quantity,
currentPeriodStart: new Date(periodStart * 1000),
currentPeriodEnd: new Date(periodEnd * 1000),
},
})
.catch((err) => console.log(err));
}
}

View File

@@ -1,27 +0,0 @@
import Stripe from "stripe";
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const updateSeats = async (subscriptionId: string, seats: number) => {
if (!STRIPE_SECRET_KEY) {
return;
}
const stripe = new Stripe(STRIPE_SECRET_KEY, {
apiVersion: "2022-11-15",
});
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const trialing = subscription.status === "trialing";
if (subscription) {
await stripe.subscriptions.update(subscriptionId, {
billing_cycle_anchor: trialing ? undefined : "now",
proration_behavior: trialing ? undefined : "create_prorations",
quantity: seats,
} as Stripe.SubscriptionUpdateParams);
}
};
export default updateSeats;

View File

@@ -1,70 +0,0 @@
import { prisma } from "../db";
import { Subscription, User } from "@prisma/client";
import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
interface UserIncludingSubscription extends User {
subscriptions: Subscription | null;
parentSubscription: Subscription | null;
}
export default async function verifySubscription(
user?: UserIncludingSubscription | null
) {
if (!user || (!user.subscriptions && !user.parentSubscription)) {
return null;
}
if (user.parentSubscription?.active) {
return user;
}
if (
!user.subscriptions?.active ||
new Date() > user.subscriptions.currentPeriodEnd
) {
const subscription = await checkSubscriptionByEmail(user.email as string);
if (
!subscription ||
!subscription.stripeSubscriptionId ||
!subscription.currentPeriodEnd ||
!subscription.currentPeriodStart ||
!subscription.quantity
) {
return null;
}
const {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
quantity,
} = subscription;
await prisma.subscription
.upsert({
where: {
userId: user.id,
},
create: {
active,
stripeSubscriptionId,
currentPeriodStart: new Date(currentPeriodStart),
currentPeriodEnd: new Date(currentPeriodEnd),
quantity,
userId: user.id,
},
update: {
active,
stripeSubscriptionId,
currentPeriodStart: new Date(currentPeriodStart),
currentPeriodEnd: new Date(currentPeriodEnd),
quantity,
},
})
.catch((err) => console.log(err));
}
return user;
}

View File

@@ -1,6 +1,6 @@
import { prisma } from "./db";
import { User } from "@prisma/client";
import verifySubscription from "./stripe/verifySubscription";
import verifySubscription from "./verifySubscription";
import bcrypt from "bcrypt";
type Props = {
@@ -33,7 +33,6 @@ export default async function verifyByCredentials({
},
include: {
subscriptions: true,
parentSubscription: true,
},
});

View File

@@ -1,76 +0,0 @@
import { prisma } from "./db";
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER) || 30000;
const stripeEnabled = process.env.NEXT_PUBLIC_STRIPE === "true";
export const hasPassedLimit = async (
userId: number,
numberOfImports: number
) => {
if (!stripeEnabled) {
const totalLinks = await prisma.link.count({
where: {
createdBy: {
id: userId,
},
},
});
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
}
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
parentSubscription: true,
subscriptions: true,
},
});
if (!user) {
return true;
}
if (
user.parentSubscription ||
(user.subscriptions && user.subscriptions?.quantity > 1)
) {
const subscription = user.parentSubscription || user.subscriptions;
if (!subscription) {
return true;
}
// Calculate the total allowed links for the organization
const totalCapacity = subscription.quantity * MAX_LINKS_PER_USER;
const totalLinks = await prisma.link.count({
where: {
createdBy: {
OR: [
{
parentSubscriptionId: subscription.id || undefined,
},
{
subscriptions: {
id: subscription.id || undefined,
},
},
],
},
},
});
return totalCapacity - (numberOfImports + totalLinks) < 0;
} else {
const totalLinks = await prisma.link.count({
where: {
createdBy: {
id: userId,
},
},
});
return MAX_LINKS_PER_USER - (numberOfImports + totalLinks) < 0;
}
};

View File

@@ -0,0 +1,73 @@
import { prisma } from "./db";
import { Subscription, User } from "@prisma/client";
import checkSubscriptionByEmail from "./checkSubscriptionByEmail";
interface UserIncludingSubscription extends User {
subscriptions: Subscription | null;
}
export default async function verifySubscription(
user?: UserIncludingSubscription
) {
if (!user) {
return null;
}
const subscription = user.subscriptions;
const currentDate = new Date();
if (!subscription?.active || currentDate > subscription.currentPeriodEnd) {
const {
active,
stripeSubscriptionId,
currentPeriodStart,
currentPeriodEnd,
} = await checkSubscriptionByEmail(user.email as string);
if (
active &&
stripeSubscriptionId &&
currentPeriodStart &&
currentPeriodEnd
) {
await prisma.subscription
.upsert({
where: {
userId: user.id,
},
create: {
active,
stripeSubscriptionId,
currentPeriodStart: new Date(currentPeriodStart),
currentPeriodEnd: new Date(currentPeriodEnd),
userId: user.id,
},
update: {
active,
stripeSubscriptionId,
currentPeriodStart: new Date(currentPeriodStart),
currentPeriodEnd: new Date(currentPeriodEnd),
},
})
.catch((err) => console.log(err));
} else if (!active) {
const subscription = await prisma.subscription.findFirst({
where: {
userId: user.id,
},
});
if (subscription)
await prisma.subscription.delete({
where: {
userId: user.id,
},
});
return null;
}
}
return user;
}

View File

@@ -1,7 +1,7 @@
import { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "./db";
import { User } from "@prisma/client";
import verifySubscription from "./stripe/verifySubscription";
import verifySubscription from "./verifySubscription";
import verifyToken from "./verifyToken";
type Props = {
@@ -30,7 +30,6 @@ export default async function verifyUser({
},
include: {
subscriptions: true,
parentSubscription: true,
},
});

View File

@@ -2,36 +2,29 @@ import { CollectionIncludingMembersAndLinkCount, Member } from "@/types/global";
import getPublicUserData from "./getPublicUserData";
import { toast } from "react-hot-toast";
import { TFunction } from "i18next";
import { User } from "@prisma/client";
const addMemberToCollection = async (
owner: User,
memberIdentifier: string,
ownerUsername: string,
memberUsername: string,
collection: CollectionIncludingMembersAndLinkCount,
setMember: (newMember: Member) => null | undefined,
t: TFunction<"translation", undefined>
) => {
const checkIfMemberAlreadyExists = collection.members.find((e) => {
const username = (e.user.username || "").toLowerCase();
const email = (e.user.email || "").toLowerCase();
return (
username === memberIdentifier.toLowerCase() ||
email === memberIdentifier.toLowerCase()
);
return username === memberUsername.toLowerCase();
});
if (
// no duplicate members
!checkIfMemberAlreadyExists &&
// member can't be empty
memberIdentifier.trim() !== "" &&
memberUsername.trim() !== "" &&
// member can't be the owner
memberIdentifier.trim().toLowerCase() !== owner.username?.toLowerCase() &&
memberIdentifier.trim().toLowerCase() !== owner.email?.toLowerCase()
memberUsername.trim().toLowerCase() !== ownerUsername.toLowerCase()
) {
// Lookup, get data/err, list ...
const user = await getPublicUserData(memberIdentifier.trim().toLowerCase());
const user = await getPublicUserData(memberUsername.trim().toLowerCase());
if (user.username) {
setMember({
@@ -44,16 +37,12 @@ const addMemberToCollection = async (
id: user.id,
name: user.name,
username: user.username,
email: user.email,
image: user.image,
},
});
}
} else if (checkIfMemberAlreadyExists) toast.error(t("user_already_member"));
else if (
memberIdentifier.trim().toLowerCase() === owner.username?.toLowerCase() ||
memberIdentifier.trim().toLowerCase() === owner.email?.toLowerCase()
)
else if (memberUsername.trim().toLowerCase() === ownerUsername.toLowerCase())
toast.error(t("you_are_already_collection_owner"));
};

View File

@@ -1,18 +0,0 @@
import * as Icons from "@phosphor-icons/react";
import { icons as iconData } from "@phosphor-icons/core";
import { IconEntry as CoreEntry } from "@phosphor-icons/core";
interface IconEntry extends CoreEntry {
Icon: Icons.Icon;
}
export const icons: ReadonlyArray<IconEntry> = iconData.map((entry) => ({
...entry,
Icon: Icons[entry.pascal_name as keyof typeof Icons] as Icons.Icon,
}));
// if (process.env.NODE_ENV === "development") {
// console.log(`${icons.length} icons`);
// }
export const iconCount = Intl.NumberFormat("en-US").format(icons.length * 6);

View File

@@ -1,47 +0,0 @@
import { useUpdateLink } from "@/hooks/store/links";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import toast from "react-hot-toast";
import { useTranslation } from "next-i18next";
import { useUser } from "@/hooks/store/user";
const usePinLink = () => {
const { t } = useTranslation();
const updateLink = useUpdateLink();
const { data: user = {} } = useUser();
// Return a function that can be used to pin/unpin the link
const pinLink = async (link: LinkIncludingShortenedCollectionAndTags) => {
const isAlreadyPinned = link?.pinnedBy && link.pinnedBy[0] ? true : false;
const load = toast.loading(t("updating"));
try {
await updateLink.mutateAsync(
{
...link,
pinnedBy: isAlreadyPinned ? [{ id: undefined }] : [{ id: user.id }],
},
{
onSettled: (data, error) => {
toast.dismiss(load);
if (error) {
toast.error(error.message);
} else {
toast.success(
isAlreadyPinned ? t("link_unpinned") : t("link_pinned")
);
}
},
}
);
} catch (e) {
toast.dismiss(load);
console.error(e);
}
};
return pinLink;
};
export default usePinLink;

View File

@@ -9,7 +9,7 @@ export const resizeImage = (file: File): Promise<Blob> =>
"JPEG", // output format
100, // quality
0, // rotation
(uri) => {
(uri: any) => {
resolve(uri as Blob);
},
"blob" // output type

View File

@@ -7,15 +7,10 @@ export function isPWA() {
}
export function isIphone() {
return (
/iPhone/.test(navigator.userAgent) &&
!(window as unknown as { MSStream?: any }).MSStream
);
return /iPhone/.test(navigator.userAgent) && !(window as any).MSStream;
}
export function dropdownTriggerer(
e: React.FocusEvent<HTMLElement> | React.MouseEvent<HTMLElement>
) {
export function dropdownTriggerer(e: any) {
let targetEl = e.currentTarget;
if (targetEl && targetEl.matches(":focus")) {
setTimeout(function () {

View File

@@ -39,9 +39,7 @@ export function monolithAvailable(
);
}
export function previewAvailable(
link: LinkIncludingShortenedCollectionAndTags
) {
export function previewAvailable(link: any) {
return (
link &&
link.preview &&

View File

@@ -2,20 +2,7 @@
module.exports = {
i18n: {
defaultLocale: "en",
locales: [
"en",
"it",
"fr",
"zh",
"zh-TW",
"uk",
"pt-BR",
"ja",
"es",
"de",
"nl",
"tr",
],
locales: ["en", "it", "fr", "zh"],
},
reloadOnPrerender: process.env.NODE_ENV === "development",
};

View File

@@ -88,10 +88,13 @@ function App({
{icon}
<span data-testid="toast-message">{message}</span>
{t.type !== "loading" && (
<div
<button
className="btn btn-xs outline-none btn-circle btn-ghost"
data-testid="close-toast-button"
onClick={() => toast.dismiss(t.id)}
></div>
>
<i className="bi bi-x"></i>
</button>
)}
</div>
)}

View File

@@ -6,7 +6,6 @@ import { useTranslation } from "next-i18next";
import getServerSideProps from "@/lib/client/getServerSideProps";
import UserListing from "@/components/UserListing";
import { useUsers } from "@/hooks/store/admin/users";
import Divider from "@/components/ui/Divider";
interface User extends U {
subscriptions: {
@@ -89,7 +88,7 @@ export default function Admin() {
</div>
</div>
<Divider className="my-3" />
<div className="divider my-3"></div>
{filteredUsers && filteredUsers.length > 0 && searchQuery !== "" ? (
UserListing(filteredUsers, deleteUserModal, setDeleteUserModal, t)
@@ -101,7 +100,9 @@ export default function Admin() {
<p>{t("no_users_found")}</p>
)}
{newUserModal && <NewUserModal onClose={() => setNewUserModal(false)} />}
{newUserModal ? (
<NewUserModal onClose={() => setNewUserModal(false)} />
) : null}
</div>
);
}

View File

@@ -11,7 +11,6 @@ import fs from "fs";
import verifyToken from "@/lib/api/verifyToken";
import generatePreview from "@/lib/api/generatePreview";
import createFolder from "@/lib/api/storage/createFolder";
import { UploadFileSchema } from "@/lib/shared/schemaValidation";
export const config = {
api: {
@@ -106,6 +105,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
response: "Collection is not accessible.",
});
// await uploadHandler(linkId, )
const MAX_LINKS_PER_USER = Number(process.env.MAX_LINKS_PER_USER || 30000);
const numberOfLinksTheUserHas = await prisma.link.count({
@@ -118,7 +119,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (numberOfLinksTheUserHas > MAX_LINKS_PER_USER)
return res.status(400).json({
response: `Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.`,
response:
"Each collection owner can only have a maximum of ${MAX_LINKS_PER_USER} Links.",
});
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
@@ -139,20 +141,6 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
"image/jpeg",
];
const dataValidation = UploadFileSchema.safeParse({
id: Number(req.query.linkId),
format: Number(req.query.format),
file: files.file,
});
if (!dataValidation.success) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
});
}
if (
err ||
!files.file ||
@@ -178,12 +166,8 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
where: { id: linkId },
});
const { mimetype } = files.file[0];
const isPDF = mimetype?.includes("pdf");
const isImage = mimetype?.includes("image");
if (linkStillExists && isImage) {
const collectionId = collectionPermissions.id;
if (linkStillExists && files.file[0].mimetype?.includes("image")) {
const collectionId = collectionPermissions.id as number;
createFolder({
filePath: `archives/preview/${collectionId}`,
});
@@ -200,11 +184,13 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
await prisma.link.update({
where: { id: linkId },
data: {
preview: isPDF ? "unavailable" : undefined,
image: isImage
preview: files.file[0].mimetype?.includes("pdf")
? "unavailable"
: undefined,
image: files.file[0].mimetype?.includes("image")
? `archives/${collectionPermissions.id}/${linkId + suffix}`
: null,
pdf: isPDF
pdf: files.file[0].mimetype?.includes("pdf")
? `archives/${collectionPermissions.id}/${linkId + suffix}`
: null,
lastPreserved: new Date().toISOString(),
@@ -220,94 +206,4 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
});
});
}
// To update the link preview
else if (req.method === "PUT") {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({
response:
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const user = await verifyUser({ req, res });
if (!user) return;
const collectionPermissions = await getPermission({
userId: user.id,
linkId,
});
if (!collectionPermissions)
return res.status(400).json({
response: "Collection is not accessible.",
});
const memberHasAccess = collectionPermissions.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canCreate
);
if (!(collectionPermissions.ownerId === user.id || memberHasAccess))
return res.status(400).json({
response: "Collection is not accessible.",
});
const NEXT_PUBLIC_MAX_FILE_BUFFER = Number(
process.env.NEXT_PUBLIC_MAX_FILE_BUFFER || 10
);
const form = formidable({
maxFields: 1,
maxFiles: 1,
maxFileSize: NEXT_PUBLIC_MAX_FILE_BUFFER * 1024 * 1024,
});
form.parse(req, async (err, fields, files) => {
const allowedMIMETypes = ["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(400).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PDF, PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
});
} else {
const fileBuffer = fs.readFileSync(files.file[0].filepath);
if (
Buffer.byteLength(fileBuffer) >
1024 * 1024 * Number(NEXT_PUBLIC_MAX_FILE_BUFFER)
)
return res.status(400).json({
response: `Sorry, we couldn't process your file. Please ensure it's a PNG, or JPG format and doesn't exceed ${NEXT_PUBLIC_MAX_FILE_BUFFER}MB.`,
});
const linkStillExists = await prisma.link.update({
where: { id: linkId },
data: {
updatedAt: new Date(),
},
});
if (linkStillExists) {
const collectionId = collectionPermissions.id;
createFolder({
filePath: `archives/preview/${collectionId}`,
});
await generatePreview(fileBuffer, collectionId, linkId);
}
fs.unlinkSync(files.file[0].filepath);
if (linkStillExists)
return res.status(200).json({
response: linkStillExists,
});
else return res.status(400).json({ response: "Link not found." });
}
});
}
}

View File

@@ -1,11 +1,9 @@
import { prisma } from "@/lib/api/db";
import sendInvitationRequest from "@/lib/api/sendInvitationRequest";
import sendVerificationRequest from "@/lib/api/sendVerificationRequest";
import updateSeats from "@/lib/api/stripe/updateSeats";
import verifySubscription from "@/lib/api/stripe/verifySubscription";
import verifySubscription from "@/lib/api/verifySubscription";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { User } from "@prisma/client";
import bcrypt from "bcrypt";
import { randomBytes } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { Adapter } from "next-auth/adapters";
import NextAuth from "next-auth/next";
@@ -135,7 +133,6 @@ if (process.env.NEXT_PUBLIC_CREDENTIALS_ENABLED !== "false") {
if (emailEnabled) {
providers.push(
EmailProvider({
id: "email",
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
maxAge: 1200,
@@ -160,56 +157,6 @@ if (emailEnabled) {
token,
});
},
}),
EmailProvider({
id: "invite",
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
maxAge: 1200,
async sendVerificationRequest({ identifier, url, provider, token }) {
const parentSubscriptionEmail = (
await prisma.user.findFirst({
where: {
email: identifier,
emailVerified: null,
},
include: {
parentSubscription: {
include: {
user: {
select: {
email: true,
},
},
},
},
},
})
)?.parentSubscription?.user.email;
if (!parentSubscriptionEmail) throw Error("Invalid email.");
const recentVerificationRequestsCount =
await prisma.verificationToken.count({
where: {
identifier,
createdAt: {
gt: new Date(new Date().getTime() - 1000 * 60 * 5), // 5 minutes
},
},
});
if (recentVerificationRequestsCount >= 4)
throw Error("Too many requests. Please try again later.");
sendInvitationRequest({
parentSubscriptionEmail,
identifier,
url,
from: provider.from as string,
token,
});
},
})
);
}
@@ -1232,52 +1179,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
},
callbacks: {
async signIn({ user, account, profile, email, credentials }) {
if (
!(user as User).emailVerified &&
!email?.verificationRequest
// && (account?.provider === "email" || account?.provider === "google")
) {
// Email is being verified for the first time...
console.log("Email is being verified for the first time...");
const parentSubscriptionId = (user as User).parentSubscriptionId;
if (parentSubscriptionId) {
// Add seat request to Stripe
const parentSubscription = await prisma.subscription.findFirst({
where: {
id: parentSubscriptionId,
},
});
// Count child users with verified email under a specific subscription, excluding the current user
const verifiedChildUsersCount = await prisma.user.count({
where: {
parentSubscriptionId: parentSubscriptionId,
id: {
not: user.id as number,
},
emailVerified: {
not: null,
},
},
});
if (
STRIPE_SECRET_KEY &&
parentSubscription?.quantity &&
verifiedChildUsersCount + 2 > // add current user and the admin
parentSubscription.quantity
) {
// Add seat if the user count exceeds the subscription limit
await updateSeats(
parentSubscription.stripeSubscriptionId,
verifiedChildUsersCount + 2
);
}
}
}
if (account?.provider !== "credentials") {
// registration via SSO can be separately disabled
const existingUser = await prisma.account.findFirst({
@@ -1386,6 +1287,8 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
async session({ session, token }) {
session.user.id = token.id;
console.log("session", session);
if (STRIPE_SECRET_KEY) {
const user = await prisma.user.findUnique({
where: {
@@ -1393,7 +1296,6 @@ export default async function auth(req: NextApiRequest, res: NextApiResponse) {
},
include: {
subscriptions: true,
parentSubscription: true,
},
});

View File

@@ -1,6 +1,5 @@
import { prisma } from "@/lib/api/db";
import sendPasswordResetRequest from "@/lib/api/sendPasswordResetRequest";
import { ForgotPasswordSchema } from "@/lib/shared/schemaValidation";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function forgotPassword(
@@ -14,18 +13,14 @@ export default async function forgotPassword(
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const dataValidation = ForgotPasswordSchema.safeParse(req.body);
const email = req.body.email;
if (!dataValidation.success) {
if (!email) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
response: "Invalid email.",
});
}
const { email } = dataValidation.data;
const recentPasswordRequestsCount = await prisma.passwordResetToken.count({
where: {
identifier: email,
@@ -50,11 +45,11 @@ export default async function forgotPassword(
if (!user || !user.email) {
return res.status(400).json({
response: "No user found with that email.",
response: "Invalid email.",
});
}
sendPasswordResetRequest(user.email, user.name || "Linkwarden User");
sendPasswordResetRequest(user.email, user.name);
return res.status(200).json({
response: "Password reset email sent.",

View File

@@ -1,7 +1,6 @@
import { prisma } from "@/lib/api/db";
import type { NextApiRequest, NextApiResponse } from "next";
import bcrypt from "bcrypt";
import { ResetPasswordSchema } from "@/lib/shared/schemaValidation";
export default async function resetPassword(
req: NextApiRequest,
@@ -14,17 +13,20 @@ export default async function resetPassword(
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const dataValidation = ResetPasswordSchema.safeParse(req.body);
const token = req.body.token;
const password = req.body.password;
if (!dataValidation.success) {
if (!password || password.length < 8) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
response: "Password must be at least 8 characters.",
});
}
const { token, password } = dataValidation.data;
if (!token || typeof token !== "string") {
return res.status(400).json({
response: "Invalid token.",
});
}
// Hashed password
const saltRounds = 10;

View File

@@ -1,6 +1,5 @@
import { prisma } from "@/lib/api/db";
import updateCustomerEmail from "@/lib/api/stripe/updateCustomerEmail";
import { VerifyEmailSchema } from "@/lib/shared/schemaValidation";
import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function verifyEmail(
@@ -14,18 +13,14 @@ export default async function verifyEmail(
"This action is disabled because this is a read-only demo of Linkwarden.",
});
const dataValidation = VerifyEmailSchema.safeParse(req.query);
const token = req.query.token;
if (!dataValidation.success) {
if (!token || typeof token !== "string") {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
response: "Invalid token.",
});
}
const { token } = dataValidation.data;
// Check token in db
const verifyToken = await prisma.verificationToken.findFirst({
where: {

View File

@@ -2,10 +2,8 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { prisma } from "@/lib/api/db";
import verifyUser from "@/lib/api/verifyUser";
import isValidUrl from "@/lib/shared/isValidUrl";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission";
import { moveFiles, removeFiles } from "@/lib/api/manageLinkFiles";
import { Collection, Link } from "@prisma/client";
import { removeFiles } from "@/lib/api/manageLinkFiles";
const RE_ARCHIVE_LIMIT = Number(process.env.RE_ARCHIVE_LIMIT) || 5;
@@ -25,16 +23,7 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: "Link not found.",
});
const collectionIsAccessible = await getPermission({
userId: user.id,
collectionId: link.collectionId,
});
const memberHasAccess = collectionIsAccessible?.members.some(
(e: UsersAndCollections) => e.userId === user.id && e.canUpdate
);
if (!(collectionIsAccessible?.ownerId === user.id || memberHasAccess))
if (link.collection.ownerId !== user.id)
return res.status(401).json({
response: "Permission denied.",
});
@@ -65,20 +54,7 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
response: "Invalid URL.",
});
await prisma.link.update({
where: {
id: link.id,
},
data: {
image: null,
pdf: null,
readable: null,
monolith: null,
preview: null,
},
});
await removeFiles(link.id, link.collection.id);
await deleteArchivedFiles(link);
return res.status(200).json({
response: "Link is being archived.",
@@ -96,3 +72,20 @@ const getTimezoneDifferenceInMinutes = (future: Date, past: Date) => {
return diffInMinutes;
};
const deleteArchivedFiles = async (link: Link & { collection: Collection }) => {
await prisma.link.update({
where: {
id: link.id,
},
data: {
image: null,
pdf: null,
readable: null,
monolith: null,
preview: null,
},
});
await removeFiles(link.id, link.collection.id);
};

View File

@@ -60,7 +60,6 @@ export default async function links(req: NextApiRequest, res: NextApiResponse) {
req.body.removePreviousTags,
req.body.newData
);
return res.status(updated.status).json({
response: updated.response,
});

View File

@@ -1,23 +1,12 @@
import type { NextApiRequest, NextApiResponse } from "next";
import verifyByCredentials from "@/lib/api/verifyByCredentials";
import createSession from "@/lib/api/controllers/session/createSession";
import { PostSessionSchema } from "@/lib/shared/schemaValidation";
export default async function session(
req: NextApiRequest,
res: NextApiResponse
) {
const dataValidation = PostSessionSchema.safeParse(req.body);
if (!dataValidation.success) {
return res.status(400).json({
response: `Error: ${
dataValidation.error.issues[0].message
} [${dataValidation.error.issues[0].path.join(", ")}]`,
});
}
const { username, password, sessionName } = dataValidation.data;
const { username, password, sessionName } = req.body;
const user = await verifyByCredentials({ username, password });

View File

@@ -9,11 +9,6 @@ export default async function tags(req: NextApiRequest, res: NextApiResponse) {
const tagId = Number(req.query.id);
if (!tagId)
return res.status(400).json({
response: "Please choose a valid name for the tag.",
});
if (req.method === "PUT") {
if (process.env.NEXT_PUBLIC_DEMO === "true")
return res.status(400).json({

Some files were not shown because too many files have changed in this diff Show More