Compare commits

..

30 Commits
tic80 ... main

Author SHA1 Message Date
Julian 940e2621bc
update nix hash 2023-05-10 22:37:38 +00:00
Julian 4ce2931348
add api route for querying user information 2023-05-10 18:29:11 -04:00
Skip R. 33e680a43f hack together change password page
not a modal yet
2023-05-09 19:01:52 -07:00
Skip R. dac227c937 update packages
theres annoying warnings on node 20 that's fixed by doing this
2023-05-09 17:56:36 -07:00
Skip R. 515d874410 (important) shrink checkmark icon to taste
looks better imo
2023-05-09 16:21:20 -07:00
Julian d05961ad15
when that server action hits 2023-05-09 16:26:36 -04:00
Julian 56e11c4d76
fix the logo tooo 2023-05-02 12:16:04 -04:00
Julian 850f4ba9ab
hacky emergency mobile support 2023-05-02 12:14:25 -04:00
Julian fbe2222d1b
thanks naku 2023-04-29 16:32:59 -04:00
Julian 5af2762e12
Force dynamic on OAuth routes
See https://github.com/vercel/next.js/discussions/48989.
2023-04-29 19:18:55 +00:00
Julian 509b4a8f42
it dont work lmfao 2023-04-29 13:12:03 -04:00
Julian 3b11a40928
Merge remote-tracking branch 'origin/nix' 2023-04-29 12:39:58 -04:00
Julian 45decdb110
resize check 2023-04-29 12:12:32 -04:00
Julian 1340bf531a
add back hold-to-unlink 2023-04-28 22:31:38 -04:00
Julian 3e24c99db4
minor annoyances I found on my laptop 2023-04-28 22:16:08 -04:00
Julian 7cb7838d4a
whoops 2023-04-29 01:19:33 +00:00
Julian 26f5051a8e
Extremely hacky Nix support 2023-04-29 01:19:06 +00:00
Julian 457c33e4a1
fix error on migadu icon 2023-04-28 18:34:36 -04:00
Julian 42f1b54fbd
I'm Julian, and you're watching How To Optimize Information Badly 101 2023-04-28 18:30:36 -04:00
Julian b38a73eeac
add migadu icon 2023-04-28 17:25:37 -04:00
Julian f5640f41f3
add tailscale icon 2023-04-28 17:12:49 -04:00
Julian aec287c003
add connection icons 2023-04-28 16:09:12 -04:00
Julian 4315459e87
Merge branch 'main' of git.n2.pm:NotNet/gluestick 2023-04-28 12:09:00 -04:00
Julian 9aa36dd589
fix some avatar shenanigans 2023-04-28 12:08:51 -04:00
Julian 15360eb6b9
raise avatar limits to 2 MB, 1024x1024 2023-04-28 12:08:36 -04:00
Julian da5373ef25
make logger a global 2023-04-28 12:07:44 -04:00
Skip R. 1e282617a9 add new logo and other tweak!!!!!1 2023-04-28 09:06:47 -07:00
Skip R. 967bb2a2d2 first proto of nice about me page :3 2023-04-28 08:53:58 -07:00
Skip R. fd79df9ec1 add lunchtype fonts 2023-04-28 07:39:04 -07:00
Julian cbcb8268b0
invite user to github automatically 2023-04-27 21:01:30 -04:00
68 changed files with 5202 additions and 1668 deletions

3
.gitignore vendored
View File

@ -38,3 +38,6 @@ database.db*
# graphql-codegen # graphql-codegen
src/__generated__ src/__generated__
# NixOS
result

View File

