diff --git a/.env.example b/.env.example index b1f36f0..472572e 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,13 @@ DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" +SALT="this is a very insecure salt, change it" + +MAIL_ENABLED=false +MAIL_SERVER="smtp.gmail.com" +MAIL_PORT=465 +MAIL_USE_SSL=true +MAIL_USERNAME="" +MAIL_PASSWORD="" +MAIL_FROM='"YABin" ' + +PUBLIC_REGISRATION_ENABLED=true +PUBLIC_URL="http://localhost:5173" diff --git a/.gitignore b/.gitignore index 6635cf5..e539ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* +.vercel \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e6304f3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,89 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make via issue, +email, or any other method with the owners of this repository before making a change. + +Please note we have a code of conduct, please follow it in all your interactions with the project. + +## Pull Request Process + +1. Ensure any install or build dependencies are removed before the end of the layer when doing a + build. +2. Update the README.md with details of changes to the interface, this includes new environment + variables, exposed ports, useful file locations and container parameters. +3. Ensure that the code is adhering to the existing code style. + +## Code of Conduct + +### Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +### Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +### Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +### Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at contact@sohamsen.me. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +### Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/README.md b/README.md index 1d45375..67344b1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Well, cause no pastebin I could find had ALL of the following features: - View raw pastes. Normally, encrypted pastebins do not have this. With this site, you can either get the Base64-encoded encrypted paste, or decrypt it on the server side (even with the password) and get the raw paste. - Keyboard shortcuts! - And of course, being fully open-source and easily self-hostable. + - **NEW** Ability to edit pastes after creation, and a dashboard for viewing all your pastes. - **Comes with a CLI tool to create and read pastes from the command line!** - **It can even be run on edge servers and in serverless environments!** @@ -48,7 +49,17 @@ See [cli/README.md](cli/README.md) for detailed instructions and library usage. **Requirements:** Node.js (tested on 18+, should work with 14+), and a SQL database (tested on PostgreSQL, should work with MySQL and SQLite). -Right now, it is using PostgreSQL (cause I had a server lying around). However, it can be run using any SQL DB such as SQLite or MySQL. To use other backends, please update the provider in [schema.prisma](src/lib/server/prisma/schema.prisma) +Right now, my instance is using PostgreSQL on Vercel. However, it can be run using any SQL DB such as SQLite or MySQL. To use other backends, please update the provider in [schema.prisma](src/lib/server/prisma/schema.prisma) + +### .env Configuration + +`DATABASE_URL` needs to point to a running SQL database. It uses PostgreSQL by default, but can be changed to MySQL or SQLite by modifying the provider in [schema.prisma](src/lib/server/prisma/schema.prisma). + +Remember to modify `SALT` to something secure if you plan on using user accounts. + +You can disable or enable public registration by modifying the `PUBLIC_REGISRATION_ENABLED` variable to `true` or `false`. + +By default, if no e-mail services are configured, all user accounts will be marked as validated. To enable e-mail validation, please configure the `MAIL_*` variables. #### Locally diff --git a/cli/poetry.lock b/cli/poetry.lock index 0d0b1b6..33ec7c5 100644 --- a/cli/poetry.lock +++ b/cli/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. [[package]] name = "black" @@ -49,13 +49,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2023.5.7" +version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] [[package]] @@ -290,13 +290,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "urllib3" -version = "2.0.3" +version = "2.0.6" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.7" files = [ - {file = "urllib3-2.0.3-py3-none-any.whl", hash = "sha256:48e7fafa40319d358848e1bc6809b208340fafe2096f1725d05d67443d0483d1"}, - {file = "urllib3-2.0.3.tar.gz", hash = "sha256:bee28b5e56addb8226c96f7f13ac28cb4c301dd5ea8a6ca179c0b9835e032825"}, + {file = "urllib3-2.0.6-py3-none-any.whl", hash = "sha256:7a7c7003b000adf9e7ca2a377c9688bbc54ed41b985789ed576570342a375cd2"}, + {file = "urllib3-2.0.6.tar.gz", hash = "sha256:b19e1a85d206b56d7df1d5e683df4a7725252a964e3993648dd0fb5a1c157564"}, ] [package.extras] diff --git a/package.json b/package.json index 0338300..3179e70 100644 --- a/package.json +++ b/package.json @@ -9,13 +9,16 @@ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --plugin-search-dir . --check . && eslint .", - "format": "prettier --plugin-search-dir . --write ." + "format": "prettier --plugin-search-dir . --write .", + "postinstall": "prisma generate --schema=./src/lib/server/prisma/schema.prisma" }, "devDependencies": { "@sveltejs/adapter-auto": "^2.0.0", "@sveltejs/adapter-node": "^1.2.4", + "@sveltejs/adapter-vercel": "^3.0.3", "@sveltejs/kit": "^1.5.0", "@types/node-cron": "^3.0.7", + "@types/nodemailer": "^6.4.11", "@types/prismjs": "^1.26.0", "@types/sanitize-html": "^2.9.0", "@typescript-eslint/eslint-plugin": "^5.45.0", @@ -24,7 +27,7 @@ "eslint": "^8.28.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-svelte": "^2.26.0", - "postcss": "^8.4.24", + "postcss": "^8.4.31", "prettier": "^2.8.0", "prettier-plugin-svelte": "^2.8.1", "prisma": "^4.15.0", @@ -40,7 +43,9 @@ "dependencies": { "@prisma/client": "^4.15.0", "base64-js": "^1.5.1", + "nanoid": "^5.0.1", "node-cron": "^3.0.2", + "nodemailer": "^6.9.5", "prism-themes": "^1.9.0", "prismjs": "^1.29.0", "sanitize-html": "^2.10.0" diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 40a59bb..031c3f1 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,6 +1,6 @@ import base64 from 'base64-js'; -export async function encrypt(plaintext: string) { +export async function encrypt(plaintext: string, keyStr: string | undefined = undefined) { const encoder = new TextEncoder(); const iv: Uint8Array = crypto.getRandomValues(new Uint8Array(12)); @@ -8,8 +8,13 @@ export async function encrypt(plaintext: string) { const alg = { name: 'AES-GCM', iv, length: 256 }; - const key = (await crypto.subtle.generateKey(alg, true, ['encrypt'])) as CryptoKey; - const keyStr = base64.fromByteArray(new Uint8Array(await crypto.subtle.exportKey('raw', key))); + let key: CryptoKey; + if (!keyStr) { + key = (await crypto.subtle.generateKey(alg, true, ['encrypt'])) as CryptoKey; + keyStr = base64.fromByteArray(new Uint8Array(await crypto.subtle.exportKey('raw', key))); + } else { + key = await crypto.subtle.importKey('raw', base64.toByteArray(keyStr), alg, false, ['encrypt']); + } const enc = await crypto.subtle.encrypt(alg, key, encoder.encode(plaintext)); const encStr = base64.fromByteArray(new Uint8Array(enc)); @@ -92,3 +97,10 @@ export async function decryptWithPassword(ciphertext: string, iv: string, passwo const dec = await crypto.subtle.decrypt(alg, key, base64.toByteArray(ciphertext)); return decoder.decode(dec); } + +export async function hashPassword(password: string, salt: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(password + salt); + const hash = await crypto.subtle.digest('SHA-512', data); + return base64.fromByteArray(new Uint8Array(hash)); +} diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts new file mode 100644 index 0000000..2eb893d --- /dev/null +++ b/src/lib/server/auth.ts @@ -0,0 +1,40 @@ +import { SALT } from '$env/static/private'; +import { hashPassword } from '$lib/crypto'; +import prisma from '@db'; +import type { Cookies } from '@sveltejs/kit'; + +export const getUserIdFromCookie = async (cookies: Cookies) => { + const token = cookies.get('token'); + if (!token) return null; + + const authToken = await prisma.authToken.findFirst({ + where: { token, expiresAt: { gt: new Date() } }, + include: { user: { select: { id: true, verified: true } } } + }); + if (!authToken) return null; + if (!authToken.user.verified) return null; + + return authToken.user.id; +}; + +export const generateVerificationHash = async (userId: string) => { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) throw new Error('User not found'); + + const hash = await hashPassword(`${user.email}${user.id}${user.password}${user.verified}`, SALT); + return hash; +}; + +export const validateVerificationHash = async (userId: string, hash: string) => { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return false; + + const newHash = await hashPassword( + `${user.email}${user.id}${user.password}${user.verified}`, + SALT + ); + if (newHash !== hash) return false; + + await prisma.user.update({ where: { id: userId }, data: { verified: true } }); + return true; +}; diff --git a/src/lib/server/email/base.ts b/src/lib/server/email/base.ts new file mode 100644 index 0000000..7d032ad --- /dev/null +++ b/src/lib/server/email/base.ts @@ -0,0 +1,39 @@ +import nodemailer from 'nodemailer'; +import { + MAIL_ENABLED, + MAIL_SERVER, + MAIL_PASSWORD, + MAIL_PORT, + MAIL_USERNAME, + MAIL_USE_SSL, + MAIL_FROM +} from '$env/static/private'; + +export async function sendEmail(to: string, subject: string, content: string) { + if (MAIL_ENABLED !== 'true') { + return false; + } + + const transporter = nodemailer.createTransport({ + host: MAIL_SERVER, + port: Number(MAIL_PORT), + secure: MAIL_USE_SSL === 'true', + auth: { + user: MAIL_USERNAME, + pass: MAIL_PASSWORD + } + }); + + const info = await transporter.sendMail({ + from: MAIL_FROM, + to, + subject, + text: content + }); + + if (info.accepted.length === 0) { + return false; + } + + return true; +} diff --git a/src/lib/server/email/verify.ts b/src/lib/server/email/verify.ts new file mode 100644 index 0000000..44a5a7a --- /dev/null +++ b/src/lib/server/email/verify.ts @@ -0,0 +1,23 @@ +import { PUBLIC_URL } from '$env/static/public'; +import prisma from '@db'; +import { generateVerificationHash } from '../auth'; +import { sendEmail } from './base'; + +export const sendVerificationEmail = async (userId: string) => { + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user) return false; + + const hash = await generateVerificationHash(userId); + + const verifyUrl = `${PUBLIC_URL}/validate?hash=${encodeURIComponent( + hash + )}&userId=${encodeURIComponent(userId)}`; + + const content = `To verify your email, please click the following link: ${verifyUrl}`; + const subject = 'YABin: Verify your email'; + + const sent = await sendEmail(user.email, subject, content); + if (!sent) return false; + + return true; +}; diff --git a/src/lib/server/prisma/migrations/20231008132503_add_user/migration.sql b/src/lib/server/prisma/migrations/20231008132503_add_user/migration.sql new file mode 100644 index 0000000..39c3a75 --- /dev/null +++ b/src/lib/server/prisma/migrations/20231008132503_add_user/migration.sql @@ -0,0 +1,22 @@ +-- AlterTable +ALTER TABLE "Paste" ADD COLUMN "ownerId" BIGINT; + +-- CreateTable +CREATE TABLE "User" ( + "id" BIGSERIAL NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- AddForeignKey +ALTER TABLE "Paste" ADD CONSTRAINT "Paste_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/lib/server/prisma/migrations/20231008143438_add_authtoken/migration.sql b/src/lib/server/prisma/migrations/20231008143438_add_authtoken/migration.sql new file mode 100644 index 0000000..14e0f9b --- /dev/null +++ b/src/lib/server/prisma/migrations/20231008143438_add_authtoken/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "AuthToken" ( + "id" BIGSERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + "token" TEXT NOT NULL, + "userId" BIGINT NOT NULL, + + CONSTRAINT "AuthToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthToken_token_key" ON "AuthToken"("token"); + +-- AddForeignKey +ALTER TABLE "AuthToken" ADD CONSTRAINT "AuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/lib/server/prisma/migrations/20231008151119_set_ondelete/migration.sql b/src/lib/server/prisma/migrations/20231008151119_set_ondelete/migration.sql new file mode 100644 index 0000000..ef4968f --- /dev/null +++ b/src/lib/server/prisma/migrations/20231008151119_set_ondelete/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "AuthToken" DROP CONSTRAINT "AuthToken_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "AuthToken" ADD CONSTRAINT "AuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/lib/server/prisma/migrations/20231008193745_use_nanoid/migration.sql b/src/lib/server/prisma/migrations/20231008193745_use_nanoid/migration.sql new file mode 100644 index 0000000..e605b83 --- /dev/null +++ b/src/lib/server/prisma/migrations/20231008193745_use_nanoid/migration.sql @@ -0,0 +1,36 @@ +/* + Warnings: + + - The primary key for the `AuthToken` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint. + +*/ +-- DropForeignKey +ALTER TABLE "AuthToken" DROP CONSTRAINT "AuthToken_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Paste" DROP CONSTRAINT "Paste_ownerId_fkey"; + +-- AlterTable +ALTER TABLE "AuthToken" DROP CONSTRAINT "AuthToken_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "userId" SET DATA TYPE TEXT, +ADD CONSTRAINT "AuthToken_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "AuthToken_id_seq"; + +-- AlterTable +ALTER TABLE "Paste" ALTER COLUMN "ownerId" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "User" DROP CONSTRAINT "User_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "User_id_seq"; + +-- AddForeignKey +ALTER TABLE "AuthToken" ADD CONSTRAINT "AuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Paste" ADD CONSTRAINT "Paste_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/lib/server/prisma/migrations/20231008201146_add_verified_to_user/migration.sql b/src/lib/server/prisma/migrations/20231008201146_add_verified_to_user/migration.sql new file mode 100644 index 0000000..b6012fc --- /dev/null +++ b/src/lib/server/prisma/migrations/20231008201146_add_verified_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "verified" BOOLEAN NOT NULL DEFAULT false; diff --git a/src/lib/server/prisma/schema.prisma b/src/lib/server/prisma/schema.prisma index eeaba9b..9d9e6f8 100644 --- a/src/lib/server/prisma/schema.prisma +++ b/src/lib/server/prisma/schema.prisma @@ -10,6 +10,26 @@ datasource db { url = env("DATABASE_URL") } +model User { + id String @id @default(nanoid(16)) + username String @unique + email String @unique + password String + name String + verified Boolean @default(false) + pastes Paste[] + AuthToken AuthToken[] +} + +model AuthToken { + id String @id @default(nanoid(16)) + createdAt DateTime @default(now()) + expiresAt DateTime + token String @unique + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + model Paste { id BigInt @id @default(autoincrement()) createdAt DateTime @default(now()) @@ -22,6 +42,8 @@ model Paste { expiresAt DateTime? expiresCount Int? readCount Int @default(0) + ownerId String? + owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull) @@index([key]) } diff --git a/src/lib/server/services.ts b/src/lib/server/services.ts index 816c00c..883ff2c 100644 --- a/src/lib/server/services.ts +++ b/src/lib/server/services.ts @@ -11,7 +11,8 @@ export async function getPaste(key: string) { initVector: true, language: true, expiresCount: true, - readCount: true + readCount: true, + ownerId: true } }); @@ -28,9 +29,9 @@ export async function getPaste(key: string) { throw error(404, 'Not found'); } - const { content, encrypted, passwordProtected, initVector, language } = data; + const { content, encrypted, passwordProtected, initVector, language, ownerId } = data; - return { key, content, encrypted, passwordProtected, initVector, language }; + return { key, content, encrypted, passwordProtected, initVector, language, ownerId }; } export async function deleteExpiredPastes() { diff --git a/src/lib/types.ts b/src/lib/types.ts index 0bcd6ed..8040292 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,13 @@ export interface Paste { initVector?: string; } +export interface PastePatch { + key: string; + content: string; + encrypted?: boolean; + initVector?: string; +} + export interface PasteCreateResponse { success: boolean; data?: { @@ -22,3 +29,11 @@ export interface PasteCreateResponse { code: number; }; } + +export interface PastePatchResponse { + success: boolean; + data?: { + key: string; + }; + error?: string; +} diff --git a/src/routes/(auth)/+layout.svelte b/src/routes/(auth)/+layout.svelte new file mode 100644 index 0000000..32adb0c --- /dev/null +++ b/src/routes/(auth)/+layout.svelte @@ -0,0 +1,50 @@ + + +
+
+
+

