temp commit

This commit is contained in:
Lea 2024-01-22 22:35:01 +01:00
parent 504c3c055b
commit be4aaf0dd4
Signed by: Lea
GPG key ID: 1BAFFE8347019C42
9 changed files with 365 additions and 30 deletions

View file

@ -37,5 +37,6 @@ Check out our [Next.js deployment documentation](https://nextjs.org/docs/deploym
### notes to add later
```sql
CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, alias TEXT NOT NULL, pending INTEGER DEFAULT 0);
CREATE TABLE aliases (id INTEGER PRIMARY KEY AUTOINCREMENT, address TEXT NOT NULL, alias TEXT NOT NULL, pending INTEGER DEFAULT 0, temporary INTEGER DEFAULT 0);
CREATE TABLE temp_alias_requests (key TEXT PRIMARY KEY, address TEXT NOT NULL, alias TEXT NOT NULL, expires INTEGER NOT NULL);
```

View file

@ -17,6 +17,7 @@
"lucide-react": "^0.311.0",
"next": "14.0.4",
"next-auth": "^4.24.5",
"random-words": "^2.0.0",
"react": "^18",
"react-dom": "^18",
"sqlite3": "^5.1.7"

View file

@ -29,6 +29,9 @@ dependencies:
next-auth:
specifier: ^4.24.5
version: 4.24.5(next@14.0.4)(react-dom@18.2.0)(react@18.2.0)
random-words:
specifier: ^2.0.0
version: 2.0.0
react:
specifier: ^18
version: 18.2.0
@ -4029,6 +4032,12 @@ packages:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
/random-words@2.0.0:
resolution: {integrity: sha512-uqpnDqFnYrZajgmvgjmBrSZL2V1UA/9bNPGrilo12CmBeBszoff/avElutUlwWxG12gvmCk/8dUhvHefYxzYjw==}
dependencies:
seedrandom: 3.0.5
dev: false
/rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
@ -4244,6 +4253,10 @@ packages:
loose-envify: 1.4.0
dev: false
/seedrandom@3.0.5:
resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==}
dev: false
/semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true

View file

@ -3,6 +3,7 @@
import ConnectionDetailsCard from "@/lib/components/ui/user/ConnectionDetailsCard";
import OwnAliasesCard from "@/lib/components/ui/user/OwnAliasesCard";
import OwnCredentialsCard from "@/lib/components/ui/user/OwnCredentialsCard";
import TempAliasesCard from "@/lib/components/ui/user/TempAliasesCard";
import useWindowDimensions from "@/lib/hooks/useWindowDimensions";
import { Grid, Heading } from "@radix-ui/themes";
@ -14,10 +15,11 @@ export default function SelfService() {
<>
<Heading className="pb-4">Account settings</Heading>
<Grid display="inline-grid" columns={mobileUi ? "1" : "3"} gap="4" width={mobileUi ? "100%" : "auto"}>
<Grid display="inline-grid" columns={mobileUi ? "1" : "4"} gap="4" width={mobileUi ? "100%" : "auto"}>
<OwnCredentialsCard />
<ConnectionDetailsCard />
<OwnAliasesCard />
<TempAliasesCard />
</Grid>
</>
);

View file

