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=
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=
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
/data
.idea
prisma/dev.db
# tests
/tests
/test-results/
/playwright-report/
/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'>
[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>
@ -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

View File

@ -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({
)}
</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
onClick={submit}
loading={submitLoader}

View File

@ -66,7 +66,9 @@ export default function Sidebar({ className }: { className?: string }) {
icon={faChartSimple}
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
@ -81,9 +83,9 @@ export default function Sidebar({ className }: { className?: string }) {
icon={faLink}
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>
<p className="text-sky-700 dark:text-sky-200 text-xs xl:text-sm font-semibold">Links</p>
</Link>
<Link
@ -96,8 +98,9 @@ export default function Sidebar({ className }: { className?: string }) {
icon={faFolder}
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>
</Link>
</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();
useEffect(() => {
if (status === "authenticated" && data.user.isSubscriber) {
if (
status === "authenticated" &&
(!process.env.NEXT_PUBLIC_STRIPE_IS_ACTIVE || data.user.isSubscriber)
) {
setCollections();
setTags();
// setLinks();

View File

@ -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<void>((_, reject) => {
setTimeout(() => {
reject(new Error("Auto scroll took too long (more than 20 seconds)."));
}, 20000);
});
const scrollingPromise = new Promise<void>((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<void>((_, reject) => {
setTimeout(() => {
reject(
new Error(
`Auto scroll took too long (more than ${AUTOSCROLL_TIMEOUT} seconds).`
)
);
}, AUTOSCROLL_TIMEOUT * 1000);
});
const scrollingPromise = new Promise<void>((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]);
};

View File

@ -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: {

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));
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: {

View File

@ -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 };
}

View File

@ -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 };
}
}

View File

@ -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,

View File

@ -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<AccountSettings, "password"> = {
...userInfo,
whitelistedUsers: newWhitelistedUsernames,
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")
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");
// });

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";
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 = `<!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> {
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)
.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);
}

View File

@ -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);
}

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" />
<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">
Password Recovery
</Link>{" "}

View File

@ -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<FormData>({
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 {

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

@ -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])

View File

@ -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;

View File

@ -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<User, "password" | "id" | "image"> {
collections: CollectionIncludingLinks[];
}