Merge branch 'feat/dark-mode' into feat/dark-mode

This commit is contained in:
Daniel 2023-08-10 20:50:30 -04:00 committed by GitHub
commit 83349ea065
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 694 additions and 190 deletions

View File

@ -0,0 +1,22 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
{
"name": "Node.js & TypeScript",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bullseye",
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "yarn install",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
"remoteUser": "root"
}

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
pgdata
.env
.devcontainer
docker-compose.yml
Dockerfile
README.md

View File

@ -6,6 +6,7 @@ NEXTAUTH_URL=http://localhost:3000
PAGINATION_TAKE_COUNT= PAGINATION_TAKE_COUNT=
STORAGE_FOLDER= STORAGE_FOLDER=
AUTOSCROLL_TIMEOUT=
# AWS S3 Settings # AWS S3 Settings
SPACES_KEY= SPACES_KEY=
@ -26,3 +27,6 @@ NEXT_PUBLIC_TRIAL_PERIOD_DAYS=
NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL= NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL=
BASE_URL=http://localhost:3000 BASE_URL=http://localhost:3000
NEXT_PUBLIC_PRICING= NEXT_PUBLIC_PRICING=
# Docker postgres settings
POSTGRES_PASSWORD=

5
.gitignore vendored
View File

@ -36,9 +36,14 @@ next-env.d.ts
# generated files and folders # generated files and folders
/data /data
.idea
prisma/dev.db
# tests # tests
/tests /tests
/test-results/ /test-results/
/playwright-report/ /playwright-report/
/playwright/.cache/ /playwright/.cache/
# docker
pgdata

27
Dockerfile Normal file
View File

@ -0,0 +1,27 @@
# playwright doesnt support debian image
FROM ubuntu:focal
RUN apt-get update && apt-get install wget xz-utils -y
RUN mkdir /data
WORKDIR /data
RUN wget https://nodejs.org/dist/v20.5.0/node-v20.5.0-linux-x64.tar.xz -O nodejs.tar.xz \
&& mkdir /opt/nodejs \
&& tar -xf nodejs.tar.xz --strip-components 1 -C /opt/nodejs \
&& rm nodejs.tar.xz
ENV PATH="$PATH:/opt/nodejs/bin"
RUN npm install -g yarn
COPY ./package.json ./yarn.lock ./playwright.config.ts ./
RUN yarn
RUN npx playwright install-deps
COPY . .
RUN yarn prisma generate
RUN yarn build
CMD yarn prisma migrate deploy && yarn start

View File

