diff --git a/next.config.js b/next.config.js
index 6097f79..ac46282 100644
--- a/next.config.js
+++ b/next.config.js
@@ -17,8 +17,8 @@ module.exports = {
'getsharex.com',
// For flameshot icon, and maybe in the future other stuff from github
'raw.githubusercontent.com',
- // Discord Icon
- 'assets-global.website-files.com',
+ // Google Icon
+ 'madeby.google.com',
],
},
poweredByHeader: false,
diff --git a/package.json b/package.json
index c1d9da7..c43718b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "zipline",
- "version": "3.6.0-rc3",
+ "version": "3.6.0-rc4",
"license": "MIT",
"scripts": {
"dev": "npm-run-all build:server dev:run",
diff --git a/prisma/migrations/20221030222208_oauth_reform/migration.sql b/prisma/migrations/20221030222208_oauth_reform/migration.sql
new file mode 100644
index 0000000..5761ff6
--- /dev/null
+++ b/prisma/migrations/20221030222208_oauth_reform/migration.sql
@@ -0,0 +1,31 @@
+/*
+ Warnings:
+
+ - You are about to drop the column `oauth` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the column `oauthAccessToken` on the `User` table. All the data in the column will be lost.
+ - You are about to drop the column `oauthProvider` on the `User` table. All the data in the column will be lost.
+
+*/
+-- CreateEnum
+CREATE TYPE "OauthProviders" AS ENUM ('DISCORD', 'GITHUB');
+
+-- AlterTable
+ALTER TABLE "User" DROP COLUMN "oauth",
+DROP COLUMN "oauthAccessToken",
+DROP COLUMN "oauthProvider";
+
+-- CreateTable
+CREATE TABLE "OAuth" (
+ "id" SERIAL NOT NULL,
+ "provider" "OauthProviders" NOT NULL,
+ "userId" INTEGER NOT NULL,
+ "token" TEXT NOT NULL,
+
+ CONSTRAINT "OAuth_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "OAuth_provider_key" ON "OAuth"("provider");
+
+-- AddForeignKey
+ALTER TABLE "OAuth" ADD CONSTRAINT "OAuth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/20221030224830_oauth_fix_refresh/migration.sql b/prisma/migrations/20221030224830_oauth_fix_refresh/migration.sql
new file mode 100644
index 0000000..f2f720c
--- /dev/null
+++ b/prisma/migrations/20221030224830_oauth_fix_refresh/migration.sql
@@ -0,0 +1,5 @@
+-- DropIndex
+DROP INDEX "OAuth_provider_key";
+
+-- AlterTable
+ALTER TABLE "OAuth" ADD COLUMN "refresh" TEXT;
diff --git a/prisma/migrations/20221030233448_fix_images/migration.sql b/prisma/migrations/20221030233448_fix_images/migration.sql
new file mode 100644
index 0000000..28aec5d
--- /dev/null
+++ b/prisma/migrations/20221030233448_fix_images/migration.sql
@@ -0,0 +1,8 @@
+-- DropForeignKey
+ALTER TABLE "Image" DROP CONSTRAINT "Image_userId_fkey";
+
+-- AlterTable
+ALTER TABLE "Image" ALTER COLUMN "userId" DROP NOT NULL;
+
+-- AddForeignKey
+ALTER TABLE "Image" ADD CONSTRAINT "Image_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
diff --git a/prisma/migrations/20221031011932_oauth_add_google/migration.sql b/prisma/migrations/20221031011932_oauth_add_google/migration.sql
new file mode 100644
index 0000000..c72dfcc
--- /dev/null
+++ b/prisma/migrations/20221031011932_oauth_add_google/migration.sql
@@ -0,0 +1,2 @@
+-- AlterEnum
+ALTER TYPE "OauthProviders" ADD VALUE 'GOOGLE';
diff --git a/prisma/migrations/20221031041758_oauth_username/migration.sql b/prisma/migrations/20221031041758_oauth_username/migration.sql
new file mode 100644
index 0000000..ce8b47d
--- /dev/null
+++ b/prisma/migrations/20221031041758_oauth_username/migration.sql
@@ -0,0 +1,8 @@
+/*
+ Warnings:
+
+ - Added the required column `username` to the `OAuth` table without a default value. This is not possible if the table is not empty.
+
+*/
+-- AlterTable
+ALTER TABLE "OAuth" ADD COLUMN "username" TEXT NOT NULL;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 1b180ea..7af8590 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -8,25 +8,23 @@ generator client {
}
model User {
- id Int @id @default(autoincrement())
- username String
- password String?
- oauth Boolean @default(false)
- oauthProvider String?
- oauthAccessToken String?
- avatar String?
- token String
- administrator Boolean @default(false)
- superAdmin Boolean @default(false)
- systemTheme String @default("system")
- embedTitle String?
- embedColor String @default("#2f3136")
- embedSiteName String? @default("{image.file} • {user.name}")
- ratelimit DateTime?
- domains String[]
- images Image[]
- urls Url[]
- Invite Invite[]
+ id Int @id @default(autoincrement())
+ username String
+ password String?
+ avatar String?
+ token String
+ administrator Boolean @default(false)
+ superAdmin Boolean @default(false)
+ systemTheme String @default("system")
+ embedTitle String?
+ embedColor String @default("#2f3136")
+ embedSiteName String? @default("{image.file} • {user.name}")
+ ratelimit DateTime?
+ domains String[]
+ oauth OAuth[]
+ images Image[]
+ urls Url[]
+ Invite Invite[]
}
enum ImageFormat {
@@ -49,8 +47,8 @@ model Image {
password String?
invisible InvisibleImage?
format ImageFormat @default(RANDOM)
- user User @relation(fields: [userId], references: [id])
- userId Int
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
+ userId Int?
}
model InvisibleImage {
@@ -86,12 +84,27 @@ model Stats {
}
model Invite {
- id Int @id @default(autoincrement())
- code String @unique
- created_at DateTime @default(now())
- expires_at DateTime?
- used Boolean @default(false)
-
- createdBy User @relation(fields: [createdById], references: [id])
+ id Int @id @default(autoincrement())
+ code String @unique
+ created_at DateTime @default(now())
+ expires_at DateTime?
+ used Boolean @default(false)
+ createdBy User @relation(fields: [createdById], references: [id])
createdById Int
}
+
+model OAuth {
+ id Int @id @default(autoincrement())
+ provider OauthProviders
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ userId Int
+ username String
+ token String
+ refresh String?
+}
+
+enum OauthProviders {
+ DISCORD
+ GITHUB
+ GOOGLE
+}
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx
index 8ee1978..788c205 100644
--- a/src/components/Layout.tsx
+++ b/src/components/Layout.tsx
@@ -29,6 +29,7 @@ import { showNotification } from '@mantine/notifications';
import useFetch from 'hooks/useFetch';
import { useVersion } from 'lib/queries/version';
import { userSelector } from 'lib/recoil/user';
+import { capitalize } from 'lib/utils/client';
import { useRecoilState } from 'recoil';
import Link from 'next/link';
import { useRouter } from 'next/router';
@@ -52,6 +53,7 @@ import {
UserIcon,
DiscordIcon,
GitHubIcon,
+ GoogleIcon,
} from './icons';
import { friendlyThemeName, themes } from './Theming';
@@ -161,7 +163,18 @@ const admin_items = [
export default function Layout({ children, props }) {
const [user, setUser] = useRecoilState(userSelector);
- const { title } = props;
+ const { title, oauth_providers: unparsed } = props;
+ const oauth_providers = JSON.parse(unparsed);
+ const icons = {
+ GitHub: GitHubIcon,
+ Discord: DiscordIcon,
+ Google: GoogleIcon,
+ };
+
+ for (const provider of oauth_providers) {
+ provider.Icon = icons[provider.name];
+ }
+
const external_links = JSON.parse(props.external_links ?? '[]');
const [token, setToken] = useState(user?.token);
@@ -393,24 +406,34 @@ export default function Layout({ children, props }) {
Logout
- {user.oauth ? (
- <>
-
- ) : (
-
- )
- }
- >
- Logged in with{' '}
- {user.oauthProvider}
-
-
+ <>
+ {oauth_providers
+ .filter((x) =>
+ user.oauth
+ ?.map(({ provider }) => provider.toLowerCase())
+ .includes(x.name.toLowerCase())
+ )
+ .map(({ name, Icon }, i) => (
+ <>
+ }
+ >
+ Logged in with {capitalize(name)}
+
+ >
+ ))}
+ {oauth_providers.filter((x) =>
+ user.oauth
+ ?.map(({ provider }) => provider.toLowerCase())
+ .includes(x.name.toLowerCase())
+ ).length ? (
- >
- ) : null}
+ ) : null}
+ >
}>
+
);
}
diff --git a/src/components/icons/GitHubIcon.tsx b/src/components/icons/GitHubIcon.tsx
index c0bcc65..3bf9f4d 100644
--- a/src/components/icons/GitHubIcon.tsx
+++ b/src/components/icons/GitHubIcon.tsx
@@ -1,5 +1,17 @@
import { GitHub } from 'react-feather';
+import Image from 'next/image';
-export default function GitHubIcon({ ...props }) {
- return ;
+// https://upload.wikimedia.org/wikipedia/commons/9/91/Octicons-mark-github.svg
+export default function GitHubIcon({ colorScheme, ...props }) {
+ return (
+
+ );
}
diff --git a/src/components/icons/GoogleIcon.tsx b/src/components/icons/GoogleIcon.tsx
new file mode 100644
index 0000000..3cccb03
--- /dev/null
+++ b/src/components/icons/GoogleIcon.tsx
@@ -0,0 +1,15 @@
+// https://developers.google.com/identity/branding-guidelines
+
+import Image from 'next/image';
+
+export default function GoogleIcon({ ...props }) {
+ return (
+
+ );
+}
diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx
index ac0db47..491e6c1 100644
--- a/src/components/icons/index.tsx
+++ b/src/components/icons/index.tsx
@@ -29,6 +29,7 @@ import DownloadIcon from './DownloadIcon';
import FlameshotIcon from './FlameshotIcon';
import GitHubIcon from './GitHubIcon';
import DiscordIcon from './DiscordIcon';
+import GoogleIcon from './GoogleIcon';
import EyeIcon from './EyeIcon';
import RefreshIcon from './RefreshIcon';
@@ -64,6 +65,7 @@ export {
FlameshotIcon,
GitHubIcon,
DiscordIcon,
+ GoogleIcon,
EyeIcon,
RefreshIcon,
};
diff --git a/src/components/pages/Manage/index.tsx b/src/components/pages/Manage/index.tsx
index 46418ae..70a92fe 100644
--- a/src/components/pages/Manage/index.tsx
+++ b/src/components/pages/Manage/index.tsx
@@ -25,6 +25,7 @@ import {
DiscordIcon,
FlameshotIcon,
GitHubIcon,
+ GoogleIcon,
RefreshIcon,
SettingsIcon,
ShareXIcon,
@@ -37,6 +38,7 @@ import { SmallTable } from 'components/SmallTable';
import useFetch from 'hooks/useFetch';
import { userSelector } from 'lib/recoil/user';
import { bytesToHuman } from 'lib/utils/bytes';
+import { capitalize } from 'lib/utils/client';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import Flameshot from './Flameshot';
@@ -57,8 +59,9 @@ function ExportDataTooltip({ children }) {
export default function Manage({ oauth_registration, oauth_providers: raw_oauth_providers }) {
const oauth_providers = JSON.parse(raw_oauth_providers);
const icons = {
- GitHub: GitHubIcon,
Discord: DiscordIcon,
+ GitHub: GitHubIcon,
+ Google: GoogleIcon,
};
for (const provider of oauth_providers) {
@@ -233,7 +236,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
setExports(
res.exports
- .map((s) => ({
+ ?.map((s) => ({
date: new Date(Number(s.name.split('_')[3].slice(0, -4))),
size: s.size,
full: s.name,
@@ -303,8 +306,10 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
}
};
- const handleOauthUnlink = async () => {
- const res = await useFetch('/api/auth/oauth', 'DELETE');
+ const handleOauthUnlink = async (provider) => {
+ const res = await useFetch('/api/auth/oauth', 'DELETE', {
+ provider,
+ });
if (res.error) {
showNotification({
title: 'Error while unlinking from OAuth',
@@ -315,7 +320,7 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
} else {
setUser(res);
showNotification({
- title: 'Unlinked from OAuth',
+ title: `Unlinked from ${provider[0] + provider.slice(1).toLowerCase()}`,
message: '',
color: 'green',
icon: ,
@@ -374,19 +379,30 @@ export default function Manage({ oauth_registration, oauth_providers: raw_oauth_
{oauth_providers
- .filter((x) => x.name.toLowerCase() !== user.oauthProvider)
+ .filter(
+ (x) =>
+ !user.oauth?.map(({ provider }) => provider.toLowerCase()).includes(x.name.toLowerCase())
+ )
.map(({ link_url, name, Icon }, i) => (
- } component='a' my='sm'>
+ } component='a' my='sm'>
Link account with {name}
))}
- {user.oauth && user.oauthProvider && (
- } my='sm' color='red'>
- Unlink account with {user.oauthProvider[0].toUpperCase() + user.oauthProvider.slice(1)}
+
+ {user?.oauth?.map(({ provider }, i) => (
+
- )}
+ ))}
)}
diff --git a/src/lib/config/Config.ts b/src/lib/config/Config.ts
index ba46de4..d9357e3 100644
--- a/src/lib/config/Config.ts
+++ b/src/lib/config/Config.ts
@@ -111,6 +111,9 @@ export interface ConfigOAuth {
discord_client_id?: string;
discord_client_secret?: string;
+
+ google_client_id?: string;
+ google_client_secret?: string;
}
export interface ConfigChunks {
diff --git a/src/lib/config/readConfig.ts b/src/lib/config/readConfig.ts
index 397587d..afb4981 100644
--- a/src/lib/config/readConfig.ts
+++ b/src/lib/config/readConfig.ts
@@ -128,6 +128,9 @@ export default function readConfig() {
map('OAUTH_DISCORD_CLIENT_ID', 'string', 'oauth.discord_client_id'),
map('OAUTH_DISCORD_CLIENT_SECRET', 'string', 'oauth.discord_client_secret'),
+ map('OAUTH_GOOGLE_CLIENT_ID', 'string', 'oauth.google_client_id'),
+ map('OAUTH_GOOGLE_CLIENT_SECRET', 'string', 'oauth.google_client_secret'),
+
map('FEATURES_INVITES', 'boolean', 'features.invites'),
map('FEATURES_OAUTH_REGISTRATION', 'boolean', 'features.oauth_registration'),
map('FEATURES_USER_REGISTRATION', 'boolean', 'features.user_registration'),
diff --git a/src/lib/config/validateConfig.ts b/src/lib/config/validateConfig.ts
index dd66f5c..59e91f5 100644
--- a/src/lib/config/validateConfig.ts
+++ b/src/lib/config/validateConfig.ts
@@ -160,6 +160,9 @@ const validator = s.object({
discord_client_id: s.string.nullable.default(null),
discord_client_secret: s.string.nullable.default(null),
+
+ google_client_id: s.string.nullable.default(null),
+ google_client_secret: s.string.nullable.default(null),
})
.nullish.default(null),
features: s
diff --git a/src/lib/middleware/getServerSideProps.ts b/src/lib/middleware/getServerSideProps.ts
index 42052c2..6fda0a1 100644
--- a/src/lib/middleware/getServerSideProps.ts
+++ b/src/lib/middleware/getServerSideProps.ts
@@ -6,6 +6,7 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
// this entire thing will also probably change before the stable release
const ghEnabled = notNull(config.oauth?.github_client_id, config.oauth?.github_client_secret);
const discEnabled = notNull(config.oauth?.discord_client_id, config.oauth?.discord_client_secret);
+ const googleEnabled = notNull(config.oauth?.google_client_id, config.oauth?.google_client_secret);
const oauth_providers = [];
@@ -22,6 +23,13 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
link_url: '/api/auth/oauth/discord?state=link',
});
+ if (googleEnabled)
+ oauth_providers.push({
+ name: 'Google',
+ url: '/api/auth/oauth/google',
+ link_url: '/api/auth/oauth/google?state=link',
+ });
+
return {
props: {
title: config.website.title,
diff --git a/src/lib/middleware/withOAuth.ts b/src/lib/middleware/withOAuth.ts
new file mode 100644
index 0000000..4d3942d
--- /dev/null
+++ b/src/lib/middleware/withOAuth.ts
@@ -0,0 +1,143 @@
+import { createToken } from 'lib/util';
+import Logger from 'lib/logger';
+import { NextApiReq, NextApiRes } from './withZipline';
+import prisma from 'lib/prisma';
+import { OauthProviders } from '@prisma/client';
+
+export interface OAuthQuery {
+ state?: string;
+ code: string;
+ host: string;
+}
+
+export interface OAuthResponse {
+ username?: string;
+ access_token?: string;
+ refresh_token?: string;
+ avatar?: string;
+
+ error?: string;
+ error_code?: number;
+ redirect?: string;
+}
+
+export const withOAuth =
+ (provider: 'discord' | 'github' | 'google', oauth: (query: OAuthQuery) => Promise) =>
+ async (req: NextApiReq, res: NextApiRes) => {
+ req.query.host = req.headers.host;
+
+ const oauth_resp = await oauth(req.query as unknown as OAuthQuery);
+
+ if (oauth_resp.error) {
+ return res.json({ error: oauth_resp.error }, oauth_resp.error_code || 500);
+ }
+
+ if (oauth_resp.redirect) {
+ return res.redirect(oauth_resp.redirect);
+ }
+
+ const { code, state } = req.query as { code: string; state?: string };
+
+ const existing = await prisma.user.findFirst({
+ where: {
+ oauth: {
+ some: {
+ provider: provider.toUpperCase() as OauthProviders,
+ username: oauth_resp.username,
+ },
+ },
+ },
+ include: {
+ oauth: true,
+ },
+ });
+
+ const user = await req.user();
+
+ const existingOauth = existing?.oauth?.find((o) => o.provider === provider.toUpperCase());
+ const existingUserOauth = user?.oauth?.find((o) => o.provider === provider.toUpperCase());
+ if (state === 'link') {
+ if (!user) return res.error('not logged in, unable to link account');
+
+ if (user.oauth && user.oauth.find((o) => o.provider === provider.toUpperCase()))
+ return res.error(`account already linked with ${provider}`);
+
+ await prisma.user.update({
+ where: {
+ id: user.id,
+ },
+ data: {
+ oauth: {
+ create: {
+ provider: OauthProviders[provider.toUpperCase()],
+ token: oauth_resp.access_token,
+ refresh: oauth_resp.refresh_token || null,
+ username: oauth_resp.username,
+ },
+ },
+ avatar: oauth_resp.avatar,
+ },
+ });
+
+ res.setUserCookie(user.id);
+ Logger.get('user').info(`User ${user.username} (${user.id}) linked account via oauth(${provider})`);
+
+ return res.redirect('/');
+ } else if (user && existingUserOauth) {
+ await prisma.oAuth.update({
+ where: {
+ id: existingUserOauth!.id,
+ },
+ data: {
+ token: oauth_resp.access_token,
+ refresh: oauth_resp.refresh_token || null,
+ username: oauth_resp.username,
+ },
+ });
+
+ res.setUserCookie(user.id);
+ Logger.get('user').info(`User ${user.username} (${user.id}) logged in via oauth(${provider})`);
+
+ return res.redirect('/dashboard');
+ } else if (existing && existingOauth) {
+ await prisma.oAuth.update({
+ where: {
+ id: existingOauth!.id,
+ },
+ data: {
+ token: oauth_resp.access_token,
+ refresh: oauth_resp.refresh_token || null,
+ username: oauth_resp.username,
+ },
+ });
+
+ res.setUserCookie(existing.id);
+ Logger.get('user').info(`User ${existing.username} (${existing.id}) logged in via oauth(${provider})`);
+
+ return res.redirect('/dashboard');
+ } else if (existing) {
+ return res.forbid('username is already taken');
+ }
+
+ const nuser = await prisma.user.create({
+ data: {
+ username: oauth_resp.username,
+ token: createToken(),
+ oauth: {
+ create: {
+ provider: OauthProviders[provider.toUpperCase()],
+ token: oauth_resp.access_token,
+ refresh: oauth_resp.refresh_token || null,
+ username: oauth_resp.username,
+ },
+ },
+ avatar: oauth_resp.avatar,
+ },
+ });
+ Logger.get('user').info(`Created user ${nuser.username} via oauth(${provider})`);
+
+ res.setUserCookie(nuser.id);
+ Logger.get('user').info(`User ${nuser.username} (${nuser.id}) logged in via oauth(${provider})`);
+
+ return res.redirect('/dashboard');
+ };
diff --git a/src/lib/middleware/withZipline.ts b/src/lib/middleware/withZipline.ts
index b060a9e..c484a0b 100644
--- a/src/lib/middleware/withZipline.ts
+++ b/src/lib/middleware/withZipline.ts
@@ -5,7 +5,7 @@ import { serialize } from 'cookie';
import { sign64, unsign64 } from 'lib/utils/crypto';
import config from 'lib/config';
import prisma from 'lib/prisma';
-import { User } from '@prisma/client';
+import { OAuth, User } from '@prisma/client';
export interface NextApiFile {
fieldname: string;
@@ -16,8 +16,12 @@ export interface NextApiFile {
size: number;
}
+interface UserExtended extends User {
+ oauth: OAuth[];
+}
+
export type NextApiReq = NextApiRequest & {
- user: () => Promise;
+ user: () => Promise;
getCookie: (name: string) => string | null;
cleanCookie: (name: string) => void;
files?: NextApiFile[];
@@ -30,6 +34,7 @@ export type NextApiRes = NextApiResponse & {
json: (json: Record, status?: number) => void;
ratelimited: (remaining: number) => void;
setCookie: (name: string, value: unknown, options: CookieSerializeOptions) => void;
+ setUserCookie: (id: number) => void;
};
export const withZipline =
@@ -109,6 +114,9 @@ export const withZipline =
where: {
id: Number(userId),
},
+ include: {
+ oauth: true,
+ },
});
if (!user) return null;
@@ -124,6 +132,15 @@ export const withZipline =
res.setCookie = (name: string, value: unknown, options?: CookieSerializeOptions) =>
setCookie(res, name, value, options || {});
+ res.setUserCookie = (id: number) => {
+ req.cleanCookie('user');
+ res.setCookie('user', String(id), {
+ sameSite: 'lax',
+ expires: new Date(Date.now() + 6.048e8 * 2),
+ path: '/',
+ });
+ };
+
return handler(req, res);
};
diff --git a/src/lib/oauth/index.ts b/src/lib/oauth.ts
similarity index 61%
rename from src/lib/oauth/index.ts
rename to src/lib/oauth.ts
index 8bed9bb..4e30d68 100644
--- a/src/lib/oauth/index.ts
+++ b/src/lib/oauth.ts
@@ -31,3 +31,20 @@ export const discord_auth = {
return res.json();
},
};
+
+export const google_auth = {
+ oauth_url: (clientId: string, origin: string, state?: string) =>
+ `https://accounts.google.com/o/oauth2/auth?client_id=${clientId}&redirect_uri=${encodeURIComponent(
+ `${origin}/api/auth/oauth/google`
+ )}&response_type=code&access_type=offline&scope=https://www.googleapis.com/auth/userinfo.profile${
+ state ? `&state=${state}` : ''
+ }`,
+ oauth_user: async (access_token: string) => {
+ const res = await fetch(
+ `https://people.googleapis.com/v1/people/me?access_token=${access_token}&personFields=names,photos`
+ );
+ if (!res.ok) return null;
+
+ return res.json();
+ },
+};
diff --git a/src/lib/recoil/user.ts b/src/lib/recoil/user.ts
index 15e2551..5587515 100644
--- a/src/lib/recoil/user.ts
+++ b/src/lib/recoil/user.ts
@@ -1,3 +1,4 @@
+import { OAuth } from '@prisma/client';
import { atom, selector } from 'recoil';
export interface User {
@@ -11,8 +12,7 @@ export interface User {
avatar?: string;
administrator: boolean;
superAdmin: boolean;
- oauth: boolean;
- oauthProvider: 'github' | 'discord';
+ oauth: OAuth[];
id: number;
}
diff --git a/src/lib/utils/client.ts b/src/lib/utils/client.ts
index 8b4c1b1..6c855aa 100644
--- a/src/lib/utils/client.ts
+++ b/src/lib/utils/client.ts
@@ -87,3 +87,7 @@ export function percentChange(initial: number, final: number) {
return ((final - initial) / initial) * 100;
}
+
+export function capitalize(str: string) {
+ return str[0].toUpperCase() + str.slice(1).toLowerCase();
+}
diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts
index 1c52fa3..11a46fd 100644
--- a/src/pages/api/auth/login.ts
+++ b/src/pages/api/auth/login.ts
@@ -35,12 +35,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
const valid = await checkPassword(password, user.password);
if (!valid) return res.forbid('Wrong password');
- res.setCookie('user', user.id, {
- sameSite: 'lax',
- expires: new Date(Date.now() + 6.048e8 * 2),
- path: '/',
- });
-
+ res.setUserCookie(user.id);
Logger.get('user').info(`User ${user.username} (${user.id}) logged in`);
return res.json({ success: true });
diff --git a/src/pages/api/auth/oauth/discord.ts b/src/pages/api/auth/oauth/discord.ts
index 0676a00..2218bcc 100644
--- a/src/pages/api/auth/oauth/discord.ts
+++ b/src/pages/api/auth/oauth/discord.ts
@@ -1,27 +1,33 @@
-import prisma from 'lib/prisma';
-import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
-import { createToken, getBase64URLFromURL, notNull } from 'lib/util';
+import { withZipline } from 'lib/middleware/withZipline';
+import { getBase64URLFromURL, notNull } from 'lib/util';
import Logger from 'lib/logger';
import config from 'lib/config';
import { discord_auth } from 'lib/oauth';
+import { withOAuth, OAuthResponse, OAuthQuery } from 'lib/middleware/withOAuth';
-async function handler(req: NextApiReq, res: NextApiRes) {
- if (!config.features.oauth_registration) return res.forbid('oauth registration disabled');
+async function handler({ code, state, host }: OAuthQuery): Promise {
+ if (!config.features.oauth_registration)
+ return {
+ error_code: 403,
+ error: 'oauth registration is disabled',
+ };
if (!notNull(config.oauth.discord_client_id, config.oauth.discord_client_secret)) {
Logger.get('oauth').error('Discord OAuth is not configured');
- return res.bad('Discord OAuth is not configured');
+ return {
+ error_code: 401,
+ error: 'Discord OAuth is not configured',
+ };
}
- const { code, state } = req.query as { code: string; state?: string };
if (!code)
- return res.redirect(
- discord_auth.oauth_url(
+ return {
+ redirect: discord_auth.oauth_url(
config.oauth.discord_client_id,
- `${config.core.https ? 'https' : 'http'}://${req.headers.host}`,
+ `${config.core.https ? 'https' : 'http'}://${host}`,
state
- )
- );
+ ),
+ };
const resp = await fetch('https://discord.com/api/oauth2/token', {
method: 'POST',
@@ -33,100 +39,31 @@ async function handler(req: NextApiReq, res: NextApiRes) {
client_secret: config.oauth.discord_client_secret,
code,
grant_type: 'authorization_code',
- redirect_uri: `${config.core.https ? 'https' : 'http'}://${req.headers.host}/api/auth/oauth/discord`,
+ redirect_uri: `${config.core.https ? 'https' : 'http'}://${host}/api/auth/oauth/discord`,
scope: 'identify',
}),
});
- if (!resp.ok) return res.error('invalid request');
+ if (!resp.ok) return { error: 'invalid request' };
+
const json = await resp.json();
- if (!json.access_token) return res.error('no access_token in response');
+ if (!json.access_token) return { error: 'no access_token in response' };
+ if (!json.refresh_token) return { error: 'no refresh_token in response' };
const userJson = await discord_auth.oauth_user(json.access_token);
- if (!userJson) return res.error('invalid user request');
+ if (!userJson) return { error: 'invalid user request' };
const avatar = userJson.avatar
? `https://cdn.discordapp.com/avatars/${userJson.id}/${userJson.avatar}.png`
: `https://cdn.discordapp.com/embed/avatars/${userJson.discriminator % 5}.png`;
const avatarBase64 = await getBase64URLFromURL(avatar);
- const existing = await prisma.user.findFirst({
- where: {
- username: userJson.username,
- },
- });
-
- if (state && state === 'link') {
- const user = await req.user();
- if (!user) return res.error('not logged in, unable to link account');
-
- if (user.oauth && user.oauthProvider === 'discord')
- return res.error('account already linked with discord');
-
- await prisma.user.update({
- where: {
- id: user.id,
- },
- data: {
- oauth: true,
- oauthProvider: 'discord',
- oauthAccessToken: json.access_token,
- avatar: avatarBase64,
- },
- });
- req.cleanCookie('user');
- res.setCookie('user', user.id, {
- sameSite: 'lax',
- expires: new Date(Date.now() + 6.048e8 * 2),
- path: '/',
- });
- Logger.get('user').info(`User ${user.username} (${user.id}) linked account via oauth(discord)`);
-
- return res.redirect('/');
- } else if (existing && existing.oauth && existing.oauthProvider === 'discord') {
- await prisma.user.update({
- where: {
- id: existing.id,
- },
- data: {
- oauthAccessToken: json.access_token,
- },
- });
-
- req.cleanCookie('user');
- res.setCookie('user', existing.id, {
- sameSite: 'lax',
- expires: new Date(Date.now() + 6.048e8 * 2),
- path: '/',
- });
- Logger.get('user').info(`User ${existing.username} (${existing.id}) logged in via oauth(discord)`);
-
- return res.redirect('/dashboard');
- } else if (existing) {
- return res.forbid('username is already taken');
- }
-
- const user = await prisma.user.create({
- data: {
- username: userJson.username,
- token: createToken(),
- oauth: true,
- oauthProvider: 'discord',
- oauthAccessToken: json.access_token,
- avatar: avatarBase64,
- },
- });
- Logger.get('user').info(`Created user ${user.username} via oauth(discord)`);
-
- req.cleanCookie('user');
- res.setCookie('user', user.id, {
- sameSite: 'lax',
- expires: new Date(Date.now() + 6.048e8 * 2),
- path: '/',
- });
- Logger.get('user').info(`User ${user.username} (${user.id}) logged in via oauth(discord)`);
-
- return res.redirect('/dashboard');
+ return {
+ username: userJson.username,
+ avatar: avatarBase64,
+ access_token: json.access_token,
+ refresh_token: json.refresh_token,
+ };
}
-export default withZipline(handler);
+export default withZipline(withOAuth('discord', handler));
diff --git a/src/pages/api/auth/oauth/github.ts b/src/pages/api/auth/oauth/github.ts
index 1311fd8..e8df4f6 100644
--- a/src/pages/api/auth/oauth/github.ts
+++ b/src/pages/api/auth/oauth/github.ts
@@ -1,21 +1,29 @@
-import prisma from 'lib/prisma';
-import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
-import { createToken, getBase64URLFromURL, notNull } from 'lib/util';
+import { withZipline } from 'lib/middleware/withZipline';
+import { getBase64URLFromURL, notNull } from 'lib/util';
import Logger from 'lib/logger';
import config from 'lib/config';
import { github_auth } from 'lib/oauth';
+import { withOAuth, OAuthResponse, OAuthQuery } from 'lib/middleware/withOAuth';
-async function handler(req: NextApiReq, res: NextApiRes) {
- if (!config.features.oauth_registration) return res.forbid('oauth registration disabled');
+async function handler({ code, state }: OAuthQuery): Promise {
+ if (!config.features.oauth_registration)
+ return {
+ error_code: 403,
+ error: 'oauth registration is disabled',
+ };
if (!notNull(config.oauth.github_client_id, config.oauth.github_client_secret)) {
Logger.get('oauth').error('GitHub OAuth is not configured');
- return res.bad('GitHub OAuth is not configured');
+ return {
+ error_code: 401,
+ error: 'GitHub OAuth is not configured',
+ };
}
- const { code, state } = req.query as { code: string; state: string };
-
- if (!code) return res.redirect(github_auth.oauth_url(config.oauth.github_client_id, state));
+ if (!code)
+ return {
+ redirect: github_auth.oauth_url(config.oauth.github_client_id, state),
+ };
const resp = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
@@ -30,93 +38,22 @@ async function handler(req: NextApiReq, res: NextApiRes) {
}),
});
- if (!resp.ok) return res.error('invalid request');
+ if (!resp.ok) return { error: 'invalid request' };
const json = await resp.json();
- if (!json.access_token) return res.error('no access_token in response');
+ if (!json.access_token) return { error: 'no access_token in response' };
const userJson = await github_auth.oauth_user(json.access_token);
- if (!userJson) return res.error('invalid user request');
+ if (!userJson) return { error: 'invalid user request' };
const avatarBase64 = await getBase64URLFromURL(userJson.avatar_url);
- const existing = await prisma.user.findFirst({
- where: {
- username: userJson.login,
- },
- });
-
- if (state && state === 'link') {
- const user = await req.user();
- if (!user) return res.error('not logged in, unable to link account');
-
- if (user.oauth && user.oauthProvider === 'github') return res.error('account already linked with github');
-
- await prisma.user.update({
- where: {
- id: user.id,
- },
- data: {
- oauth: true,
- oauthProvider: 'github',
- oauthAccessToken: json.access_token,
- avatar: avatarBase64,
- },
- });
- req.cleanCookie('user');
- res.setCookie('user', user.id, {
- sameSite: 'lax',
- expires: new Date(Date.now() + 6.048e8 * 2),
- path: '/',
- });
- Logger.get('user').info(`User ${user.username} (${user.id}) linked account via oauth(github)`);
-
- return res.redirect('/');
- } else if (existing && existing.oauth && existing.oauthProvider === 'github') {
- await prisma.user.update({
- where: {
- id: existing.id,
- },
- data: {
- oauthAccessToken: json.access_token,
- },
- });
-
- req.cleanCookie('user');
- res.setCookie('user', existing.id, {
- sameSite: 'lax',
- expires: new Date(Date.now() + 6.048e8 * 2),
- path: '/',
- });
- Logger.get('user').info(`User ${existing.username} (${existing.id}) logged in via oauth(github)`);
-
- return res.redirect('/dashboard');
- } else if (existing) {
- return res.forbid('username is already taken');
- }
-
- const user = await prisma.user.create({
- data: {
- username: userJson.login,
- token: createToken(),
- oauth: true,
- oauthProvider: 'github',
- oauthAccessToken: json.access_token,
- avatar: avatarBase64,
- },
- });
- Logger.get('user').info(`Created user ${user.username} via oauth(github)`);
-
- req.cleanCookie('user');
- res.setCookie('user', user.id, {
- sameSite: 'lax',
- expires: new Date(Date.now() + 6.048e8 * 2),
- path: '/',
- });
- Logger.get('user').info(`User ${user.username} (${user.id}) logged in via oauth(github)`);
-
- return res.redirect('/dashboard');
+ return {
+ username: userJson.login,
+ avatar: avatarBase64,
+ access_token: json.access_token,
+ };
}
-export default withZipline(handler);
+export default withZipline(withOAuth('github', handler));
diff --git a/src/pages/api/auth/oauth/google.ts b/src/pages/api/auth/oauth/google.ts
new file mode 100644
index 0000000..05ef6be
--- /dev/null
+++ b/src/pages/api/auth/oauth/google.ts
@@ -0,0 +1,65 @@
+import { withZipline } from 'lib/middleware/withZipline';
+import { getBase64URLFromURL, notNull } from 'lib/util';
+import Logger from 'lib/logger';
+import config from 'lib/config';
+import { google_auth } from 'lib/oauth';
+import { withOAuth, OAuthResponse, OAuthQuery } from 'lib/middleware/withOAuth';
+
+async function handler({ code, state, host }: OAuthQuery): Promise {
+ if (!config.features.oauth_registration)
+ return {
+ error_code: 403,
+ error: 'oauth registration is disabled',
+ };
+
+ if (!notNull(config.oauth.google_client_id, config.oauth.google_client_secret)) {
+ Logger.get('oauth').error('Google OAuth is not configured');
+ return {
+ error_code: 401,
+ error: 'Google OAuth is not configured',
+ };
+ }
+
+ if (!code)
+ return {
+ redirect: google_auth.oauth_url(
+ config.oauth.google_client_id,
+ `${config.core.https ? 'https' : 'http'}://${host}`,
+ state
+ ),
+ };
+
+ const resp = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ code,
+ client_id: config.oauth.google_client_id,
+ client_secret: config.oauth.google_client_secret,
+ redirect_uri: `${config.core.https ? 'https' : 'http'}://${host}/api/auth/oauth/google`,
+ grant_type: 'authorization_code',
+ }),
+ });
+
+ if (!resp.ok) return { error: 'invalid request' };
+
+ const json = await resp.json();
+
+ if (!json.access_token) return { error: 'no access_token in response' };
+
+ const userJson = await google_auth.oauth_user(json.access_token);
+ if (!userJson) return { error: 'invalid user request' };
+
+ const avatarBase64 = await getBase64URLFromURL(userJson?.photos[0]?.url);
+
+ return {
+ username: userJson.names[0].displayName,
+ avatar: avatarBase64,
+ access_token: json.access_token,
+ refresh_token: json.refresh_token,
+ };
+}
+
+export default withZipline(withOAuth('google', handler));
diff --git a/src/pages/api/auth/oauth/index.ts b/src/pages/api/auth/oauth/index.ts
index b00c303..43bfb20 100644
--- a/src/pages/api/auth/oauth/index.ts
+++ b/src/pages/api/auth/oauth/index.ts
@@ -1,22 +1,42 @@
import prisma from 'lib/prisma';
import { NextApiReq, NextApiRes, withZipline } from 'lib/middleware/withZipline';
+import { OauthProviders } from '@prisma/client';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
if (!user) return res.error('not logged in');
if (req.method === 'DELETE') {
- if (!user.password)
+ if (!user.password && user.oauth.length === 1)
return res.forbid("can't unlink account without a password, please set one then unlink.");
+ const { provider } = req.body as { provider: OauthProviders };
+
+ if (!provider) {
+ const nuser = await prisma.user.update({
+ where: { id: user.id },
+ data: {
+ oauth: {
+ deleteMany: {},
+ },
+ },
+ });
+
+ delete nuser.password;
+
+ return res.json(nuser);
+ }
const nuser = await prisma.user.update({
where: {
id: user.id,
},
data: {
- oauth: false,
- oauthProvider: null,
- oauthAccessToken: null,
+ oauth: {
+ deleteMany: [{ provider }],
+ },
+ },
+ include: {
+ oauth: true,
},
});
@@ -24,11 +44,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
return res.json(nuser);
} else {
- return res.json({
- enabled: user.oauth,
- provider: user.oauthProvider,
- access_token: user.oauthAccessToken,
- });
+ return res.json(user.oauth);
}
}
diff --git a/src/pages/api/user/[id].ts b/src/pages/api/user/[id].ts
index 0714240..e43bdaf 100644
--- a/src/pages/api/user/[id].ts
+++ b/src/pages/api/user/[id].ts
@@ -141,6 +141,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
username: true,
domains: true,
avatar: true,
+ oauth: true,
},
});
diff --git a/src/pages/api/user/index.ts b/src/pages/api/user/index.ts
index 934e4c7..b7f1ff1 100644
--- a/src/pages/api/user/index.ts
+++ b/src/pages/api/user/index.ts
@@ -3,7 +3,7 @@ import { hashPassword } from 'lib/util';
import { NextApiReq, NextApiRes, withZipline } from 'middleware/withZipline';
import Logger from 'lib/logger';
import config from 'lib/config';
-import { discord_auth, github_auth } from 'lib/oauth';
+import { discord_auth, github_auth, google_auth } from 'lib/oauth';
async function handler(req: NextApiReq, res: NextApiRes) {
const user = await req.user();
@@ -11,33 +11,112 @@ async function handler(req: NextApiReq, res: NextApiRes) {
if (user.oauth) {
// this will probably change before the stable release
- if (user.oauthProvider === 'github') {
- const resp = await github_auth.oauth_user(user.oauthAccessToken);
+ if (user.oauth.find((o) => o.provider === 'GITHUB')) {
+ const resp = await github_auth.oauth_user(user.oauth.find((o) => o.provider === 'GITHUB').token);
if (!resp) {
- req.cleanCookie('user');
- Logger.get('user').info(`User ${user.username} (${user.id}) logged out (oauth token expired)`);
-
return res.json({
error: 'oauth token expired',
redirect_uri: github_auth.oauth_url(config.oauth.github_client_id),
});
}
- } else if (user.oauthProvider === 'discord') {
+ } else if (user.oauth.find((o) => o.provider === 'DISCORD')) {
const resp = await fetch('https://discord.com/api/users/@me', {
headers: {
- Authorization: `Bearer ${user.oauthAccessToken}`,
+ Authorization: `Bearer ${user.oauth.find((o) => o.provider === 'DISCORD').token}`,
},
});
if (!resp.ok) {
- req.cleanCookie('user');
- Logger.get('user').info(`User ${user.username} (${user.id}) logged out (oauth token expired)`);
+ const provider = user.oauth.find((o) => o.provider === 'DISCORD');
+ if (!provider.refresh)
+ return res.json({
+ error: 'oauth token expired',
+ redirect_uri: discord_auth.oauth_url(
+ config.oauth.discord_client_id,
+ `${config.core.https ? 'https' : 'http'}://${req.headers.host}`
+ ),
+ });
- return res.json({
- error: 'oauth token expired',
- redirect_uri: discord_auth.oauth_url(
- config.oauth.discord_client_id,
- `${config.core.https ? 'https' : 'http'}://${req.headers.host}`
- ),
+ const resp2 = await fetch('https://discord.com/api/oauth2/token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ client_id: config.oauth.discord_client_id,
+ client_secret: config.oauth.discord_client_secret,
+ grant_type: 'refresh_token',
+ refresh_token: provider.refresh,
+ }),
+ });
+ if (!resp2.ok)
+ return res.json({
+ error: 'oauth token expired',
+ redirect_uri: discord_auth.oauth_url(
+ config.oauth.discord_client_id,
+ `${config.core.https ? 'https' : 'http'}://${req.headers.host}`
+ ),
+ });
+
+ const json = await resp2.json();
+
+ await prisma.oAuth.update({
+ where: {
+ id: provider.id,
+ },
+ data: {
+ token: json.access_token,
+ refresh: json.refresh_token,
+ },
+ });
+ }
+ } else if (user.oauth.find((o) => o.provider === 'GOOGLE')) {
+ const resp = await fetch(
+ `https://people.googleapis.com/v1/people/me?access_token=${
+ user.oauth.find((o) => o.provider === 'GOOGLE').token
+ }&personFields=names,photos`
+ );
+ if (!resp.ok) {
+ const provider = user.oauth.find((o) => o.provider === 'GOOGLE');
+ if (!provider.refresh)
+ return res.json({
+ error: 'oauth token expired',
+ redirect_uri: google_auth.oauth_url(
+ config.oauth.google_client_id,
+ `${config.core.https ? 'https' : 'http'}://${req.headers.host}`
+ ),
+ });
+
+ const resp2 = await fetch('https://oauth2.googleapis.com/token', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: new URLSearchParams({
+ client_id: config.oauth.google_client_id,
+ client_secret: config.oauth.google_client_secret,
+ grant_type: 'refresh_token',
+ refresh_token: provider.refresh,
+ }),
+ });
+ if (!resp2.ok)
+ return res.json({
+ error: 'oauth token expired',
+ redirect_uri: google_auth.oauth_url(
+ config.oauth.google_client_id,
+ `${config.core.https ? 'https' : 'http'}://${req.headers.host}`
+ ),
+ });
+
+ const json = await resp2.json();
+
+ await prisma.oAuth.update({
+ where: {
+ id: provider.id,
+ },
+ data: {
+ token: json.access_token,
+ refresh: json.refresh_token,
+ },
});
}
}
@@ -122,8 +201,6 @@ async function handler(req: NextApiReq, res: NextApiRes) {
where: { id: user.id },
data: { domains },
});
-
- return res.json({ domains });
}
const newUser = await prisma.user.findFirst({
@@ -143,6 +220,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
username: true,
domains: true,
avatar: true,
+ oauth: true,
},
});
diff --git a/src/pages/api/users.ts b/src/pages/api/users.ts
index b8d2368..e934dcb 100644
--- a/src/pages/api/users.ts
+++ b/src/pages/api/users.ts
@@ -43,7 +43,7 @@ async function handler(req: NextApiReq, res: NextApiRes) {
for (let i = 0; i !== files.length; ++i) {
try {
await datasource.delete(files[i].file);
- } catch (e) {}
+ } catch {}
}
const { count } = await prisma.image.deleteMany({
diff --git a/src/pages/auth/login.tsx b/src/pages/auth/login.tsx
index bd2e660..87ef0ac 100644
--- a/src/pages/auth/login.tsx
+++ b/src/pages/auth/login.tsx
@@ -1,11 +1,11 @@
-import { Button, Center, TextInput, Title, PasswordInput, Divider } from '@mantine/core';
+import { Button, Center, TextInput, Title, PasswordInput, Divider, Group } from '@mantine/core';
import { useForm } from '@mantine/form';
import Link from 'next/link';
import useFetch from 'hooks/useFetch';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import Head from 'next/head';
-import { GitHubIcon, DiscordIcon } from 'components/icons';
+import { GitHubIcon, DiscordIcon, GoogleIcon } from 'components/icons';
export { getServerSideProps } from 'middleware/getServerSideProps';
export default function Login({ title, user_registration, oauth_registration, oauth_providers: unparsed }) {
@@ -16,6 +16,7 @@ export default function Login({ title, user_registration, oauth_registration, oa
const icons = {
GitHub: GitHubIcon,
Discord: DiscordIcon,
+ Google: GoogleIcon,
};
for (const provider of oauth_providers) {
@@ -67,18 +68,30 @@ export default function Login({ title, user_registration, oauth_registration, oa