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", "lucide-react": "^0.468.0",
"next": "15.0.4", "next": "15.0.4",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"random-words": "^2.0.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"tailwind-merge": "^2.5.5", "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": { "node_modules/react": {
"version": "19.0.0", "version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==" "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": { "node_modules/semver": {
"version": "7.6.3", "version": "7.6.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",

View file

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

View file

@ -32,7 +32,7 @@ export async function POST(request: Request) {
// Fetch our custom user data // Fetch our custom user data
const {data: userData, error: userError} = await supabase const {data: userData, error: userError} = await supabase
.from('users') .from('users')
.select('*') .select('*, public_key')
.eq('uuid', user?.id) .eq('uuid', user?.id)
.single() .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) { if (error) {
return NextResponse.json({threads: []}, {status: 200}); return NextResponse.json({error}, {status: 400})
} }
return NextResponse.json({threads: data}, {status: 200}); 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 search_term: uuid
}); });
const {data, error} = rpcResult;
if (error) { if (error) {
return NextResponse.json({error: error}, {status: 500}); 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}); 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 {NextResponse} from "next/server";
import {SupabaseClient} from "@supabase/supabase-js"; import {SupabaseClient} from "@supabase/supabase-js";
import getUserByUUID from "@/lib/api/helpers/getUserByUUID"; import getUserByUUID from "@/lib/api/helpers/getUserByUUID";
import updateUserRequests from "@/lib/api/helpers/updateUserRequests";
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'
};
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
@ -49,7 +28,7 @@ export async function POST(request: Request) {
const userSuuid = getUser.suuid; const userSuuid = getUser.suuid;
if (userSuuid === searchTerm) { 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); 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; message: string;
action?: ToastActionElement | undefined; action?: ToastActionElement | undefined;
} }
if (!isLogin) { if (!isLogin) {
response = await Register(username, password); response = await Register(username, password);
} else { } 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. * @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) { export default async function Register(username: string, password: string) {
try { 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 // Sends the request to the API
let res = await fetch('/api/auth/register', { let res = await fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', '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 // Default error handler, if not OK just return whatever the API returned

View file

@ -17,11 +17,16 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger AlertDialogTrigger
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import {CryptoManager} from "@/lib/crypto/keys";
import UpdateKey from "@/lib/crypto/helpers/updateKey";
export default function SiPher() { export default function SiPher() {
const {theme, systemTheme} = useTheme(); const {theme, systemTheme} = useTheme();
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
/** CryptoManager Alert */
const [privateKeyPresent, setPrivateKeyPresent] = useState(true);
/** Consent Form states */ /** Consent Form states */
const [showConsentForm, setShowConsentForm] = useState(false); const [showConsentForm, setShowConsentForm] = useState(false);
const [formError, setFormError] = useState(""); const [formError, setFormError] = useState("");
@ -37,6 +42,15 @@ export default function SiPher() {
setMounted(true); 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.) * @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" "Content-Type": "application/json"
}, },
body: JSON.stringify({ 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 }; const {sent} = await req.json() as { sent: boolean };
// If the user does not exist, just return it // If the user does not exist, just return it
@ -96,43 +114,96 @@ export default function SiPher() {
const currentTheme = getTheme(); const currentTheme = getTheme();
return ( const MainPageAlerts = () => {
<> return (
<AlertDialog open={showConsentForm} onOpenChange={(open) => { <>
if (!open) setFormError(""); <AlertDialog open={showConsentForm} onOpenChange={(open) => {
}}> if (!open) setFormError("");
<AlertDialogTrigger/> }}>
<AlertDialogContent> <AlertDialogTrigger/>
<AlertDialogHeader> <AlertDialogContent>
<AlertDialogTitle>Consent Form</AlertDialogTitle> <AlertDialogHeader>
<AlertDialogDescription className={"flex flex-col space-y-1"}> <AlertDialogTitle>Consent Form</AlertDialogTitle>
<span> <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>? Are you sure you want to contact <span className={"font-bold"}>{inputValue}</span>?
</span> </span>
<span> <span>
By continuing, <span className={"font-bold"}>{inputValue}</span> will receive a notification to accept 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. it. If accepted, that user will appear on your sidebar, if rejected, you will never know about it.
</span> </span>
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel <AlertDialogCancel
onClick={() => { onClick={() => {
setShowConsentForm(false); setShowConsentForm(false);
setInputDisabled(false); setInputDisabled(false);
}} }}
>Cancel</AlertDialogCancel> >Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={() => { disabled={formError.length < 0}
sendRequest(inputValue).then((result) => { onClick={() => {
if (!result) setFormError("Could not send notification for whatever reason. Sorry."); sendRequest(inputValue);
}); setInputDisabled(false);
setInputDisabled(false); setShowConsentForm(false);
}} }}
>Continue</AlertDialogAction> >Continue</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </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 <div
className={`relative flex-1 ${currentTheme === "dark" ? "dark" : ""} w-full max-h-[600px] bg-gradient-to-b from-background to-background/95`}> 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"> <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"> <div className="max-w-2xl space-y-6 text-center">
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary"> <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 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. whispers remain unheard by all but their intended recipients.
</p> </p>

View file

@ -27,6 +27,7 @@ export function RealtimeRequests(
table: 'users', table: 'users',
filter: `uuid=eq.${user.uuid}`, filter: `uuid=eq.${user.uuid}`,
}, async (payload) => { }, async (payload) => {
console.log(payload)
if (payload.new.requests !== payload.old.requests) { if (payload.new.requests !== payload.old.requests) {
try { try {
setRequests(payload.new.requests) 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" "use client"
import React, {useCallback, useEffect, useState} from "react" import React from "react"
import {usePathname} from "next/navigation"
import Link from "next/link" import Link from "next/link"
import {AnimatePresence, motion} from "framer-motion" 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 {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 Image from "next/image";
import MobileHeader from "@/components/main/sidebar/mobile"; import MobileHeader from "@/components/main/sidebar/mobile";
import {useUser} from "@/contexts/user";
import {useRefs, useUIState} from "@/hooks/shared-states"; import {useRefs, useUIState} from "@/hooks/shared-states";
import {useToast} from "@/hooks/use-toast"; import {useToast} from "@/hooks/use-toast";
import {useTheme} from "next-themes"; import {useTheme} from "next-themes";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"; import RightSidebarContent from "@/components/main/sidebar/rightsidebar";
import {DropdownMenu, DropdownMenuContent, DropdownMenuTrigger} from "@/components/ui/dropdown-menu";
import {RealtimeRequests} from "@/components/main/realtime/request";
type SidebarProps = { type SidebarProps = {
children?: React.ReactNode children?: React.ReactNode
@ -28,213 +20,24 @@ function Sidebar(
children children
}: SidebarProps }: 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 {theme, systemTheme} = useTheme();
const {toast} = useToast(); const {toast} = useToast();
const {isDrawerOpen, setIsDrawerOpen} = useUIState(); const {isDrawerOpen, setIsDrawerOpen} = useUIState();
const {drawerRef} = useRefs(); 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" const isDarkMode = theme === "system"
? systemTheme === "dark" ? systemTheme === "dark"
: theme === "dark" : theme === "dark"
const RightSidebarContent = () => (
<div className={`flex flex-col h-full w-[240px]`}> const handleAcceptRequest = async () => {
<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>
)
return ( return (
<> <>
<MobileHeader/> <MobileHeader/>
<RealtimeRequests setRequests={setRequests}/>
<aside <aside
className={`hidden lg:flex flex-col items-end h-screen max-h-[900px] sticky top-0 border-r border-border ${ className={`hidden lg:flex flex-col items-end h-screen max-h-[900px] sticky top-0 border-r border-border ${
isDarkMode ? "bg-background" : "white" isDarkMode ? "bg-background" : "white"
@ -244,7 +47,7 @@ function Sidebar(
<Link href={"/"} passHref className={"flex flex-row items-center ml-1.5"}> <Link href={"/"} passHref className={"flex flex-row items-center ml-1.5"}>
<Image <Image
src={isDarkMode ? "/logos/logo.png" : "/logos/logo-light.png"} src={isDarkMode ? "/logos/logo.png" : "/logos/logo-light.png"}
alt="Tocka&lsquo;s Nest" alt="SiPher Space"
width={64} width={64}
height={64} height={64}
className="w-16 h-16 cursor-pointer rounded-full antialiased" 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> <p className={"text-center text-xl font-bold antialiased"}>SiPher</p>
</Link> </Link>
</div> </div>
<RightSidebarContent/> <RightSidebarContent
isDarkMode={isDarkMode}
/>
</aside> </aside>
<AnimatePresence> <AnimatePresence>
{isDrawerOpen && ( {isDrawerOpen && (
@ -276,7 +81,9 @@ function Sidebar(
<X className="w-5 h-5"/> <X className="w-5 h-5"/>
<span className="sr-only">Close menu</span> <span className="sr-only">Close menu</span>
</Button> </Button>
<RightSidebarContent/> <RightSidebarContent
isDarkMode={isDarkMode}
/>
</div> </div>
</motion.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) { export default async function getUserByUUID(supabase: SupabaseClient<any, "public", any>, uuid: string) {
const {data: userData, error: userError} = await supabase const {data: userData, error: userError} = await supabase
.from('users') .from('users')
.select('*') .select('*, public_key')
.eq('uuid', uuid) .eq('uuid', uuid)
.single(); .single()
if (userError) throw userError; if (userError) throw userError;
return userData; 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 { declare global {
namespace SiPher { namespace SiPher {
type Messages = { type Messages = {
id: string; thread_id: string;
participants: string[]; participants: string[];
name?: string;
messages: { messages: {
id: string; id: string;
content: string; content: string;
@ -12,20 +13,13 @@ declare global {
} }
type User = { type User = {
/** Represents the unique username of a user. */ created_at: string
username: string, indexable: boolean | null
/** The encrypted password of said user. */ public_key: Json | null
password: string, requests: string[] | null
/** Unique UUID, long */ suuid: string
uuid: string, username: string
/** Short UUID, for index reasons */ uuid: string
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
} }
} }
} }

View file

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