@ -11,7 +11,7 @@
<div align='center'> <div align='center'>
[Homepage](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app/getting-started) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/linkwarden/linkwarden#roadmap) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [Support ❤](https://github.com/linkwarden/linkwarden#support-) [Website](https://linkwarden.app) | [Getting Started](https://docs.linkwarden.app/getting-started) | [Features](https://github.com/linkwarden/linkwarden#features) | [Roadmap](https://github.com/linkwarden/linkwarden#roadmap) | [Screenshots](https://github.com/linkwarden/linkwarden#screenshots) | [Support ❤](https://github.com/linkwarden/linkwarden#support-)
</div> </div>
@ -54,7 +54,6 @@ We highly recommend you **not** to use the old version as it is no longer mainta
There are _many_ upcoming features, below are only _some_ of the 100% planned ones: There are _many_ upcoming features, below are only _some_ of the 100% planned ones:
- 🐳 Docker version.
- 🌒 Dark mode. - 🌒 Dark mode.
- 📦 Import/Export your data. - 📦 Import/Export your data.
- 🧩 Browser extention. - 🧩 Browser extention.
@ -63,7 +62,7 @@ Also make sure to check out our [public roadmap](https://github.com/orgs/linkwar
## Docs ## Docs
Currently, the Documentation is a bit targeted towards a more tech-savvy audience and has so much room to improve, you can find it [here](https://docs.linkwarden.app). For information on how to get started or to set up your own instance, please visit the [documentation](https://docs.linkwarden.app).
## Development ## Development

View File

@ -6,6 +6,9 @@ import { signOut, useSession } from "next-auth/react";
import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons";
import SubmitButton from "../../SubmitButton"; import SubmitButton from "../../SubmitButton";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import Link from "next/link";
import ClickAwayHandler from "@/components/ClickAwayHandler";
import useInitialData from "@/hooks/useInitialData";
type Props = { type Props = {
toggleSettingsModal: Function; toggleSettingsModal: Function;
@ -21,6 +24,7 @@ export default function PrivacySettings({
const { update, data } = useSession(); const { update, data } = useSession();
const { account, updateAccount } = useAccountStore(); const { account, updateAccount } = useAccountStore();
const [importDropdown, setImportDropdown] = useState(false);
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState( const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState(
@ -46,6 +50,38 @@ export default function PrivacySettings({
return wordsArray; return wordsArray;
}; };
const postJSONFile = async (e: any) => {
const file: File = e.target.files[0];
if (file) {
var reader = new FileReader();
reader.readAsText(file, "UTF-8");
reader.onload = async function (e) {
const load = toast.loading("Importing...");
const response = await fetch("/api/data", {
method: "POST",
body: e.target?.result,
});
const data = await response.json();
toast.dismiss(load);
toast.success("Imported the Bookmarks! Reloading the page...");
setImportDropdown(false);
setTimeout(() => {
location.reload();
}, 2000);
};
reader.onerror = function (e) {
console.log("Error:", e);
};
}
};
const submit = async () => { const submit = async () => {
setSubmitLoader(true); setSubmitLoader(true);
@ -115,6 +151,58 @@ export default function PrivacySettings({
)} )}
</div> </div>
<div className="mt-5">
<p className="text-sm text-sky-700 mb-2">Import/Export Data</p>
<div className="flex gap-2">
<div
onClick={() => setImportDropdown(true)}
className="w-fit relative"
id="import-dropdown"
>
<div
id="import-dropdown"
className="border border-slate-200 rounded-md bg-white px-2 text-center select-none cursor-pointer text-sky-900 duration-100 hover:border-sky-700"
>
Import From
</div>
{importDropdown ? (
<ClickAwayHandler
onClickOutside={(e: Event) => {
const target = e.target as HTMLInputElement;
if (target.id !== "import-dropdown") setImportDropdown(false);
}}
className={`absolute top-7 left-0 w-36 py-1 shadow-md border border-sky-100 bg-gray-50 rounded-md flex flex-col z-20`}
>
<div className="cursor-pointer rounded-md">
<label
htmlFor="import-file"
title="JSON"
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-200 duration-100 cursor-pointer"
>
Linkwarden
<input
type="file"
name="photo"
id="import-file"
accept=".json"
className="hidden"
onChange={postJSONFile}
/>
</label>
</div>
</ClickAwayHandler>
) : null}
</div>
<Link className="w-fit" href="/api/data">
<div className="border border-slate-200 rounded-md bg-white px-2 text-center select-none cursor-pointer text-sky-900 duration-100 hover:border-sky-700">
Export Data
</div>
</Link>
</div>
</div>
<SubmitButton <SubmitButton
onClick={submit} onClick={submit}
loading={submitLoader} loading={submitLoader}

View File

@ -66,7 +66,9 @@ export default function Sidebar({ className }: { className?: string }) {
icon={faChartSimple} icon={faChartSimple}
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`} className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`}
/> />
<p className="text-sky-700 dark:text-sky-200 text-xs font-semibold">Dashboard</p>
<p className="text-sky-700 dark:text-sky-200 text-xs xl:text-sm font-semibold">Dashboard</p>
</Link> </Link>
<Link <Link
@ -81,9 +83,9 @@ export default function Sidebar({ className }: { className?: string }) {
icon={faLink} icon={faLink}
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`} className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`}
/> />
<p className="text-sky-700 dark:text-sky-200 text-xs font-semibold">
<span className="hidden xl:inline-block">All</span> Links <p className="text-sky-700 dark:text-sky-200 text-xs xl:text-sm font-semibold">Links</p>
</p>
</Link> </Link>
<Link <Link
@ -96,8 +98,9 @@ export default function Sidebar({ className }: { className?: string }) {
icon={faFolder} icon={faFolder}
className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`} className={`w-8 h-8 drop-shadow text-sky-500 dark:text-sky-100`}
/> />
<p className="text-sky-700 dark:text-sky-200 text-xs font-semibold">
<span className="hidden xl:inline-block">All</span> Collections <p className="text-sky-700 dark:text-sky-200 text-xs xl:text-sm font-semibold">
Collections
</p> </p>
</Link> </Link>
</div> </div>

21
docker-compose.yml Normal file
View File

@ -0,0 +1,21 @@
version: "3.5"
services:
postgres:
image: postgres
env_file: .env
restart: always
volumes:
- ./pgdata:/var/lib/postgresql/data
linkwarden:
env_file: .env
platform: linux/x86_64
environment:
- DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/postgres
restart: always
build: .
ports:
- 3000:3000
volumes:
- ./data:/data/data
depends_on:
- postgres

View File

@ -13,7 +13,10 @@ export default function useInitialData() {
const { setAccount } = useAccountStore(); const { setAccount } = useAccountStore();
useEffect(() => { useEffect(() => {
if (status === "authenticated" && data.user.isSubscriber) { if (
status === "authenticated" &&
(!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE || data.user.isSubscriber)
) {
setCollections(); setCollections();
setTags(); setTags();
// setLinks(); // setLinks();

View File

@ -2,11 +2,7 @@ import { Page, chromium, devices } from "playwright";
import { prisma } from "@/lib/api/db"; import { prisma } from "@/lib/api/db";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
export default async function archive( export default async function archive(linkId: number, url: string) {
url: string,
collectionId: number,
linkId: number
) {
const browser = await chromium.launch(); const browser = await chromium.launch();
const context = await browser.newContext(devices["Desktop Chrome"]); const context = await browser.newContext(devices["Desktop Chrome"]);
const page = await context.newPage(); const page = await context.newPage();
@ -14,7 +10,10 @@ export default async function archive(
try { try {
await page.goto(url, { waitUntil: "domcontentloaded" }); await page.goto(url, { waitUntil: "domcontentloaded" });
await autoScroll(page); await page.evaluate(
autoScroll,
Number(process.env.AUTOSCROLL_TIMEOUT) || 30
);
const linkExists = await prisma.link.findUnique({ const linkExists = await prisma.link.findUnique({
where: { where: {
@ -35,12 +34,12 @@ export default async function archive(
createFile({ createFile({
data: screenshot, data: screenshot,
filePath: `archives/${collectionId}/${linkId}.png`, filePath: `archives/${linkExists.collectionId}/${linkId}.png`,
}); });
createFile({ createFile({
data: pdf, data: pdf,
filePath: `archives/${collectionId}/${linkId}.pdf`, filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`,
}); });
} }
@ -51,12 +50,15 @@ export default async function archive(
} }
} }
const autoScroll = async (page: Page) => { const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => {
await page.evaluate(async () => {
const timeoutPromise = new Promise<void>((_, reject) => { const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => { setTimeout(() => {
reject(new Error("Auto scroll took too long (more than 20 seconds).")); reject(
}, 20000); new Error(
`Auto scroll took too long (more than ${AUTOSCROLL_TIMEOUT} seconds).`
)
);
}, AUTOSCROLL_TIMEOUT * 1000);
}); });
const scrollingPromise = new Promise<void>((resolve) => { const scrollingPromise = new Promise<void>((resolve) => {
@ -75,5 +77,4 @@ const autoScroll = async (page: Page) => {
}); });
await Promise.race([scrollingPromise, timeoutPromise]); await Promise.race([scrollingPromise, timeoutPromise]);
});
}; };