@ -1,10 +1,13 @@
"use server";
import { getServerSession } from "next-auth";
import { AliasEntry, approveAliasEntry, createAliasEntry, createUserEntry, database, deleteAliasEntry, getAlias, getAllAliases, getUserAliases, setUserPassword } from "./db";
import { aliasesNeedApproval, isAdmin } from "./util";
import { AuditLog, auditLog } from "./audit";
import crypto from "crypto";
import fs from "fs/promises";
import { getServerSession } from "next-auth";
import * as random_words from "random-words";
import { AuditLog, auditLog } from "./audit";
import { AliasEntry, AliasRequestEntry, approveAliasEntry, createAliasEntry, createTempAliasRequestEntry, createUserEntry, database, deleteAliasEntry, deleteTempAliasRequestEntry, getAlias, getAllAliases, getTempAliasRequestEntry, getUserAliases, setUserPassword } from "./db";
import { aliasesNeedApproval, isAdmin } from "./util";
import { TEMP_EMAIL_DOMAIN } from "./constants";
export async function fetchAllUsers(): Promise<string[]> {
return new Promise(async (resolve, reject) => {
@ -28,10 +31,10 @@ export async function changeOwnPassword(newPass: string) {
auditLog("changeOwnPassword");
}
export async function fetchOwnAliases() {
export async function fetchOwnAliases(tempAliases?: boolean) {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
return await getUserAliases(session.user.email);
return await getUserAliases(session.user.email, tempAliases);
}
export async function fetchUserAliases(email: string) {
@ -50,14 +53,14 @@ export async function fetchAllAliases() {
return await getAllAliases();
}
export async function aliasAvailable(email: string) {
export async function aliasAvailable(email: string, searchTempRequests: boolean = false) {
const session = await getServerSession();
if (!session?.user) throw new Error("Unauthenticated");
return new Promise<boolean>((resolve, reject) => {
const db = database('aliases');
db.get('SELECT id FROM aliases WHERE alias = ?', email.toLowerCase(), (err, res) => {
db.close();
if (!searchTempRequests || err) db.close();
if (err) return reject(err);
if (res != undefined) return resolve(false);
@ -65,7 +68,17 @@ export async function aliasAvailable(email: string) {
authDb.get('SELECT key FROM passwords WHERE key = ?', email.toLowerCase(), (err, res) => {
authDb.close();
if (err) return reject(err);
resolve(res == undefined);
if (!searchTempRequests) {
return resolve(res == undefined);
} else {
db.get('SELECT key FROM temp_alias_requests WHERE alias = ?', email.toLowerCase(), (err, res) => {
db.close();
if (err) return reject(err);
return resolve(res == undefined);
});
}
});
});
});
@ -78,11 +91,13 @@ export async function createAlias(user: string, alias: string): Promise<AliasEnt
if (!await aliasAvailable(alias)) throw new Error("Alias unavailable");
const id = await createAliasEntry(user, alias.toLowerCase(), false);
const res = {
id: id,
address: user,
alias: alias,
pending: false,
temporary: false,
};
auditLog('createAlias', res);
@ -101,13 +116,99 @@ export async function createAliasSelf(alias: string): Promise<AliasEntry> {
id: id,
address: session.user.email,
alias: alias,
pending: pending
pending: pending,
temporary: false,
};
auditLog('requestAlias', res);
return res;
}
export async function requestTemporaryAlias(
label: string,
labelAtEnd: boolean,
style: 'words' | 'random',
oldToken?: string,
): Promise<AliasRequestEntry> {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
if (!label.length || label.length > 16) throw new Error("Malformed request");
let email: string;
do {
let randomString: string;
switch (style) {
case 'words':
randomString = random_words.generate(2).join('');
break;
case 'random':
randomString = crypto
.randomBytes(12)
.toString('base64')
.replace(/\W/, ''); // Delete special characters
break;
default:
throw new Error("Invalid style");
}
email = `${labelAtEnd ? `${randomString}-${label}` : `${label}-${randomString}`}@${TEMP_EMAIL_DOMAIN}`;
} while (!await aliasAvailable(email, true));
const request: AliasRequestEntry = {
key: crypto.randomBytes(12).toString('base64'),
address: session.user.email,
alias: email,
expires: Math.floor(Date.now() / 1000) + (60 * 60 * 4), // In 4 hours
};
await createTempAliasRequestEntry(
request.key,
request.address,
request.alias,
request.expires,
);
if (oldToken) {
await deleteTempAliasRequestEntry(oldToken);
}
return request;
}
export async function claimTemporaryAlias(key: string): Promise<AliasEntry> {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
const data = await getTempAliasRequestEntry(key);
if (!data || data.address != session.user.email) throw new Error("Unknown alias key");
await deleteTempAliasRequestEntry(key);
if (data.expires < Math.floor(Date.now() / 1000)) throw new Error("Alias request expired");
const id = await createAliasEntry(data.address, data.alias, false, true);
const alias: AliasEntry = {
id: id,
address: data.address,
alias: data.alias,
pending: false,
temporary: true,
};
return alias;
}
export async function disposeTempAliasRequest(key: string) {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
const data = await getTempAliasRequestEntry(key);
if (!data) return;
if (data.address != session.user.email) throw new Error("Unauthorized");
await deleteTempAliasRequestEntry(key);
}
export async function deleteAlias(alias: string) {
const session = await getServerSession();
if (!session?.user?.email) throw new Error("Unauthenticated");
@ -161,7 +262,7 @@ export async function fetchAuditLog(page: number): Promise<{ page: number, perPa
.split("\n")
.reverse();
if (page > Math.floor(lines.length / itemsPerPage)) {
if (page > Math.floor(lines.length / itemsPerPage)) {
page = Math.ceil(lines.length / itemsPerPage) - 1;
} else if (page < 0) {
page = 0;

View file

@ -21,7 +21,7 @@ export default function OwnAliasesCard() {
const toast = useContext(ToastContext);
useEffect(() => {
fetchOwnAliases().then(setAliases);
fetchOwnAliases(false).then(setAliases);
}, []);
useEffect(() => {

View file

@ -0,0 +1,153 @@
import { disposeTempAliasRequest, fetchOwnAliases, requestTemporaryAlias, claimTemporaryAlias } from "@/lib/actions";
import { GRAVATAR_DEFAULT } from "@/lib/constants";
import { AliasEntry, AliasRequestEntry } from "@/lib/db";
import { sha256sum } from "@/lib/util";
import { Avatar, Button, Card, Dialog, Flex, Heading, IconButton, Select, Text, TextField } from "@radix-ui/themes";
import { CircleUserIcon, RefreshCcwIcon } from "lucide-react";
import { useSession } from "next-auth/react";
import { useEffect, useState } from "react";
export default function TempAliasesCard() {
const session = useSession().data;
const [aliases, setAliases] = useState<AliasEntry[] | null>(null);
const [aliasPreview, setAliasPreview] = useState<AliasRequestEntry | undefined>(undefined);
const [aliasLabel, setAliasLabel] = useState("");
const [labelAtEnd, setLabelAtEnd] = useState(false);
const [aliasStyle, setAliasStyle] = useState<"words" | "random">("words");
const [open, setOpen] = useState(false);
const [created, setCreated] = useState(false);
useEffect(() => {
fetchOwnAliases(true).then(setAliases);
}, []);
const refreshAlias = () => {
if (aliasPreview?.key) disposeTempAliasRequest(aliasPreview.key); // It's fine if this errors
if (!aliasLabel) {
setAliasPreview(undefined);
return;
}
requestTemporaryAlias(
aliasLabel,
labelAtEnd,
aliasStyle,
aliasPreview?.key,
).then(setAliasPreview);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(refreshAlias, [aliasLabel, aliasStyle, labelAtEnd]);
return (
<Card className="h-fit">
<Flex direction="row" justify="between" mb="2">
<Heading size="3">Temporary aliases</Heading>
<Dialog.Root open={open} onOpenChange={(open) => {
setOpen(open);
if (!open) {
setAliasLabel("");
setLabelAtEnd(false);
setAliasStyle("words");
setCreated(false);
}
}}>
<Dialog.Trigger>
<Button variant="outline" size="1">Generate alias</Button>
</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Generate alias</Dialog.Title>
<Dialog.Description>
Temporary aliases can be used to sign up with services without exposing your primary email address.
If an alias starts receiving spam or you no longer use the service, you can delete the alias.
</Dialog.Description>
<Flex direction="row" gap="3" mt="4">
<Flex direction="column" gap="3" grow="1">
<Text ml="1" size="1" color="gray" mb="-1">Alias label</Text>
<TextField.Root>
<TextField.Slot>
<CircleUserIcon size="16" />
</TextField.Slot>
<TextField.Input
placeholder="Alias label"
value={aliasLabel}
onChange={(e) => setAliasLabel(e.currentTarget.value.substring(0, 16))}
/>
</TextField.Root>
</Flex>
<Flex direction="column" gap="3">
<Text ml="1" size="1" color="gray" mb="-1">Alias style</Text>
<Select.Root value={aliasStyle} onValueChange={(value) => setAliasStyle(value as any)}>
<Select.Trigger />
<Select.Content>
<Select.Group>
<Select.Label>Alias style</Select.Label>
<Select.Item value="words">Words</Select.Item>
<Select.Item value="random">Random</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</Flex>
<Flex direction="column" gap="3">
<Text ml="1" size="1" color="gray" mb="-1">Label position</Text>
<Select.Root value={labelAtEnd ? "after" : "before"} onValueChange={(value) => setLabelAtEnd(value == "after")}>
<Select.Trigger />
<Select.Content>
<Select.Group>
<Select.Label>Alias style</Select.Label>
<Select.Item value="before">Label first</Select.Item>
<Select.Item value="after">Label at end</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</Flex>
</Flex>
<Card mt="4">
<Flex direction="row" justify="between" align="center">
<Flex direction="row" align="center" gap="3">
<Avatar
size="3"
src={`https://gravatar.com/avatar/${sha256sum(aliasPreview?.alias || "")}?d=${GRAVATAR_DEFAULT}`}
fallback={"@"}
/>
<Flex direction="column" gap="0">
<Text size="3" weight="medium">Generated alias</Text>
<Text size="2" weight="light">{aliasPreview?.alias || "Enter a label first"}</Text>
</Flex>
</Flex>
<IconButton onClick={() => refreshAlias()} disabled={!aliasLabel} variant="surface" size="2">
<RefreshCcwIcon size="16" />
</IconButton>
</Flex>
</Card>
<Flex gap="3" mt="4" justify="end">
<Dialog.Close>
<Button variant="outline">Close</Button>
</Dialog.Close>
<Button
disabled={!aliasPreview}
variant="soft"
onClick={async () => {
try {
//const alias = await claimTemporaryAlias(aliasPreview!.key);
} catch(e) {
setOpen(false);
}
}}
>
Create
</Button>
</Flex>
</Dialog.Content>
</Dialog.Root>
</Flex>
<Text weight="light" size="2">Freely generate and dispose of randomized aliases.</Text>
</Card>
);
}

View file

@ -8,4 +8,5 @@ export const SMTP_SECURITY = "SSL/TLS";
export const IMAP_SECURITY = "SSL/TLS";
export const WEBMAIL_URL = "https://webmail.amogus.cloud";
export const ALIAS_DOMAINS = ["amogus.cloud", "lea.pet", "futacockinside.me"];
export const GRAVATAR_DEFAULT = "retro";
export const GRAVATAR_DEFAULT = "retro";
export const TEMP_EMAIL_DOMAIN = "t.amogus.cloud";

View file

@ -2,6 +2,9 @@ import sqlite from "sqlite3";
import bcrypt from "bcryptjs";
import { PHASE_PRODUCTION_BUILD } from "next/dist/shared/lib/constants";
export type AliasEntry = { id: number, address: string, alias: string, pending: boolean, temporary: boolean };
export type AliasRequestEntry = { key: string, address: string, alias: string, expires: number };
export const database = (type: 'credentials' | 'aliases') => {
if (process.env.NEXT_PHASE != PHASE_PRODUCTION_BUILD) {
for (const v of ["CREDENTIALS_DB_PATH", "ALIASES_DB_PATH"]) {
@ -75,7 +78,6 @@ export function setUserPassword(email: string, newPass: string) {
});
}
export type AliasEntry = { id: number, address: string, alias: string, pending: boolean };
export function getAllAliases() {
return new Promise<AliasEntry[]>(async (resolve, reject) => {
const db = database('aliases');
@ -86,22 +88,31 @@ export function getAllAliases() {
resolve(res.map((data) => ({
...data,
pending: !!data.pending,
temporary: !!data.temporary,
})));
});
});
}
export function getUserAliases(email: string) {
export function getUserAliases(email: string, tempAliases?: boolean) {
return new Promise<AliasEntry[]>(async (resolve, reject) => {
const db = database('aliases');
db.all("SELECT id, address, alias, pending FROM aliases WHERE address = ?", email, (err, res: any[]) => {
db.close();
if (err) return reject(err);
resolve(res.map((data) => ({
...data,
pending: !!data.pending,
})));
});
db.all(
"SELECT id, address, alias, pending FROM aliases WHERE address = ?1 " + (typeof tempAliases != 'undefined' ? "AND temporary = ?2" : ""),
{
1: email,
2: tempAliases ? 1 : 0,
},
(err, res: any[]) => {
db.close();
if (err) return reject(err);
resolve(res.map((data) => ({
...data,
pending: !!data.pending,
temporary: !!data.temporary,
})));
});
});
}
@ -116,23 +127,25 @@ export function getAlias(alias: string) {
resolve({
...res,
pending: !!res.pending,
temporary: !!res.temporary,
});
});
});
}
export function createAliasEntry(user: string, alias: string, pending: boolean) {
export function createAliasEntry(user: string, alias: string, pending: boolean, temporary: boolean = false) {
return new Promise<number>(async (resolve, reject) => {
const db = database('aliases');
db.run(
"INSERT INTO aliases (address, alias, pending) VALUES (?1, ?2, ?3)",
"INSERT INTO aliases (address, alias, pending, temporary) VALUES (?1, ?2, ?3, ?4)",
{
1: user,
2: alias,
3: pending ? 1 : 0,
4: temporary ? 1 : 0,
},
function(err: any) {
function (err: any) {
db.close();
if (err) return reject(err);
resolve(this.lastID);
@ -140,6 +153,56 @@ export function createAliasEntry(user: string, alias: string, pending: boolean)
});
}
export function createTempAliasRequestEntry(key: string, account: string, alias: string, expires: number) {
return new Promise<number>(async (resolve, reject) => {
const db = database('aliases');
db.run(
"INSERT INTO temp_alias_requests (key, address, alias, expires) VALUES (?1, ?2, ?3, ?4)",
{
1: key,
2: account,
3: alias,
4: expires,
},
function (err: any) {
db.close();
if (err) return reject(err);
resolve(this.lastID);
});
});
}
export function getTempAliasRequestEntry(key: string) {
return new Promise<AliasRequestEntry | undefined>(async (resolve, reject) => {
const db = database('aliases');
db.get(
"SELECT key, address, alias, expires FROM temp_alias_requests WHERE key = ?",
key,
function (err, res: any) {
db.close();
if (err) return reject(err);
resolve(res);
});
});
}
export function deleteTempAliasRequestEntry(key: string) {
return new Promise<void>(async (resolve, reject) => {
const db = database('aliases');
db.run(
"DELETE FROM temp_alias_requests WHERE key = ?",
key,
function (err: any) {
db.close();
if (err) return reject(err);
resolve();
});
});
}
export function approveAliasEntry(alias: string) {
return new Promise<void>(async (resolve, reject) => {
const db = database('aliases');
@ -147,7 +210,7 @@ export function approveAliasEntry(alias: string) {
db.run(
"UPDATE aliases SET pending = 0 WHERE alias = ?",
alias,
function(err: any) {
function (err: any) {
db.close();
if (err) return reject(err);
resolve();
@ -162,7 +225,7 @@ export function deleteAliasEntry(alias: string) {
db.run(
"DELETE FROM aliases WHERE alias = ?",
alias,
function(err: any) {
function (err: any) {
db.close();
if (err) return reject(err);
resolve();