This is hard

Dealing with realtime is actually really hard (imposter syndrome is hitting real hard rn)
This commit is contained in:
Nyxian 2024-12-13 17:46:14 -03:00
parent eef9803249
commit 17ce35ed6c
23 changed files with 759 additions and 292 deletions

BIN
database.types.ts Normal file

Binary file not shown.

14
package-lock.json generated
View file

@ -28,6 +28,7 @@
"lucide-react": "^0.468.0",
"next": "15.0.4",
"next-themes": "^0.4.4",
"random-words": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5",
@ -5774,6 +5775,14 @@
}
]
},
"node_modules/random-words": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/random-words/-/random-words-2.0.1.tgz",
"integrity": "sha512-nZNJAmgcFmtJMTDDIUCm/iK4R6RydC6NvALvWhYItXQrgYGk1F7Gww416LpVROFQtfVd5TaLEf4WuSsko03N7w==",
"dependencies": {
"seedrandom": "^3.0.5"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
@ -5971,6 +5980,11 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="
},
"node_modules/semver": {
"version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",

View file

@ -29,6 +29,7 @@
"lucide-react": "^0.468.0",
"next": "15.0.4",
"next-themes": "^0.4.4",
"random-words": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5",

View file

@ -32,7 +32,7 @@ export async function POST(request: Request) {
// Fetch our custom user data
const {data: userData, error: userError} = await supabase
.from('users')
.select('*')
.select('*, public_key')
.eq('uuid', user?.id)
.single()

View file

@ -0,0 +1,58 @@
import {NextResponse} from "next/server";
import {createClient} from "@/lib/supabase/server";
import getUserByUUID from "@/lib/api/helpers/getUserByUUID";
import updateUserRequests from "@/lib/api/helpers/updateUserRequests";
export async function POST(req: Request) {
const {participant} = await req.json();
if (!participant) {
return NextResponse.json({error: 'Participant not found'}, {status: 400});
}
const supabase = await createClient()
const {data: {user}, error: userError} = await supabase.auth.getUser()
console.log("From user: ", user?.id)
if (userError) {
return NextResponse.json(
{error: userError},
{status: userError?.status}
)
} else if (!user) {
return NextResponse.json(
{error: "User not found"},
{status: 401}
)
}
/** First we need to check if the requested participant is in the user's request array */
const dbUser = await getUserByUUID(supabase, user.id)
if (!dbUser) {
return NextResponse.json(
{error: "User not found"},
{status: 401}
)
}
const requests = dbUser.requests as string[]
if (!requests.includes(participant)) {
return NextResponse.json({error: "Requested user not in requests array."}, {status: 400})
} else if (participant === dbUser.suuid) {
return NextResponse.json({error: "Cannot add self to a new thread"}, {status: 400})
}
/** Then we can create the thread */
const {error} = await supabase.rpc('create_private_thread', {
participant_suuid: participant
});
if (error) {
return NextResponse.json({error}, {status: 500});
}
return NextResponse.json({success: true}, {status: 200});
}

View file

@ -26,8 +26,8 @@ export async function GET() {
}
)
if (data.length === 0) {
return NextResponse.json({threads: []}, {status: 200});
if (error) {
return NextResponse.json({error}, {status: 400})
}
return NextResponse.json({threads: data}, {status: 200});

View file

@ -28,15 +28,12 @@ export async function GET(request: Request) {
)
}
const rpcResult = await supabase.rpc('search_users', {
const {data, error} = await supabase.rpc('search_users', {
search_term: uuid
});
const {data, error} = rpcResult;
if (error) {
return NextResponse.json({error: error}, {status: 500});
} else if (data.length === 0) {
return NextResponse.json({user: []}, {status: 200});
}
return NextResponse.json({exists: !!(data[0].suuid && data[0].username)}, {status: 200});

View file

View file

@ -2,28 +2,7 @@ import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";
import {SupabaseClient} from "@supabase/supabase-js";
import getUserByUUID from "@/lib/api/helpers/getUserByUUID";
async function updateUserRequests(searchTerm: string, requestSuuid: string, supabase: SupabaseClient<any, "public", any>) {
try {
const {data, error} = await supabase.rpc('update_user_requests', {
search_term: searchTerm,
new_request: requestSuuid
});
if (error) {
throw error;
}
return {success: true, data};
} catch (error) {
console.error('Error updating user requests:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
import updateUserRequests from "@/lib/api/helpers/updateUserRequests";
export async function POST(request: Request) {
try {
@ -49,7 +28,7 @@ export async function POST(request: Request) {
const userSuuid = getUser.suuid;
if (userSuuid === searchTerm) {
return NextResponse.json({success: false, hint: "Used self SUUID"}, {status: 409});
return NextResponse.json({success: false, hint: "Cannot send request to self"}, {status: 409});
}
const result = await updateUserRequests(searchTerm, userSuuid, supabase);

View file

@ -0,0 +1,24 @@
// app/api/user/keys/update/route.ts
import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";
export async function POST(request: Request) {
try {
const {publicKey} = await request.json();
const supabase = await createClient();
const {error} = await supabase
.from('users')
.update({public_key: publicKey})
.eq('uuid', (await supabase.auth.getUser()).data.user?.id);
if (error) throw error;
return NextResponse.json({success: true});
} catch (error) {
return NextResponse.json(
{error: 'Failed to update public key'},
{status: 500}
);
}
}

View file

@ -75,6 +75,7 @@ export default function AuthPage() {
message: string;
action?: ToastActionElement | undefined;
}
if (!isLogin) {
response = await Register(username, password);
} else {

View file

@ -1,3 +1,5 @@
import {CryptoManager} from "@/lib/crypto/keys";
/**
*
* @param username - The unique username of that user. This will be checked for collision.
@ -6,13 +8,19 @@
*/
export default async function Register(username: string, password: string) {
try {
const keyPair = await CryptoManager.generateUserKeys();
await CryptoManager.storePrivateKey(keyPair.privateKey);
// Export public key for server
const exportedPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
// Sends the request to the API
let res = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({username, password}), // Stringifies the JSON
body: JSON.stringify({username, password, publicKey: exportedPublic}), // Stringifies the JSON
});
// Default error handler, if not OK just return whatever the API returned

View file

@ -17,11 +17,16 @@ import {
AlertDialogTitle,
AlertDialogTrigger
} from "@/components/ui/alert-dialog";
import {CryptoManager} from "@/lib/crypto/keys";
import UpdateKey from "@/lib/crypto/helpers/updateKey";
export default function SiPher() {
const {theme, systemTheme} = useTheme();
const [mounted, setMounted] = useState(false);
/** CryptoManager Alert */
const [privateKeyPresent, setPrivateKeyPresent] = useState(true);
/** Consent Form states */
const [showConsentForm, setShowConsentForm] = useState(false);
const [formError, setFormError] = useState("");
@ -37,6 +42,15 @@ export default function SiPher() {
setMounted(true);
}, []);
useEffect(() => {
CryptoManager.getPrivateKey().then((res) => {
if (!res) {
console.log(res)
setPrivateKeyPresent(false);
}
})
}, [])
/**
* @param search_term Either the SUUID or username (If not indexable, will return false.)
*/
@ -73,11 +87,15 @@ export default function SiPher() {
"Content-Type": "application/json"
},
body: JSON.stringify({
searchTerm: user, // SUUID or username
})
searchTerm: user, // SUUID or username
})
});
if (!req.ok) return false;
if (!req.ok) {
const res = await req.json();
setFormError(res.hint);
return false;
}
const {sent} = await req.json() as { sent: boolean };
// If the user does not exist, just return it
@ -96,43 +114,96 @@ export default function SiPher() {
const currentTheme = getTheme();
return (
<>
<AlertDialog open={showConsentForm} onOpenChange={(open) => {
if (!open) setFormError("");
}}>
<AlertDialogTrigger/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Consent Form</AlertDialogTitle>
<AlertDialogDescription className={"flex flex-col space-y-1"}>
<span>
const MainPageAlerts = () => {
return (
<>
<AlertDialog open={showConsentForm} onOpenChange={(open) => {
if (!open) setFormError("");
}}>
<AlertDialogTrigger/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Consent Form</AlertDialogTitle>
<AlertDialogDescription className={"flex flex-col space-y-1"}>
{
formError ? (
<span className={"text-red-500"}>{formError}</span>
) : null
}
<span>
Are you sure you want to contact <span className={"font-bold"}>{inputValue}</span>?
</span>
<span>
<span>
By continuing, <span className={"font-bold"}>{inputValue}</span> will receive a notification to accept
it. If accepted, that user will appear on your sidebar, if rejected, you will never know about it.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setShowConsentForm(false);
setInputDisabled(false);
}}
>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
sendRequest(inputValue).then((result) => {
if (!result) setFormError("Could not send notification for whatever reason. Sorry.");
});
setInputDisabled(false);
}}
>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setShowConsentForm(false);
setInputDisabled(false);
}}
>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={formError.length < 0}
onClick={() => {
sendRequest(inputValue);
setInputDisabled(false);
setShowConsentForm(false);
}}
>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!privateKeyPresent}>
<AlertDialogTrigger/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Private Key Missing</AlertDialogTitle>
<AlertDialogDescription className={"flex flex-col space-y-1"}>
<span>This app could not retrieve your private key, which means it's either lost, never stored or corrupted. Want to try again or insert it from a backup?</span>
<span>You can also regenerate it if you do not have it backed up, but this would mean that you'll loose access to all old messages.</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setShowConsentForm(false);
setInputDisabled(false);
}}
>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
sendRequest(inputValue).then((result) => {
if (!result) setFormError("Could not send notification for whatever reason. Sorry.");
});
setInputDisabled(false);
}}
>Try Again</AlertDialogAction>
<AlertDialogAction
onClick={() => {
UpdateKey().then((result) => {
if (result.status !== 200) {
return;
}
setPrivateKeyPresent(true)
})
}}
>Regenerate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
return (
<>
<MainPageAlerts/>
<div
className={`relative flex-1 ${currentTheme === "dark" ? "dark" : ""} w-full max-h-[600px] bg-gradient-to-b from-background to-background/95`}>
<div className="relative flex flex-col justify-center items-center h-screen px-4 select-none space-y-8">
@ -153,7 +224,8 @@ export default function SiPher() {
<div className="max-w-2xl space-y-6 text-center">
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary">
Where shadows dance and secrets nest, Silent Whisper serves as the dark sanctuary for those
who value discretion above all. Born from ancient corvid traditions, this messenger&rsquo;s haven ensures your
who value discretion above all. Born from ancient corvid traditions, this messenger&rsquo;s haven ensures
your
whispers remain unheard by all but their intended recipients.
</p>

View file

@ -27,6 +27,7 @@ export function RealtimeRequests(
table: 'users',
filter: `uuid=eq.${user.uuid}`,
}, async (payload) => {
console.log(payload)
if (payload.new.requests !== payload.old.requests) {
try {
setRequests(payload.new.requests)

View file

@ -0,0 +1,66 @@
// hooks/useRealtime.ts
import {useEffect} from 'react'
import {createBrowserClient} from '@/lib/supabase/browser'
import {useUser} from '@/contexts/user'
import {useToast} from '@/hooks/use-toast'
interface UseRealtimeProps {
setThreads: React.Dispatch<React.SetStateAction<SiPher.Messages[]>>;
threads: SiPher.Messages[]
}
export function useRealtime({setThreads}: UseRealtimeProps) {
const supabase = createBrowserClient();
const {user, updateUser} = useUser();
const {toast} = useToast();
const fetchAndUpdateThreads = async () => {
try {
const response = await fetch("/api/user/get/threads");
if (response.ok) {
const {threads} = await response.json();
console.log('Setting threads:', threads);
setThreads(threads);
}
} catch (error) {
console.error('Error fetching threads:', error);
}
};
useEffect(() => {
if (!user) return;
const userUpdate = supabase
.channel("request updates")
.on("postgres_changes", {
event: "*",
schema: 'public',
table: 'users',
filter: `uuid=eq.${user.uuid}`,
}, async (payload) => {
if (payload.eventType === "UPDATE") {
// This will also handle updates for the threads, but only for the user that accepted the request.
// Why? Because the function that creates the thread will also update the current user request field and remove
// the corresponding request.
if (payload.new.requests !== payload.old.requests) {
updateUser({
...user,
requests: payload.new.requests
})
}
}
}).subscribe()
const threadUpdate = supabase
.channel("thread updates")
.on("postgres_changes", {
event: "*",
schema: 'public',
// Using on this one because it's easier
table: "thread_participants",
filter: `user_uuid=${user.uuid}`,
}, async (payload) => {
console.log(payload)
}).subscribe()
}, [user?.uuid]);
}

View file

@ -0,0 +1,202 @@
import React, {useCallback, useEffect, useState} from "react";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {Avatar, AvatarFallback} from "@/components/ui/avatar";
import {Separator} from "@/components/ui/separator";
import {ScrollArea} from "@/components/ui/scroll-area";
import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {Check, LogOut, Mail, MailPlus, X} from "lucide-react";
import {Button} from "@/components/ui/button";
import {GearIcon} from "@radix-ui/react-icons";
import Link from "next/link";
import {useRealtime} from "@/components/main/realtime/threads";
import {useUser} from "@/contexts/user";
interface RightSidebarContentProps {
isDarkMode: boolean;
}
export default function RightSidebarContent(
{
isDarkMode,
}: RightSidebarContentProps) {
const [selectedThreads, setSelectedThreads] = useState("");
const [threadMenu, setThreadMenu] = useState<SiPher.Messages[] | []>([]);
const [pendingRequest, setPendingRequest] = useState<number>(0);
const [threads, setThreads] = useState<SiPher.Messages[]>([]);
useRealtime(
{setThreads}
);
const [copied, setCopied] = useState<boolean>(false);
const {user} = useUser();
const {username, suuid, requests = []} = user;
// No need for separate requests state since it's in user object
const pendingRequests = requests?.length ?? 0;
// Move fetch to separate function
const fetchThreads = useCallback(async () => {
try {
const req = await fetch("/api/user/get/threads")
if (req.ok) {
const {threads} = await req.json() as { threads: SiPher.Messages[] | [] }
setThreads(threads)
} else {
setThreads([])
}
} catch (error) {
console.log(error);
setThreads([])
}
}, []);
useEffect(() => {
fetchThreads();
}, [fetchThreads]);
const handleAccept = async (request: string) => {
try {
const response = await fetch("/api/user/create/thread", {
method: "POST",
body: JSON.stringify({participant: request}),
});
if (response.ok) {
// Optionally refresh threads after successful creation
fetchThreads();
}
} catch (error) {
console.error('Error accepting request:', error);
}
}
return (
<>
<div className={`flex flex-col h-full w-[240px]`}>
<TooltipProvider>
<Tooltip open={copied} onOpenChange={setCopied}>
<TooltipTrigger/>
<TooltipContent arrowPadding={10} className={"p-2 shadow-cyan-950 shadow-md"}>
Copied SUUID to clipboard!
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div
onClick={() => {
setCopied(true)
navigator.clipboard.writeText(suuid)
}}
className={`flex items-center p-3 m-2 ${isDarkMode ? "hover:bg-accent/90" : "hover:bg-secondary/20"} rounded-full transition-colors duration-200 cursor-pointer select-none`}>
<Avatar className="w-12 h-12 mr-3">
<AvatarFallback>{username.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<h3 className={`font-semibold text-[17px] ${isDarkMode ? "text-white" : "text-black"}`}>{username}</h3>
<p className="text-xs text-muted-foreground">${suuid}</p>
</div>
</div>
<Separator className="my-2"/>
<ScrollArea className="flex-grow max-h-[590px] px-4 py-4">
<nav>
<ul className="space-y-1">
<DropdownMenu>
<DropdownMenuTrigger>
<div className={"flex flex-row items-center w-full justify-start text-[17px]"}>
{
(user.requests?.length ?? 0) > 0 ? (
<MailPlus className="w-8 h-8 mr-3 p-1"/>
) : (
<Mail className="w-8 h-8 mr-3 p-1"/>
)}
Requests
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="px-4 py-1 w-56" side={"right"}>
<div className={"flex flex-row w-full justify-between items-center select-none"}>
<p>User</p>
<p>Decline | Accept</p>
</div>
{
pendingRequests > 0 && requests!.map((request, item) => {
return (
<div key={item} className={"flex flex-col w-full"}>
<Separator className="my-2"/>
<div key={item} className={"flex flex-row space-x-2 w-full items-center"}>
<p className={"text-secondary-foreground"}>{request}</p>
<div className={"flex flex-row justify-end space-x-1 w-full"}>
<Button size={"icon"} className={"bg-red-500"}>
<X className={"w-4 h-4"}/>
</Button>
<Button onClick={() => {
handleAccept(request)
}} size={"icon"} className={"bg-green-500"}>
<Check className={"w-4 h-4"}/>
</Button>
</div>
</div>
</div>
)
}) || (
<p>Nothing new here</p>
)
}
</DropdownMenuContent>
</DropdownMenu>
<Separator className="my-2"/>
{threads && threads.length > 0 ? (
threads.map((thread, index) => {
const otherUser = thread.participants.filter((user) => user !== username)[0];
console.log(thread)
return (
<li key={index}>
<Link href={`/${thread.thread_id}`} passHref>
<div className="flex flex-row items-center mt-2">
<Avatar className="w-8 h-8 mr-3">
<AvatarFallback>{otherUser.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
{otherUser}
</div>
</Link>
</li>
)
})
) : (
<p>No threads available</p>
)}
</ul>
</nav>
</ScrollArea>
<div className="p-3 space-y-3">
<Separator/>
<Button
variant="outline"
className="w-full justify-start text-[17px] py-2 text-primary"
onClick={() => window.location.href = "/config"}
>
<GearIcon className="w-4 h-4 mr-3"/>
Settings
</Button>
<Button onClick={() => {
fetch("/api/auth/logout", {
method: "GET",
headers: {
"Content-Type": "application/json"
},
}).then((response) => {
if (response.ok) {
window.location.href = "/auth/login"
}
})
}} variant="outline" className="w-full justify-start text-[17px] py-2 text-destructive">
<LogOut className="w-4 h-4 mr-3"/>
Log Out
</Button>
</div>
</div>
</>
)
}

View file

@ -1,23 +1,15 @@
"use client"
import React, {useCallback, useEffect, useState} from "react"
import {usePathname} from "next/navigation"
import React from "react"
import Link from "next/link"
import {AnimatePresence, motion} from "framer-motion"
import {Check, LogOut, Mail, MailPlus, X} from "lucide-react"
import {X} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Avatar, AvatarFallback} from "@/components/ui/avatar"
import {Separator} from "@/components/ui/separator"
import {ScrollArea} from "@/components/ui/scroll-area"
import {GearIcon} from "@radix-ui/react-icons"
import Image from "next/image";
import MobileHeader from "@/components/main/sidebar/mobile";
import {useUser} from "@/contexts/user";
import {useRefs, useUIState} from "@/hooks/shared-states";
import {useToast} from "@/hooks/use-toast";
import {useTheme} from "next-themes";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {RealtimeRequests} from "@/components/main/realtime/request";
import RightSidebarContent from "@/components/main/sidebar/rightsidebar";
type SidebarProps = {
children?: React.ReactNode
@ -28,213 +20,24 @@ function Sidebar(
children
}: SidebarProps
) {
const pathname = usePathname()
const [selectedThreads, setSelectedThreads] = useState("");
const [threads, setThreads] = useState<SiPher.Messages[] | []>([]);
const [threadMenu, setThreadMenu] = useState<SiPher.Messages[] | []>([]);
const [copied, setCopied] = useState<boolean>(false);
const {theme, systemTheme} = useTheme();
const {toast} = useToast();
const {isDrawerOpen, setIsDrawerOpen} = useUIState();
const {drawerRef} = useRefs();
const [requests, setRequests] = useState<string[]>([]);
const [pendingRequest, setPendingRequest] = useState<number>(0);
const {user, getUser} = useUser();
const {
username,
suuid
} = user;
useEffect(() => {
setPendingRequest(requests.length || 0);
}, [requests, setPendingRequest]);
useEffect(() => {
setPendingRequest(user.requests.length);
setRequests(user.requests);
}, []);
useEffect(() => {
const getThreads = async () => {
try {
const req = await fetch("/api/user/get/threads")
if (req.ok) {
const {threads} = await req.json() as { threads: SiPher.Messages[] | [] }
setThreads(threads)
} else {
setThreads([])
toast({
title: "Error",
description: "An unknown error occurred",
variant: "destructive",
duration: 5000,
})
}
} catch (error) {
setThreads([])
}
}
getThreads()
return () => setThreads([])
}, [toast])
const generateThreads = useCallback(() => {
threads.map(async (thread) => {
if (thread.participants.length > 2) {
return (
<li key={thread.id}>
<Link href={thread.id} passHref>
<Button
variant={pathname === thread.id ? "secondary" : "ghost"}
className="w-full justify-start text-[17px] py-4"
>
<Avatar className="w-8 h-8 mr-3 p-1">
<AvatarFallback>{thread.name!}</AvatarFallback>
</Avatar>
{thread.name!}
</Button>
</Link>
</li>
)
} else {
const fetchOtherUser = async () => {
await getUser("fetchOtherUser - const", thread.id)
}
}
})
}, [threads])
const isDarkMode = theme === "system"
? systemTheme === "dark"
: theme === "dark"
const RightSidebarContent = () => (
<div className={`flex flex-col h-full w-[240px]`}>
<TooltipProvider>
<Tooltip open={copied} onOpenChange={setCopied}>
<TooltipTrigger/>
<TooltipContent arrowPadding={10} className={"p-2 shadow-cyan-950 shadow-md"}>
Copied SUUID to clipboard!
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div
onClick={() => {
setCopied(true)
navigator.clipboard.writeText(suuid)
}}
className={`flex items-center p-3 m-2 ${isDarkMode ? "hover:bg-accent/90" : "hover:bg-secondary/20"} rounded-full transition-colors duration-200 cursor-pointer select-none`}>
<Avatar className="w-12 h-12 mr-3">
<AvatarFallback>{username.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<h3 className={`font-semibold text-[17px] ${isDarkMode ? "text-white" : "text-black"}`}>{username}</h3>
<p className="text-xs text-muted-foreground">${suuid}</p>
</div>
</div>
<Separator className="my-2"/>
<ScrollArea className="flex-grow max-h-[590px] px-4 py-4">
<nav>
<ul className="space-y-1">
<DropdownMenu>
<DropdownMenuTrigger>
<div className={"flex flex-row items-center w-full justify-start text-[17px]"}>
{
pendingRequest > 0 ? (
<MailPlus className="w-8 h-8 mr-3 p-1"/>
) : (
<Mail className="w-8 h-8 mr-3 p-1"/>
)
}
Requests
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="px-4 py-1 w-56" side={"right"}>
<div className={"flex flex-row w-full justify-between items-center select-none"}>
<p>User</p>
<p>Decline | Accept</p>
</div>
{
pendingRequest > 0 && requests.map((request, item) => {
return (
<div key={item} className={"flex flex-col w-full"}>
<Separator className="my-2"/>
<div key={item} className={"flex flex-row space-x-2 w-full items-center"}>
<p className={"text-secondary-foreground"}>{request}</p>
<div className={"flex flex-row justify-end space-x-1 w-full"}>
<Button size={"icon"} className={"bg-red-500"}>
<X className={"w-4 h-4"}/>
</Button>
<Button size={"icon"} className={"bg-green-500"}>
<Check className={"w-4 h-4"}/>
</Button>
</div>
</div>
</div>
)
}) || (
<p>Nothing new here</p>
)
}
</DropdownMenuContent>
</DropdownMenu>
{threads.map((thread) => (
<li key={thread.id}>
<Link href={thread.id} passHref>
<Button
variant={pathname === thread.id ? "secondary" : "ghost"}
className="w-full justify-start text-[17px] py-4"
>
<Avatar className="w-8 h-8 mr-3 p-1">
<AvatarFallback>{thread.id}</AvatarFallback>
</Avatar>
{thread.id}
</Button>
</Link>
</li>
))}
</ul>
</nav>
</ScrollArea>
<div className="p-3 space-y-3">
<Separator/>
<Button
variant="outline"
className="w-full justify-start text-[17px] py-2 text-primary"
onClick={() => window.location.href = "/config"}
>
<GearIcon className="w-4 h-4 mr-3"/>
Settings
</Button>
<Button onClick={() => {
fetch("/api/auth/logout", {
method: "GET",
headers: {
"Content-Type": "application/json"
},
}).then((response) => {
if (response.ok) {
window.location.href = "/auth/login"
}
})
}} variant="outline" className="w-full justify-start text-[17px] py-2 text-destructive">
<LogOut className="w-4 h-4 mr-3"/>
Log Out
</Button>
</div>
</div>
)
const handleAcceptRequest = async () => {
}
return (
<>
<MobileHeader/>
<RealtimeRequests setRequests={setRequests}/>
<aside
className={`hidden lg:flex flex-col items-end h-screen max-h-[900px] sticky top-0 border-r border-border ${
isDarkMode ? "bg-background" : "white"
@ -244,7 +47,7 @@ function Sidebar(
<Link href={"/"} passHref className={"flex flex-row items-center ml-1.5"}>
<Image
src={isDarkMode ? "/logos/logo.png" : "/logos/logo-light.png"}
alt="Tocka&lsquo;s Nest"
alt="SiPher Space"
width={64}
height={64}
className="w-16 h-16 cursor-pointer rounded-full antialiased"
@ -252,7 +55,9 @@ function Sidebar(
<p className={"text-center text-xl font-bold antialiased"}>SiPher</p>
</Link>
</div>
<RightSidebarContent/>
<RightSidebarContent
isDarkMode={isDarkMode}
/>
</aside>
<AnimatePresence>
{isDrawerOpen && (
@ -276,7 +81,9 @@ function Sidebar(
<X className="w-5 h-5"/>
<span className="sr-only">Close menu</span>
</Button>
<RightSidebarContent/>
<RightSidebarContent
isDarkMode={isDarkMode}
/>
</div>
</motion.div>
)}

View file

@ -3,9 +3,9 @@ import {SupabaseClient} from "@supabase/supabase-js";
export default async function getUserByUUID(supabase: SupabaseClient<any, "public", any>, uuid: string) {
const {data: userData, error: userError} = await supabase
.from('users')
.select('*')
.select('*, public_key')
.eq('uuid', uuid)
.single();
.single()
if (userError) throw userError;
return userData;

View file

@ -0,0 +1,23 @@
import {SupabaseClient} from "@supabase/supabase-js";
export default async function updateUserRequests(searchTerm: string, requestSuuid: string, supabase: SupabaseClient<any, "public", any>) {
try {
const {data, error} = await supabase.rpc('update_user_requests', {
search_term: searchTerm,
new_request: requestSuuid
});
if (error) {
throw error;
}
return {success: true, data};
} catch (error) {
console.error('Error updating user requests:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}

View file

@ -0,0 +1,27 @@
"use client";
import {CryptoManager} from "@/lib/crypto/keys";
export default async function UpdateKey() {
const keyPair = await CryptoManager.generateUserKeys();
await CryptoManager.storePrivateKey(keyPair.privateKey);
const exportedPublic = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
const req = await fetch("/api/user/send/update/key", {
method: "POST",
body: JSON.stringify({publicKey: exportedPublic}),
})
if(req.status !== 200) {
await CryptoManager.deletePrivateKey();
return {
status: req.status,
message: "Failed to update public key",
}
}
return {
status: 200,
message: "Successfully updated keys",
}
}

192
src/lib/crypto/keys.ts Normal file
View file

@ -0,0 +1,192 @@
"use client"
/**
* @filedoc: When creating this, I thought that using PBKDF2 would be the best choice, which it isn't since I would have
* to share passwords between user, and to do that I would have to pass the password through the server, which would defeat
* both PBKDF2 and E2EE methods.
* So I went with a better approach: Using public/private keys and signing messages with the public user's key and my own
* key
*/
/**
* A kinda-simple CryptoManager to handle keys and encrypt/decrypt messages.
* Uses IndexedDB to store private keys securely.
*/
export class CryptoManager {
private static readonly DB_NAME = 'SipherKeyStore';
private static readonly DB_VERSION = 1;
private static readonly STORE_NAME = 'keys';
private static readonly KEY_ID = 'private_key';
/**
* Opens db and creates the object store if needed.
* @returns {Promise<IDBDatabase>} A promise that resolves to the database instance.
*/
private static async openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
db.createObjectStore(this.STORE_NAME);
};
});
}
/**
* Generates a fresh RSA key pair. Yay, new keys!
* @returns {Promise<CryptoKeyPair>} The generated RSA key pair.
*/
static async generateUserKeys(): Promise<CryptoKeyPair> {
return await crypto.subtle.generateKey(
{
name: "RSA-OAEP",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
true,
["encrypt", "decrypt"]
);
}
/**
* Stores the private key.
* @param {CryptoKey} privateKey - The private key to store.
* @returns {Promise<void>}
*/
static async storePrivateKey(privateKey: CryptoKey): Promise<void> {
const exportedPrivate = await crypto.subtle.exportKey('jwk', privateKey);
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this.STORE_NAME, 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.put(exportedPrivate, this.KEY_ID);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
transaction.oncomplete = () => db.close();
});
}
/**
* Deletes the private key.
* @param {CryptoKey} privateKey - The private key to store.
* @returns {Promise<void>}
*/
static async deletePrivateKey(): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this.STORE_NAME, 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.delete(this.KEY_ID);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
transaction.oncomplete = () => db.close();
});
}
/**
* Gets the stored private key from IndexedDB. Might return `null` if nothing's there.
* @returns {Promise<CryptoKey | null>} The private key or `null` if not found.
*/
static async getPrivateKey(): Promise<CryptoKey | null> {
try {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction(this.STORE_NAME, 'readonly');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.get(this.KEY_ID);
request.onerror = () => reject(request.error);
request.onsuccess = async () => {
if (!request.result) {
resolve(null);
return;
}
const privateKey = await crypto.subtle.importKey(
'jwk',
request.result,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["decrypt"]
);
resolve(privateKey);
};
transaction.oncomplete = () => db.close();
});
} catch (error) {
console.error('Oops! Error retrieving private key:', error);
return null;
}
}
/**
* Encrypts a message using the recipient's public key.
* @param {string} message - The message you wanna encrypt.
* @param {JsonWebKey} recipientPublicKey - The recipient's public key in JWK format.
* @returns {Promise<string>} The encrypted message in base64 format.
*/
static async encryptMessage(message: string, recipientPublicKey: JsonWebKey): Promise<string> {
const publicKey = await crypto.subtle.importKey(
'jwk',
recipientPublicKey,
{
name: "RSA-OAEP",
hash: "SHA-256",
},
true,
["encrypt"]
);
const encoder = new TextEncoder();
const encrypted = await crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
publicKey,
encoder.encode(message)
);
return btoa(String.fromCharCode(...new Uint8Array(encrypted)));
}
/**
* Decrypts a message using your own private key.
* @param {string} encryptedMessage - The encrypted message (base64 format).
* @returns {Promise<string>} The decrypted message.
* @throws Will throw an error if no private key is found.
*/
static async decryptMessage(encryptedMessage: string): Promise<string> {
const privateKey = await this.getPrivateKey();
if (!privateKey) throw new Error("No private key found");
const encrypted = new Uint8Array(
atob(encryptedMessage).split('').map((char) => char.charCodeAt(0))
);
const decrypted = await crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
privateKey,
encrypted
);
return new TextDecoder().decode(decrypted);
}
}

26
src/types/user.d.ts vendored
View file

@ -1,9 +1,10 @@
import {Json} from "../../database.types";
declare global {
namespace SiPher {
type Messages = {
id: string;
thread_id: string;
participants: string[];
name?: string;
messages: {
id: string;
content: string;
@ -12,20 +13,13 @@ declare global {
}
type User = {
/** Represents the unique username of a user. */
username: string,
/** The encrypted password of said user. */
password: string,
/** Unique UUID, long */
uuid: string,
/** Short UUID, for index reasons */
suuid: string,
/** Created at timestamp in UTC */
created_at: string,
/** Messages field */
messages: Messages[]
/** Consent Requests */
requests: string[] // Only accessible to the current user logged in. Will contain an array of SUUIDs
created_at: string
indexable: boolean | null
public_key: Json | null
requests: string[] | null
suuid: string
username: string
uuid: string
}
}
}

View file

@ -0,0 +1 @@
v2.0.0