View File

@ -40,14 +40,6 @@ export default async function postCollection(
name: collection.name.trim(), name: collection.name.trim(),
description: collection.description, description: collection.description,
color: collection.color, color: collection.color,
members: {
create: collection.members.map((e) => ({
user: { connect: { id: e.user.id } },
canCreate: e.canCreate,
canUpdate: e.canUpdate,
canDelete: e.canDelete,
})),
},
}, },
include: { include: {
_count: { _count: {

View File

@ -0,0 +1,24 @@
import { prisma } from "@/lib/api/db";
export default async function getData(userId: number) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
collections: {
include: {
links: {
include: {
tags: true,
},
},
},
},
},
});
if (!user) return { response: "User not found.", status: 404 };
const { password, id, image, ...userData } = user;
return { response: userData, status: 200 };
}

View File

@ -0,0 +1,86 @@
import { prisma } from "@/lib/api/db";
import { Backup } from "@/types/global";
import createFolder from "@/lib/api/storage/createFolder";
export default async function getData(userId: number, data: Backup) {
// Import collections
try {
data.collections.forEach(async (e) => {
e.name = e.name.trim();
const findCollection = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
collections: {
where: {
name: e.name,
},
},
},
});
const checkIfCollectionExists = findCollection?.collections[0];
let collectionId = findCollection?.collections[0]?.id;
if (!checkIfCollectionExists) {
const newCollection = await prisma.collection.create({
data: {
owner: {
connect: {
id: userId,
},
},
name: e.name,
description: e.description,
color: e.color,
},
});
createFolder({ filePath: `archives/${newCollection.id}` });
collectionId = newCollection.id;
}
// Import Links
e.links.forEach(async (e) => {
const newLink = await prisma.link.create({
data: {
url: e.url,
name: e.name,
description: e.description,
collection: {
connect: {
id: collectionId,
},
},
tags: {
connectOrCreate: e.tags.map((tag) => ({
where: {
name_ownerId: {
name: tag.name.trim(),
ownerId: userId,
},
},
create: {
name: tag.name.trim(),
owner: {
connect: {
id: userId,
},
},
},
})),
},
},
});
});
});
} catch (err) {
console.log(err);
}
return { response: "Success.", status: 200 };
}

View File