YABin

+ + + + +
+
+ + +
diff --git a/src/routes/(auth)/login/+page.server.ts b/src/routes/(auth)/login/+page.server.ts new file mode 100644 index 0000000..4218826 --- /dev/null +++ b/src/routes/(auth)/login/+page.server.ts @@ -0,0 +1,58 @@ +import type { Actions } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import prisma from '@db'; +import { hashPassword } from '$lib/crypto'; +import { nanoid } from 'nanoid'; +import { SALT } from '$env/static/private'; + +export const actions: Actions = { + default: async ({ cookies, request }) => { + const data = await request.formData(); + + const usernameOrEmail = data.get('username-email'); + const password = data.get('password'); + + if (!usernameOrEmail || !password) { + return fail(400, { success: false, errors: ['All fields are required'] }); + } + + const hashedPassword = await hashPassword(password.toString(), SALT); + const user = await prisma.user.findFirst({ + where: { + OR: [ + { username: usernameOrEmail.toString(), password: hashedPassword }, + { email: usernameOrEmail.toString(), password: hashedPassword } + ] + } + }); + + if (!user) { + return fail(400, { success: false, errors: ['Invalid username or password'] }); + } + + if (!user.verified) { + return fail(401, { success: false, errors: ['Account not verified'] }); + } + + await prisma.authToken.deleteMany({ where: { expiresAt: { lte: new Date() } } }); + + const authToken = await prisma.authToken.create({ + data: { + user: { connect: { id: user.id } }, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days + token: nanoid(32) + } + }); + + cookies.set('token', authToken.token, { + path: '/', + maxAge: 60 * 60 * 24 * 30, // 30 days + secure: true, + httpOnly: true, + sameSite: 'strict' + }); + + throw redirect(303, '/'); + } +}; diff --git a/src/routes/(auth)/login/+page.svelte b/src/routes/(auth)/login/+page.svelte new file mode 100644 index 0000000..9c36273 --- /dev/null +++ b/src/routes/(auth)/login/+page.svelte @@ -0,0 +1,48 @@ + + +
+

User Login

+
+ {#if form?.errors} +
    + {#each form.errors as error} +
  • {error}
  • + {/each} +
+ {/if} + {#if form?.success} +
Success, redirecting...
+ {/if} + +
+
+ + +
+
+ + +
+ +
+ + Don't have an account? Register. + + + +
+ +
+ Forgot password? + Click here. +
+
+
+
diff --git a/src/routes/(auth)/logout/+page.server.ts b/src/routes/(auth)/logout/+page.server.ts new file mode 100644 index 0000000..638c546 --- /dev/null +++ b/src/routes/(auth)/logout/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect, type Actions, type RequestHandler } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies }) => { + cookies.delete('token'); + throw redirect(303, '/'); +}; + +export const actions: Actions = { + default({ cookies }) { + cookies.delete('token'); + throw redirect(303, '/'); + } +}; diff --git a/src/routes/(auth)/register/+page.server.ts b/src/routes/(auth)/register/+page.server.ts new file mode 100644 index 0000000..1c8b655 --- /dev/null +++ b/src/routes/(auth)/register/+page.server.ts @@ -0,0 +1,113 @@ +import type { Actions } from './$types'; +import { fail, redirect } from '@sveltejs/kit'; +import prisma from '@db'; +import { hashPassword } from '$lib/crypto'; +import { nanoid } from 'nanoid'; +import { MAIL_ENABLED, SALT } from '$env/static/private'; +import { PUBLIC_REGISRATION_ENABLED } from '$env/static/public'; +import { sendVerificationEmail } from '$lib/server/email/verify'; + +const emailRegex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/g; + +export const actions: Actions = { + default: async ({ cookies, request }) => { + if (PUBLIC_REGISRATION_ENABLED !== 'true') { + return fail(404, { success: false, errors: ['Not found'] }); + } + + const data = await request.formData(); + + const name = data.get('name'); + const username = data.get('username'); + const email = data.get('email'); + const password = data.get('password'); + const cnfPassword = data.get('confirm-password'); + + const errors: string[] = []; + + if (!name || !username || !email || !password || !cnfPassword) { + errors.push('All fields are required'); + } + + if (email && !emailRegex.test(email?.toString())) { + errors.push('Invalid email address'); + } + + if (password && password.toString().length < 8) { + errors.push('Password must be at least 8 characters long'); + } + + if (password && password !== cnfPassword) { + errors.push('Passwords do not match'); + } + + if (name && name.toString().length > 50) { + errors.push('Name is too long'); + } + + if (username && username.toString().length > 50) { + errors.push('Username is too long'); + } + + if (username && email) { + const existingCount = await prisma.user.count({ + where: { + OR: [{ username: username.toString() }, { email: email.toString() }] + } + }); + if (existingCount > 0) { + errors.push('Username or email already exists'); + } + } + + if (errors.length > 0) { + return fail(400, { success: false, errors }); + } + + if (name && username && email && password) { + const user = await prisma.user.create({ + data: { + name: name.toString(), + username: username.toString(), + email: email.toString(), + password: await hashPassword(password.toString(), SALT), + verified: false + } + }); + + if (MAIL_ENABLED === 'true') { + const sentVerificationEmail = await sendVerificationEmail(user.id); + if (sentVerificationEmail) { + return { success: true, message: 'Please check your e-mail for verification link' }; + } + } + + await prisma.user.update({ where: { id: user.id }, data: { verified: true } }); + + const authToken = await prisma.authToken.create({ + data: { + user: { + connect: { + id: user.id + } + }, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days + token: nanoid(32) + } + }); + + cookies.set('token', authToken.token, { + path: '/', + maxAge: 60 * 60 * 24 * 30, // 30 days + secure: true, + httpOnly: true, + sameSite: 'strict' + }); + + throw redirect(303, '/'); + } + + return { success: false, errors: ['Unknown error'] }; + } +}; diff --git a/src/routes/(auth)/register/+page.svelte b/src/routes/(auth)/register/+page.svelte new file mode 100644 index 0000000..ea8a784 --- /dev/null +++ b/src/routes/(auth)/register/+page.svelte @@ -0,0 +1,83 @@ + + +
+

New User Registration

+
+ {#if form?.errors} +
    + {#each form.errors as error} +
  • {error}
  • + {/each} +
+ {/if} + {#if form?.success} +
{form.message}
+ {/if} + + {#if PUBLIC_REGISRATION_ENABLED == 'true'} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + Already have an account? Login. + + + +
+
+ {:else} +

Registration has been disabled.

+ {/if} +
+
diff --git a/src/routes/(auth)/validate/+page.server.ts b/src/routes/(auth)/validate/+page.server.ts new file mode 100644 index 0000000..396cceb --- /dev/null +++ b/src/routes/(auth)/validate/+page.server.ts @@ -0,0 +1,20 @@ +import { redirect, error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; +import { validateVerificationHash } from '$lib/server/auth'; + +export const load: PageServerLoad = async ({ url }) => { + const userId = url.searchParams.get('userId'); + const hash = url.searchParams.get('hash'); + + if (!userId || !hash) { + throw error(404, 'Not found'); + } + + const isValid = await validateVerificationHash(decodeURIComponent(userId), decodeURIComponent(hash)); + + if (!isValid) { + throw error(404, 'Not found'); + } + + throw redirect(303, '/login'); +}; diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts new file mode 100644 index 0000000..b5527f0 --- /dev/null +++ b/src/routes/+page.server.ts @@ -0,0 +1,6 @@ +import { getUserIdFromCookie } from '$lib/server/auth'; + +export async function load({ cookies }) { + const userId = await getUserIdFromCookie(cookies); + return { loggedIn: !!userId }; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 29d6c5f..9187fca 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,6 +6,10 @@ import Select from 'svelte-select'; import { encrypt, encryptWithPassword } from '$lib/crypto'; import Hamburger from '$lib/components/Hamburger.svelte'; + import { PUBLIC_REGISRATION_ENABLED } from '$env/static/public'; + import type { PageData } from './$types'; + + export let data: PageData; const initialConfig: PasteConfig = { language: 'plaintext', @@ -209,7 +213,7 @@ Save -
+
@@ -222,6 +226,19 @@
+ {#if PUBLIC_REGISRATION_ENABLED == 'true'} +
+ {#if data.loggedIn} +
+ +
+ {:else} + Login + Register + {/if} +
+ {/if} + + +
+ {:else} +