@ -50,6 +50,7 @@ After cloning, create an `.env.local` with the following contents (in `key=value
- `LDAP_BIND_PASSWORD`: the password of the bind user - `LDAP_BIND_PASSWORD`: the password of the bind user
- `BASE_DOMAIN`: the domain gluestick is deployed on, with a protocol and trailing slash - `BASE_DOMAIN`: the domain gluestick is deployed on, with a protocol and trailing slash
- This domain will be used for OAuth redirects - if you are testing locally, set it to `http://localhost:3000/` - This domain will be used for OAuth redirects - if you are testing locally, set it to `http://localhost:3000/`
- `DATABASE_URL`: a Prisma-like path to your database
Example config: Example config:
@ -69,6 +70,7 @@ GITHUB_TOKEN=redacted
GITHUB_ORG=n2pm GITHUB_ORG=n2pm
BASE_DOMAIN=https://gluestick.n2.pm/ BASE_DOMAIN=https://gluestick.n2.pm/
DATABASE_URL=file:./database.db
``` ```
### Generating code ### Generating code
@ -93,7 +95,7 @@ export GRAPHQL_CODEDGEN_AUTH=...
Then, generate the GraphQL and database code: Then, generate the GraphQL and database code:
```shell ```shell
npm run graphql-codegen GRAPHQL_USE_INTROSPECTION=true npm run graphql-codegen
npm run prisma-generate npm run prisma-generate
``` ```
@ -108,7 +110,36 @@ npm run start
## Developing ## Developing
You'll want to run these two commands at the same time: ### Generating GraphQL code
Because the LLDAP GraphQL API is locked behind authentication, and of a quirk with `graphl-codegen` configuration files, we need to set a temporary environment variable to generate GraphQL code. If not using introspection, you will need a running LLDAP server.
Run the `get-token.js` helper script and set the environment variable from its output:
```shell
node get-token.js
export GRAPHQL_CODEDGEN_AUTH=...
```
Then, generate the GraphQL code:
```shell
npm run graphql-codegen
```
If you want to use introspection, set `GRAPHQL_USE_INTROSPECTION=true` before generating the code. You won't need to set the auth environment variable in this case.
### Working with Prisma
gluestick uses [Prisma](https://www.prisma.io/) for accessing the database. If you will be modifying the database schema, you will need to work with it. Consider taking some time to familiarize yourself with the [Prisma CLI](https://www.prisma.io/docs/reference/api-reference/command-reference) first.
When first cloning, generate the Prisma client:
```shell
npm run prisma-generate
```
### Running the server
```shell ```shell
# Next.js hot reload # Next.js hot reload
@ -119,5 +150,3 @@ npm run dev | pino-pretty
# Only required if working on GraphQL code # Only required if working on GraphQL code
npm run graphql-codegen -- -w npm run graphql-codegen -- -w
``` ```
If you're interacting with the database, take some time to familiarize yourself with the [Prisma CLI](https://www.prisma.io/docs/reference/api-reference/command-reference).

View File

@ -4,24 +4,32 @@ import { CodegenConfig } from "@graphql-codegen/cli";
import * as dotenv from "dotenv"; import * as dotenv from "dotenv";
dotenv.config({ path: ".env.local" }); dotenv.config({ path: ".env.local" });
const useIntrospection = ["1", "true"].includes(
process.env.GRAPHQL_USE_INTROSPECTION?.toLowerCase() ?? ""
);
const config: CodegenConfig = { const config: CodegenConfig = {
schema: { schema: useIntrospection
[`http://${process.env.LDAP_HOST}:17170/api/graphql`]: { ? "introspection.json"
headers: { : {
// can't make the request automatically (await on top level) [`http://${process.env.LDAP_HOST}:17170/api/graphql`]: {
Authorization: `Bearer ${process.env.GRAPHQL_CODEGEN_AUTH}` headers: {
} // can't make the request automatically (await on top level)
} Authorization: `Bearer ${process.env.GRAPHQL_CODEGEN_AUTH}`
}, }
}
},
documents: ["src/**/*.ts", "src/**/*.tsx"], documents: ["src/**/*.ts", "src/**/*.tsx"],
generates: { generates: {
"./src/__generated__/": { "./src/__generated__/": {
preset: "client", preset: "client",
plugins: [],
presetConfig: { presetConfig: {
gqlTagName: "gql" gqlTagName: "gql"
} }
},
"introspection.json": {
plugins: ["introspection"]
} }
}, },
ignoreNoDocuments: true ignoreNoDocuments: true

3
environment.d.ts vendored
View File

@ -2,11 +2,13 @@ import { PrismaClient } from "@prisma/client";
import { Client as LDAPClient } from "ldapts"; import { Client as LDAPClient } from "ldapts";
import { ApolloClient, InMemoryCache } from "@apollo/client"; import { ApolloClient, InMemoryCache } from "@apollo/client";
import { LLDAPAuthResponse } from "@/ldap"; import { LLDAPAuthResponse } from "@/ldap";
import { Logger } from "pino";
declare global { declare global {
var prisma: PrismaClient | undefined; var prisma: PrismaClient | undefined;
var ldapClient: LDAPClient | undefined; var ldapClient: LDAPClient | undefined;
var authResponse: LLDAPAuthResponse | undefined; var authResponse: LLDAPAuthResponse | undefined;
var logger: Logger;
namespace NodeJS { namespace NodeJS {
interface ProcessEnv { interface ProcessEnv {
@ -25,6 +27,7 @@ declare global {
GITHUB_ORG: string; GITHUB_ORG: string;
BASE_DOMAIN: string; BASE_DOMAIN: string;
API_TOKEN?: string;
} }
} }
} }

61
flake.lock Normal file
View File

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1682656005,
"narHash": "sha256-fYplYo7so1O+rSQ2/aS+SbTPwLTeoUXk4ekKNtSl4P8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6806b63e824f84b0f0e60b6d660d4ae753de0477",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

182
flake.nix Normal file
View File

@ -0,0 +1,182 @@
{
description =
"NotNet's one stop shop for authentication and account onboarding";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
let
packages = flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages."${system}";
# I really cannot be assed to pick this apart
inputs = with pkgs; [ vips pkg-config python3 ];
# https://github.com/prisma/prisma/issues/3026#issuecomment-927258138
prismaHook = with pkgs; ''
export PRISMA_MIGRATION_ENGINE_BINARY="${prisma-engines}/bin/migration-engine"
export PRISMA_QUERY_ENGINE_BINARY="${prisma-engines}/bin/query-engine"
export PRISMA_QUERY_ENGINE_LIBRARY="${prisma-engines}/lib/libquery_engine.node"
export PRISMA_INTROSPECTION_ENGINE_BINARY="${prisma-engines}/bin/introspection-engine"
export PRISMA_FMT_BINARY="${prisma-engines}/bin/prisma-fmt"
'';
in rec {
packages.gluestick = pkgs.buildNpmPackage {
pname = "gluestick";
version = "0.1.0";
src = ./.;
npmDepsHash = "sha256-JPsXIPyiGycT/4dcg78qAz+qqIRYpSR24NWeu+5jLk0=";
nativeBuildInputs = inputs;
buildInputs = inputs;
preBuild = ''
${prismaHook}
# Use the introspection.json, because we can't connect to the API at build time
GRAPHQL_USE_INTROSPECTION=true npm run graphql-codegen
npm run prisma-generate
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r .next/standalone $out/server
cp -r .next/static $out/server/.next/static
cp -r public $out/server/public
cp -r prisma $out/prisma
mkdir -p $out/bin
cat > $out/bin/gluestick <<EOF
#!${pkgs.stdenv.shell}
${prismaHook}
${pkgs.nodejs}/bin/node $out/server/server.js \$@
EOF
chmod +x $out/bin/gluestick
cat > $out/bin/prisma <<EOF
#!${pkgs.stdenv.shell}
${prismaHook}
${pkgs.nodePackages.prisma}/bin/prisma \$@
EOF
chmod +x $out/bin/prisma
runHook postInstall
'';
meta = with pkgs.lib; {
description =
"NotNet's one stop shop for authentication and account onboarding";
homepage = "https://git.n2.pm/NotNet/gluestick";
license = licenses.mit;
};
};
apps.gluestick = flake-utils.lib.mkApp {
name = "gluestick";
drv = packages.gluestick;
};
devShell = pkgs.mkShell {
inputsFrom = [ packages.gluestick ];
shellHook = ''
${prismaHook}
if [ -f .env.local ]; then
set -a
source .env.local
set +a
fi
'';
};
});
in packages // {
nixosModule = { config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.gluestick;
pkg = self.packages.${pkgs.system}.gluestick;
in {
options.services.gluestick = {
enable = mkEnableOption "gluestick";
user = mkOption {
type = types.str;
default = "gluestick";
};
group = mkOption {
type = types.str;
default = "gluestick";
};
port = mkOption {
type = types.int;
default = 3000;
};
envFile = mkOption {
type = types.path;
default = "/var/lib/gluestick/.env.local";
};
databaseFile = mkOption {
type = types.path;
default = "/var/lib/gluestick/database.db";
};
};
config = mkIf cfg.enable {
systemd.services.gluestick = {
description = "gluestick";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
preStart = ''
export DATABASE_URL="file:${cfg.databaseFile}"
${pkg}/bin/prisma migrate deploy --schema=${pkg}/prisma/schema.prisma
'';
script = ''
export PORT=${toString cfg.port}
export NODE_ENV=production
export DATABASE_URL="file:${cfg.databaseFile}"
set -a
source ${cfg.envFile}
set +a
${pkg}/bin/gluestick
'';
serviceConfig = {
User = cfg.user;
Group = cfg.group;
Restart = "always";
WorkingDirectory = "/var/lib/gluestick";
};
};
users = {
users = mkIf (cfg.user == "gluestick") {
gluestick = {
home = "/var/lib/gluestick";
createHome = true;
group = cfg.group;
isSystemUser = true;
};
};
groups = mkIf (cfg.group == "gluestick") { gluestick = { }; };
};
};
};
};
}

2123
introspection.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,8 @@
const nextConfig = { const nextConfig = {
experimental: { experimental: {
appDir: true appDir: true
} },
output: "standalone"
}; };
module.exports = nextConfig; module.exports = nextConfig;

2566
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -25,18 +25,20 @@
"formik": "^2.2.9", "formik": "^2.2.9",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"ldapts": "^4.2.5", "ldapts": "^4.2.5",
"next": "13.3.1", "next": "^13.4.2-canary.4",
"pino": "^8.11.0", "pino": "^8.11.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"sharp": "^0.32.0", "sharp": "^0.32.0",
"typescript": "5.0.4", "typescript": "5.0.4",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"yup": "^1.1.1" "zod": "^3.21.4",
"zod-formik-adapter": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "^3.3.1", "@graphql-codegen/cli": "^3.3.1",
"@graphql-codegen/client-preset": "^3.0.1", "@graphql-codegen/client-preset": "^3.0.1",
"@graphql-codegen/introspection": "^3.0.1",
"@types/uuid": "^9.0.1", "@types/uuid": "^9.0.1",
"pino-pretty": "^10.0.0", "pino-pretty": "^10.0.0",
"prisma": "^4.13.0" "prisma": "^4.13.0"

View File

@ -4,7 +4,7 @@ generator client {
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = "file:./database.db" url = env("DATABASE_URL")
} }
model AuthTicket { model AuthTicket {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,29 @@
"use server";
import { getUser } from "@/auth/auth";
import { getUserInfo, setPassword, validateUser } from "@/ldap";
import { ActionResponse } from ".";
import { PasswordUpdateSchema, passwordUpdateSchema } from "@/schemas";
export default async function changePassword(
data: PasswordUpdateSchema
): Promise<ActionResponse> {
const user = await getUser();
if (user == null) return { ok: false, error: "noUser" };
const userInfo = await getUserInfo(user);
if (userInfo == null) {
return { ok: false, error: "notRegisteredYet" };
}
const { password, newPassword } = passwordUpdateSchema.parse(data);
const passwordMatches = await validateUser(user.username!, password);
if (!passwordMatches) {
return { ok: false, error: "incorrectPassword" };
}
await setPassword(user.username!, newPassword);
return { ok: true };
}

4
src/actions/index.ts Normal file
View File

@ -0,0 +1,4 @@
export type ActionResponse = {
ok: boolean;
error?: string;
};

26
src/actions/login.ts Normal file
View File

@ -0,0 +1,26 @@
"use server";
import * as ldap from "@/ldap";
import { LoginSchema, loginSchema } from "@/schemas";
import { ActionResponse } from ".";
import { getLogger } from "@/logger";
import { authTicketLogin } from "@/auth/auth";
type Response = ActionResponse & {
ticket?: string;
};
export default async function login(data: LoginSchema): Promise<Response> {
const { username, password } = await loginSchema.parse(data);
const valid = await ldap.validateUser(username, password);
if (!valid) {
return {
ok: false,
error: "invalidCredentials"
};
}
const [_, ticket] = await authTicketLogin(username);
return { ok: true, ticket: ticket.ticket };
}

View File

@ -1,38 +1,47 @@
"use server";
import * as ldap from "@/ldap"; import * as ldap from "@/ldap";
import prisma from "@/prisma"; import prisma from "@/prisma";
import { getUser } from "@/auth/auth"; import { getUser } from "@/auth/auth";
import { getLogger } from "@/logger"; import { getLogger } from "@/logger";
import { registerServerSchema } from "@/schemas"; import { RegisterSchema, registerSchema } from "@/schemas";
import { ActionResponse } from ".";
const logger = getLogger("/api/register"); const logger = getLogger("/actions/register");
export async function POST(request: Request) { export default async function register(
data: RegisterSchema
): Promise<ActionResponse> {
const user = await getUser(); const user = await getUser();
if (user == null) return new Response(null, { status: 401 });
// user already has an account, don't re-register if (user == null) {
return { ok: false, error: "invalidAuth" };
}
if (user.username != null) { if (user.username != null) {
logger.info( logger.info(
{ username: user.username, id: user.id }, { username: user.username, id: user.id },
`user tried to register twice` `user tried to register twice`
); );
return new Response(null, { status: 403 }); return { ok: false, error: "invalidAuth" };
} }
const { username, displayName, email, password, avatar } = const { username, displayName, email, password, avatar } =
await registerServerSchema.validate(await request.json()); await registerSchema.parse(data);
let avatarBuf = avatar != null ? Buffer.from(avatar, "base64") : null; let avatarBuf = null;
if (avatar != null) {
const parts = avatar.split(",");
const data = parts.length === 2 ? parts[1] : parts[0];
avatarBuf = Buffer.from(data, "base64");
}
const users = await ldap.getUsers(); const users = await ldap.getUsers();
for (const user of users) { for (const user of users) {
if (user.id.toLowerCase() === username.toLowerCase()) { if (user.id.toLowerCase() === username.toLowerCase()) {
return new Response( return {
JSON.stringify({ ok: false,
ok: false, error: "usernameTaken"
error: "usernameTaken" };
}),
{ status: 400 }
);
} }
} }
@ -49,10 +58,5 @@ export async function POST(request: Request) {
}); });
logger.info(outputUser, "registered user"); logger.info(outputUser, "registered user");
return new Response( return { ok: true };
JSON.stringify({
ok: true
}),
{ status: 201 }
);
} }

View File

