diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6aff804 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5d4b3d1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +node_modules +pgdata +.env +.devcontainer +docker-compose.yml +Dockerfile +README.md diff --git a/.env.sample b/.env.sample index ea08bfa..4bf7c56 100644 --- a/.env.sample +++ b/.env.sample @@ -6,6 +6,7 @@ NEXTAUTH_URL=http://localhost:3000 PAGINATION_TAKE_COUNT= STORAGE_FOLDER= +AUTOSCROLL_TIMEOUT= # AWS S3 Settings SPACES_KEY= @@ -25,4 +26,7 @@ PRICE_ID= NEXT_PUBLIC_TRIAL_PERIOD_DAYS= NEXT_PUBLIC_STRIPE_BILLING_PORTAL_URL= BASE_URL=http://localhost:3000 -NEXT_PUBLIC_PRICING= \ No newline at end of file +NEXT_PUBLIC_PRICING= + +# Docker postgres settings +POSTGRES_PASSWORD= diff --git a/.gitignore b/.gitignore index 3cec5f6..77956c8 100644 --- a/.gitignore +++ b/.gitignore @@ -36,9 +36,14 @@ next-env.d.ts # generated files and folders /data +.idea +prisma/dev.db # tests /tests /test-results/ /playwright-report/ /playwright/.cache/ + +# docker +pgdata \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a875e5 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index e1d53cb..e1bf05c 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@
-[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-)
@@ -54,16 +54,15 @@ 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: -- 🐳 Docker version. - 🌒 Dark mode. - 📦 Import/Export your data. - 🧩 Browser extention. -Also make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1). +Also make sure to check out our [public roadmap](https://github.com/orgs/linkwarden/projects/1). ## 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 diff --git a/components/Modal/User/PrivacySettings.tsx b/components/Modal/User/PrivacySettings.tsx index 1f66103..e209ae2 100644 --- a/components/Modal/User/PrivacySettings.tsx +++ b/components/Modal/User/PrivacySettings.tsx @@ -6,6 +6,9 @@ import { signOut, useSession } from "next-auth/react"; import { faPenToSquare } from "@fortawesome/free-regular-svg-icons"; import SubmitButton from "../../SubmitButton"; import { toast } from "react-hot-toast"; +import Link from "next/link"; +import ClickAwayHandler from "@/components/ClickAwayHandler"; +import useInitialData from "@/hooks/useInitialData"; type Props = { toggleSettingsModal: Function; @@ -21,6 +24,7 @@ export default function PrivacySettings({ const { update, data } = useSession(); const { account, updateAccount } = useAccountStore(); + const [importDropdown, setImportDropdown] = useState(false); const [submitLoader, setSubmitLoader] = useState(false); const [whitelistedUsersTextbox, setWhiteListedUsersTextbox] = useState( @@ -46,6 +50,38 @@ export default function PrivacySettings({ 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 () => { setSubmitLoader(true); @@ -115,6 +151,58 @@ export default function PrivacySettings({ )} +
+

Import/Export Data

+ +
+
setImportDropdown(true)} + className="w-fit relative" + id="import-dropdown" + > +
+ Import From +
+ {importDropdown ? ( + { + 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`} + > +
+ +
+
+ ) : null} +
+ + +
+ Export Data +
+ +
+
+ -

Dashboard

+ +

Dashboard

+ -

- All Links -

+ +

Links

+ -

- All Collections + +

+ Collections

diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..81457f5 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/hooks/useInitialData.tsx b/hooks/useInitialData.tsx index b79d5a8..a0bd1c4 100644 --- a/hooks/useInitialData.tsx +++ b/hooks/useInitialData.tsx @@ -13,7 +13,10 @@ export default function useInitialData() { const { setAccount } = useAccountStore(); useEffect(() => { - if (status === "authenticated" && data.user.isSubscriber) { + if ( + status === "authenticated" && + (!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE || data.user.isSubscriber) + ) { setCollections(); setTags(); // setLinks(); diff --git a/lib/api/archive.ts b/lib/api/archive.ts index 24c3ff6..1163f26 100644 --- a/lib/api/archive.ts +++ b/lib/api/archive.ts @@ -2,11 +2,7 @@ import { Page, chromium, devices } from "playwright"; import { prisma } from "@/lib/api/db"; import createFile from "@/lib/api/storage/createFile"; -export default async function archive( - url: string, - collectionId: number, - linkId: number -) { +export default async function archive(linkId: number, url: string) { const browser = await chromium.launch(); const context = await browser.newContext(devices["Desktop Chrome"]); const page = await context.newPage(); @@ -14,7 +10,10 @@ export default async function archive( try { 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({ where: { @@ -35,12 +34,12 @@ export default async function archive( createFile({ data: screenshot, - filePath: `archives/${collectionId}/${linkId}.png`, + filePath: `archives/${linkExists.collectionId}/${linkId}.png`, }); createFile({ data: pdf, - filePath: `archives/${collectionId}/${linkId}.pdf`, + filePath: `archives/${linkExists.collectionId}/${linkId}.pdf`, }); } @@ -51,29 +50,31 @@ export default async function archive( } } -const autoScroll = async (page: Page) => { - await page.evaluate(async () => { - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("Auto scroll took too long (more than 20 seconds).")); - }, 20000); - }); - - const scrollingPromise = new Promise((resolve) => { - let totalHeight = 0; - let distance = 100; - let scrollDown = setInterval(() => { - let scrollHeight = document.body.scrollHeight; - window.scrollBy(0, distance); - totalHeight += distance; - if (totalHeight >= scrollHeight) { - clearInterval(scrollDown); - window.scroll(0, 0); - resolve(); - } - }, 100); - }); - - await Promise.race([scrollingPromise, timeoutPromise]); +const autoScroll = async (AUTOSCROLL_TIMEOUT: number) => { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject( + new Error( + `Auto scroll took too long (more than ${AUTOSCROLL_TIMEOUT} seconds).` + ) + ); + }, AUTOSCROLL_TIMEOUT * 1000); }); + + const scrollingPromise = new Promise((resolve) => { + let totalHeight = 0; + let distance = 100; + let scrollDown = setInterval(() => { + let scrollHeight = document.body.scrollHeight; + window.scrollBy(0, distance); + totalHeight += distance; + if (totalHeight >= scrollHeight) { + clearInterval(scrollDown); + window.scroll(0, 0); + resolve(); + } + }, 100); + }); + + await Promise.race([scrollingPromise, timeoutPromise]); }; diff --git a/lib/api/controllers/collections/postCollection.ts b/lib/api/controllers/collections/postCollection.ts index 917f02f..86ca379 100644 --- a/lib/api/controllers/collections/postCollection.ts +++ b/lib/api/controllers/collections/postCollection.ts @@ -40,14 +40,6 @@ export default async function postCollection( name: collection.name.trim(), description: collection.description, 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: { _count: { diff --git a/lib/api/controllers/data/getData.ts b/lib/api/controllers/data/getData.ts new file mode 100644 index 0000000..6b9e2b0 --- /dev/null +++ b/lib/api/controllers/data/getData.ts @@ -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 }; +} diff --git a/lib/api/controllers/data/postData.ts b/lib/api/controllers/data/postData.ts new file mode 100644 index 0000000..b9af119 --- /dev/null +++ b/lib/api/controllers/data/postData.ts @@ -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 }; +} diff --git a/lib/api/controllers/links/getLinks.ts b/lib/api/controllers/links/getLinks.ts index d5ed6df..e61f2a1 100644 --- a/lib/api/controllers/links/getLinks.ts +++ b/lib/api/controllers/links/getLinks.ts @@ -5,6 +5,7 @@ export default async function getLink(userId: number, body: string) { const query: LinkRequestQuery = JSON.parse(decodeURIComponent(body)); console.log(query); + const POSTGRES_IS_ENABLED = process.env.DATABASE_URL.startsWith("postgresql"); // Sorting logic let order: any; 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 : 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 : 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 : 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 ? { contains: query.searchQuery, - mode: "insensitive", + mode: POSTGRES_IS_ENABLED + ? "insensitive" + : undefined, } : undefined, OR: [ @@ -114,7 +117,9 @@ export default async function getLink(userId: number, body: string) { query.searchFilter?.tags ? query.searchQuery : undefined, - mode: "insensitive", + mode: POSTGRES_IS_ENABLED + ? "insensitive" + : undefined, }, collection: { members: { diff --git a/lib/api/controllers/links/postLink.ts b/lib/api/controllers/links/postLink.ts index 3a5bfbd..66d8e91 100644 --- a/lib/api/controllers/links/postLink.ts +++ b/lib/api/controllers/links/postLink.ts @@ -20,12 +20,12 @@ export default async function postLink( }; } - link.collection.name = link.collection.name.trim(); - if (!link.collection.name) { link.collection.name = "Unnamed Collection"; } + link.collection.name = link.collection.name.trim(); + if (link.collection.id) { const collectionIsAccessible = (await getPermission( userId, @@ -51,7 +51,7 @@ export default async function postLink( ? link.description : await getTitle(link.url); - const newLink: Link = await prisma.link.create({ + const newLink = await prisma.link.create({ data: { url: link.url, name: link.name, @@ -94,7 +94,7 @@ export default async function postLink( createFolder({ filePath: `archives/${newLink.collectionId}` }); - archive(newLink.url, newLink.collectionId, newLink.id); + archive(newLink.id, newLink.url); return { response: newLink, status: 200 }; } diff --git a/lib/api/controllers/links/updateLink.ts b/lib/api/controllers/links/updateLink.ts index cdd0f67..24836c1 100644 --- a/lib/api/controllers/links/updateLink.ts +++ b/lib/api/controllers/links/updateLink.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/api/db"; import { LinkIncludingShortenedCollectionAndTags } from "@/types/global"; import { Collection, Link, UsersAndCollections } from "@prisma/client"; import getPermission from "@/lib/api/getPermission"; +import moveFile from "@/lib/api/storage/moveFile"; export default async function updateLink( 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 }; } } diff --git a/lib/api/controllers/users/getUsers.ts b/lib/api/controllers/users/getUsers.ts index 381a713..9d752a6 100644 --- a/lib/api/controllers/users/getUsers.ts +++ b/lib/api/controllers/users/getUsers.ts @@ -17,14 +17,23 @@ export default async function getUser({ id: params.lookupId, username: params.lookupUsername?.toLowerCase(), }, + include: { + whitelistedUsers: { + select: { + username: true + } + } + } }); if (!user) return { response: "User not found.", status: 404 }; + const whitelistedUsernames = user.whitelistedUsers?.map(usernames => usernames.username); + if ( !isSelf && user?.isPrivate && - !user.whitelistedUsers.includes(username.toLowerCase()) + !whitelistedUsernames.includes(username.toLowerCase()) ) { return { response: "This profile is private.", status: 401 }; } @@ -33,7 +42,7 @@ export default async function getUser({ const data = isSelf ? // If user is requesting its own data - lessSensitiveInfo + {...lessSensitiveInfo, whitelistedUsers: whitelistedUsernames} : { // If user is requesting someone elses data id: lessSensitiveInfo.id, diff --git a/lib/api/controllers/users/updateUser.ts b/lib/api/controllers/users/updateUser.ts index 8f4eb0f..f953557 100644 --- a/lib/api/controllers/users/updateUser.ts +++ b/lib/api/controllers/users/updateUser.ts @@ -3,7 +3,11 @@ import { AccountSettings } from "@/types/global"; import bcrypt from "bcrypt"; import removeFile from "@/lib/api/storage/removeFile"; 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( user: AccountSettings, @@ -14,9 +18,14 @@ export default async function updateUser( isSubscriber: boolean; } ) { - if (!user.username || !user.email) + if (emailEnabled && !user.email) return { - response: "Username/Email invalid.", + response: "Email invalid.", + status: 400, + }; + else if (!user.username) + return { + response: "Username invalid.", status: 400, }; @@ -32,14 +41,20 @@ export default async function updateUser( const userIsTaken = await prisma.user.findFirst({ where: { id: { not: sessionUser.id }, - OR: [ - { - username: user.username.toLowerCase(), - }, - { - email: user.email.toLowerCase(), - }, - ], + OR: emailEnabled + ? [ + { + username: user.username.toLowerCase(), + }, + { + email: user.email?.toLowerCase(), + }, + ] + : [ + { + username: user.username.toLowerCase(), + }, + ], }, }); @@ -58,6 +73,8 @@ export default async function updateUser( try { const base64Data = profilePic.replace(/^data:image\/jpeg;base64,/, ""); + createFolder({ filePath: `uploads/avatar` }); + await createFile({ filePath: `uploads/avatar/${sessionUser.id}.jpg`, data: base64Data, @@ -91,29 +108,71 @@ export default async function updateUser( username: user.username.toLowerCase(), email: user.email?.toLowerCase(), isPrivate: user.isPrivate, - whitelistedUsers: user.whitelistedUsers, password: user.newPassword && user.newPassword !== "" ? newHashedPassword : 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 PRICE_ID = process.env.PRICE_ID; - if (STRIPE_SECRET_KEY && PRICE_ID) + if (STRIPE_SECRET_KEY && PRICE_ID && emailEnabled) await updateCustomerEmail( STRIPE_SECRET_KEY, PRICE_ID, sessionUser.email, - user.email + user.email as string ); - const { password, ...userInfo } = updatedUser; - const response: Omit = { ...userInfo, + whitelistedUsers: newWhitelistedUsernames, profilePic: `/api/avatar/${userInfo.id}?${Date.now()}`, }; diff --git a/lib/api/db.ts b/lib/api/db.ts index 49e21fa..01c4c7d 100644 --- a/lib/api/db.ts +++ b/lib/api/db.ts @@ -10,9 +10,10 @@ export const prisma = if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; -if (process.env.NODE_ENV !== "production") - prisma.$on("query" as any, (e: any) => { - console.log("Query: " + e.query); - console.log("Params: " + e.params); - console.log("\x1b[31m", `Duration: ${e.duration}ms`, "\x1b[0m"); // For benchmarking - }); +// For benchmarking | uncomment when needed +// if (process.env.NODE_ENV !== "production") +// prisma.$on("query" as any, (e: any) => { +// console.log("Query: " + e.query); +// console.log("Params: " + e.params); +// console.log("\x1b[31m", `Duration: ${e.duration}ms`, "\x1b[0m"); +// }); diff --git a/lib/api/storage/moveFile.ts b/lib/api/storage/moveFile.ts new file mode 100644 index 0000000..bbd8887 --- /dev/null +++ b/lib/api/storage/moveFile.ts @@ -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); + }); + } +} diff --git a/lib/api/storage/readFile.ts b/lib/api/storage/readFile.ts index 0cad83b..57dd950 100644 --- a/lib/api/storage/readFile.ts +++ b/lib/api/storage/readFile.ts @@ -9,12 +9,14 @@ import s3Client from "./s3Client"; import util from "util"; type ReturnContentTypes = - | "text/plain" + | "text/html" | "image/jpeg" | "image/png" | "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; if (s3Client) { @@ -28,6 +30,7 @@ export default async function readFile({ filePath }: { filePath: string }) { | { file: Buffer | string; contentType: ReturnContentTypes; + status: number; } | undefined; @@ -38,11 +41,12 @@ export default async function readFile({ filePath }: { filePath: string }) { try { await headObjectAsync(bucketParams); } catch (err) { - contentType = "text/plain"; + contentType = "text/html"; 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, + status: isRequestingAvatar ? 200 : 400, }; } @@ -60,14 +64,14 @@ export default async function readFile({ filePath }: { filePath: string }) { // if (filePath.endsWith(".jpg")) contentType = "image/jpeg"; } - returnObject = { file: data as Buffer, contentType }; + returnObject = { file: data as Buffer, contentType, status: 200 }; } return returnObject; } catch (err) { console.log("Error:", err); - contentType = "text/plain"; + contentType = "text/html"; return { file: "An internal occurred, please contact support.", contentType, @@ -77,13 +81,7 @@ export default async function readFile({ filePath }: { filePath: string }) { const storagePath = process.env.STORAGE_FOLDER || "data"; const creationPath = path.join(process.cwd(), storagePath + "/" + filePath); - const file = fs.existsSync(creationPath) - ? 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")) { + if (filePath.endsWith(".pdf")) { contentType = "application/pdf"; } else if (filePath.endsWith(".png")) { contentType = "image/png"; @@ -92,7 +90,16 @@ export default async function readFile({ filePath }: { filePath: string }) { 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))); }); }; + +const fileNotFoundTemplate = ` + + + + + File not found + + +

File not found

+

It is possible that the file you're looking for either doesn't exist or hasn't been created yet.

+

Some possible reasons are:

+
    +
  • You are trying to access a file too early, before it has been fully archived.
  • +
  • The file doesn't exist either because it encountered an error while being archived, or it simply doesn't exist.
  • +
+ + `; diff --git a/lib/client/avatarExists.ts b/lib/client/avatarExists.ts index 7490f3b..2e82fa6 100644 --- a/lib/client/avatarExists.ts +++ b/lib/client/avatarExists.ts @@ -1,4 +1,4 @@ export default async function avatarExists(fileUrl: string): Promise { const response = await fetch(fileUrl, { method: "HEAD" }); - return !(response.headers.get("content-type") === "text/plain"); + return !(response.headers.get("content-type") === "text/html"); } diff --git a/pages/api/archives/[...params].ts b/pages/api/archives/[...params].ts index 757f635..3e659a6 100644 --- a/pages/api/archives/[...params].ts +++ b/pages/api/archives/[...params].ts @@ -31,10 +31,10 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { .status(401) .json({ response: "You don't have access to this collection." }); - const { file, contentType } = await readFile({ - filePath: `archives/${collectionId}/${linkId}`, - }); - res.setHeader("Content-Type", contentType).status(200); + const { file, contentType, status } = await readFile( + `archives/${collectionId}/${linkId}` + ); + res.setHeader("Content-Type", contentType).status(status as number); return res.send(file); } diff --git a/pages/api/avatar/[id].ts b/pages/api/avatar/[id].ts index 73b155b..a006dbf 100644 --- a/pages/api/avatar/[id].ts +++ b/pages/api/avatar/[id].ts @@ -13,7 +13,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { if (!userId || !username) return res - .setHeader("Content-Type", "text/plain") + .setHeader("Content-Type", "text/html") .status(401) .send("You must be logged in."); else if (session?.user?.isSubscriber === false) @@ -24,7 +24,7 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { if (!queryId) return res - .setHeader("Content-Type", "text/plain") + .setHeader("Content-Type", "text/html") .status(401) .send("Invalid parameters."); @@ -33,23 +33,28 @@ export default async function Index(req: NextApiRequest, res: NextApiResponse) { where: { id: queryId, }, + include: { + whitelistedUsers: true, + }, }); - if ( - targetUser?.isPrivate && - !targetUser.whitelistedUsers.includes(username) - ) { + const whitelistedUsernames = targetUser?.whitelistedUsers.map( + (whitelistedUsername) => whitelistedUsername.username + ); + + if (targetUser?.isPrivate && !whitelistedUsernames?.includes(username)) { return res - .setHeader("Content-Type", "text/plain") + .setHeader("Content-Type", "text/html") .send("This profile is private."); } } - const { file, contentType } = await readFile({ - filePath: `uploads/avatar/${queryId}.jpg`, - }); + const { file, contentType, status } = await readFile( + `uploads/avatar/${queryId}.jpg` + ); - res.setHeader("Content-Type", contentType); - - return res.send(file); + return res + .setHeader("Content-Type", contentType) + .status(status as number) + .send(file); } diff --git a/pages/api/data/index.ts b/pages/api/data/index.ts new file mode 100644 index 0000000..c01245f --- /dev/null +++ b/pages/api/data/index.ts @@ -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 }); + } +} diff --git a/pages/confirmation.tsx b/pages/confirmation.tsx index 59f4e0b..e9025c0 100644 --- a/pages/confirmation.tsx +++ b/pages/confirmation.tsx @@ -15,7 +15,7 @@ export default function EmailConfirmaion() {

- If you didn't recieve anything, go to the{" "} + If you didn't receive anything, go to the{" "} Password Recovery {" "} diff --git a/pages/register.tsx b/pages/register.tsx index d02ddbc..d2236ab 100644 --- a/pages/register.tsx +++ b/pages/register.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { toast } from "react-hot-toast"; import SubmitButton from "@/components/SubmitButton"; import { signIn } from "next-auth/react"; -import Image from "next/image"; +import { useRouter } from "next/router"; import CenteredForm from "@/layouts/CenteredForm"; const emailEnabled = process.env.NEXT_PUBLIC_EMAIL_PROVIDER; @@ -18,6 +18,7 @@ type FormData = { export default function Register() { const [submitLoader, setSubmitLoader] = useState(false); + const router = useRouter(); const [form, setForm] = useState({ name: "", @@ -28,7 +29,7 @@ export default function Register() { }); async function registerUser() { - const checkHasEmptyFields = () => { + const checkFields = () => { if (emailEnabled) { return ( form.name !== "" && @@ -46,14 +47,7 @@ export default function Register() { } }; - const sendConfirmation = async () => { - await signIn("email", { - email: form.email, - callbackUrl: "/", - }); - }; - - if (checkHasEmptyFields()) { + if (checkFields()) { if (form.password !== form.passwordConfirmation) return toast.error("Passwords do not match."); else if (form.password.length < 8) @@ -78,7 +72,12 @@ export default function Register() { setSubmitLoader(false); 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!"); } else { diff --git a/prisma/migrations/20230804230549_created_whitelisted_users_table/migration.sql b/prisma/migrations/20230804230549_created_whitelisted_users_table/migration.sql new file mode 100644 index 0000000..c3bc00e --- /dev/null +++ b/prisma/migrations/20230804230549_created_whitelisted_users_table/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 43d8b90..eca457b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4,22 +4,22 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") + url = env("DATABASE_URL") } model Account { - id String @id @default(cuid()) - userId Int - type String - provider String - providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - session_state String? + id String @id @default(cuid()) + userId Int + type String + provider String + providerAccountId String + refresh_token String? + access_token String? + expires_at Int? + token_type String? + scope String? + id_token String? + session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -35,29 +35,37 @@ model Session { } model User { - id Int @id @default(autoincrement()) - name String + id Int @id @default(autoincrement()) + name String - username String? @unique + username String? @unique - email String? @unique - emailVerified DateTime? - image String? + email String? @unique + emailVerified DateTime? + image String? - accounts Account[] - sessions Session[] - - password String - collections Collection[] + accounts Account[] + sessions Session[] - tags Tag[] + password String + collections Collection[] - pinnedLinks Link[] - - collectionsJoined UsersAndCollections[] - isPrivate Boolean @default(false) - whitelistedUsers String[] @default([]) - createdAt DateTime @default(now()) + tags Tag[] + + pinnedLinks Link[] + + collectionsJoined UsersAndCollections[] + isPrivate Boolean @default(false) + whitelistedUsers WhitelistedUser[] + 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 { @@ -69,55 +77,55 @@ model VerificationToken { } model Collection { - id Int @id @default(autoincrement()) - name String - description String @default("") - color String @default("#0ea5e9") - isPublic Boolean @default(false) + id Int @id @default(autoincrement()) + name String + description String @default("") + color String @default("#0ea5e9") + isPublic Boolean @default(false) - - owner User @relation(fields: [ownerId], references: [id]) - ownerId Int - members UsersAndCollections[] - links Link[] - createdAt DateTime @default(now()) + owner User @relation(fields: [ownerId], references: [id]) + ownerId Int + members UsersAndCollections[] + links Link[] + createdAt DateTime @default(now()) @@unique([name, ownerId]) } model UsersAndCollections { - user User @relation(fields: [userId], references: [id]) - userId Int + user User @relation(fields: [userId], references: [id]) + userId Int - collection Collection @relation(fields: [collectionId], references: [id]) + collection Collection @relation(fields: [collectionId], references: [id]) collectionId Int - canCreate Boolean - canUpdate Boolean - canDelete Boolean + canCreate Boolean + canUpdate Boolean + canDelete Boolean @@id([userId, collectionId]) } model Link { - id Int @id @default(autoincrement()) - name String - url String - description String @default("") + id Int @id @default(autoincrement()) + name String + url String + description String @default("") - pinnedBy User[] + pinnedBy User[] - collection Collection @relation(fields: [collectionId], references: [id]) - collectionId Int - tags Tag[] - createdAt DateTime @default(now()) + collection Collection @relation(fields: [collectionId], references: [id]) + collectionId Int + tags Tag[] + + createdAt DateTime @default(now()) } model Tag { - id Int @id @default(autoincrement()) - name String - links Link[] - owner User @relation(fields: [ownerId], references: [id]) + id Int @id @default(autoincrement()) + name String + links Link[] + owner User @relation(fields: [ownerId], references: [id]) ownerId Int @@unique([name, ownerId]) diff --git a/types/enviornment.d.ts b/types/enviornment.d.ts index 047994c..1b2bb51 100644 --- a/types/enviornment.d.ts +++ b/types/enviornment.d.ts @@ -6,6 +6,7 @@ declare global { NEXTAUTH_URL: string; PAGINATION_TAKE_COUNT?: string; STORAGE_FOLDER?: string; + AUTOSCROLL_TIMEOUT?: string; SPACES_KEY?: string; SPACES_SECRET?: string; diff --git a/types/global.ts b/types/global.ts index 95b2640..c4adcb5 100644 --- a/types/global.ts +++ b/types/global.ts @@ -36,6 +36,7 @@ export interface CollectionIncludingMembersAndLinkCount export interface AccountSettings extends User { profilePic: string; newPassword?: string; + whitelistedUsers: string[]; } interface LinksIncludingTags extends Link { @@ -76,3 +77,11 @@ export type PublicLinkRequestQuery = { cursor?: number; collectionId: number; }; + +interface CollectionIncludingLinks extends Collection { + links: LinksIncludingTags[]; +} + +export interface Backup extends Omit { + collections: CollectionIncludingLinks[]; +}