@ -5,6 +5,7 @@ export default async function getLink(userId: number, body: string) {
const query: LinkRequestQuery = JSON.parse(decodeURIComponent(body)); const query: LinkRequestQuery = JSON.parse(decodeURIComponent(body));
console.log(query); console.log(query);
const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql");
// Sorting logic // Sorting logic
let order: any; let order: any;
if (query.sort === Sort.DateNewestFirst) if (query.sort === Sort.DateNewestFirst)
@ -66,7 +67,7 @@ export default async function getLink(userId: number, body: string) {
query.searchQuery && query.searchFilter?.name query.searchQuery && query.searchFilter?.name
? query.searchQuery ? query.searchQuery
: undefined, : undefined,
mode: "insensitive", mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
}, },
}, },
{ {
@ -75,7 +76,7 @@ export default async function getLink(userId: number, body: string) {
query.searchQuery && query.searchFilter?.url query.searchQuery && query.searchFilter?.url
? query.searchQuery ? query.searchQuery
: undefined, : undefined,
mode: "insensitive", mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
}, },
}, },
{ {
@ -84,7 +85,7 @@ export default async function getLink(userId: number, body: string) {
query.searchQuery && query.searchFilter?.description query.searchQuery && query.searchFilter?.description
? query.searchQuery ? query.searchQuery
: undefined, : undefined,
mode: "insensitive", mode: POSTGRES_IS_ENABLED ? "insensitive" : undefined,
}, },
}, },
{ {
@ -100,7 +101,9 @@ export default async function getLink(userId: number, body: string) {
query.searchQuery && query.searchFilter?.tags query.searchQuery && query.searchFilter?.tags
? { ? {
contains: query.searchQuery, contains: query.searchQuery,
mode: "insensitive", mode: POSTGRES_IS_ENABLED
? "insensitive"
: undefined,
} }
: undefined, : undefined,
OR: [ OR: [
@ -114,7 +117,9 @@ export default async function getLink(userId: number, body: string) {
query.searchFilter?.tags query.searchFilter?.tags
? query.searchQuery ? query.searchQuery
: undefined, : undefined,
mode: "insensitive", mode: POSTGRES_IS_ENABLED
? "insensitive"
: undefined,
}, },
collection: { collection: {
members: { members: {

View File

@ -20,12 +20,12 @@ export default async function postLink(
}; };
} }
link.collection.name = link.collection.name.trim();
if (!link.collection.name) { if (!link.collection.name) {
link.collection.name = "Unnamed Collection"; link.collection.name = "Unnamed Collection";
} }
link.collection.name = link.collection.name.trim();
if (link.collection.id) { if (link.collection.id) {
const collectionIsAccessible = (await getPermission( const collectionIsAccessible = (await getPermission(
userId, userId,
@ -51,7 +51,7 @@ export default async function postLink(
? link.description ? link.description
: await getTitle(link.url); : await getTitle(link.url);
const newLink: Link = await prisma.link.create({ const newLink = await prisma.link.create({
data: { data: {
url: link.url, url: link.url,
name: link.name, name: link.name,
@ -94,7 +94,7 @@ export default async function postLink(
createFolder({ filePath: `archives/${newLink.collectionId}` }); createFolder({ filePath: `archives/${newLink.collectionId}` });
archive(newLink.url, newLink.collectionId, newLink.id); archive(newLink.id, newLink.url);
return { response: newLink, status: 200 }; return { response: newLink, status: 200 };
} }

View File

@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db";
import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global";
import { Collection, Link, UsersAndCollections } from "@prisma/client"; import { Collection, Link, UsersAndCollections } from "@prisma/client";
import getPermission from "@/lib/api/getPermission"; import getPermission from "@/lib/api/getPermission";
import moveFile from "@/lib/api/storage/moveFile";
export default async function updateLink( export default async function updateLink(
link: LinkIncludingShortenedCollectionAndTags, link: LinkIncludingShortenedCollectionAndTags,
@ -98,6 +99,18 @@ export default async function updateLink(
}, },
}); });
if (targetLink.collection.id !== link.collection.id) {
await moveFile(
`archives/${targetLink.collection.id}/${link.id}.pdf`,
`archives/${link.collection.id}/${link.id}.pdf`
);
await moveFile(
`archives/${targetLink.collection.id}/${link.id}.png`,
`archives/${link.collection.id}/${link.id}.png`
);
}
return { response: updatedLink, status: 200 }; return { response: updatedLink, status: 200 };
} }
} }

View File

@ -17,14 +17,23 @@ export default async function getUser({
id: params.lookupId, id: params.lookupId,
username: params.lookupUsername?.toLowerCase(), username: params.lookupUsername?.toLowerCase(),
}, },
include: {
whitelistedUsers: {
select: {
username: true
}
}
}
}); });
if (!user) return { response: "User not found.", status: 404 }; if (!user) return { response: "User not found.", status: 404 };
const whitelistedUsernames = user.whitelistedUsers?.map(usernames => usernames.username);
if ( if (
!isSelf && !isSelf &&
user?.isPrivate && user?.isPrivate &&
!user.whitelistedUsers.includes(username.toLowerCase()) !whitelistedUsernames.includes(username.toLowerCase())
) { ) {
return { response: "This profile is private.", status: 401 }; return { response: "This profile is private.", status: 401 };
} }
@ -33,7 +42,7 @@ export default async function getUser({
const data = isSelf const data = isSelf
? // If user is requesting its own data ? // If user is requesting its own data
lessSensitiveInfo {...lessSensitiveInfo, whitelistedUsers: whitelistedUsernames}
: { : {
// If user is requesting someone elses data // If user is requesting someone elses data
id: lessSensitiveInfo.id, id: lessSensitiveInfo.id,

View File

@ -3,7 +3,11 @@ import { AccountSettings } from "@/types/global";
import bcrypt from "bcrypt"; import bcrypt from "bcrypt";
import removeFile from "@/lib/api/storage/removeFile"; import removeFile from "@/lib/api/storage/removeFile";
import createFile from "@/lib/api/storage/createFile"; import createFile from "@/lib/api/storage/createFile";
import updateCustomerEmail from "../../updateCustomerEmail"; import updateCustomerEmail from "@/lib/api/updateCustomerEmail";
import createFolder from "@/lib/api/storage/createFolder";
const emailEnabled =
process.env.EMAIL_FROM && process.env.EMAIL_SERVER ? true : false;
export default async function updateUser( export default async function updateUser(
user: AccountSettings, user: AccountSettings,
@ -14,9 +18,14 @@ export default async function updateUser(
isSubscriber: boolean; isSubscriber: boolean;
} }
) { ) {
if (!user.username || !user.email) if (emailEnabled && !user.email)
return { return {
response: "Username/Email invalid.", response: "Email invalid.",
status: 400,
};
else if (!user.username)
return {
response: "Username invalid.",
status: 400, status: 400,
}; };
@ -32,12 +41,18 @@ export default async function updateUser(
const userIsTaken = await prisma.user.findFirst({ const userIsTaken = await prisma.user.findFirst({
where: { where: {
id: { not: sessionUser.id }, id: { not: sessionUser.id },
OR: [ OR: emailEnabled
? [
{ {
username: user.username.toLowerCase(), username: user.username.toLowerCase(),
}, },
{ {
email: user.email.toLowerCase(), email: user.email?.toLowerCase(),
},
]
: [
{
username: user.username.toLowerCase(),
}, },
], ],
}, },
@ -58,6 +73,8 @@ export default async function updateUser(
try { try {
const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, ""); const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, "");
createFolder({ filePath: `uploads/avatar` });
await createFile({ await createFile({
filePath: `uploads/avatar/${sessionUser.id}.jpg`, filePath: `uploads/avatar/${sessionUser.id}.jpg`,
data: base64Data, data: base64Data,
@ -91,29 +108,71 @@ export default async function updateUser(
username: user.username.toLowerCase(), username: user.username.toLowerCase(),
email: user.email?.toLowerCase(), email: user.email?.toLowerCase(),
isPrivate: user.isPrivate, isPrivate: user.isPrivate,
whitelistedUsers: user.whitelistedUsers,
password: password:
user.newPassword && user.newPassword !== "" user.newPassword && user.newPassword !== ""
? newHashedPassword ? newHashedPassword
: undefined, : undefined,
}, },
include: {
whitelistedUsers: true,
},
}); });
const { whitelistedUsers, password, ...userInfo } = updatedUser;
// If user.whitelistedUsers is not provided, we will assume the whitelistedUsers should be removed
const newWhitelistedUsernames: string[] = user.whitelistedUsers || [];
// Get the current whitelisted usernames
const currentWhitelistedUsernames: string[] = whitelistedUsers.map(
(user) => user.username
);
// Find the usernames to be deleted (present in current but not in new)
const usernamesToDelete: string[] = currentWhitelistedUsernames.filter(
(username) => !newWhitelistedUsernames.includes(username)
);
// Find the usernames to be created (present in new but not in current)
const usernamesToCreate: string[] = newWhitelistedUsernames.filter(
(username) =>
!currentWhitelistedUsernames.includes(username) && username.trim() !== ""
);
// Delete whitelistedUsers that are not present in the new list
await prisma.whitelistedUser.deleteMany({
where: {
userId: sessionUser.id,
username: {
in: usernamesToDelete,
},
},
});
// Create new whitelistedUsers that are not in the current list, no create many ;(
for (const username of usernamesToCreate) {
await prisma.whitelistedUser.create({
data: {
username,
userId: sessionUser.id,
},
});
}
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY; const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
const PRICE_ID = process.env.PRICE_ID; const PRICE_ID = process.env.PRICE_ID;
if (STRIPE_SECRET_KEY && PRICE_ID) if (STRIPE_SECRET_KEY && PRICE_ID && emailEnabled)
await updateCustomerEmail( await updateCustomerEmail(
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
PRICE_ID, PRICE_ID,
sessionUser.email, sessionUser.email,
user.email user.email as string
); );
const { password, ...userInfo } = updatedUser;
const response: Omit<AccountSettings, "password"> = { const response: Omit<AccountSettings, "password"> = {
...userInfo, ...userInfo,
whitelistedUsers: newWhitelistedUsernames,
profilePic: `/api/avatar/${userInfo.id}?${Date.now()}`, profilePic: `/api/avatar/${userInfo.id}?${Date.now()}`,
}; };

View File

@ -10,9 +10,10 @@ export const prisma =
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
if (process.env.NODE_ENV !== "production") // For benchmarking | uncomment when needed
prisma.$on("query" as any, (e: any) => { // if (process.env.NODE_ENV !== "production")
console.log("Query: " + e.query); // prisma.$on("query" as any, (e: any) => {
console.log("Params: " + e.params); // console.log("Query: " + e.query);
console.log("\x1b[31m", `Duration: ${e.duration}ms`, "\x1b[0m"); // For benchmarking // console.log("Params: " + e.params);
}); // console.log("\x1b[31m", `Duration: ${e.duration}ms`, "\x1b[0m");
// });

View File

@ -0,0 +1,37 @@
import fs from "fs";
import path from "path";
import s3Client from "./s3Client";
import removeFile from "./removeFile";
export default async function moveFile(from: string, to: string) {
if (s3Client) {
const Bucket = process.env.BUCKET_NAME;
const copyParams = {
Bucket: Bucket,
CopySource: `/${Bucket}/${from}`,
Key: to,
};
try {
s3Client.copyObject(copyParams, async (err: any) => {
if (err) {
console.error("Error copying the object:", err);
} else {
await removeFile({ filePath: from });
}
});
} catch (err) {
console.log("Error:", err);
}
} else {
const storagePath = process.env.STORAGE_FOLDER || "data";
const directory = (file: string) =>
path.join(process.cwd(), storagePath + "/" + file);
fs.rename(directory(from), directory(to), (err) => {
if (err) console.log("Error copying file:", err);
});
}
}

View File

@ -9,12 +9,14 @@ import s3Client from "./s3Client";
import util from "util"; import util from "util";
type ReturnContentTypes = type ReturnContentTypes =
| "text/plain" | "text/html"
| "image/jpeg" | "image/jpeg"
| "image/png" | "image/png"
| "application/pdf"; | "application/pdf";
export default async function readFile({ filePath }: { filePath: string }) { export default async function readFile(filePath: string) {
const isRequestingAvatar = filePath.startsWith("uploads/avatar");
let contentType: ReturnContentTypes; let contentType: ReturnContentTypes;
if (s3Client) { if (s3Client) {
@ -28,6 +30,7 @@ export default async function readFile({ filePath }: { filePath: string }) {
| { | {
file: Buffer | string; file: Buffer | string;
contentType: ReturnContentTypes; contentType: ReturnContentTypes;
status: number;
} }
| undefined; | undefined;
@ -38,11 +41,12 @@ export default async function readFile({ filePath }: { filePath: string }) {
try { try {
await headObjectAsync(bucketParams); await headObjectAsync(bucketParams);
} catch (err) { } catch (err) {
contentType = "text/plain"; contentType = "text/html";
returnObject = { returnObject = {
file: "File not found, it's possible that the file you're looking for either doesn't exist or hasn't been created yet.", file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate,
contentType, contentType,
status: isRequestingAvatar ? 200 : 400,
}; };
} }
@ -60,14 +64,14 @@ export default async function readFile({ filePath }: { filePath: string }) {
// if (filePath.endsWith(".jpg")) // if (filePath.endsWith(".jpg"))
contentType = "image/jpeg"; contentType = "image/jpeg";
} }
returnObject = { file: data as Buffer, contentType }; returnObject = { file: data as Buffer, contentType, status: 200 };
} }
return returnObject; return returnObject;
} catch (err) { } catch (err) {
console.log("Error:", err); console.log("Error:", err);
contentType = "text/plain"; contentType = "text/html";
return { return {
file: "An internal occurred, please contact support.", file: "An internal occurred, please contact support.",
contentType, contentType,
@ -77,13 +81,7 @@ export default async function readFile({ filePath }: { filePath: string }) {
const storagePath = process.env.STORAGE_FOLDER || "data"; const storagePath = process.env.STORAGE_FOLDER || "data";
const creationPath = path.join(process.cwd(), storagePath + "/" + filePath); const creationPath = path.join(process.cwd(), storagePath + "/" + filePath);
const file = fs.existsSync(creationPath) if (filePath.endsWith(".pdf")) {
? fs.readFileSync(creationPath)
: "File not found, it's possible that the file you're looking for either doesn't exist or hasn't been created yet.";
if (file.toString().startsWith("File not found")) {
contentType = "text/plain";
} else if (filePath.endsWith(".pdf")) {
contentType = "application/pdf"; contentType = "application/pdf";
} else if (filePath.endsWith(".png")) { } else if (filePath.endsWith(".png")) {
contentType = "image/png"; contentType = "image/png";
@ -92,7 +90,16 @@ export default async function readFile({ filePath }: { filePath: string }) {
contentType = "image/jpeg"; contentType = "image/jpeg";
} }
return { file, contentType }; if (!fs.existsSync(creationPath))
return {
file: isRequestingAvatar ? "File not found." : fileNotFoundTemplate,
contentType: "text/html",
status: isRequestingAvatar ? 200 : 400,
};
else {
const file = fs.readFileSync(creationPath);
return { file, contentType, status: 200 };
}
} }
} }
@ -105,3 +112,21 @@ const streamToBuffer = (stream: any) => {
stream.on("end", () => resolve(Buffer.concat(chunks))); stream.on("end", () => resolve(Buffer.concat(chunks)));
}); });
}; };
const fileNotFoundTemplate = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File not found</title>
</head>
<body style="margin-left: auto; margin-right: auto; max-width: 500px; padding: 1rem; font-family: sans-serif; background-color: rgb(251, 251, 251);">
<h1>File not found</h1>
<h2>It is possible that the file you're looking for either doesn't exist or hasn't been created yet.</h2>
<h3>Some possible reasons are:</h3>
<ul>
<li>You are trying to access a file too early, before it has been fully archived.</li>
<li>The file doesn't exist either because it encountered an error while being archived, or it simply doesn't exist.</li>
</ul>
</body>
</html>`;

View File

@ -1,4 +1,4 @@
export default async function avatarExists(fileUrl: string): Promise<boolean> { export default async function avatarExists(fileUrl: string): Promise<boolean> {
const response = await fetch(fileUrl, { method: "HEAD" }); const response = await fetch(fileUrl, { method: "HEAD" });
return !(response.headers.get("content-type") === "text/plain"); return !(response.headers.get("content-type") === "text/html");
} }

View File

@ -31,10 +31,10 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
.status(401) .status(401)
.json({ response: "You don't have access to this collection." }); .json({ response: "You don't have access to this collection." });
const { file, contentType } = await readFile({ const { file, contentType, status } = await readFile(
filePath: `archives/${collectionId}/${linkId}`, `archives/${collectionId}/${linkId}`
}); );
res.setHeader("Content-Type", contentType).status(200); res.setHeader("Content-Type", contentType).status(status as number);
return res.send(file); return res.send(file);
} }

View File

@ -13,7 +13,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (!userId || !username) if (!userId || !username)
return res return res
.setHeader("Content-Type", "text/plain") .setHeader("Content-Type", "text/html")
.status(401) .status(401)
.send("You must be logged in."); .send("You must be logged in.");
else if (session?.user?.isSubscriber === false) else if (session?.user?.isSubscriber === false)
@ -24,7 +24,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
if (!queryId) if (!queryId)
return res return res
.setHeader("Content-Type", "text/plain") .setHeader("Content-Type", "text/html")
.status(401) .status(401)
.send("Invalid parameters."); .send("Invalid parameters.");
@ -33,23 +33,28 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) {
where: { where: {
id: queryId, id: queryId,
}, },
include: {
whitelistedUsers: true,
},
}); });
if ( const whitelistedUsernames = targetUser?.whitelistedUsers.map(
targetUser?.isPrivate && (whitelistedUsername) => whitelistedUsername.username
!targetUser.whitelistedUsers.includes(username) );
) {
if (targetUser?.isPrivate && !whitelistedUsernames?.includes(username)) {
return res return res
.setHeader("Content-Type", "text/plain") .setHeader("Content-Type", "text/html")
.send("This profile is private."); .send("This profile is private.");
} }
} }
const { file, contentType } = await readFile({ const { file, contentType, status } = await readFile(
filePath: `uploads/avatar/${queryId}.jpg`, `uploads/avatar/${queryId}.jpg`
}); );
res.setHeader("Content-Type", contentType); return res
.setHeader("Content-Type", contentType)
return res.send(file); .status(status as number)
.send(file);
} }

31
pages/api/data/index.ts Normal file
View File

@ -0,0 +1,31 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import getData from "@/lib/api/controllers/data/getData";
import postData from "@/lib/api/controllers/data/postData";
export default async function users(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession(req, res, authOptions);
if (!session?.user.id) {
return res.status(401).json({ response: "You must be logged in." });
} else if (session?.user?.isSubscriber === false)
res.status(401).json({
response:
"You are not a subscriber, feel free to reach out to us at support@linkwarden.app in case of any issues.",
});
if (req.method === "GET") {
const data = await getData(session.user.id);
if (data.status === 200)
return res
.setHeader("Content-Type", "application/json")
.setHeader("Content-Disposition", "attachment; filename=backup.json")
.status(data.status)
.json(data.response);
} else if (req.method === "POST") {
console.log(JSON.parse(req.body));
const data = await postData(session.user.id, JSON.parse(req.body));
return res.status(data.status).json({ response: data.response });
}
}

View File

@ -15,7 +15,7 @@ export default function EmailConfirmaion() {
<hr className="my-5" /> <hr className="my-5" />
<p className="text-sm text-gray-500 "> <p className="text-sm text-gray-500 ">
If you didn&apos;t recieve anything, go to the{" "} If you didn&apos;t receive anything, go to the{" "}
<Link href="/forgot" className="font-bold"> <Link href="/forgot" className="font-bold">
Password Recovery Password Recovery
</Link>{" "} </Link>{" "}

View File

@ -3,7 +3,7 @@ import { useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import SubmitButton from "@/components/SubmitButton"; import SubmitButton from "@/components/SubmitButton";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import Image from "next/image"; import { useRouter } from "next/router";
import CenteredForm from "@/layouts/CenteredForm"; import CenteredForm from "@/layouts/CenteredForm";
const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER;
@ -18,6 +18,7 @@ type FormData = {
export default function Register() { export default function Register() {
const [submitLoader, setSubmitLoader] = useState(false); const [submitLoader, setSubmitLoader] = useState(false);
const router = useRouter();
const [form, setForm] = useState<FormData>({ const [form, setForm] = useState<FormData>({
name: "", name: "",
@ -28,7 +29,7 @@ export default function Register() {
}); });
async function registerUser() { async function registerUser() {
const checkHasEmptyFields = () => { const checkFields = () => {
if (emailEnabled) { if (emailEnabled) {
return ( return (
form.name !== "" && form.name !== "" &&
@ -46,14 +47,7 @@ export default function Register() {
} }
}; };
const sendConfirmation = async () => { if (checkFields()) {
await signIn("email", {
email: form.email,
callbackUrl: "/",
});
};
if (checkHasEmptyFields()) {
if (form.password !== form.passwordConfirmation) if (form.password !== form.passwordConfirmation)
return toast.error("Passwords do not match."); return toast.error("Passwords do not match.");
else if (form.password.length < 8) else if (form.password.length < 8)
@ -78,7 +72,12 @@ export default function Register() {
setSubmitLoader(false); setSubmitLoader(false);
if (response.ok) { if (response.ok) {
if (form.email) await sendConfirmation(); if (form.email && emailEnabled)
await signIn("email", {
email: form.email,
callbackUrl: "/",
});
else if (!emailEnabled) router.push("/login");
toast.success("User Created!"); toast.success("User Created!");
} else { } else {

View File

@ -0,0 +1,20 @@
/*
Warnings:
- You are about to drop the column `whitelistedUsers` on the `User` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "User" DROP COLUMN "whitelistedUsers";
-- CreateTable
CREATE TABLE "WhitelistedUser" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL DEFAULT '',
"userId" INTEGER,
CONSTRAINT "WhitelistedUser_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "WhitelistedUser" ADD CONSTRAINT "WhitelistedUser_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -13,12 +13,12 @@ model Account {
type String type String
provider String provider String
providerAccountId String providerAccountId String
refresh_token String? @db.Text refresh_token String?
access_token String? @db.Text access_token String?
expires_at Int? expires_at Int?
token_type String? token_type String?
scope String? scope String?
id_token String? @db.Text id_token String?
session_state String? session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -56,10 +56,18 @@ model User {
collectionsJoined UsersAndCollections[] collectionsJoined UsersAndCollections[]
isPrivate Boolean @default(false) isPrivate Boolean @default(false)
whitelistedUsers String[] @default([]) whitelistedUsers WhitelistedUser[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model WhitelistedUser {
id Int @id @default(autoincrement())
username String @default("")
User User? @relation(fields: [userId], references: [id])
userId Int?
}
model VerificationToken { model VerificationToken {
identifier String identifier String
token String @unique token String @unique
@ -75,7 +83,6 @@ model Collection {
color String @default("#0ea5e9") color String @default("#0ea5e9")
isPublic Boolean @default(false) isPublic Boolean @default(false)
owner User @relation(fields: [ownerId], references: [id]) owner User @relation(fields: [ownerId], references: [id])
ownerId Int ownerId Int
members UsersAndCollections[] members UsersAndCollections[]
@ -110,6 +117,7 @@ model Link {
collection Collection @relation(fields: [collectionId], references: [id]) collection Collection @relation(fields: [collectionId], references: [id])
collectionId Int collectionId Int
tags Tag[] tags Tag[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }

View File

@ -6,6 +6,7 @@ declare global {
NEXTAUTH_URL: string; NEXTAUTH_URL: string;
PAGINATION_TAKE_COUNT?: string; PAGINATION_TAKE_COUNT?: string;
STORAGE_FOLDER?: string; STORAGE_FOLDER?: string;
AUTOSCROLL_TIMEOUT?: string;
SPACES_KEY?: string; SPACES_KEY?: string;
SPACES_SECRET?: string; SPACES_SECRET?: string;

View File

@ -36,6 +36,7 @@ export interface CollectionIncludingMembersAndLinkCount
export interface AccountSettings extends User { export interface AccountSettings extends User {
profilePic: string; profilePic: string;
newPassword?: string; newPassword?: string;
whitelistedUsers: string[];
} }
interface LinksIncludingTags extends Link { interface LinksIncludingTags extends Link {
@ -76,3 +77,11 @@ export type PublicLinkRequestQuery = {
cursor?: number; cursor?: number;
collectionId: number; collectionId: number;
}; };
interface CollectionIncludingLinks extends Collection {
links: LinksIncludingTags[];
}
export interface Backup extends Omit<User, "password" | "id" | "image"> {
collections: CollectionIncludingLinks[];
}