@ -1,3 +1,6 @@
"use server";
import { ValidAuthProvider } from "@/auth/AuthProvider";
import { import {
AuthState, AuthState,
getAuthState, getAuthState,
@ -35,8 +38,7 @@ async function deleteUser(id: number) {
} }
}); });
} }
export default async function unlink(provider?: ValidAuthProvider) {
export async function POST(request: Request) {
const authState = await getAuthState(); const authState = await getAuthState();
if (authState == AuthState.Registering) { if (authState == AuthState.Registering) {
@ -49,30 +51,22 @@ export async function POST(request: Request) {
await deleteUser(registeringUser.id); await deleteUser(registeringUser.id);
return new Response(null, { status: 200 }); return;
} }
const user = await getUser(); const user = await getUser();
if (user == null) return new Response(null, { status: 401 }); if (user == null) return;
const { searchParams } = new URL(request.url);
const provider = searchParams.get("provider");
switch (provider) { switch (provider) {
case "discord": case "Discord":
const discord = await user.getDiscord(); const discord = await user.getDiscord();
if (discord == null) return new Response(null, { status: 400 }); if (discord == null) return;
await unlinkDiscord(await discord.getId()); await unlinkDiscord(await discord.getId());
break; break;
case "github": case "GitHub":
const github = await user.getGitHub(); const github = await user.getGitHub();
if (github == null) return new Response(null, { status: 400 }); if (github == null) return;
await unlinkGitHub(await github.getId()); await unlinkGitHub(await github.getId());
break; break;
default:
return new Response(null, { status: 400 });
} }
return new Response(null, { status: 200 });
} }

View File

@ -1,30 +1,28 @@
"use server";
import { AboutMeSchema, aboutMeSchema } from "@/schemas";
import { ActionResponse } from ".";
import { getLogger } from "@/logger";
import { getUser } from "@/auth/auth"; import { getUser } from "@/auth/auth";
import { getUserInfo, updateUser } from "@/ldap"; import { getUserInfo, updateUser } from "@/ldap";
import { getLogger } from "@/logger";
type RequestBody = { const logger = getLogger("/actions/update");
displayName?: string;
email?: string;
avatar?: string;
};
export async function POST(request: Request) {
const logger = getLogger("/api/update");
export default async function update(
data: AboutMeSchema
): Promise<ActionResponse> {
const user = await getUser(); const user = await getUser();
if (user == null) return new Response(null, { status: 401 }); if (user == null) {
return { ok: false, error: "invalidAuth" };
}
const userInfo = await getUserInfo(user); const userInfo = await getUserInfo(user);
if (userInfo == null) { if (userInfo == null) {
// no user info = hasn't registered yet // no user info = hasn't registered yet
return new Response(null, { status: 409 }); return { ok: false, error: "invalidAuth" };
} }
const { const { displayName, email, avatar } = await aboutMeSchema.parse(data);
displayName,
email,
avatar: avatarBase64
} = (await request.json()) as RequestBody;
let changeDisplayName = false; let changeDisplayName = false;
if ( if (
@ -47,25 +45,24 @@ export async function POST(request: Request) {
let avatarBuf = undefined; let avatarBuf = undefined;
if ( if (
avatarBase64 !== undefined && avatar !== undefined &&
typeof avatarBase64 === "string" && typeof avatar === "string" &&
avatarBase64 !== userInfo.avatar avatar !== userInfo.avatar
) { ) {
avatarBuf = Buffer.from(avatarBase64, "base64"); const parts = avatar.split(",");
const data = parts.length === 2 ? parts[1] : parts[0];
avatarBuf = Buffer.from(data, "base64");
if (avatarBuf.length > 1_000_000) { if (avatarBuf.length > 2_000_000) {
return new Response( return {
JSON.stringify({ ok: false,
ok: false, error: "avatarBig"
error: "avatarBig" };
}),
{ status: 400 }
);
} }
} }
if (!changeDisplayName && !changeEmail && !avatarBuf) { if (!changeDisplayName && !changeEmail && !avatarBuf) {
return new Response(null, { status: 200 }); return { ok: true };
} }
await updateUser( await updateUser(
@ -85,12 +82,5 @@ export async function POST(request: Request) {
"updated user" "updated user"
); );
return new Response( return { ok: true };
JSON.stringify({
ok: true
}),
{
status: 200
}
);
} }

View File

@ -1,29 +0,0 @@
import { authTicketLogin } from "@/auth/auth";
import * as ldap from "@/ldap";
import { loginSchema } from "@/schemas";
type RequestBody = {
username: string;
password: string;
};
export async function POST(request: Request) {
const { username, password } = await loginSchema.validate(
await request.json()
);
const valid = await ldap.validateUser(username, password);
if (!valid) {
return new Response(
JSON.stringify({
ok: false,
error: "invalidCredentials"
}),
{ status: 401 }
);
}
const [_, ticket] = await authTicketLogin(username);
// not confident if we can set-cookie and I cba to try
return new Response(JSON.stringify({ ok: true, ticket: ticket.ticket }));
}

View File

@ -0,0 +1,41 @@
import { NextRequest } from "next/server";
import prisma from "@/prisma";
import * as ldap from "@/ldap";
export const dynamic = "force-dynamic";
export async function GET(
request: NextRequest,
{ params }: { params: { username: string } }
) {
const { username } = params;
if (
process.env.API_TOKEN == null ||
process.env.API_TOKEN !== request.headers.get("Authorization")
) {
return new Response(null, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { username: username as string }
});
if (user == null) {
return new Response(null, { status: 404 });
}
const ldapUser = await ldap.getUserInfo(user);
if (ldapUser == null) {
return new Response(null, { status: 404 });
}
return new Response(
JSON.stringify({
...ldapUser,
avatar: ldapUser.avatar ?? null,
discordId: ldapUser.discordId ?? null,
githubId: ldapUser.githubId ?? null
})
);
}

53
src/app/fonts.css Normal file
View File

@ -0,0 +1,53 @@
@font-face {
font-family: lunchtype;
font-weight: 400;
src: url("/fonts/lunchtype22-regular.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-style: italic;
src: url("/fonts/lunchtype22-regular-italic.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
src: url("/fonts/lunchtype22-medium.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-style: italic;
src: url("/fonts/lunchtype22-medium.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-stretch: condensed;
src: url("/fonts/lunchtype25-regular-condensed.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-stretch: condensed;
src: url("/fonts/lunchtype25-medium-condensed.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 400;
font-stretch: expanded;
src: url("/fonts/lunchtype24-regular-expanded.woff2") format("woff2");
}
@font-face {
font-family: lunchtype;
font-weight: 500;
font-stretch: expanded;
src: url("/fonts/lunchtype24-medium-expanded.woff2") format("woff2");
}

View File

@ -48,6 +48,16 @@
:root { :root {
--theme-transition: 0.5s ease; --theme-transition: 0.5s ease;
/* Defined here for Firefox, which doesn't support @property */
--bg: #2d2a2e;
--bg-dark: #403e41;
--bg-darker: #221f22;
--fg: #fcfcfa;
--fg-dark: #727072;
--fg-darker: #5b595c;
--error: #ff6188;
--warning: #ffd866;
} }
* { * {
@ -62,27 +72,46 @@ body {
overflow-x: hidden; overflow-x: hidden;
color: var(--fg); color: var(--fg);
background-color: var(--bg); background-color: var(--bg);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, font-family: lunchtype, system-ui, -apple-system, BlinkMacSystemFont,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue",
sans-serif;
}
h2 {
font-size: 2rem;
font-stretch: expanded;
font-weight: 500;
} }
html, html,
body, body,
input, input,
button,
label { label {
transition: background-color var(--theme-transition), transition: background-color var(--theme-transition),
color var(--theme-transition); color var(--theme-transition);
} }
input:disabled, button:disabled {
opacity: 0.5;
cursor: not-allowed !important;
}
input, input,
button { button {
font: inherit; font: inherit;
color: inherit; color: inherit;
background-color: inherit;
}
button,
input[type="submit"] {
padding: 0.5em 1em;
background-color: var(--bg-dark);
border-radius: 0.25rem;
border: none;
cursor: pointer;
}
input:disabled,
button:disabled {
opacity: 0.5;
cursor: not-allowed !important;
} }
input::placeholder { input::placeholder {
@ -94,3 +123,8 @@ a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
} }
::selection {
background: var(--fg);
color: var(--bg);
}

View File

@ -1,5 +1,6 @@
import ColorChanger from "@/components/ColorChanger"; import ColorChanger from "@/components/ColorChanger";
import "./globals.css"; import "./globals.css";
import "./fonts.css";
export const metadata = { export const metadata = {
title: "gluestick", title: "gluestick",

View File

@ -1,54 +1,34 @@
"use client"; "use client";
import login from "@/actions/login";
import Input from "@/components/Input"; import Input from "@/components/Input";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import { LoginFormValues, loginSchema } from "@/schemas"; import { LoginSchema, loginSchema } from "@/schemas";
import { Form, Formik, FormikHelpers, FormikValues } from "formik"; import { Form, Formik, FormikHelpers, FormikValues } from "formik";
import React from "react"; import React from "react";
import { toFormikValidationSchema } from "zod-formik-adapter";
export default function LoginForm() { export default function LoginForm() {
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
async function handleFormSubmit( async function handleFormSubmit(
{ username, password }: LoginFormValues, data: LoginSchema,
{ setSubmitting }: FormikHelpers<LoginFormValues> { setSubmitting }: FormikHelpers<LoginSchema>
) { ) {
setSubmitting(true); setSubmitting(true);
if (username === "greets") { if (data.username === "greets") {
window.location.href = "/sekrit"; window.location.href = "/sekrit";
return; return;
} }
const req = await fetch("/api/login", { const res = await login(data);
method: "POST", if (res.ok) {
headers: { document.cookie = `ticket=${res.ticket}; path=/;`;
"Content-Type": "application/json" window.location.href = "/me";
}, } else {
body: JSON.stringify({ // only error is invalidCredentials, I am lazy
username, setGlobalError("Invalid credentials.");
password
})
});
try {
const res: {
ok: boolean;
error?: string;
ticket: string;
} = await req.json();
if (res.ok) {
document.cookie = `ticket=${res.ticket}; path=/;`;
window.location.href = "/me";
} else {
// only error is invalidCredentials, I am lazy
setGlobalError("Invalid credentials.");
}
} catch (err) {
console.error(err);
setGlobalError("shits fucked up yo");
setSubmitting(false);
} }
} }
@ -57,7 +37,7 @@ export default function LoginForm() {
<Formik <Formik
initialValues={{ username: "", password: "" }} initialValues={{ username: "", password: "" }}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
validationSchema={loginSchema} validationSchema={toFormikValidationSchema(loginSchema)}
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<Form> <Form>

View File

@ -1,28 +1,37 @@
.content { .content {
max-width: 700px; width: min-content;
margin: 0 auto; margin: 2rem auto;
} }
.profileGrid { .profileGrid {
/* todo */ display: grid;
grid-template-columns: 300px 1fr;
column-gap: 2rem;
max-width: 100vw;
} }
.divider { .profileTower *:first-child {
width: 400px; margin-top: 0 !important;
background-color: var(--fg-darker);
height: 1px;
border: none;
margin: 1rem auto;
} }
.logout { .connections {
background: var(--bg-dark); margin-top: 1rem;
border: 0; }
border-radius: 0.15rem;
cursor: pointer; .connections > *:nth-child(2) {
padding: 0.5em 1em; margin-top: 0.5rem;
}
.rightGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
column-gap: 2rem;
}
.userName {
font-size: 3rem;
text-transform: uppercase;
margin: 0;
} }
/* stolen from prettyform */ /* stolen from prettyform */
@ -32,6 +41,8 @@
border-radius: 0.15rem; border-radius: 0.15rem;
cursor: pointer; cursor: pointer;
padding: 0.5em 1em; padding: 0.5em 1em;
width: 100%;
} }
.authProviderList { .authProviderList {
@ -48,25 +59,32 @@
height: 100%; height: 100%;
} }
/* the !importants here piss me off but it wouldn't accept the property otherwise */ .multiButtons {
.progress { margin: 1rem 0;
background: linear-gradient( white-space: nowrap;
to right, display: flex;
var(--fg-darker) 50%, justify-content: space-between;
var(--bg-dark) 50% }
) !important;
background-size: 200% 100% !important;
background-position: right bottom !important;
transition: all 0s linear !important;
.spacer {
border: 0; border: 0;
border-radius: 0.15rem; border-bottom: 1px solid var(--fg-darker);
cursor: pointer; width: 100%;
padding: 0.5em 1em; margin: 1rem 0;
} }
/* when clicked */ /* stack if we're out of space */
.progress:active { @media (max-width: 800px) {
transition: all 3s linear !important; .profileGrid {
background-position: left bottom !important; grid-template-columns: 1fr;
}
.profileGrid > * {
max-width: 100vw;
}
.rightGrid {
display: flex;
flex-direction: column;
}
} }

View File

@ -5,100 +5,35 @@ import { UserInfo } from "@/ldap";
import React from "react"; import React from "react";
import styles from "./AboutMe.module.css"; import styles from "./AboutMe.module.css";
import AvatarChanger from "@/components/AvatarChanger"; import AvatarChanger from "@/components/AvatarChanger";
import Input from "@/components/Input"; import Input, { Hint, Label } from "@/components/Input";
import { Form, Formik, FormikHelpers } from "formik"; import { Form, Formik, FormikHelpers } from "formik";
import {
AboutMeFormValues,
PasswordUpdateFormValues,
aboutMeSchema,
passwordUpdateSchema
} from "@/schemas";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import Toast from "@/components/Toast"; import Toast from "@/components/Toast";
import { AuthProviderState } from "@/auth/AuthProvider"; import { AuthProviderState } from "@/auth/AuthProvider";
import { exec } from "child_process"; import Connection from "@/components/Connection";
import DiscordIcon from "@/components/icons/DiscordIcon";
type UpdateResponse = { import GitHubIcon from "@/components/icons/GitHubIcon";
ok: boolean; import TailscaleIcon from "@/components/icons/TailscaleIcon";
error?: string; import MigaduIcon from "@/components/icons/MigaduIcon";
}; import { AboutMeSchema, aboutMeSchema } from "@/schemas";
import update from "@/actions/update";
async function fileAsBase64(f: File) { import { toFormikValidationSchema } from "zod-formik-adapter";
const reader = new FileReader(); import { useRouter } from "next/navigation";
reader.readAsArrayBuffer(f);
return new Promise<string>((resolve, reject) => {
reader.onload = () => {
const result = reader.result as ArrayBuffer;
const buffer = Buffer.from(result);
resolve(buffer.toString("base64"));
};
reader.onerror = () => reject(reader.error);
});
}
function AuthProviderButton({ provider }: { provider: AuthProviderState }) {
// bullshit hack
const holdTime = provider.connected ? 3000 : 0;
const interval = React.useRef<NodeJS.Timeout | null>();
const inputRef = React.useRef<HTMLInputElement>(null);
const execute = async () => {
const name = provider.name.toLowerCase();
if (!provider.connected) {
window.location.href = `/oauth/${name}/login`;
} else {
await fetch(`/api/unlink?provider=${name}`, { method: "POST" });
window.location.reload();
}
};
const mouseDown = () => {
interval.current = setTimeout(execute, holdTime);
};
const mouseUp = () => {
if (interval.current) clearTimeout(interval.current);
};
return (
<input
type="submit"
className={
styles.fancyInput + " " + (provider.connected ? styles.progress : "")
}
onMouseDown={mouseDown}
onMouseUp={mouseUp}
value={provider.connected ? "Disconnect" : "Connect"}
ref={inputRef}
/>
);
}
function AuthProviderEntry({ provider }: { provider: AuthProviderState }) {
return (
<>
<p>
{provider.name}:{" "}
{provider.connected ? provider.username : "Not connected"}
</p>
<AuthProviderButton provider={provider} />
</>
);
}
export default function AboutMe({ export default function AboutMe({
info, info,
providers providers: [discordState, githubState]
}: { }: {
info: UserInfo; info: UserInfo;
providers: AuthProviderState[]; providers: AuthProviderState[];
}) { }) {
// TODO: Reimplement password changing.
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const [madeProfileChanges, setMadeChanges] = React.useState(false); const [madeProfileChanges, setMadeChanges] = React.useState(false);
const [madePasswordChanges, setMadePasswordChanges] = React.useState(false); const [madePasswordChanges, setMadePasswordChanges] = React.useState(false);
const router = useRouter();
const initialValues: AboutMeFormValues = { const initialValues: AboutMeSchema = {
username: info.username, username: info.username,
displayName: info.displayName, displayName: info.displayName,
email: info.email, email: info.email,
@ -106,205 +41,142 @@ export default function AboutMe({
}; };
async function handleFormSubmit( async function handleFormSubmit(
{ displayName, email, avatar }: AboutMeFormValues, data: AboutMeSchema,
{ setSubmitting }: FormikHelpers<AboutMeFormValues> { setSubmitting }: FormikHelpers<AboutMeSchema>
) { ) {
setMadeChanges(false); setMadeChanges(false);
setSubmitting(true); setSubmitting(true);
const req = await fetch("/api/update", { const res = await update(data);
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
displayName,
email,
avatar: avatar != null ? avatar.split(",")[1] : null
})
});
setSubmitting(false); setSubmitting(false);
try { if (res.ok) {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "avatarBig":
break;
}
}
setMadeChanges(true); setMadeChanges(true);
} catch { } else {
console.error(req); if (res.error != undefined) {
} setGlobalError("Unknown error: " + res.error);
}
const [passwordError, setPasswordError] = React.useState<string | null>(null);
const initialPasswordValues: PasswordUpdateFormValues = {
password: "",
newPassword: "",
confirmPassword: ""
};
async function handlePasswordSubmit(
{ password, newPassword }: PasswordUpdateFormValues,
{ setFieldError, setSubmitting }: FormikHelpers<PasswordUpdateFormValues>
) {
setMadePasswordChanges(false);
setSubmitting(true);
const req = await fetch("/api/changePassword", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
currentPassword: password,
newPassword: newPassword
})
});
setSubmitting(false);
try {
const res: UpdateResponse = await req.json();
if (!res.ok && res.error !== null) {
switch (res.error) {
case "incorrectPassword":
setFieldError("password", "Incorrect password.");
break;
}
} }
setMadePasswordChanges(true);
} catch {
console.error(req);
} }
} }
return ( return (
<div className={styles.content}> <div className={styles.content}>
<h2 className={styles.userName}>{info.username}</h2>
<PrettyForm globalError={globalError}> <PrettyForm globalError={globalError}>
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
validationSchema={aboutMeSchema} validationSchema={toFormikValidationSchema(aboutMeSchema)}
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<Form className={styles.profileGrid}> <Form className={styles.profileGrid}>
{madeProfileChanges ? <Toast>Saved your changes.</Toast> : null} <div className={styles.profileTower}>
<Input <Input
type="text" type="file"
name="username" name="avatar"
label="Username" customRender={(fieldProps) => (
defaultValue={info.username} <AvatarChanger
disabled currentAvatarBlob={fieldProps.field.value}
title="You can't change your username." onChange={(newBlob) =>
/> fieldProps.form.setFieldValue("avatar", newBlob)
<Input }
type="text" vertical
name="displayName" />
label="Display name" )}
defaultValue={info.displayName} />
/> </div>
<Input
type="email"
name="email"
label="Email"
defaultValue={info.email}
/>
<Input <div>
type="file" <h2 className={styles.userName}>{info.username}</h2>
name="avatar" <div className={styles.rightGrid}>
label="Avatar" <div className={styles.profile}>
accept="image/png, image/jpeg" {madeProfileChanges ? (
customRender={(fieldProps) => ( <Toast>Saved your changes.</Toast>
<AvatarChanger ) : null}
currentAvatarBlob={fieldProps.field.value} <Input
onChange={(newBlob) => type="text"
fieldProps.form.setFieldValue("avatar", newBlob) name="username"
} label="Username"
/> defaultValue={info.username}
)} disabled
/> hint="This can&rsquo;t be changed."
/>
<Input
type="text"
name="displayName"
label="Display name"
defaultValue={info.displayName}
/>
<Input
type="email"
name="email"
label="Email"
defaultValue={info.email}
/>
<input <hr className={styles.spacer} />
type="submit"
value="Save" <input
className={styles.fancyInput} type="submit"
disabled={isSubmitting} value="Save"
/> className={styles.fancyInput}
disabled={isSubmitting}
/>
<div className={styles.multiButtons}>
<button
type="button"
onClick={() => {
router.push("/me/change-password");
}}
>
Change password
</button>
<button
type="button"
onClick={async () => {
document.cookie =
"ticket=; expires=" +
new Date().toUTCString() +
"; path=/";
window.location.href = "/";
}}
>
Log out
</button>
</div>
</div>
<div className={styles.connections}>
<Label>Connections</Label>
<Hint>Click to link, hold to unlink.</Hint>
<Connection
service="Discord"
authState={discordState}
icon={DiscordIcon}
/>
<Connection
service="GitHub"
authState={githubState}
icon={GitHubIcon}
/>
<Connection
service="Tailscale"
icon={TailscaleIcon}
unavailable
/>
<Connection
service="Migadu"
icon={MigaduIcon}
unavailable
/>
</div>
</div>
</div>
</Form> </Form>
)} )}
</Formik> </Formik>
</PrettyForm> </PrettyForm>
<hr className={styles.divider} />
<h2 className={styles.header}>Change password</h2>
<PrettyForm globalError={passwordError}>
<Formik
initialValues={initialPasswordValues}
onSubmit={handlePasswordSubmit}
validationSchema={passwordUpdateSchema}
>
{({ isSubmitting }) => (
<Form>
{madePasswordChanges ? (
<Toast>Changed your password.</Toast>
) : null}
<Input
type="password"
name="password"
label="Current"
minLength={12}
required
/>
<Input
type="password"
name="newPassword"
label="New"
minLength={12}
required
/>
<Input
type="password"
name="confirmPassword"
label="Confirm"
minLength={12}
required
/>
<input
type="submit"
value="Save"
className={styles.fancyInput}
disabled={isSubmitting}
/>
</Form>
)}
</Formik>
</PrettyForm>
<hr className={styles.divider} />
<h2 className={styles.header}>Connections</h2>
<div className={styles.authProviderList}>
{providers.map((provider) => (
<AuthProviderEntry provider={provider} key={provider.name} />
))}
</div>
<hr className={styles.divider} />
<input
type="button"
value="Log out"
className={styles.logout}
onClick={async () => {
document.cookie =
"ticket=; expires=" + new Date().toUTCString() + "; path=/";
window.location.href = "/";
}}
/>
</div> </div>
); );
} }

View File

@ -0,0 +1,76 @@
"use client";
import changePassword from "@/actions/changePassword";
import Input from "@/components/Input";
import PrettyForm from "@/components/PrettyForm";
import { PasswordUpdateSchema, passwordUpdateSchema } from "@/schemas";
import { Form, Formik, FormikHelpers } from "formik";
import { useRouter } from "next/navigation";
import React from "react";
import { toFormikValidationSchema } from "zod-formik-adapter";
export default function ChangePasswordForm({
onSuccess
}: {
onSuccess?: () => void;
}) {
const [globalError, setGlobalError] = React.useState<string | null>(null);
const router = useRouter();
const initialValues: PasswordUpdateSchema = {
password: "",
newPassword: "",
confirmPassword: ""
};
async function handleFormSubmit(
data: PasswordUpdateSchema,
helpers: FormikHelpers<PasswordUpdateSchema>
) {
helpers.setSubmitting(true);
setGlobalError(null);
const res = await changePassword(data);
if (!res.ok) {
setGlobalError(res.error!); // should probably make this more human readable :trolley:
} else {
if (onSuccess == null) {
console.log("changed password :3");
router.push("/me");
} else {
onSuccess();
}
}
helpers.setSubmitting(false);
}
return (
<>
<PrettyForm globalError={globalError}>
<Formik
initialValues={initialValues}
onSubmit={handleFormSubmit}
validationSchema={toFormikValidationSchema(passwordUpdateSchema)}
>
{({ isSubmitting }) => (
<Form>
<Input type="password" name="password" label="Current Password" />
<Input type="password" name="newPassword" label="New Password" />
<Input
type="password"
name="confirmPassword"
label="Confirm New Password"
hint="Re-enter your new password. Better safe than sorry!"
/>
<button type="submit" disabled={isSubmitting}>
Change Password
</button>
</Form>
)}
</Formik>
</PrettyForm>
</>
);
}

View File

@ -0,0 +1,11 @@
import ChangePasswordForm from "./ChangePasswordForm";
export default function ChangePassword() {
return (
// fuck it im lazy
<div style={{ maxWidth: "400px", margin: "2rem auto" }}>
<h1>Change Password</h1>
<ChangePasswordForm />
</div>
);
}

View File

@ -1,6 +1,8 @@
import { DiscordAuthProvider } from "@/auth/discord"; import { DiscordAuthProvider } from "@/auth/discord";
import { v4 } from "uuid"; import { v4 } from "uuid";
export const dynamic = "force-dynamic";
export async function GET(request: Request) { export async function GET(request: Request) {
let url = `https://discord.com/oauth2/authorize`; let url = `https://discord.com/oauth2/authorize`;
let state = v4(); let state = v4();

View File

@ -21,11 +21,6 @@ export async function GET(request: Request) {
const id = await provider.getId(); const id = await provider.getId();
const permitted = await provider.isPermitted(); const permitted = await provider.isPermitted();
if (!permitted) {
logger.info({ id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
// If someone clicked register on the front page with an existing account, // If someone clicked register on the front page with an existing account,
// wire it to their user via the auth ticket // wire it to their user via the auth ticket
let gluestickId = null; let gluestickId = null;
@ -35,6 +30,11 @@ export async function GET(request: Request) {
gluestickId = currentUser!.id; gluestickId = currentUser!.id;
} }
if (!permitted && gluestickId == null) {
logger.info({ id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
const userId = await DiscordAuthProvider.update( const userId = await DiscordAuthProvider.update(
id, id,
tokenBody.access_token, tokenBody.access_token,

View File

@ -1,6 +1,8 @@
import { GitHubAuthProvider } from "@/auth/github"; import { GitHubAuthProvider } from "@/auth/github";
import { v4 } from "uuid"; import { v4 } from "uuid";
export const dynamic = "force-dynamic";
export async function GET(request: Request) { export async function GET(request: Request) {
let url = `https://github.com/login/oauth/authorize`; let url = `https://github.com/login/oauth/authorize`;
let state = v4(); let state = v4();

View File

@ -1,5 +1,5 @@
import { getLogger } from "@/logger"; import { getLogger } from "@/logger";
import { GitHubAuthProvider } from "@/auth/github"; import { GitHubAuthProvider, inviteToGitHub } from "@/auth/github";
import { import {
AuthState, AuthState,
authTicketOAuth, authTicketOAuth,
@ -21,11 +21,6 @@ export async function GET(request: Request) {
const id = await provider.getId(); const id = await provider.getId();
const permitted = await provider.isPermitted(); const permitted = await provider.isPermitted();
if (!permitted) {
logger.info({ id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
// If someone clicked register on the front page with an existing account, // If someone clicked register on the front page with an existing account,
// wire it to their user via the auth ticket // wire it to their user via the auth ticket
let gluestickId = null; let gluestickId = null;
@ -35,6 +30,11 @@ export async function GET(request: Request) {
gluestickId = currentUser!.id; gluestickId = currentUser!.id;
} }
if (!permitted && gluestickId == null) {
logger.info({ id }, "user tried to sign up");
return new Response("not permitted to register account", { status: 403 });
}
const userId = await GitHubAuthProvider.update( const userId = await GitHubAuthProvider.update(
id, id,
tokenBody.access_token, tokenBody.access_token,
@ -42,6 +42,7 @@ export async function GET(request: Request) {
); );
if (gluestickId != null) { if (gluestickId != null) {
await inviteToGitHub(provider);
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
@ -52,6 +53,16 @@ export async function GET(request: Request) {
const [user, authTicket] = await authTicketOAuth(userId); const [user, authTicket] = await authTicketOAuth(userId);
if (user?.username !== null) {
return new Response(null, {
status: 302,
headers: {
"Set-Cookie": `ticket=${authTicket.ticket}; Path=/;`,
Location: "/me"
}
});
}
const username = await provider.getDisplayName(); const username = await provider.getDisplayName();
const email = await provider.getEmail(); const email = await provider.getEmail();
const avatarUrl = await provider.getAvatar(); const avatarUrl = await provider.getAvatar();

View File

@ -3,26 +3,36 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
} }
.form { .content {
margin-top: 2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-stretch: expanded;
line-height: 1.5;
} }
.form div { .content p {
text-align: center;
}
.icons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; align-items: center;
padding: 1rem;
} }
.form div label { .icons svg {
margin-right: 1rem; width: 4rem;
height: 4rem;
padding: 0.5rem;
} }
.form div input { .icons svg:hover {
width: 15rem; background-color: var(--bg-dark);
} border-radius: 0.25rem;
}

View File

@ -1,21 +1,31 @@
import Logo from "@/components/Logo";
import styles from "./page.module.css"; import styles from "./page.module.css";
import Image from "next/image"; import NotNetIcon from "@/components/icons/NotNetIcon";
import GitHubIcon from "@/components/icons/GitHubIcon";
import DiscordIcon from "@/components/icons/DiscordIcon";
export default function Home() { export default function Home() {
return ( return (
<main className={styles.main}> <main className={styles.main}>
<Image src="/icon.svg" alt="gluestick logo" width="256" height="256" /> <Logo />
<p <div className={styles.content}>
style={{ <p>login with</p>
display: "flex",
flexDirection: "column" <div className={styles.icons}>
}} <a href="/login" title="NotNet">
> <NotNetIcon />
<a href="/login">login</a> </a>
<a href="/oauth/discord/login">register (discord)</a>
<a href="/oauth/github/login">register (github)</a> <a href="/oauth/discord/login" title="Discord">
</p> <DiscordIcon />
</a>
<a href="/oauth/github/login" title="GitHub">
<GitHubIcon />
</a>
</div>
</div>
</main> </main>
); );
} }

View File

@ -2,20 +2,17 @@
import React from "react"; import React from "react";
import styles from "./RegisterForm.module.css"; import styles from "./RegisterForm.module.css";
import { Form, Formik, FormikHelpers, yupToFormErrors } from "formik"; import { Form, Formik, FormikHelpers } from "formik";
import { RegisterFormValues, registerSchema } from "@/schemas"; import { registerSchema, RegisterSchema } from "@/schemas";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { fileAsBase64 } from "@/forms";
import Input from "@/components/Input"; import Input from "@/components/Input";
import PrettyForm from "@/components/PrettyForm"; import PrettyForm from "@/components/PrettyForm";
import HugeSubmit from "@/components/HugeSubmit"; import HugeSubmit from "@/components/HugeSubmit";
import AvatarChanger from "@/components/AvatarChanger"; import AvatarChanger from "@/components/AvatarChanger";
import { ValidAuthProvider } from "@/auth/AuthProvider"; import { ValidAuthProvider } from "@/auth/AuthProvider";
import { toFormikValidationSchema } from "zod-formik-adapter";
type RegisterResponse = { import register from "@/actions/register";
ok: boolean; import unlink from "@/actions/unlink";
error?: string;
};
export default function RegisterForm({ export default function RegisterForm({
initialDisplayName, initialDisplayName,
@ -31,7 +28,7 @@ export default function RegisterForm({
const [globalError, setGlobalError] = React.useState<string | null>(null); const [globalError, setGlobalError] = React.useState<string | null>(null);
const router = useRouter(); const router = useRouter();
const initialValues: RegisterFormValues = { const initialValues: RegisterSchema = {
username: "", username: "",
displayName: initialDisplayName ?? "", displayName: initialDisplayName ?? "",
email: initialEmail ?? "", email: initialEmail ?? "",
@ -41,49 +38,33 @@ export default function RegisterForm({
}; };
async function handleFormSubmit( async function handleFormSubmit(
{ avatar, username, displayName, email, password }: RegisterFormValues, data: RegisterSchema,
{ setFieldError, setSubmitting }: FormikHelpers<RegisterFormValues> { setFieldError, setSubmitting }: FormikHelpers<RegisterSchema>
) { ) {
setSubmitting(true); setSubmitting(true);
const resp = await fetch(`/api/register`, { const res = await register(data);
method: "POST", if (res.ok) {
headers: { router.replace("/me");
"Content-Type": "application/json" } else {
}, if (res.error !== null) {
body: JSON.stringify({ switch (res.error) {
username, case "avatarBig":
displayName, setFieldError(
email, "avatar",
password, "avatar was too big, but only the server caught you what the fuck are you doing!!"
avatar: avatar != null ? avatar.split(",")[1] : undefined );
}) break;
});
try { case "usernameTaken":
const res: RegisterResponse = await resp.json(); setFieldError("username", "Username is already taken.");
break;
if (res.ok) { default:
router.replace("/me"); setGlobalError("Unknown error: " + res.error);
} else { break;
if (res.error !== null) {
switch (res.error) {
case "avatarBig":
setFieldError(
"avatar",
"avatar was too big, but only the server caught you what the fuck are you doing!!"
);
break;
case "usernameTaken":
setFieldError("username", "Username is already taken.");
break;
}
} }
} }
} catch (err) {
console.error(err);
setGlobalError("you done fucked up kiddo");
} }
setSubmitting(false); setSubmitting(false);
@ -94,7 +75,7 @@ export default function RegisterForm({
<Formik <Formik
initialValues={initialValues} initialValues={initialValues}
onSubmit={handleFormSubmit} onSubmit={handleFormSubmit}
validationSchema={registerSchema} validationSchema={toFormikValidationSchema(registerSchema)}
enableReinitialize enableReinitialize
> >
{({ isSubmitting }) => ( {({ isSubmitting }) => (
@ -141,7 +122,7 @@ export default function RegisterForm({
(avatarSource != null (avatarSource != null
? `We found your avatar from ${avatarSource}, but you can change it if you'd like.` ? `We found your avatar from ${avatarSource}, but you can change it if you'd like.`
: "") + : "") +
" This will automatically be used as your avatar with supported services - maximum 1 MB. " " This will automatically be used as your avatar with supported services - maximum 2 MB. "
} }
type="file" type="file"
name="avatar" name="avatar"
@ -163,7 +144,7 @@ export default function RegisterForm({
<a <a
className={styles.bail} className={styles.bail}
onClick={async () => { onClick={async () => {
await fetch("/api/unlink", { method: "POST" }); await unlink();
document.cookie = document.cookie =
"ticket=; expires=" + new Date().toUTCString() + "; path=/"; "ticket=; expires=" + new Date().toUTCString() + "; path=/";
window.location.href = "/"; window.location.href = "/";

View File

@ -20,10 +20,6 @@ function avatarUrlSource(url: URL): ValidAuthProvider | null {
return null; return null;
} }
function avatarUrlAllowed(url: URL): boolean {
return avatarUrlSource(url) !== null;
}
export default async function Page({ export default async function Page({
searchParams searchParams
}: { }: {
@ -55,7 +51,7 @@ export default async function Page({
const blob = await req.blob(); const blob = await req.blob();
const arrayBuffer = await blob.arrayBuffer(); const arrayBuffer = await blob.arrayBuffer();
const buffer = Buffer.from(arrayBuffer); const buffer = Buffer.from(arrayBuffer);
if (buffer.length <= 1_000_000) { if (buffer.length <= 2_000_000) {
// I hope you are doing well, you deserve the best of luck while working on this project -Ari // I hope you are doing well, you deserve the best of luck while working on this project -Ari
try { try {
const jpg = await ensureJpg(buffer); const jpg = await ensureJpg(buffer);

View File

@ -2,7 +2,7 @@ export type ValidAuthProvider = "Discord" | "GitHub";
// Can't send the providers across the wire, do this instead // Can't send the providers across the wire, do this instead
export type AuthProviderState = { export type AuthProviderState = {
name: string; name: ValidAuthProvider;
} & ({ connected: false } | { connected: true; id: string; username: string }); } & ({ connected: false } | { connected: true; id: string; username: string });
export abstract class AuthProvider { export abstract class AuthProvider {

View File

@ -68,7 +68,7 @@ export class DiscordAuthProvider extends AuthProvider {
async getAvatar(): Promise<string | null> { async getAvatar(): Promise<string | null> {
const me = await this.getMe(); const me = await this.getMe();
return me.avatar !== null return me.avatar !== null
? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png` ? `https://cdn.discordapp.com/avatars/${me.id}/${me.avatar}.png?size=1024`
: null; : null;
} }

View File

@ -14,6 +14,18 @@ type GitHubUserResponse = {
email: string; email: string;
}; };
async function getMembers(): Promise<GitHubUserResponse[]> {
const req = await fetch(
`https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`,
{
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}
}
);
return await req.json();
}
export class GitHubAuthProvider extends AuthProvider { export class GitHubAuthProvider extends AuthProvider {
private async getMe(): Promise<GitHubUserResponse> { private async getMe(): Promise<GitHubUserResponse> {
const req = await fetch("https://api.github.com/user", { const req = await fetch("https://api.github.com/user", {
@ -27,16 +39,8 @@ export class GitHubAuthProvider extends AuthProvider {
async isPermitted(): Promise<boolean> { async isPermitted(): Promise<boolean> {
const me = await this.getMe(); const me = await this.getMe();
const req = await fetch( const members = await getMembers();
`https://api.github.com/orgs/${process.env.GITHUB_ORG}/members`, return members.some((user) => user.login === me.login);
{
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
}
}
);
const res: GitHubUserResponse[] = await req.json();
return res.some((user) => user.login === me.login);
} }
async getDisplayName(): Promise<string> { async getDisplayName(): Promise<string> {
@ -139,3 +143,24 @@ export class GitHubAuthProvider extends AuthProvider {
return a.userId; return a.userId;
} }
} }
export async function inviteToGitHub(auth: GitHubAuthProvider) {
const id = await auth.getId();
const members = await getMembers();
if (members.find((x) => x.id === parseInt(id))) return;
await fetch(
`https://api.github.com/orgs/${process.env.GITHUB_ORG}/invitations`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github.v3+json"
},
body: JSON.stringify({
invitee_id: parseInt(id),
role: "direct_member"
})
}
);
}

View File

@ -2,32 +2,44 @@
display: flex; display: flex;
flex-flow: row nowrap; flex-flow: row nowrap;
gap: 1rem; gap: 1rem;
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.avatarChanger :is(img, svg) { .vertical {
flex-direction: column;
}
.avatarChanger .currentAvatar,
.avatarChanger svg {
width: 3em; width: 3em;
height: 3em; height: 3em;
border-radius: 0.25rem; border-radius: 0.25rem;
} }
.vertical.vertical .currentAvatar {
display: block;
width: 100%;
height: inherit;
aspect-ratio: 1/1;
}
.avatarChanger button svg { .avatarChanger button svg {
width: 1.2em; width: 1.2em;
height: 1.2em; height: 1.2em;
margin-right: 0.5em; margin-right: 0.5em;
} }
.avatarChanger input[type=file] { .avatarChanger input[type="file"] {
display: none; display: none;
} }
.uploadButton { .uploadButton {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
background: var(--bg-dark); background: var(--bg-dark);
border: 0; border: 0;
border-radius: 0.15rem; border-radius: 0.15rem;
padding: 0.25em 1em; padding: 0.5em 1em;
cursor: pointer; cursor: pointer;
} }

View File

@ -1,3 +1,4 @@
/* eslint-disable @next/next/no-img-element */
import React, { ChangeEvent } from "react"; import React, { ChangeEvent } from "react";
import classnames from "classnames"; import classnames from "classnames";
@ -8,10 +9,12 @@ import UserIcon from "./icons/UserIcon";
export default function AvatarChanger({ export default function AvatarChanger({
currentAvatarBlob, currentAvatarBlob,
onChange onChange,
vertical = false
}: { }: {
currentAvatarBlob: string | null; currentAvatarBlob: string | null;
onChange: (newAvatar: string) => void; onChange: (newAvatar: string) => void;
vertical?: boolean;
}) { }) {
const input = React.useRef<HTMLInputElement>(null); const input = React.useRef<HTMLInputElement>(null);
@ -25,11 +28,20 @@ export default function AvatarChanger({
// I give you the most support and well wishes while you work on this project -Ari // I give you the most support and well wishes while you work on this project -Ari
return ( return (
<div className={classnames(styles.avatarChanger, "avatar-changer")}> <div
className={classnames(
styles.avatarChanger,
vertical ? styles.vertical : null
)}
>
{currentAvatarBlob != null ? ( {currentAvatarBlob != null ? (
<img src={currentAvatarBlob!} alt="Your avatar" /> <img
className={styles.currentAvatar}
src={currentAvatarBlob!}
alt="Your avatar"
/>
) : ( ) : (
<UserIcon /> <UserIcon className={styles.currentAvatar} />
)} )}
<button <button

View File

@ -1,14 +1,12 @@
.colorChanger { .colorChanger {
position: fixed; position: fixed;
right: 0; right: 10px;
bottom: 0; bottom: 10px;
transition: filter 100ms ease-in-out;
padding: 8px; filter: grayscale(100%) opacity(20%);
cursor: pointer;
opacity: 30%;
transition: opacity 0.25s ease-in-out;
} }
.colorChanger:hover { .colorChanger:hover {
opacity: 100%; filter: none;
} }

View File

@ -179,8 +179,8 @@ export default function ColorChanger() {
return ( return (
<Image <Image
src="/paint.svg" src="/paint.svg"
width="64" width="32"
height="64" height="32"
alt="paint" alt="paint"
title={current} title={current}
onClick={() => { onClick={() => {

View File

@ -0,0 +1,80 @@
.connection {
padding: 1rem 2rem;
background-color: var(--bg-dark);
margin: 1rem 0;
border-radius: 0.25rem;
width: 300px;
display: flex;
flex-flow: row nowrap;
column-gap: 1rem;
align-items: center;
text-align: left;
}
.connection .iconContainer > svg {
width: 3rem;
height: 3rem;
margin-left: auto;
}
.connection > svg {
height: 1.5rem;
margin-left: auto;
}
.connection .dot {
width: 1rem;
height: 1rem;
margin: 1rem;
background-color: var(--fg);
border-radius: 50%;
}
.iconContainer {
font-size: 2.5em;
margin-right: 0.75rem;
display: flex;
align-items: center;
}
.info {
display: flex;
flex-flow: column nowrap;
row-gap: 0.25rem;
}
.serviceName {
font-weight: 500;
font-stretch: expanded;
font-size: 1.3em;
}
.linkedIdentity {
opacity: 0.7;
}
.unavailable.unavailable {
background-color: var(--bg-darker);
color: var(--fg-dark);
opacity: 0.5;
cursor: not-allowed;
}
/* the !importants here piss me off but it wouldn't accept the property otherwise */
.progress {
background: linear-gradient(
to right,
var(--fg-darker) 50%,
var(--bg-dark) 50%
) !important;
background-size: 200% 100% !important;
background-position: right bottom !important;
transition: all 0s linear !important;
}
/* when clicked */
.progress:active {
transition: all 3s linear !important;
background-position: left bottom !important;
}

View File

@ -0,0 +1,79 @@
import React from "react";
import styles from "./Connection.module.css";
import classnames from "classnames";
import CheckIcon from "./icons/CheckIcon";
import { type AuthProviderState } from "@/auth/AuthProvider";
import { useRouter } from "next/navigation";
import unlink from "@/actions/unlink";
export default function Connection({
service,
unavailable = false,
authState,
icon
}: {
service: string;
unavailable?: boolean;
authState?: AuthProviderState;
icon?: () => JSX.Element;
}) {
const router = useRouter();
const holdTime = authState?.connected ? 3000 : 0;
const interval = React.useRef<NodeJS.Timeout | null>();
const execute = async () => {
const name = authState?.name;
if (!authState?.connected) {
router.push(`/oauth/${name?.toLowerCase()}/login`);
} else {
await unlink(name);
router.refresh();
}
};
const down = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
interval.current = setTimeout(execute, holdTime);
};
const up = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
if (interval.current) clearTimeout(interval.current);
};
return (
<button
type="button"
className={classnames(
styles.connection,
unavailable ? styles.unavailable : null,
!authState?.connected ? styles.disconnected : styles.progress
)}
disabled={unavailable}
onMouseDown={down}
onMouseUp={up}
onTouchStart={down}
onTouchEnd={up}
>
<div className={styles.iconContainer}>
{icon ? icon() : <span className={styles.dot}></span>}
</div>
<div className={styles.info}>
<div className={styles.serviceName}>{service}</div>
{authState?.connected !== false ? (
<div
className={styles.linkedIdentity}
title={
authState?.id ?? "This integration is currently unavailable."
}
>
{unavailable ? "Unavailable" : authState!.username}
</div>
) : null}
</div>
{authState?.connected && !unavailable ? <CheckIcon /> : null}
</button>
);
}

View File

@ -13,7 +13,7 @@
margin: 1rem 0; margin: 1rem 0;
} }
.formRow label { .label {
display: block; display: block;
font-variant: all-small-caps; font-variant: all-small-caps;
font-size: 105%; font-size: 105%;

View File

@ -1,6 +1,7 @@
import { Field, FieldProps, FieldAttributes, FormikProps } from "formik"; import { Field, FieldProps, FieldAttributes, FormikProps } from "formik";
import React from "react"; import React, { LabelHTMLAttributes } from "react";
import styles from "./Input.module.css"; import styles from "./Input.module.css";
import classnames from "classnames";
type CustomInputProps<T> = { type CustomInputProps<T> = {
customRender?: (fieldProps: FieldProps) => React.ReactNode; customRender?: (fieldProps: FieldProps) => React.ReactNode;
@ -11,15 +12,37 @@ type CustomInputProps<T> = {
) => void; ) => void;
}; };
export function Label({
children,
...props
}: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label className={classnames(styles.label, props.className)} {...props}>
{children}
</label>
);
}
export function Hint({
children,
...props
}: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label className={classnames(styles.hint, props.className)} {...props}>
{children}
</label>
);
}
export default function Input<T>( export default function Input<T>(
props: CustomInputProps<T> & props: CustomInputProps<T> &
FieldAttributes<{ hint?: string; label: string; disabled?: boolean }> FieldAttributes<{ hint?: string; label?: string; disabled?: boolean }>
) { ) {
const generatedId = React.useId(); const generatedId = React.useId();
return ( return (
<div className={styles.formRow}> <div className={classnames("form-row", styles.formRow)}>
<label htmlFor={generatedId}>{props.label}</label> {props.label ? <Label htmlFor={generatedId}>{props.label}</Label> : null}
<Field id={generatedId} {...props}> <Field id={generatedId} {...props}>
{(fieldProps: FieldProps) => { {(fieldProps: FieldProps) => {
let { field, meta, form } = fieldProps; let { field, meta, form } = fieldProps;
@ -51,7 +74,6 @@ export default function Input<T>(
title={props.title} title={props.title}
{...inputFields} {...inputFields}
onChange={(event) => { onChange={(event) => {
console.log(event);
if (props.customOnChange) { if (props.customOnChange) {
console.log("using custom on change"); console.log("using custom on change");
props.customOnChange(event, form); props.customOnChange(event, form);

View File

@ -0,0 +1,3 @@
.logo {
max-width: 700px;
}

115
src/components/Logo.tsx Normal file
View File

@ -0,0 +1,115 @@
import React from "react";
import styles from "./Logo.module.css";
export default function Logo() {
return (
<svg
viewBox="0 0 385 100"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
className={styles.logo}
>
<defs>
<linearGradient
x1="0%"
y1="50%"
x2="99.3908435%"
y2="50%"
id="linearGradient-neowcgjypg-1"
>
<stop stopColor="currentColor" offset="0%"></stop>
<stop
stopColor="currentColor"
stopOpacity="0.15"
offset="100%"
></stop>
</linearGradient>
<path
d="M3.8458278,-5.15911009e-17 L13.2928824,-1.94256078e-16 C14.6301605,-7.98342804e-16 15.1150899,0.139238417 15.6039788,0.400699056 C16.0928677,0.662159695 16.4765505,1.04584256 16.7380112,1.53473144 C16.9994718,2.02362033 17.1387102,2.50854969 17.1387102,3.8458278 L17.1387102,50.4723403 C17.1387102,51.8096184 16.9994718,52.2945477 16.7380112,52.7834366 C16.4765505,53.2723255 16.0928677,53.6560084 15.6039788,53.917469 C15.1150899,54.1789296 14.6301605,54.3181681 13.2928824,54.3181681 L3.8458278,54.3181681 C2.50854969,54.3181681 2.02362033,54.1789296 1.53473144,53.917469 C1.04584256,53.6560084 0.662159695,53.2723255 0.400699056,52.7834366 C0.139238417,52.2945477 6.81641737e-16,51.8096184 -2.80991422e-15,50.4723403 L-2.71214089e-16,3.8458278 C-7.93416146e-16,2.50854969 0.139238417,2.02362033 0.400699056,1.53473144 C0.662159695,1.04584256 1.04584256,0.662159695 1.53473144,0.400699056 C2.02362033,0.139238417 2.50854969,-8.81235264e-16 3.8458278,-5.15911009e-17 Z"
id="path-neowcgjypg-2"
></path>
<rect
id="path-neowcgjypg-4"
x="7.20209614e-15"
y="-4.94132864e-15"
width="26.0091852"
height="21.4354612"
></rect>
</defs>
<g id="Logo" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g
id="Logos/Ari-Inspired-Dark"
transform="translate(-73.669529, -60.500000)"
>
<g id="Group" transform="translate(73.669529, 60.500000)">
<path
d="M27.072843,77.2472777 C33.3387658,83.1496155 40.9633072,86.1007845 49.9464672,86.1007845 C63.4212073,86.1007845 141.296207,74.7480477 207.142001,74.7480477 C251.039197,74.7480477 306.545511,77.2640648 373.660942,82.296099"
id="glue"
stroke="url(#linearGradient-neowcgjypg-1)"
strokeWidth="15"
strokeLinecap="round"
strokeLinejoin="round"
></path>
<text
id="text"
fontFamily="lunchtype"
fontSize="75.8217291"
fontStretch="expanded"
fontWeight="500"
letterSpacing="-3.37037037"
fill="currentColor"
>
<tspan x="45.3058029" y="72">
gluestick
</tspan>
</text>
<g
id="gluestick"
transform="translate(19.029903, 46.901517) rotate(-10.000000) translate(-19.029903, -46.901517) translate(6.025311, 11.065381)"
>
<path
d="M21.6562264,60.1722714 L20.1948974,69.3055775 C20.1062227,69.8597942 20.0511475,70.0678092 19.91889,70.2630516 C19.367519,70.6507522 19.1534152,70.6722714 18.5921494,70.6722714 L8.61779953,70.6722714 C8.05653367,70.6722714 7.84242989,70.6507522 7.62874419,70.5510023 C7.15880144,70.0678092 7.10372619,69.8597942 7.01505152,69.3055775 L5.55372254,60.1722714 L21.6562264,60.1722714 Z"
id="glue"
stroke="currentColor"
strokeWidth="2"
fill="currentColor"
></path>
<g id="Rectangle-2" transform="translate(5.000000, 6.000000)">
<mask id="mask-neowcgjypg-3" fill="white">
<use xlinkHref="#path-neowcgjypg-2"></use>
</mask>
<path
stroke="currentColor"
strokeWidth="2"
d="M13.2928824,-1 C14.9133577,-1 15.4869268,-0.795928047 16.0755776,-0.481114142 C16.7413126,-0.125075161 17.2637854,0.397397573 17.6198244,1.06313259 C17.9346383,1.65178341 18.1387102,2.22535247 18.1387102,3.8458278 L18.1387102,50.4723403 C18.1387102,52.0928156 17.9346383,52.6663846 17.6198244,53.2550355 C17.2637854,53.9207705 16.7413126,54.4432432 16.0755776,54.7992822 C15.4869268,55.1140961 14.9133577,55.3181681 13.2928824,55.3181681 L3.8458278,55.3181681 C2.22535247,55.3181681 1.65178341,55.1140961 1.06313259,54.7992822 C0.397397573,54.4432432 -0.125075161,53.9207705 -0.481114142,53.2550355 C-0.795928047,52.6663846 -1,52.0928156 -1,50.4723403 L-1,3.8458278 C-1,2.22535247 -0.795928047,1.65178341 -0.481114142,1.06313259 C-0.125075161,0.397397573 0.397397573,-0.125075161 1.06313259,-0.481114142 C1.65178341,-0.795928047 2.22535247,-1 3.8458278,-1 Z"
></path>
</g>
<g
id="orange-border-stick-2"
transform="translate(-0.000000, -0.000000)"
>
<mask id="mask-neowcgjypg-5" fill="white">
<use xlinkHref="#path-neowcgjypg-4"></use>
</mask>
<g id="Rectangle"></g>
<path
d="M8.8458278,6 L18.2928824,6 C19.6301605,6 20.1150899,6.13923842 20.6039788,6.40069906 C21.0928677,6.66215969 21.4765505,7.04584256 21.7380112,7.53473144 C21.9994718,8.02362033 22.1387102,8.50854969 22.1387102,9.8458278 L22.1387102,56.4723403 C22.1387102,57.8096184 21.9994718,58.2945477 21.7380112,58.7834366 C21.4765505,59.2723255 21.0928677,59.6560084 20.6039788,59.917469 C20.1150899,60.1789296 19.6301605,60.3181681 18.2928824,60.3181681 L8.8458278,60.3181681 C7.50854969,60.3181681 7.02362033,60.1789296 6.53473144,59.917469 C6.04584256,59.6560084 5.66215969,59.2723255 5.40069906,58.7834366 C5.13923842,58.2945477 5,57.8096184 5,56.4723403 L5,9.8458278 C5,8.50854969 5.13923842,8.02362033 5.40069906,7.53473144 C5.66215969,7.04584256 6.04584256,6.66215969 6.53473144,6.40069906 C7.02362033,6.13923842 7.50854969,6 8.8458278,6 Z"
id="orange-border-stick"
fill="currentColor"
mask="url(#mask-neowcgjypg-5)"
></path>
</g>
<path
d="M8,6 L19.1387102,6 C20.7955645,6 22.1387102,7.34314575 22.1387102,9 L22.1387102,21.444419 L22.1387102,21.444419 L5,21.444419 L5,9 C5,7.34314575 6.34314575,6 8,6 Z"
id="orange-portion"
fill="currentColor"
></path>
</g>
</g>
</g>
</g>
</svg>
);
}

View File

@ -1,7 +1,3 @@
.form {
max-width: 500px;
}
.form :is(button, input)[type="submit"] { .form :is(button, input)[type="submit"] {
background: var(--bg-dark); background: var(--bg-dark);
border: 0; border: 0;

View File

@ -5,7 +5,7 @@ export default function PrettyForm({
globalError, globalError,
children children
}: { }: {
globalError: string | null; globalError?: string | null;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (

View File

@ -6,8 +6,8 @@ export default function CheckIcon() {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
> >
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="check" fill="currentColor" fill-rule="nonzero"> <g id="check" fill="currentColor" fillRule="nonzero">
<path <path
d="M107.782834,4.91476009 C111.16323,-0.155833631 118.014111,-1.52600976 123.084704,1.85438606 C128.155298,5.23478187 129.525474,12.0856625 126.145078,17.1562562 L64.5253312,123.085877 C60.662855,128.879591 52.465466,129.691293 47.5417556,124.767582 L3.23188204,89.4577087 C-1.07729401,85.1485327 -1.07729401,78.1619779 3.23188204,73.8528018 C7.54105809,69.5436258 14.5276129,69.5436258 18.8367889,73.8528018 L53.6283699,99.643429 L107.782834,4.91476009 Z" d="M107.782834,4.91476009 C111.16323,-0.155833631 118.014111,-1.52600976 123.084704,1.85438606 C128.155298,5.23478187 129.525474,12.0856625 126.145078,17.1562562 L64.5253312,123.085877 C60.662855,128.879591 52.465466,129.691293 47.5417556,124.767582 L3.23188204,89.4577087 C-1.07729401,85.1485327 -1.07729401,78.1619779 3.23188204,73.8528018 C7.54105809,69.5436258 14.5276129,69.5436258 18.8367889,73.8528018 L53.6283699,99.643429 L107.782834,4.91476009 Z"
id="Path-4" id="Path-4"

View File

@ -0,0 +1,13 @@
import React from "react";
// https://discord.com/branding
export default function DiscordIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36">
<path
fill="currentColor"
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import React from "react";
// https://github.com/logos
export default function GitHubIcon() {
return (
<svg
width="98"
height="96"
viewBox="0 0 98 96"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import React from "react";
// https://www.migadu.com/svg/logo_bg.svg
export default function MigaduIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fillRule="evenodd"
strokeLinejoin="round"
strokeMiterlimit={2}
clipRule="evenodd"
viewBox="0 0 415 349"
>
<path
fill="currentColor"
d="M244.75086422 348.3169321l-74.66970891.13434972-.20802007-113.77942005c1.68490153-30.73219447-16.74452614-54.87687007-47.61447477-56.04309707-18.69104704-.70603615-48.77863129 14.51785772-47.40962266 54.1403628 2.27958386 13.48489808 11.02256637 27.6516038 23.33561618 34.76524588 5.96894497 2.99415378 13.0523201 5.05841775 21.20064188 6.21929758 1.00064603.0642412 2.02105696.10203019 3.05792476.11007084l.00049907.27737388.42275408.04877057-.17002297 74.34665265-.1188864-.00639026.00000594.00330207C41.90779697 346.73036845-.82135413 282.37044643.16572087 221.6698232l-.00648785-3.60586044c-.01158759-.93446799-.01322144-1.84253724-.0048956-2.72090568-.01813527-13.74983137-.0434497-25.98399478-.08764166-43.20426606-.0013546-.75287196.0104753-1.51897597.03547784-2.30491616l-.10376496-50.33012717C1.41495687 53.41005395 55.4632909.11958949 121.93065794-.00000214c64.55216642-.1161457 117.56801589 49.96027937 122.08713264 113.39832671l.31397589.15463287c14.5939395-6.20445116 30.64246059-9.64767795 47.48962173-9.67799025l.78919473-.00141997c67.06911019.3052941 121.48637072 54.79077748 121.60722189 121.95818335l.04528936 32.51220498c.07884347 56.66686486.06505123 71.02433651.1745432 89.66768709l-74.66970891.1343497-.22422783-122.787467c-.73513767-25.01193853-19.72143626-44.82988648-44.42788174-46.81951503l-6.60414.01188252-.30703904.03027117c-2.13273916.22507674-4.25538198.5558019-6.34804476 1.03176467-3.89817024.88206518-7.69644066 2.22954399-11.2693623 4.02570031-4.6220412 2.3197727-8.86297784 5.39833827-12.49193835 9.08338558-5.5110203 5.59043208-9.56991873 12.57503157-11.72580294 20.12416492-.79737077 2.79829702-1.3403455 5.67208417-1.61581096 8.56850457-.15237519 1.56876249-.21557667 3.14727077-.2226489 4.72237598l.21983257 122.17989207zM74.54003064 143.61350486c9.74771064-.0175386 17.67371156 7.88328793 17.69124422 17.6276965.0175386 9.74771064-7.87997992 17.6770077-17.62769056 17.6945463-9.74771064.0175386-17.67371156-7.88328794-17.69125016-17.63099858-.01753266-9.74440857 7.87998586-17.67370562 17.6276965-17.69124422z"
/>
</svg>
);
}

View File

@ -0,0 +1,72 @@
import React from "react";
export default function NotNetIcon() {
return (
<svg
width="4"
height="4"
viewBox="0 0 4 4"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
>
<g id="notnet">
<g id="red">
<rect
fill="currentColor"
id="rect1"
width="2"
height="1"
x="1"
y="0"
/>
<rect
fill="currentColor"
id="rect2"
width="1"
height="2"
x="0"
y="1"
/>
<rect
fill="currentColor"
id="rect3"
width="1"
height="1"
x="1"
y="2"
/>
</g>
<g id="blue">
<rect
fill="currentColor"
id="rect4"
width="2"
height="1"
x="-3"
y="-4"
transform="scale(-1)"
/>
<rect
fill="currentColor"
id="rect5"
width="1"
height="2"
x="-4"
y="-3"
transform="scale(-1)"
/>
<rect
fill="currentColor"
id="rect6"
width="1"
height="1"
x="-3"
y="-2"
transform="scale(-1)"
/>
</g>
</g>
</svg>
);
}

View File

@ -0,0 +1,26 @@
import React from "react";
// https://tailscale.com/files/dist/tailscale-press-kit.zip
export default function TailscaleIcon() {
return (
<svg
fill="none"
height="120"
viewBox="30 30 60 60"
width="120"
xmlns="http://www.w3.org/2000/svg"
>
<g fill="currentColor">
<circle cx="40.625" cy="59.5" r="6.625" />
<circle cx="60.4999" cy="59.5" r="6.625" />
<circle cx="40.625" cy="79.375" opacity=".2" r="6.625" />
<circle cx="80.375" cy="79.375" opacity=".2" r="6.625" />
<circle cx="60.4999" cy="79.375" r="6.625" />
<circle cx="80.375" cy="59.5" r="6.625" />
<circle cx="40.625" cy="39.625" opacity=".2" r="6.625" />
<circle cx="60.4999" cy="39.625" opacity=".2" r="6.625" />
<circle cx="80.375" cy="39.625" opacity=".2" r="6.625" />
</g>
</svg>
);
}

View File

@ -1,12 +1,13 @@
import React from "react"; import React from "react";
export default function UserIcon() { export default function UserIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
viewBox="0 0 128 128" viewBox="0 0 128 128"
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink" xmlnsXlink="http://www.w3.org/1999/xlink"
{...props}
> >
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd"> <g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<circle fill="currentColor" cx="64" cy="48" r="32"></circle> <circle fill="currentColor" cx="64" cy="48" r="32"></circle>

View File

@ -1,7 +1,7 @@
import sharp from "sharp"; import sharp from "sharp";
export async function ensureJpg(avatar: Buffer) { export async function ensureJpg(avatar: Buffer) {
const img = await sharp(avatar).toFormat("jpeg").resize(512, 512); const img = await sharp(avatar).toFormat("jpeg").resize(1024, 1024);
const buf = await img.toBuffer(); const buf = await img.toBuffer();
return buf.toString("base64"); return buf.toString("base64");
} }

View File

@ -1,7 +1,9 @@
import pino from "pino"; import pino from "pino";
const logger = pino(); if (global.logger == null) {
global.logger = pino();
}
export function getLogger(name: string) { export function getLogger(name: string) {
return logger.child({ name }); return global.logger.child({ name });
} }

View File

@ -1,107 +1,71 @@
import * as Yup from "yup"; import { z } from "zod";
const REQUIRED = "Required."; const USERNAME = z
const USERNAME = Yup.string() .string()
.required(REQUIRED) .min(1, "Username is too short.")
.min(1, "Username is too short."); .regex(/^[a-z0-9]+$/, "Username must be lowercase alphanumeric.");
const DISPLAY_NAME = Yup.string() const DISPLAY_NAME = z.string().min(1, "Display name is too short.");
.required(REQUIRED) const EMAIL = z.string().email("Not an email.");
.min(1, "Display name is too short."); const PASSWORD = z
const EMAIL = Yup.string().required(REQUIRED).email("Not an email."); .string()
const PASSWORD = Yup.string()
.required(REQUIRED)
.min(12, "Password must be at least 12 characters long."); .min(12, "Password must be at least 12 characters long.");
const CONFIRM_PASSWORD = (name: string) => const AVATAR = z.string().refine(
Yup.string() (val) => {
.required(REQUIRED) const parts = val.split(",");
.oneOf([Yup.ref(name, {})], "Passwords must match."); const data = parts.length === 2 ? parts[1] : parts[0];
const AVATAR = Yup.string().test(
"file-size",
"File is bigger than 1 MB.",
(value) => {
if (value == null) return true;
try { try {
const buf = Buffer.from(value, "base64"); const buf = Buffer.from(data, "base64");
return buf.length <= 1_000_000; return buf.length <= 2_000_000;
} catch (e) { } catch (e) {
return false; return false;
} }
},
{
message: "File is bigger than 2 MB.",
path: ["avatar"]
} }
); );
export const loginSchema = Yup.object().shape({ export const loginSchema = z.object({
username: USERNAME, username: USERNAME,
password: PASSWORD password: PASSWORD
}); });
export type LoginSchema = z.infer<typeof loginSchema>;
export type LoginFormValues = { export const registerSchema = z
username: string; .object({
password: string;
};
export const registerSchema: Yup.Schema<RegisterFormValues> =
Yup.object().shape({
username: USERNAME, username: USERNAME,
displayName: DISPLAY_NAME, displayName: DISPLAY_NAME,
email: EMAIL, email: EMAIL,
password: PASSWORD, password: PASSWORD,
confirmPassword: CONFIRM_PASSWORD("password"), confirmPassword: PASSWORD,
avatar: AVATAR avatar: AVATAR.optional()
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"]
}); });
export interface RegisterFormValues { export type RegisterSchema = z.infer<typeof registerSchema>;
username: string;
displayName: string;
email: string;
password: string;
confirmPassword: string;
avatar?: string;
}
export const aboutMeSchema: Yup.Schema<AboutMeFormValues> = Yup.object().shape({ export const aboutMeSchema = z.object({
username: USERNAME, username: USERNAME,
displayName: DISPLAY_NAME, displayName: DISPLAY_NAME,
email: EMAIL, email: EMAIL,
avatar: AVATAR avatar: AVATAR.optional()
}); });
export type AboutMeSchema = z.infer<typeof aboutMeSchema>;
export interface AboutMeFormValues { export const passwordUpdateSchema = z
username: string; .object({
displayName: string;
email: string;
avatar?: string;
}
export const passwordUpdateSchema: Yup.Schema<PasswordUpdateFormValues> =
Yup.object().shape({
password: PASSWORD, password: PASSWORD,
newPassword: PASSWORD, newPassword: PASSWORD,
confirmPassword: CONFIRM_PASSWORD("newPassword") confirmPassword: PASSWORD
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords do not match.",
path: ["confirmPassword"]
}); });
export interface PasswordUpdateFormValues { export type PasswordUpdateSchema = z.infer<typeof passwordUpdateSchema>;
password: string;
newPassword: string;
confirmPassword: string;
}
// Types specific to the server, because sometimes we omit fields (like confirmPassword)
export const registerServerSchema: Yup.Schema<RegisterServerFormValues> =
Yup.object().shape({
username: USERNAME,
displayName: DISPLAY_NAME,
email: EMAIL,
password: PASSWORD,
avatar: AVATAR
});
export interface RegisterServerFormValues {
username: string;
displayName: string;
email: string;
password: string;
avatar?: string;
}