This is hard
Dealing with realtime is actually really hard (imposter syndrome is hitting real hard rn)
This commit is contained in:
parent
eef9803249
commit
17ce35ed6c
23 changed files with 759 additions and 292 deletions
BIN
database.types.ts
Normal file
BIN
database.types.ts
Normal file
Binary file not shown.
14
package-lock.json
generated
14
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
58
src/app/api/user/create/thread/route.ts
Normal file
58
src/app/api/user/create/thread/route.ts
Normal 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});
|
||||
}
|
||||
|
|
@ -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});
|
||||
|
|
|
|||
|
|
@ -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});
|
||||
|
|
|
|||
0
src/app/api/user/send/message/route.ts
Normal file
0
src/app/api/user/send/message/route.ts
Normal 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);
|
||||
|
|
|
|||
24
src/app/api/user/send/update/key/route.ts
Normal file
24
src/app/api/user/send/update/key/route.ts
Normal 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}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,7 @@ export default function AuthPage() {
|
|||
message: string;
|
||||
action?: ToastActionElement | undefined;
|
||||
}
|
||||
|
||||
if (!isLogin) {
|
||||
response = await Register(username, password);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
144
src/app/page.tsx
144
src/app/page.tsx
|
|
@ -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’s haven ensures your
|
||||
who value discretion above all. Born from ancient corvid traditions, this messenger’s haven ensures
|
||||
your
|
||||
whispers remain unheard by all but their intended recipients.
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
66
src/components/main/realtime/threads.tsx
Normal file
66
src/components/main/realtime/threads.tsx
Normal 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]);
|
||||
}
|
||||
202
src/components/main/sidebar/rightsidebar.tsx
Normal file
202
src/components/main/sidebar/rightsidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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‘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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
23
src/lib/api/helpers/updateUserRequests.ts
Normal file
23
src/lib/api/helpers/updateUserRequests.ts
Normal 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'
|
||||
};
|
||||
}
|
||||
}
|
||||
27
src/lib/crypto/helpers/updateKey.ts
Normal file
27
src/lib/crypto/helpers/updateKey.ts
Normal 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
192
src/lib/crypto/keys.ts
Normal 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
26
src/types/user.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
|
|
@ -0,0 +1 @@
|
|||
v2.0.0
|
||||
Loading…
Add table
Reference in a new issue