Stable Release (I think)
Added all SQL scripts by using a python script to fetch them. Also added a "About" page and a skeleton to the chat page. Fixed the register function that was not setting the public_key on the database
This commit is contained in:
parent
ca8e649932
commit
8b27c6b140
48 changed files with 736 additions and 445 deletions
256
README.md
256
README.md
|
|
@ -1,33 +1,249 @@
|
|||
- 1 - What will your software do?
|
||||
# Silent Whisper - SiPher
|
||||
|
||||
My software will encrypt messages just like WhatsApp does by using a system of people having a key and sharing them
|
||||
with one another.
|
||||
[//]: # (TODO:)
|
||||
|
||||
- 1.1 - What features will it have?
|
||||
### Video Demo: <URL HERE>
|
||||
|
||||
I'll let the user choose multiple encryption methods, this will make it more secure and reliable.
|
||||
Only the user will have its password that he could share with another user.
|
||||
### Description:
|
||||
|
||||
- 1.2 How will it be executed?
|
||||
I created this app mainly to learn more about design and improve my skills in this area, plus learn a bit more about how
|
||||
E2EE encryption works.
|
||||
|
||||
Mainly by creating a database that would only hold a username and a password, could use Supabase for that
|
||||
or a simple MongoDb cluster.
|
||||
I ran into LOTS of problems (like, seriously, a ton) when starting the app, which made me use some workarounds to get it
|
||||
working 100%.
|
||||
|
||||
- 2- What new skills will you need to acquire?
|
||||
#### What does it do?
|
||||
|
||||
For this one, mainly how cryptography works on message exchanging.
|
||||
Here's what it does:
|
||||
|
||||
- 2.1 - What topics will you need to research?
|
||||
1. You register your account with just a Username and Password - no email or obvious identification needed
|
||||
2. You share your SUUID with another user who then requests consent to start a chat
|
||||
3. Once a chat starts, you can send messages to that user, following this flow:
|
||||
- You send a message
|
||||
- It gets encrypted using RSA-OAEP with SHA-256 and then encoded in Base64 format
|
||||
- It's sent to the server, stored in the database, and then triggers Supabase's Realtime to update both chats in
|
||||
real-time
|
||||
- Rinse & Repeat
|
||||
|
||||
I'll also have to research about the recommended cases on how to store or handle each user.
|
||||
That's the basic functionality of the app - just encrypting messages and sending them to a server that eventually stores
|
||||
them and uses a websocket connection (not really sure if it's a websocket, but through debugging, I noticed that at
|
||||
least in development, it uses websocket). Nothing special or functionality that would make the app really secure or
|
||||
ideal for real use.
|
||||
|
||||
- 3- If working with one or two classmates, who will do what?
|
||||
---
|
||||
|
||||
Will do by myself.
|
||||
#### Design Choices
|
||||
|
||||
- 4 - In the world of software, most everything takes longer to implement than you expect. And so it’s not uncommon to accomplish less in a fixed amount of time than you hope. What might you consider to be a good outcome for your project? A better outcome? The best outcome?
|
||||
#### Tech Stack
|
||||
|
||||
The best outcome for this would be an app that could at least:
|
||||
Log in/Register the user
|
||||
Let the user choose its encryption method
|
||||
Let the user change his password to a maximum of a 12-letter word
|
||||
For the tech stack, I decided to use:
|
||||
|
||||
- NextJs - Makes my life easier since Vercel can host it in a free plan
|
||||
- Supabase - Has the Realtime feature, in which Vercel
|
||||
|
||||
And that's it, really. I only used those two to create this app. Along with obviously WebApis that are supported in
|
||||
browsers.
|
||||
|
||||
If curious, though, I use IntelliJ products to code because I like their products.
|
||||
|
||||
#### Front-End
|
||||
|
||||
I had a lot of trouble with the design, mainly because I wanted the app to be pretty, minimalist, and work well enough.
|
||||
|
||||
For the front-end design, I'll admit I used Claude (Anthropic) to make better decisions about the app, such as styling
|
||||
issues (Mainly trying to make it mobile compatible).
|
||||
Even though I used AI for help, I had in mind what I wanted: Similar to WhatsApp. With an empty margin and
|
||||
the app UI smaller than the total browser screen. This really helped make the design cleaner, for some reason.
|
||||
|
||||
I also decided that, in the main design, I wanted to use a more striking color with a deeper color - in this case,
|
||||
orange and black had a great contrast.
|
||||
|
||||
I did use ShadCn to make my life easier since it's a really good library for better development on the front-end. I also
|
||||
considered using bootstrap or other libraries such as MaterialUi, but ShadCn had the easiest setup, was more
|
||||
minimalistic
|
||||
and I could control the components in a better way.
|
||||
|
||||
#### Back-End
|
||||
|
||||
The back-end design was a bit easier to do, thanks to how easy Supabase and NextJS API routes are to use, so there
|
||||
wasn't much debate about this specific part. Even though I had many problems, mainly with RLS policies in Supabase, due
|
||||
to pure lack of experience with it. For a better experience, I also used Supabase's own AI to help debug scripts, drop
|
||||
functions, and request the best approach method for this project.
|
||||
|
||||
I debated myself a lot when making the SQL scripts, though. They changed way too much and probably this has a weird DB
|
||||
structure. First I had in mind that each thread should be "indexable" (meaning, if the thread could be searched or not
|
||||
for joining), then I changed it to each user being indexable or not (meaning a user could search for another using by
|
||||
either using that user's SUUID or username) and I went with that.<br/>
|
||||
Then I had to change the message structure due to forgetting that each message sent should be encrypted for the current
|
||||
user too, else that user wouldn't be able to read what he sent to that user due to that message being encrypted only
|
||||
with
|
||||
the public key of the receiver end. With that, I also had to change the thread structures, making them separate in 3
|
||||
tables:
|
||||
|
||||
- "message_threads" - The main table
|
||||
- "thread_participants" - Holds the participants in each thread by indexing the thread id and
|
||||
user id
|
||||
- "messages" - Holds the messages for both the user that sent them (By encrypting that message with the user's own
|
||||
public
|
||||
key for access) and the receiver. The front-end can differenciate between the sender/receiver by using the key "
|
||||
sender_uuid"
|
||||
and comparing the logged user's uuid with that key. Each message is indexed to the thread_id for retrieval
|
||||
|
||||
The main issue I did run into was: Supabase does not support username-only login.<br/>
|
||||
So I had to improvise. I have a few domains that I bought some years ago and set the app to use that domain as a false
|
||||
e-mail:
|
||||
|
||||
```typescript
|
||||
const domain = process.env.DOMAIN;
|
||||
|
||||
if (!domain) {
|
||||
return NextResponse.json({
|
||||
error: "Server is misconfigured, please check env variables and try again."
|
||||
},
|
||||
{
|
||||
status: 500
|
||||
})
|
||||
} else if (!username || !password || !public_key) {
|
||||
return NextResponse.json({
|
||||
error: "Missing params"
|
||||
}, {status: 400})
|
||||
}
|
||||
|
||||
// First create the auth user
|
||||
const {data: {user}, error: authError} = await supabase.auth.signUp({
|
||||
email: `${username}@${domain}`, // Using username as email
|
||||
password: password,
|
||||
})
|
||||
```
|
||||
|
||||
This function represents the register, but the login-flow also works in a similar way, you can check
|
||||
its [script](./src/app/api/auth/login/route.ts) too.
|
||||
|
||||
Is this a breach on their policy? Well, I don't think it is... At least I hope it isn't.
|
||||
|
||||
But this works when setting a username-only login without having too much trouble.
|
||||
|
||||
Also, here's a cool badge:
|
||||
|
||||
[](https://wakatime.com/badge/user/e0979afa-f854-452d-b8a8-56f9d69eaa3b/project/eea66021-88c7-4467-8434-937fabc8149a)
|
||||
|
||||
---
|
||||
|
||||
##### Team MVPs
|
||||
|
||||
By team MVPs, I mean the functions that took the most work and time to get done and finished to a state where they
|
||||
worked well enough (as far as I could test).
|
||||
|
||||
1. [CryptoManager](./src/lib/crypto/keys.ts)
|
||||
|
||||
This function really gave me A LOT of headaches, seriously, A LOT of headaches.
|
||||
|
||||
Starting with how the encryption would work, I first thought of something like PGP, but it would be VERY long and
|
||||
possibly conflict with Supabase when storing it since I didn't know how it would handle a very long context. I admit
|
||||
I asked Claude for help to decide the best method for this situation, and I still feel it's not as secure as I
|
||||
wanted, but it works perfectly and isn't too complex.
|
||||
|
||||
Another important point that I decided on design-wise is that both users would need to have the same message
|
||||
encrypted 2x. One from who sent it using their own public key (So that user can read their own message) and one for
|
||||
who will receive it using that user's public key (So they can also read the received message).
|
||||
|
||||
Here are the key functions with detailed explanations:
|
||||
<br/><br/>
|
||||
`static async generateUserKeys(): Promise<CryptoKeyPair>`:
|
||||
Generates a private and public key when called
|
||||
<br/><br/>
|
||||
`static async storePrivateKey(privateKey: CryptoKey): Promise<void>`:
|
||||
Stores the private key in the "IndexedDB" database
|
||||
<br/><br/>
|
||||
`static async deletePrivateKey(): Promise<void>`:
|
||||
Deletes the previously recorded private key. If there isn't one, returns an error.
|
||||
<br/><br/>
|
||||
`static async getPrivateKey(): Promise<CryptoKey | null>`:
|
||||
Returns the user's current key for message decryption. Returns "null" if there isn't a key
|
||||
<br/><br/>
|
||||
`static async prepareAndSendMessage(message: string, senderPublicKey: JsonWebKey, recipientPublicKey: JsonWebKey, threadId: string): Promise<void>`:
|
||||
Prepares the message for both users using the "encryptMessage" method, and then sends it to the "
|
||||
/api/user/send/message" API that invokes the SQL function in Supabase
|
||||
<br/><br/>
|
||||
`static async decryptThreadMessages(messages: any[], userUuid: string): Promise<SiPher.DecryptedMessage[]>`:
|
||||
Receives an array of messages (from Supabase's API) and decrypts both the sent and received messages using the
|
||||
current user's private key. For messages that the user themselves sent, decryption is also done using the current
|
||||
user's private key, since it was encrypted for both sender and recipient.
|
||||
<br/><br/>
|
||||
`static async encryptMessage(message: string, recipientPublicKey: JsonWebKey): Promise<string>`:
|
||||
Encrypts a message, returning a base64 encoded string after being encrypted using RSA-OAEP
|
||||
<br/><br/>
|
||||
`static async exportPrivateKey(filename: string = 'private-key-backup'): Promise<{ text: string, file: File } | null>`:
|
||||
Helper function to facilitate the backup of the current private key
|
||||
<br/><br/>
|
||||
`static async validateKeyPair(privateKeyJwk: JsonWebKey, publicKeyJwk: JsonWebKey): Promise<boolean>`:
|
||||
Validates the current private key with the public key stored in the database by encrypting a message with a
|
||||
timestamp, then trying to decrypt it afterward. Returns a boolean in both cases.
|
||||
<br/><br/>
|
||||
`static async restoreFromBackup(privateKeyJwk: JsonWebKey, publicKeyJwk: JsonWebKey): Promise<boolean>`:
|
||||
Helper function to restore a backup. Not currently being used.
|
||||
<br/><br/>
|
||||
`private static async openDB(): Promise<IDBDatabase>`:
|
||||
Private function to open the database connection.
|
||||
|
||||
2. [SQL Functions](./supabase/sql_snippets)
|
||||
|
||||
Seriously, the amount of trouble I had with SQL functions is unreal... Not just functions, but also RLS policies,
|
||||
realtime permissions, etc. I had to ask for help from Supabase AI (and a bit from Claude, since honestly,
|
||||
Supabase's doesn't give as much explanation for corrections and other stuff).
|
||||
|
||||
The main functions are:
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.create_private_thread(participant_suuid TEXT) RETURNS UUID
|
||||
```
|
||||
Creates a private thread by getting the current user suuid (current_user_suuid) and the target user, checks if
|
||||
there's already a thread with those 2 participants and creates one if there isn't or returns an existing thread
|
||||
id
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.get_thread(thread_uuid UUID, user_id UUID)
|
||||
```
|
||||
Retrieves a thread using its uuid along with the user_id. If found, returns the thread information (thread_id,
|
||||
participants, participants_suuids, messages). If the thread doesn't exist, returns an empty value.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.get_user_threads(user_id UUID)
|
||||
```
|
||||
Retrieves a user's threads using their own uuid, returning an array of existing threads
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION public.send_message(
|
||||
thread_uuid UUID,
|
||||
sender_content TEXT,
|
||||
recipient_content TEXT
|
||||
) RETURNS UUID
|
||||
```
|
||||
Inserts both users' messages into the database, both encrypted with their respective keys
|
||||
|
||||
It's totally possible I forgot some functions or that others were deleted during development, so I included all
|
||||
the functions made, along with RLS policies and triggers.
|
||||
Some functions weren't mentioned because they weren't as problematic to make. There is also a high possibility of
|
||||
this app being really insecure since I am not too familiar with SQL (I always preferred NoSQL dbs.)
|
||||
|
||||
I will not document each page since I don't think it's necessary and that would make this README too long and
|
||||
cluttered.
|
||||
|
||||
I did re-use code of previous projects as inspiration. Mainly the middleware and some other styling (Such as the
|
||||
Sidebar).
|
||||
|
||||
I did not mention any API because the API routes mainly use supabase's functions to work, so I do not think it is
|
||||
necessary to mention them here.
|
||||
|
||||
---
|
||||
|
||||
For clarification, I did use AI to help me on this project:
|
||||
|
||||
- Claude - Helped with NextJs and React debugging (I don't know how to read the errors on react, sometimes it just
|
||||
outputs a simple message without explicit details on where the error happened), helping on some SQL functions too (
|
||||
Mainly RLS issues on realtime). Also helped when I couldn't really fix the style of some components.
|
||||
- Supabase's AI - I don't think it helped that much since, honestly, I don't think it's quite good at the purpose it was
|
||||
made to serve. Might be a skill issue on my part though. It helped mainly in debugging of some scripts that weren't
|
||||
working properly, since Supabase does not really support logs (at least, I never found where to look at)
|
||||
|
||||
You can check it out by using this link: https://sipher.space
|
||||
|
|
@ -46,9 +46,9 @@ import {useSharedState} from "@/hooks/shared-states";
|
|||
import {createBrowserClient} from '@/lib/supabase/browser'
|
||||
import {CryptoManager} from "@/lib/crypto/keys";
|
||||
import {REALTIME_SUBSCRIBE_STATES} from "@supabase/realtime-js";
|
||||
import ChatSkeleton from "@/app/[id]/skeleton";
|
||||
|
||||
export default function ChatPage() {
|
||||
const {theme} = useTheme();
|
||||
const {toast} = useToast();
|
||||
const supabase = createBrowserClient();
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ export default function ChatPage() {
|
|||
const [showUserDialog, setShowUserDialog] = useState(false);
|
||||
const [isEncrypted, setIsEncrypted] = useState(true);
|
||||
|
||||
const [realtimeSubscribed, setRealtimeSubscribed] = useState<REALTIME_SUBSCRIBE_STATES>()
|
||||
const [realtimeSubscribed, setRealtimeSubscribed] = useState<REALTIME_SUBSCRIBE_STATES>(REALTIME_SUBSCRIBE_STATES.CLOSED);
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState<boolean>(false);
|
||||
|
||||
|
|
@ -115,13 +115,14 @@ export default function ChatPage() {
|
|||
)
|
||||
.subscribe((status) => {
|
||||
setRealtimeSubscribed(status)
|
||||
console.log('Realtime subscription status:', status)
|
||||
console.info(`Subscription for thread ${threadId} has the status "${status}"`)
|
||||
console.info("If closed, something bad might be happening at the backend.")
|
||||
})
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [threadId])
|
||||
}, [threadId, currentUser.uuid, supabase])
|
||||
|
||||
useEffect(() => {
|
||||
const getUserDataAndChat = async () => {
|
||||
|
|
@ -157,17 +158,29 @@ export default function ChatPage() {
|
|||
setMessages([])
|
||||
setIsLoaded(false)
|
||||
}
|
||||
}, [setUser, setMessages, setIsLoaded, threads])
|
||||
}, [setUser, setMessages, setIsLoaded, threads, currentUser.suuid, currentUser.uuid, getUser, threadId, toast]) // Damn, quite a lot of dependencies, but lint said I should add it so....
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeSubscribed) return;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (realtimeSubscribed === 'TIMED_OUT' || realtimeSubscribed === 'CLOSED') {
|
||||
toast({
|
||||
title: "Connection Issue",
|
||||
description: "You might need to restart your browser due to connection issues.",
|
||||
variant: "destructive",
|
||||
duration: 10000,
|
||||
});
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [realtimeSubscribed, toast]);
|
||||
|
||||
if (!isLoaded || !user || realtimeSubscribed !== "SUBSCRIBED") {
|
||||
return (
|
||||
<>
|
||||
a
|
||||
</>
|
||||
)
|
||||
return <ChatSkeleton/>;
|
||||
}
|
||||
|
||||
// Mock functions - replace with actual implementations
|
||||
const checkUserValidity = async () => {
|
||||
// Implementation for checking user validity
|
||||
setShowUserDialog(true);
|
||||
|
|
@ -193,12 +206,10 @@ export default function ChatPage() {
|
|||
user.public_key,
|
||||
threadId
|
||||
)
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen max-h-[900px] w-full">
|
||||
{/* Chat Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
|
|
@ -279,7 +290,6 @@ export default function ChatPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence>
|
||||
|
|
@ -312,7 +322,6 @@ export default function ChatPage() {
|
|||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
|
|
@ -332,7 +341,6 @@ export default function ChatPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
|
|
|||
50
src/app/[id]/skeleton.tsx
Normal file
50
src/app/[id]/skeleton.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {Skeleton} from "@/components/ui/skeleton";
|
||||
import {ScrollArea} from "@/components/ui/scroll-area";
|
||||
|
||||
export default function ChatSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col h-screen max-h-[900px] w-full animate-in fade-in-50">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton className="h-10 w-10 rounded-full"/>
|
||||
<Skeleton className="h-4 w-32"/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="h-9 w-9 rounded-md"/>
|
||||
<Skeleton className="h-9 w-9 rounded-md"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Skeleton */}
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Left message */}
|
||||
<div className="flex justify-start">
|
||||
<Skeleton className="h-16 w-[250px] rounded-lg"/>
|
||||
</div>
|
||||
{/* Right message */}
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-12 w-[200px] rounded-lg"/>
|
||||
</div>
|
||||
{/* Left message */}
|
||||
<div className="flex justify-start">
|
||||
<Skeleton className="h-20 w-[300px] rounded-lg"/>
|
||||
</div>
|
||||
{/* Right message */}
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-14 w-[180px] rounded-lg"/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area Skeleton */}
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Skeleton className="h-10 flex-1 rounded-md"/>
|
||||
<Skeleton className="h-10 w-10 rounded-md"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/app/about/page.tsx
Normal file
234
src/app/about/page.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
"use client"
|
||||
import {motion} from "framer-motion";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger,} from "@/components/ui/accordion";
|
||||
import {Separator} from "@/components/ui/separator";
|
||||
import {AlertTriangle, KeyRound, Lock, MessageSquare, Shield, UserCheck,} from "lucide-react";
|
||||
|
||||
export default function AboutPage() {
|
||||
const containerVariants = {
|
||||
hidden: {opacity: 0},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: {opacity: 0, y: 20},
|
||||
visible: {opacity: 1, y: 0}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container max-w-4xl mx-auto py-8 px-4 space-y-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div variants={itemVariants} className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold">About SiPher</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Where privacy meets simplicity in secure communication
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<Separator/>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4"/>
|
||||
<AlertTitle>Important Notice</AlertTitle>
|
||||
<AlertDescription>
|
||||
SiPher is a CS50X final project and is not intended for production use.
|
||||
While we implement strong encryption, please do not use it for sensitive communications.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>How SiPher Works</CardTitle>
|
||||
<CardDescription>
|
||||
Understanding the security behind your messages
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<KeyRound className="h-6 w-6 text-primary mt-1"/>
|
||||
<div>
|
||||
<h3 className="font-semibold">Key Generation</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each user has a unique public-private key pair generated in their browser. Lost it and didn't
|
||||
make a
|
||||
backup? Welp, skill issue I guess.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Lock className="h-6 w-6 text-primary mt-1"/>
|
||||
<div>
|
||||
<h3 className="font-semibold">End-to-End Encryption</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Messages are encrypted before leaving your device
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<Shield className="h-6 w-6 text-primary mt-1"/>
|
||||
<div>
|
||||
<h3 className="font-semibold">Zero (And A Half) Trust</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Server never sees your decrypted messages. But we do store their encrypted version though lmao.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<UserCheck className="h-6 w-6 text-primary mt-1"/>
|
||||
<div>
|
||||
<h3 className="font-semibold">User Privacy</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Users are identified by unique IDs, not personal information. No e-mail, no nothing, only your ID
|
||||
(and probably IP due to Supabase logging it)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Technical Details</CardTitle>
|
||||
<CardDescription>
|
||||
The technology powering SiPher's "security"
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold">Encryption</h3>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
<li>RSA-OAEP for key exchange</li>
|
||||
<li>AES-GCM for message encryption</li>
|
||||
<li>PBKDF2 for key derivation</li>
|
||||
<li>SHA-256 for message integrity</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold">Implementation</h3>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||
<li>Web Crypto API for cryptographic operations</li>
|
||||
<li>Next.js for the application framework</li>
|
||||
<li>Supabase for real-time messaging</li>
|
||||
<li>TailwindCSS and ShadcnUI for the interface (I suck at design)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Frequently Asked Questions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>How secure are my messages?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Messages are encrypted using industry-standard algorithms and never stored in plaintext.
|
||||
However, as this is an educational project, I recommend not using it for sensitive communications.
|
||||
If you do and I get a notice, I will give out the data I have on you. I don't care.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>What happens if I lose my private key?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
If you lose your private key, you won't be able to decrypt previous messages.
|
||||
You can generate a new key pair, but you'll need to start fresh conversations, previous messages
|
||||
from
|
||||
other conversations will be lost forever.
|
||||
Always backup your private key in the settings.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-3">
|
||||
<AccordionTrigger>Can I recover deleted messages?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
You can't even delete chats, imagine messages lmao.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-4">
|
||||
<AccordionTrigger>How do I verify a user's identity?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Each user has a unique SUUID (Short UUID) that can be shared and verified.
|
||||
You can verify a user's identity by comparing their SUUID in a secure channel.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger>Is SiPher open source?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Not yet. As this is a CS50X final project, the code will be made available
|
||||
for educational purposes in the future.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="item-5">
|
||||
<AccordionTrigger>Will you continue this project after submitting it?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
Probably. It's quite fun dealing with encryption.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Message Flow</CardTitle>
|
||||
<CardDescription>
|
||||
How your message travels from you to the other user
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative flex justify-between items-center py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<MessageSquare className="w-6 h-6 text-primary"/>
|
||||
</div>
|
||||
<div className="absolute left-[calc(50%-4px)] top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-primary"/>
|
||||
<div className="absolute left-[20%] right-[20%] top-1/2 -translate-y-1/2 h-0.5 bg-primary/20"/>
|
||||
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Lock className="w-6 h-6 text-primary"/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-center text-muted-foreground">
|
||||
Messages are encrypted on your device before being sent through our servers,
|
||||
ensuring end-to-end encryption for all communications.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={itemVariants} className="text-center text-sm text-muted-foreground">
|
||||
<p>Built with 💖 as a CS50X final project</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import {NextResponse} from 'next/server'
|
|||
import {createClient} from "@/lib/supabase/server";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const {username, password} = await request.json()
|
||||
const {username, password, public_key} = await request.json()
|
||||
const supabase = await createClient()
|
||||
|
||||
try {
|
||||
|
|
@ -15,6 +15,10 @@ export async function POST(request: Request) {
|
|||
{
|
||||
status: 500
|
||||
})
|
||||
} else if (!username || !password || !public_key) {
|
||||
return NextResponse.json({
|
||||
error: "Missing params"
|
||||
}, {status: 400})
|
||||
}
|
||||
|
||||
// First create the auth user
|
||||
|
|
@ -32,6 +36,7 @@ export async function POST(request: Request) {
|
|||
.insert({
|
||||
uuid: user.id,
|
||||
username: username,
|
||||
public_key
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// app/api/user/keys/update/route.ts
|
||||
import {createClient} from "@/lib/supabase/server";
|
||||
import {NextResponse} from "next/server";
|
||||
|
||||
|
|
|
|||
|
|
@ -39,11 +39,19 @@ export default function AuthPage() {
|
|||
check().then(() => {
|
||||
console.log("Login page check finished")
|
||||
})
|
||||
}, []);
|
||||
}, [check]);
|
||||
|
||||
if (!mounted) {
|
||||
return <div className="min-h-screen flex items-center justify-center">
|
||||
{/* Optional: Add a loading spinner or skeleton here */}
|
||||
<svg aria-hidden="true" class="w-8 h-8 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
|
||||
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"/>
|
||||
</svg>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default async function Register(username: string, password: string) {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({username, password, publicKey: exportedPublic}), // Stringifies the JSON
|
||||
body: JSON.stringify({username, password, public_key: exportedPublic}), // Stringifies the JSON
|
||||
});
|
||||
|
||||
// Default error handler, if not OK just return whatever the API returned
|
||||
|
|
|
|||
|
|
@ -54,11 +54,13 @@ export default async function RootLayout(
|
|||
<ThemeProvider>
|
||||
<UserProvider initialUser={initialUser}>
|
||||
<SharedStateProvider>
|
||||
<div className={`max-h-[1080px] p-6 bg-secondary`}>
|
||||
<div className="flex bg-background">
|
||||
<Sidebar>
|
||||
{children}
|
||||
</Sidebar>
|
||||
<div className="min-h-screen flex items-center justify-center p-0 sm:p-4">
|
||||
<div className="w-full min-h-screen sm:min-h-0 sm:h-[900px] max-w-[1920px] flex bg-secondary sm:p-6">
|
||||
<div className="w-full h-full flex bg-background sm:rounded-lg overflow-hidden">
|
||||
<Sidebar>
|
||||
{children}
|
||||
</Sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SharedStateProvider>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function SiPher() {
|
|||
|
||||
/** CryptoManager Alert */
|
||||
const [privateKeyPresent, setPrivateKeyPresent] = useState(true);
|
||||
const [backupPanel, setBackupPanel] = useState(false);
|
||||
const [backupPanel, setBackupPanel] = useState(false); // I still need to do this, but... ugh.
|
||||
|
||||
/** Consent Form states */
|
||||
const [showConsentForm, setShowConsentForm] = useState(false);
|
||||
|
|
@ -206,7 +206,7 @@ export default function SiPher() {
|
|||
<MainPageAlerts/>
|
||||
|
||||
<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 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 group">
|
||||
<div
|
||||
|
|
@ -287,7 +287,7 @@ export default function SiPher() {
|
|||
<p className="text-lg md:text-xl font-medium leading-relaxed text-primary">
|
||||
F.A.Q
|
||||
</p>
|
||||
<Accordion type={"single"} collapsible className={"w-full-30%"}>
|
||||
<Accordion type={"single"} collapsible className={"w-full"}>
|
||||
<AccordionItem value={"works"}>
|
||||
<AccordionTrigger>How does this works?</AccordionTrigger>
|
||||
<AccordionContent asChild>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,27 @@
|
|||
// hooks/useRealtime.ts
|
||||
import {Dispatch, SetStateAction, useEffect} from 'react'
|
||||
import {Dispatch, SetStateAction, useCallback, useEffect} from 'react'
|
||||
import {createBrowserClient} from '@/lib/supabase/browser'
|
||||
import {useUser} from '@/contexts/user'
|
||||
import {useToast} from '@/hooks/use-toast'
|
||||
|
||||
interface UseRealtimeProps {
|
||||
setThreads: Dispatch<SetStateAction<SiPher.Thread[]>>;
|
||||
threads: SiPher.Thread[]
|
||||
}
|
||||
|
||||
export function useRealtime({setThreads, threads}: UseRealtimeProps) {
|
||||
export function useRealtime({setThreads}: UseRealtimeProps) {
|
||||
const supabase = createBrowserClient();
|
||||
const {user, updateUser} = useUser();
|
||||
const {toast} = useToast();
|
||||
|
||||
const fetchAndUpdateThreads = async () => {
|
||||
const fetchAndUpdateThreads = useCallback(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);
|
||||
}
|
||||
};
|
||||
}, [setThreads])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
|
@ -77,5 +73,5 @@ export function useRealtime({setThreads, threads}: UseRealtimeProps) {
|
|||
userUpdate.unsubscribe()
|
||||
}
|
||||
|
||||
}, [user?.uuid]);
|
||||
}, [user?.uuid, fetchAndUpdateThreads, supabase, updateUser, user]);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// components/RealtimeRequests.tsx
|
||||
'use client'
|
||||
|
||||
import {Dispatch, SetStateAction, useEffect} from 'react'
|
||||
import {useToast} from "@/hooks/use-toast"
|
||||
import {useUser} from "@/contexts/user"
|
||||
import {createBrowserClient} from "@/lib/supabase/browser";
|
||||
|
||||
interface RealtimeRequests {
|
||||
setRequests: Dispatch<SetStateAction<string[]>>
|
||||
}
|
||||
|
||||
export function RealtimeRequests(
|
||||
{
|
||||
setRequests,
|
||||
}: RealtimeRequests
|
||||
) {
|
||||
const {toast} = useToast()
|
||||
const {user, updateUser} = useUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
createBrowserClient().channel("realtime requests").on("postgres_changes", {
|
||||
event: 'UPDATE',
|
||||
schema: 'public',
|
||||
table: 'users',
|
||||
filter: `uuid=eq.${user.uuid}`,
|
||||
}, async (payload) => {
|
||||
console.log(payload)
|
||||
if (payload.new.requests !== payload.old.requests) {
|
||||
try {
|
||||
setRequests(payload.new.requests)
|
||||
} catch (error) {
|
||||
console.error('Error writing to stream:', error)
|
||||
}
|
||||
}
|
||||
}).subscribe()
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ 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 {useRealtime} from "@/components/main/realtime";
|
||||
import {useUser} from "@/contexts/user";
|
||||
import {usePathname} from "next/navigation";
|
||||
import {useSharedState} from "@/hooks/shared-states";
|
||||
|
|
@ -25,7 +25,7 @@ export default function RightSidebarContent(
|
|||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
const {threads, setThreads} = useSharedState();
|
||||
useRealtime({setThreads, threads});
|
||||
useRealtime({setThreads});
|
||||
|
||||
const {user} = useUser();
|
||||
const {username, suuid, requests = []} = user;
|
||||
|
|
@ -46,7 +46,7 @@ export default function RightSidebarContent(
|
|||
console.log(error);
|
||||
setThreads([])
|
||||
}
|
||||
}, []);
|
||||
}, [setThreads]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchThreads();
|
||||
|
|
@ -92,7 +92,7 @@ export default function RightSidebarContent(
|
|||
</div>
|
||||
</div>
|
||||
<Separator className="my-2"/>
|
||||
<ScrollArea className="flex-grow max-h-[590px] px-4 py-4">
|
||||
<ScrollArea className="flex-grow max-h-[500px] px-4 py-4">
|
||||
<nav>
|
||||
<ul className="space-y-1">
|
||||
<DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ function Sidebar(
|
|||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className={"max-h-[900px] w-full"}>{
|
||||
<div className={"flex-1 overflow-auto"}>{
|
||||
children ?? null
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
15
src/components/ui/skeleton.tsx
Normal file
15
src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import {cn} from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {Skeleton}
|
||||
|
|
@ -239,7 +239,7 @@ export class CryptoManager {
|
|||
try {
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "RSA-OAEP" // hash is only needed during key import
|
||||
name: "RSA-OAEP"
|
||||
},
|
||||
privateKey,
|
||||
encrypted
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export async function middleware(request: NextRequest) {
|
|||
try {
|
||||
const supabase = await createClient();
|
||||
const {data: {user}, error} = await supabase.auth.getUser();
|
||||
|
||||
const path = request.nextUrl.pathname;
|
||||
|
||||
if (!user && !isPublicRoute(path)) {
|
||||
|
|
@ -43,6 +44,7 @@ export async function middleware(request: NextRequest) {
|
|||
return redirect;
|
||||
}
|
||||
|
||||
|
||||
if (user && path.startsWith('/auth/') && !path.includes("/auth/complete")) {
|
||||
return NextResponse.redirect(new URL('/', request.url));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
v2.0.0
|
||||
116
supabase/main.py
Normal file
116
supabase/main.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import requests
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
"""
|
||||
Sanitize the filename while preserving as much of the original name as possible.
|
||||
"""
|
||||
invalid_chars = '<>:"/\\|?*'
|
||||
for char in invalid_chars:
|
||||
filename = filename.replace(char, '_')
|
||||
return filename
|
||||
|
||||
|
||||
def download_sql_snippets(
|
||||
access_token: str,
|
||||
project_ref: Optional[str] = None,
|
||||
output_dir: Optional[str] = None
|
||||
) -> None:
|
||||
"""
|
||||
Download SQL snippets from Supabase using the Management API.
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
base_url = "https://api.supabase.com"
|
||||
params = {'project_ref': project_ref} if project_ref else {}
|
||||
snippets_url = f"{base_url}/v1/snippets"
|
||||
|
||||
try:
|
||||
# Get list of all snippets
|
||||
response = requests.get(snippets_url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
snippets = response.json().get('data', [])
|
||||
|
||||
if not snippets:
|
||||
print("No SQL snippets found")
|
||||
return
|
||||
|
||||
output_dir = output_dir or "./sql_snippets"
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
used_names = set()
|
||||
|
||||
for i, snippet in enumerate(snippets, 1):
|
||||
snippet_id = snippet.get('id')
|
||||
name = snippet.get('name')
|
||||
|
||||
if not snippet_id:
|
||||
continue
|
||||
|
||||
snippet_url = f"{snippets_url}/{snippet_id}"
|
||||
print(f"Fetching snippet {i}/{len(snippets)}: {name or snippet_id}")
|
||||
|
||||
# Get the detailed snippet
|
||||
snippet_response = requests.get(snippet_url, headers=headers, params=params)
|
||||
snippet_response.raise_for_status()
|
||||
full_snippet = snippet_response.json()
|
||||
|
||||
# Use name from either response
|
||||
name = name or full_snippet.get('name')
|
||||
if not name:
|
||||
filename = f"snippet_{snippet_id}.sql"
|
||||
else:
|
||||
filename = name if name.lower().endswith('.sql') else f"{name}.sql"
|
||||
filename = sanitize_filename(filename)
|
||||
|
||||
# Handle duplicate filenames
|
||||
base_filename = filename
|
||||
counter = 1
|
||||
while filename in used_names:
|
||||
name_parts = base_filename.rsplit('.', 1)
|
||||
filename = f"{name_parts[0]}_{counter}.{name_parts[1]}"
|
||||
counter += 1
|
||||
used_names.add(filename)
|
||||
|
||||
filepath = Path(output_dir) / filename
|
||||
|
||||
# Get the SQL content from the correct nested structure
|
||||
content_obj = full_snippet.get('content', {})
|
||||
sql_content = content_obj.get('sql', '')
|
||||
|
||||
if not sql_content:
|
||||
print(f"Warning: No SQL content found for {filename}")
|
||||
continue
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(sql_content)
|
||||
|
||||
print(f"Saved: {filename}")
|
||||
|
||||
print(f"\nSuccessfully downloaded {len(snippets)} SQL snippets to {output_dir}")
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"\nError accessing Supabase API:")
|
||||
print(f"- Error type: {type(e).__name__}")
|
||||
print(f"- Error message: {str(e)}")
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f"- Status code: {e.response.status_code}")
|
||||
print(f"- Response body: {e.response.text}")
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Supabase SQL Snippet Downloader")
|
||||
print("-" * 30)
|
||||
|
||||
access_token = os.getenv("SUPABASE_ACCESS_TOKEN") or input("Enter your Supabase access token (sbp_...): ").strip()
|
||||
project_ref = os.getenv("SUPABASE_PROJECT_REF") or input(
|
||||
"Enter your project reference ID (optional, press Enter to skip): ").strip() or None
|
||||
output_dir = input("Enter output directory (press Enter for default): ").strip() or None
|
||||
|
||||
download_sql_snippets(access_token, project_ref, output_dir)
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
-- RLS Policies
|
||||
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE message_threads ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE thread_participants ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users policies
|
||||
CREATE POLICY "Users can view their own profile"
|
||||
ON users FOR SELECT
|
||||
USING (auth.uid() = uuid);
|
||||
|
||||
CREATE POLICY "Users can view indexable profiles"
|
||||
ON users FOR SELECT
|
||||
USING (indexable = true);
|
||||
|
||||
CREATE POLICY "Users can update their own profile"
|
||||
ON users FOR UPDATE
|
||||
USING (auth.uid() = uuid);
|
||||
|
||||
-- Message threads policies
|
||||
CREATE POLICY "Users can view their threads"
|
||||
ON message_threads FOR SELECT
|
||||
USING (is_thread_participant(id));
|
||||
|
||||
-- Thread participants policies
|
||||
CREATE POLICY "Users can view their thread participants"
|
||||
ON thread_participants FOR SELECT
|
||||
USING (is_thread_participant(thread_id));
|
||||
|
||||
-- Messages policies
|
||||
CREATE POLICY "Users can view their messages"
|
||||
ON messages FOR SELECT
|
||||
USING (is_thread_participant(thread_id));
|
||||
|
||||
CREATE POLICY "Users can send messages"
|
||||
ON messages FOR INSERT
|
||||
WITH CHECK (is_thread_participant(thread_id));
|
||||
1
supabase/sql_snippets/Add public key to users.sql
Normal file
1
supabase/sql_snippets/Add public key to users.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE public.users
ADD COLUMN IF NOT EXISTS public_key JSONB;
-- Function to update user's public key
CREATE
OR REPLACE FUNCTION public.update_user_public_key(
new_public_key JSONB
) RETURNS boolean AS $$
BEGIN
UPDATE public.users
SET public_key = new_public_key
WHERE uuid = auth.uid();
RETURN
FOUND;
END;
$$
LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.update_user_public_key TO authenticated;
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- First, verify realtime is enabled
SELECT *
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
AND tablename = 'messages';
-- Check REPLICA IDENTITY
SELECT relname, relreplident
FROM pg_class
WHERE oid = 'messages'::regclass;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE public.messages REPLICA IDENTITY FULL;
-- 2. Drop existing policies
DROP
POLICY IF EXISTS "messages_access" ON public.messages;
-- 3. Create one simple policy for messages
CREATE
POLICY "messages_realtime" ON public.messages
FOR ALL
USING (
sender_uuid = auth.uid() OR -- Either you sent it
thread_id IN ( -- Or you're in the thread
SELECT thread_id
FROM thread_participants
WHERE user_uuid = auth.uid()
)
);
|
||||
1
supabase/sql_snippets/Create Private Thread Function.sql
Normal file
1
supabase/sql_snippets/Create Private Thread Function.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE OR REPLACE FUNCTION public.create_private_thread(
participant_suuid TEXT
) RETURNS UUID AS $$
DECLARE
current_user_uuid UUID;
current_user_suuid TEXT;
target_user_uuid UUID;
new_thread_id UUID;
existing_thread_id UUID;
BEGIN
-- Get current user's UUID and SUUID
SELECT uuid, suuid INTO STRICT current_user_uuid, current_user_suuid
FROM public.users
WHERE uuid = auth.uid();
-- Get target user's UUID
SELECT uuid INTO STRICT target_user_uuid
FROM public.users
WHERE suuid = participant_suuid;
-- Check if thread already exists between these users
SELECT tp1.thread_id INTO existing_thread_id
FROM thread_participants tp1
JOIN thread_participants tp2 ON tp1.thread_id = tp2.thread_id
WHERE tp1.user_uuid = current_user_uuid
AND tp2.user_uuid = target_user_uuid
AND (
SELECT COUNT(*)
FROM thread_participants tp3
WHERE tp3.thread_id = tp1.thread_id
) = 2;
-- If thread exists, return it
IF existing_thread_id IS NOT NULL THEN
RETURN existing_thread_id;
END IF;
-- Create new thread
INSERT INTO message_threads DEFAULT VALUES
RETURNING id INTO new_thread_id;
-- Add participants
INSERT INTO thread_participants (thread_id, user_uuid)
VALUES
(new_thread_id, current_user_uuid),
(new_thread_id, target_user_uuid);
-- Update users with both requests array and a timestamp update to force change detection
UPDATE users
SET
requests = array_remove(COALESCE(requests, ARRAY[]::text[]), participant_suuid),
created_at = created_at -- This forces a row update
WHERE uuid = current_user_uuid;
UPDATE users
SET
requests = array_remove(COALESCE(requests, ARRAY[]::text[]), current_user_suuid),
created_at = created_at -- This forces a row update
WHERE uuid = target_user_uuid;
RETURN new_thread_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
|
@ -0,0 +1 @@
|
|||
CREATE
OR REPLACE FUNCTION public.create_private_thread(
participant_suuid TEXT
) RETURNS UUID AS $$
DECLARE
current_user_uuid UUID;
current_user_suuid
TEXT;
target_user_uuid
UUID;
new_thread_id
UUID;
existing_thread_id
UUID;
BEGIN
-- Get current user's UUID and SUUID
SELECT uuid, suuid
INTO STRICT current_user_uuid, current_user_suuid
FROM public.users
WHERE uuid = auth.uid();
-- Get target user's UUID
SELECT uuid
INTO STRICT target_user_uuid
FROM public.users
WHERE suuid = participant_suuid;
-- Check if thread already exists between these users
SELECT tp1.thread_id
INTO existing_thread_id
FROM thread_participants tp1
JOIN thread_participants tp2 ON tp1.thread_id = tp2.thread_id
WHERE tp1.user_uuid = current_user_uuid
AND tp2.user_uuid = target_user_uuid
AND (SELECT COUNT(*)
FROM thread_participants tp3
WHERE tp3.thread_id = tp1.thread_id) = 2;
-- If thread exists, return it
IF
existing_thread_id IS NOT NULL THEN
RETURN existing_thread_id;
END IF;
-- Create new thread
INSERT INTO message_threads DEFAULT
VALUES RETURNING id
INTO new_thread_id;
-- Add participants
INSERT INTO thread_participants (thread_id, user_uuid)
VALUES (new_thread_id, current_user_uuid),
(new_thread_id, target_user_uuid);
-- Update users with both requests array and a timestamp update to force change detection
UPDATE users
SET requests = array_remove(COALESCE(requests, ARRAY[]::text[]), participant_suuid),
created_at = created_at -- This forces a row update
WHERE uuid = current_user_uuid;
UPDATE users
SET requests = array_remove(COALESCE(requests, ARRAY[]::text[]), current_user_suuid),
created_at = created_at -- This forces a row update
WHERE uuid = target_user_uuid;
RETURN new_thread_id;
END;
$$
LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- Check and set the publication
SELECT *
FROM pg_publication;
-- If not set correctly, reset it:
DROP
PUBLICATION IF EXISTS supabase_realtime;
CREATE
PUBLICATION supabase_realtime FOR ALL TABLES;
-- Enable FULL replica identity for our tables
ALTER TABLE public.users REPLICA IDENTITY FULL;
ALTER TABLE public.messages REPLICA IDENTITY FULL;
ALTER TABLE public.thread_participants REPLICA IDENTITY FULL;
ALTER TABLE public.message_threads REPLICA IDENTITY FULL;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE public.users REPLICA IDENTITY FULL;
ALTER
PUBLICATION supabase_realtime ADD TABLE public.users;
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- This snippet checks if the messages table is already part of the publication before attempting to add it.
-- Enable replication for the messages table
ALTER TABLE public.messages REPLICA IDENTITY FULL;
-- Check if the messages table is already part of the publication
DO
$$
BEGIN
IF
NOT EXISTS (
SELECT 1
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime' AND schemaname = 'public' AND tablename = 'messages'
) THEN
ALTER
PUBLICATION supabase_realtime
ADD TABLE public.messages;
END IF;
END $$;
|
||||
1
supabase/sql_snippets/Get Thread Details.sql
Normal file
1
supabase/sql_snippets/Get Thread Details.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE OR REPLACE FUNCTION public.get_thread(thread_uuid UUID, user_id UUID)
RETURNS TABLE (
thread_id UUID,
participants TEXT[],
participant_suuids TEXT[],
messages JSON[]
) AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM public.thread_participants tp
WHERE tp.thread_id = thread_uuid
AND tp.user_uuid = user_id
) THEN
RETURN;
END IF;
RETURN QUERY
WITH thread_info AS (
-- Get thread participants info first
SELECT
mt.id as tid,
array_agg(DISTINCT u.username) as usernames,
array_agg(DISTINCT u.suuid::TEXT) as suuids
FROM public.message_threads mt
JOIN public.thread_participants tp ON mt.id = tp.thread_id
JOIN public.users u ON tp.user_uuid = u.uuid
WHERE mt.id = thread_uuid
GROUP BY mt.id
),
messages_info AS (
-- Get messages separately
SELECT
m.thread_id,
array_agg(
json_build_object(
'id', m.id,
'content', CASE
WHEN m.sender_uuid = user_id THEN m.sender_content
ELSE m.recipient_content
END,
'sender_uuid', m.sender_uuid,
'created_at', m.created_at
)
ORDER BY m.created_at ASC -- Add ordering here
) FILTER (WHERE m.id IS NOT NULL) as msg_array
FROM public.messages m
WHERE m.thread_id = thread_uuid
GROUP BY m.thread_id
)
SELECT
t.tid,
t.usernames,
t.suuids,
COALESCE(m.msg_array, ARRAY[]::JSON[])
FROM thread_info t
LEFT JOIN messages_info m ON t.tid = m.thread_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
1
supabase/sql_snippets/Get User Threads.sql
Normal file
1
supabase/sql_snippets/Get User Threads.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE OR REPLACE FUNCTION public.get_user_threads(user_id UUID)
RETURNS TABLE (
thread_id UUID,
participants TEXT[],
messages JSON[]
) AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM public.thread_participants
WHERE user_uuid = user_id
) THEN
-- Return empty result if user has no threads
RETURN;
END IF;
RETURN QUERY
SELECT
mt.id,
array_agg(DISTINCT u.username),
COALESCE(array_agg(
CASE WHEN m.id IS NOT NULL THEN
json_build_object(
'id', m.id,
'content', CASE
WHEN m.sender_uuid = user_id THEN m.sender_content
ELSE m.recipient_content
END,
'sender_uuid', m.sender_uuid,
'created_at', m.created_at
)
ELSE NULL END
) FILTER (WHERE m.id IS NOT NULL), ARRAY[]::JSON[])
FROM public.message_threads mt
JOIN public.thread_participants tp ON mt.id = tp.thread_id
JOIN public.users u ON tp.user_uuid = u.uuid
LEFT JOIN public.messages m ON mt.id = m.thread_id
WHERE mt.id IN (
SELECT tp2.thread_id
FROM public.thread_participants tp2
WHERE tp2.user_uuid = user_id
)
GROUP BY mt.id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- This snippet updates the policy to allow the sender of messages and participants in the thread to receive realtime events.
-- First, let's drop the existing policy
DROP
POLICY IF EXISTS "Thread participants access" ON public.messages;
-- 1. First ensure RLS is enabled
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
-- 2. Set REPLICA IDENTITY to FULL (required for realtime)
ALTER TABLE public.messages REPLICA IDENTITY FULL;
-- Check current publication configuration
SELECT *
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime';
-- Just set these then:
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.messages REPLICA IDENTITY FULL;
GRANT
SELECT
,
INSERT ON public.messages TO authenticated;
GRANT
USAGE
ON
SCHEMA
public TO authenticated;
CREATE
POLICY "Thread participants access" ON public.messages FOR ALL USING (
auth.uid () IN (
SELECT
user_uuid
FROM
thread_participants
WHERE
thread_id = messages.thread_id
)
);
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- RLS Policies
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE message_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE thread_participants ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- Users policies
CREATE
POLICY "Users can view their own profile"
ON users FOR
SELECT
USING (auth.uid() = uuid);
CREATE
POLICY "Users can view indexable profiles"
ON users FOR
SELECT
USING (indexable = true);
CREATE
POLICY "Users can update their own profile"
ON users FOR
UPDATE
USING (auth.uid() = uuid);
-- Message threads policies
CREATE
POLICY "Users can view their threads"
ON message_threads FOR
SELECT
USING (is_thread_participant(id));
-- Thread participants policies
CREATE
POLICY "Users can view their thread participants"
ON thread_participants FOR
SELECT
USING (is_thread_participant(thread_id));
-- Messages policies
CREATE
POLICY "Users can view their messages"
ON messages FOR
SELECT
USING (is_thread_participant(thread_id));
CREATE
POLICY "Users can send messages"
ON messages FOR INSERT
WITH CHECK (is_thread_participant(thread_id));
|
||||
1
supabase/sql_snippets/Send Message Function.sql
Normal file
1
supabase/sql_snippets/Send Message Function.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP FUNCTION IF EXISTS send_message(uuid,text,text);
CREATE
OR REPLACE FUNCTION public.send_message(
thread_uuid UUID,
sender_content TEXT,
recipient_content TEXT
) RETURNS UUID AS $$
DECLARE
message_id UUID;
recipient_uuid
UUID;
BEGIN
IF
NOT EXISTS (
SELECT 1
FROM thread_participants tp
WHERE tp.thread_id = thread_uuid
AND tp.user_uuid = auth.uid()
) THEN
RAISE EXCEPTION 'User not authorized to send message in this thread';
END IF;
-- Get the recipient's UUID (the other participant)
SELECT tp.user_uuid
INTO recipient_uuid
FROM thread_participants tp
WHERE tp.thread_id = thread_uuid
AND tp.user_uuid != auth.uid()
LIMIT 1;
-- Insert message with both encrypted versions
INSERT INTO messages (thread_id,
sender_uuid,
sender_content,
recipient_content)
VALUES (thread_uuid,
auth.uid(),
sender_content,
recipient_content) RETURNING id
INTO message_id;
RETURN message_id;
END;
$$
LANGUAGE plpgsql SECURITY DEFINER;
|
||||
1
supabase/sql_snippets/Update User Requests Function.sql
Normal file
1
supabase/sql_snippets/Update User Requests Function.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
DROP FUNCTION update_user_requests(uuid,text[]);
-- Create function to update user requests
CREATE
OR REPLACE FUNCTION public.update_user_requests(
search_term TEXT,
new_request TEXT -- Single SUUID to add/remove
) RETURNS boolean AS $$
DECLARE
target_user_uuid UUID;
current_requests
TEXT[];
BEGIN
-- First, find the target user based on SUUID or username (if indexable)
SELECT uuid, requests
INTO target_user_uuid, current_requests
FROM public.users
WHERE suuid = search_term
OR (
username = search_term
AND indexable = true
)
LIMIT 1;
IF
target_user_uuid IS NULL THEN
RETURN false;
END IF;
-- Update the requests array
-- Add if not exists, remove if exists
IF
new_request = ANY(current_requests) THEN
-- Remove the request
UPDATE public.users
SET requests = array_remove(requests, new_request)
WHERE uuid = target_user_uuid;
ELSE
-- Add the request
UPDATE public.users
SET requests = array_append(requests, new_request)
WHERE uuid = target_user_uuid;
END IF;
RETURN
FOUND;
END;
$$
LANGUAGE plpgsql SECURITY DEFINER;
-- Grant access to authenticated users
GRANT EXECUTE ON FUNCTION public.update_user_requests TO authenticated;
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- Drop existing policies and function
DROP
POLICY IF EXISTS "Allow SUUID searches" ON public.users;
DROP
POLICY IF EXISTS "Allow SUUID searches - Exact Match" ON public.users;
DROP
POLICY IF EXISTS "Allow SUUID searches - Permissive" ON public.users;
DROP FUNCTION IF EXISTS search_users(text);
-- Create a new policy to explicitly allow SUUID searches
CREATE
POLICY "Allow SUUID searches - Exact Match" ON public.users
FOR
SELECT
USING (
suuid = current_setting('request.jwt.claims')::json->>'search_term'
OR indexable = true
);
-- Create an alternative approach: more permissive policy for SUUID searches
CREATE
POLICY "Allow SUUID searches - Permissive" ON public.users
FOR
SELECT
USING (
suuid = ANY (
ARRAY (
SELECT
unnest(
regexp_split_to_array(
current_setting('request.jwt.claims')::json->>'search_term', ','
)
)
)
)
OR indexable = true
);
-- Create or replace the search_users function
CREATE
OR REPLACE FUNCTION public.search_users (search_term TEXT)
RETURNS TABLE (
uuid UUID,
suuid TEXT,
username TEXT,
indexable BOOLEAN,
public_key JSONB
) AS $$
BEGIN
-- Set the search term in the current transaction
PERFORM
set_config('request.jwt.claims', json_build_object('search_term', search_term)::text, true);
RETURN QUERY
SELECT u.uuid,
u.suuid::TEXT, CASE
WHEN u.suuid = search_term OR u.indexable THEN u.username
ELSE NULL
END,
u.indexable,
u.public_key
FROM public.users u
WHERE u.suuid = search_term
OR (
u.indexable = true AND
u.username ILIKE '%' || search_term || '%'
);
END;
$$
LANGUAGE plpgsql SECURITY DEFINER;
|
||||
1
supabase/sql_snippets/User Management Functions.sql
Normal file
1
supabase/sql_snippets/User Management Functions.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
-- For generate_short_uuid
CREATE
OR REPLACE FUNCTION public.generate_short_uuid () RETURNS TEXT AS $$
DECLARE
chars TEXT := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
result
TEXT := '';
i
INTEGER := 0;
max_attempts
INTEGER := 10;
current_attempt
INTEGER := 0;
is_unique
BOOLEAN := false;
BEGIN
WHILE
NOT is_unique AND current_attempt < max_attempts LOOP
result := '';
FOR i IN 1..8 LOOP
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
END LOOP;
SELECT COUNT(*) = 0
INTO is_unique
FROM public.users
WHERE suuid = result;
current_attempt
:= current_attempt + 1;
END LOOP;
IF
NOT is_unique THEN
RAISE EXCEPTION 'Could not generate unique short UUID after % attempts', max_attempts;
END IF;
RETURN result;
END;
$$
LANGUAGE plpgsql;
CREATE
OR REPLACE FUNCTION public.search_users(search_term TEXT)
RETURNS TABLE (
uuid UUID,
suuid TEXT,
username TEXT,
indexable BOOLEAN
) AS $$
BEGIN
RETURN QUERY
SELECT u.uuid,
u.suuid::TEXT,
-- Simplified CASE logic: show username if SUUID match OR (username match AND indexable) CASE
WHEN u.suuid = search_term OR u.indexable THEN u.username
ELSE NULL
END,
u.indexable
FROM public.users u
WHERE u.suuid = search_term -- Case 1: SUUID match (always show)
OR (
u.indexable = true AND -- Case 2: Username match + indexable
u.username ILIKE '%' || search_term || '%'
);
END;
$$
LANGUAGE plpgsql;
-- For is_thread_participant
CREATE
OR REPLACE FUNCTION public.is_thread_participant (thread_uuid UUID) RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (SELECT 1
FROM public.thread_participants
WHERE thread_id = thread_uuid
AND user_uuid = auth.uid());
END;
$$
LANGUAGE plpgsql SECURITY DEFINER;
-- For get_user_threads
CREATE
OR REPLACE FUNCTION public.get_user_threads (user_id UUID) RETURNS TABLE (
thread_id UUID,
participants TEXT[],
messages JSON[]
) AS $$
BEGIN
IF
NOT EXISTS (
SELECT 1
FROM public.thread_participants
WHERE user_uuid = user_id
) THEN
-- Return empty result if user has no threads
RETURN;
END IF;
RETURN QUERY
SELECT mt.id,
array_agg(DISTINCT u.username),
COALESCE(array_agg(
CASE
WHEN m.id IS NOT NULL THEN
json_build_object(
'id', m.id,
'content', m.content,
'created_at', m.created_at
)
ELSE NULL END
) FILTER(WHERE m.id IS NOT NULL), ARRAY[] ::JSON[])
FROM public.message_threads mt
JOIN public.thread_participants tp ON mt.id = tp.thread_id
JOIN public.users u ON tp.user_uuid = u.uuid
LEFT JOIN public.messages m ON mt.id = m.thread_id
WHERE mt.id IN (SELECT thread_id
FROM public.thread_participants
WHERE user_uuid = user_id)
GROUP BY mt.id;
END;
$$
LANGUAGE plpgsql SECURITY DEFINER;
|
||||
1
supabase/sql_snippets/User Management Table.sql
Normal file
1
supabase/sql_snippets/User Management Table.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
-- Drop everything related to users
DROP TABLE IF EXISTS public.users CASCADE;
-- Create new users table
CREATE TABLE
public.users
(
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suuid CHAR(8) UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL CHECK (
length(username) >= 3
AND username ~ '^[a-zA-Z0-9_-]+$'
) ,
indexable BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE ('utc'::text, NOW()) NOT NULL
);
-- Create trigger function for SUUID generation
CREATE
OR REPLACE FUNCTION public.handle_new_user () RETURNS TRIGGER AS $$
BEGIN
NEW.suuid
:= public.generate_short_uuid();
RETURN NEW;
END;
$$
LANGUAGE plpgsql;
-- Create the trigger
CREATE TRIGGER on_user_created
BEFORE INSERT
ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user ();
-- Add policies
CREATE
POLICY "Users can view their own profile" ON public.users FOR
SELECT
USING (auth.uid () = uuid);
CREATE
POLICY "Users can view indexable profiles" ON public.users FOR
SELECT
USING (indexable = true);
CREATE
POLICY "Allow user registration" ON public.users FOR INSERT
WITH
CHECK (true);
CREATE
POLICY "Users can update their own profile" ON public.users
FOR
UPDATE
USING (auth.uid () = uuid);
-- Enable RLS
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Create index for better performance
CREATE INDEX idx_users_suuid ON public.users (suuid);
CREATE INDEX idx_users_indexable ON public.users (indexable) WHERE
indexable = true;
|
||||
1
supabase/sql_snippets/User Registration Policy.sql
Normal file
1
supabase/sql_snippets/User Registration Policy.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
-- Drop everything related to users
DROP TABLE IF EXISTS public.users CASCADE;
-- Create new users table
CREATE TABLE
public.users
(
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suuid CHAR(8) UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL CHECK (
length(username) >= 3
AND username ~ '^[a-zA-Z0-9_-]+$'
) ,
password TEXT NOT NULL CHECK (length(password) >= 8),
indexable BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE ('utc'::text, NOW()) NOT NULL
);
-- Create trigger function for SUUID generation
CREATE
OR REPLACE FUNCTION public.handle_new_user () RETURNS TRIGGER AS $$
BEGIN
NEW.suuid
:= public.generate_short_uuid();
RETURN NEW;
END;
$$
LANGUAGE plpgsql;
-- Create the trigger
CREATE TRIGGER on_user_created
BEFORE INSERT
ON public.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user ();
-- Add policies
CREATE
POLICY "Users can view their own profile" ON public.users FOR
SELECT
USING (auth.uid () = uuid);
CREATE
POLICY "Users can view indexable profiles" ON public.users FOR
SELECT
USING (indexable = true);
CREATE
POLICY "Allow user registration" ON public.users FOR INSERT
WITH
CHECK (true);
CREATE
POLICY "Users can update their own profile" ON public.users
FOR
UPDATE
USING (auth.uid () = uuid);
-- Enable RLS
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Create index for better performance
CREATE INDEX idx_users_suuid ON public.users (suuid);
CREATE INDEX idx_users_indexable ON public.users (indexable) WHERE
indexable = true;
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- Add requests array to users table
ALTER TABLE public.users
ADD COLUMN requests TEXT[] DEFAULT ARRAY[]::TEXT[];
-- Create index for the requests array for better performance
CREATE INDEX idx_users_requests ON public.users USING GIN (requests);
-- Add policy for requests field
CREATE
POLICY "Users can only see their own requests" ON public.users FOR
SELECT
USING (auth.uid () = uuid);
|
||||
1
supabase/sql_snippets/User and Message Indexes.sql
Normal file
1
supabase/sql_snippets/User and Message Indexes.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
CREATE INDEX idx_users_suuid ON users (suuid);
CREATE INDEX idx_users_indexable ON users (indexable) WHERE indexable = true;
CREATE INDEX idx_thread_participants_user ON thread_participants (user_uuid);
CREATE INDEX idx_messages_thread ON messages (thread_id);
|
||||
1
supabase/sql_snippets/Users Table.sql
Normal file
1
supabase/sql_snippets/Users Table.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
-- Base Tables
CREATE TABLE
users
(
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
suuid CHAR(8) UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL CHECK (
length(username) >= 3
AND username ~ '^[a-zA-Z0-9_-]+$'
) ,
password TEXT NOT NULL CHECK (length(password) >= 8),
indexable BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE ('utc'::text, NOW()) NOT NULL
);
CREATE TABLE
message_threads
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
);
CREATE TABLE
thread_participants
(
thread_id UUID REFERENCES message_threads (id) ON DELETE CASCADE,
user_uuid UUID REFERENCES users (uuid) ON DELETE CASCADE,
PRIMARY KEY (thread_id, user_uuid)
);
CREATE TABLE
messages
(
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
thread_id UUID REFERENCES message_threads (id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
);
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
-- Drop the existing policy if it exists
|
||||
DROP POLICY IF EXISTS "Allow SUUID searches" ON public.users;
|
||||
|
||||
-- Create a new policy to explicitly allow SUUID searches
|
||||
CREATE POLICY "Allow SUUID searches - Exact Match" ON public.users
|
||||
FOR SELECT
|
||||
USING (
|
||||
suuid = current_setting('request.jwt.claims')::json ->> 'search_term'
|
||||
OR indexable = true
|
||||
);
|
||||
|
||||
-- Create an alternative approach: more permissive policy for SUUID searches
|
||||
CREATE POLICY "Allow SUUID searches - Permissive" ON public.users
|
||||
FOR SELECT
|
||||
USING (
|
||||
suuid = ANY (
|
||||
ARRAY (
|
||||
SELECT
|
||||
unnest(
|
||||
regexp_split_to_array(
|
||||
current_setting('request.jwt.claims')::json ->> 'search_term',
|
||||
','
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
OR indexable = true
|
||||
);
|
||||
|
||||
-- Create or replace the search_users function
|
||||
CREATE OR REPLACE FUNCTION public.search_users (search_term TEXT)
|
||||
RETURNS TABLE (
|
||||
uuid UUID,
|
||||
suuid TEXT,
|
||||
username TEXT,
|
||||
indexable BOOLEAN
|
||||
) AS $$
|
||||
BEGIN
|
||||
-- Set the search term in the current transaction
|
||||
SET LOCAL "request.jwt.claim.search_term" = search_term;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
u.uuid,
|
||||
u.suuid::TEXT,
|
||||
CASE
|
||||
WHEN u.suuid = search_term OR u.indexable THEN u.username
|
||||
ELSE NULL
|
||||
END,
|
||||
u.indexable
|
||||
FROM public.users u
|
||||
WHERE
|
||||
u.suuid = search_term
|
||||
OR (
|
||||
u.indexable = true AND
|
||||
u.username ILIKE '%' || search_term || '%'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
-- Indexes
|
||||
CREATE INDEX idx_users_suuid ON users(suuid);
|
||||
CREATE INDEX idx_users_indexable ON users(indexable) WHERE indexable = true;
|
||||
CREATE INDEX idx_thread_participants_user ON thread_participants(user_uuid);
|
||||
CREATE INDEX idx_messages_thread ON messages(thread_id);
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
-- For generate_short_uuid
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public.generate_short_uuid () RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
chars TEXT := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
result TEXT := '';
|
||||
i INTEGER := 0;
|
||||
max_attempts INTEGER := 10;
|
||||
current_attempt INTEGER := 0;
|
||||
is_unique BOOLEAN := false;
|
||||
BEGIN
|
||||
WHILE NOT is_unique AND current_attempt < max_attempts LOOP
|
||||
result := '';
|
||||
FOR i IN 1..8 LOOP
|
||||
result := result || substr(chars, floor(random() * length(chars) + 1)::integer, 1);
|
||||
END LOOP;
|
||||
|
||||
SELECT COUNT(*) = 0 INTO is_unique
|
||||
FROM public.users
|
||||
WHERE suuid = result;
|
||||
|
||||
current_attempt := current_attempt + 1;
|
||||
END LOOP;
|
||||
|
||||
IF NOT is_unique THEN
|
||||
RAISE EXCEPTION 'Could not generate unique short UUID after % attempts', max_attempts;
|
||||
END IF;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.search_users(search_term TEXT)
|
||||
RETURNS TABLE (
|
||||
uuid UUID,
|
||||
suuid TEXT,
|
||||
username TEXT,
|
||||
indexable BOOLEAN
|
||||
) AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
u.uuid,
|
||||
u.suuid::TEXT,
|
||||
-- Simplified CASE logic: show username if SUUID match OR (username match AND indexable)
|
||||
CASE
|
||||
WHEN u.suuid = search_term OR u.indexable THEN u.username
|
||||
ELSE NULL
|
||||
END,
|
||||
u.indexable
|
||||
FROM public.users u
|
||||
WHERE
|
||||
u.suuid = search_term -- Case 1: SUUID match (always show)
|
||||
OR (
|
||||
u.indexable = true AND -- Case 2: Username match + indexable
|
||||
u.username ILIKE '%' || search_term || '%'
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- For is_thread_participant
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public.is_thread_participant (thread_uuid UUID) RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
RETURN EXISTS (
|
||||
SELECT 1
|
||||
FROM public.thread_participants
|
||||
WHERE thread_id = thread_uuid
|
||||
AND user_uuid = auth.uid()
|
||||
);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- For get_user_threads
|
||||
CREATE
|
||||
OR REPLACE FUNCTION public.get_user_threads (user_id UUID) RETURNS TABLE (
|
||||
thread_id UUID,
|
||||
participants TEXT[],
|
||||
messages JSON[]
|
||||
) AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.thread_participants
|
||||
WHERE user_uuid = user_id
|
||||
) THEN
|
||||
-- Return empty result if user has no threads
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
mt.id,
|
||||
array_agg(DISTINCT u.username),
|
||||
COALESCE(array_agg(
|
||||
CASE WHEN m.id IS NOT NULL THEN
|
||||
json_build_object(
|
||||
'id', m.id,
|
||||
'content', m.content,
|
||||
'created_at', m.created_at
|
||||
)
|
||||
ELSE NULL END
|
||||
) FILTER (WHERE m.id IS NOT NULL), ARRAY[]::JSON[])
|
||||
FROM public.message_threads mt
|
||||
JOIN public.thread_participants tp ON mt.id = tp.thread_id
|
||||
JOIN public.users u ON tp.user_uuid = u.uuid
|
||||
LEFT JOIN public.messages m ON mt.id = m.thread_id
|
||||
WHERE mt.id IN (
|
||||
SELECT thread_id
|
||||
FROM public.thread_participants
|
||||
WHERE user_uuid = user_id
|
||||
)
|
||||
GROUP BY mt.id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
-- Drop everything related to users
|
||||
DROP TABLE IF EXISTS public.users CASCADE;
|
||||
|
||||
-- Create new users table
|
||||
CREATE TABLE public.users (
|
||||
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
suuid CHAR(8) UNIQUE NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL CHECK (length(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
|
||||
indexable BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
|
||||
);
|
||||
|
||||
-- Create trigger function for SUUID generation
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.suuid := public.generate_short_uuid();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger
|
||||
CREATE TRIGGER on_user_created
|
||||
BEFORE INSERT ON public.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_new_user();
|
||||
|
||||
-- Add policies
|
||||
CREATE POLICY "Users can view their own profile" ON public.users
|
||||
FOR SELECT USING (auth.uid() = uuid);
|
||||
|
||||
CREATE POLICY "Users can view indexable profiles" ON public.users
|
||||
FOR SELECT USING (indexable = true);
|
||||
|
||||
CREATE POLICY "Allow user registration" ON public.users
|
||||
FOR INSERT WITH CHECK (true);
|
||||
|
||||
CREATE POLICY "Users can update their own profile" ON public.users
|
||||
FOR UPDATE USING (auth.uid() = uuid);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create index for better performance
|
||||
CREATE INDEX idx_users_suuid ON public.users(suuid);
|
||||
CREATE INDEX idx_users_indexable ON public.users(indexable) WHERE indexable = true;
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
-- Drop everything related to users
|
||||
DROP TABLE IF EXISTS public.users CASCADE;
|
||||
|
||||
-- Create new users table
|
||||
CREATE TABLE public.users (
|
||||
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
suuid CHAR(8) UNIQUE NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL CHECK (length(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
|
||||
password TEXT NOT NULL CHECK (length(password) >= 8),
|
||||
indexable BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
|
||||
);
|
||||
|
||||
-- Create trigger function for SUUID generation
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.suuid := public.generate_short_uuid();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger
|
||||
CREATE TRIGGER on_user_created
|
||||
BEFORE INSERT ON public.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION public.handle_new_user();
|
||||
|
||||
-- Add policies
|
||||
CREATE POLICY "Users can view their own profile" ON public.users
|
||||
FOR SELECT USING (auth.uid() = uuid);
|
||||
|
||||
CREATE POLICY "Users can view indexable profiles" ON public.users
|
||||
FOR SELECT USING (indexable = true);
|
||||
|
||||
CREATE POLICY "Allow user registration" ON public.users
|
||||
FOR INSERT WITH CHECK (true);
|
||||
|
||||
CREATE POLICY "Users can update their own profile" ON public.users
|
||||
FOR UPDATE USING (auth.uid() = uuid);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Create index for better performance
|
||||
CREATE INDEX idx_users_suuid ON public.users(suuid);
|
||||
CREATE INDEX idx_users_indexable ON public.users(indexable) WHERE indexable = true;
|
||||
|
|
@ -1 +0,0 @@
|
|||
SELECT * FROM public.search_users('chCzlx84');
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
-- Base Tables
|
||||
CREATE TABLE users (
|
||||
uuid UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
suuid CHAR(8) UNIQUE NOT NULL,
|
||||
username TEXT UNIQUE NOT NULL CHECK (length(username) >= 3 AND username ~ '^[a-zA-Z0-9_-]+$'),
|
||||
password TEXT NOT NULL CHECK (length(password) >= 8),
|
||||
indexable BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE message_threads (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE thread_participants (
|
||||
thread_id UUID REFERENCES message_threads(id) ON DELETE CASCADE,
|
||||
user_uuid UUID REFERENCES users(uuid) ON DELETE CASCADE,
|
||||
PRIMARY KEY (thread_id, user_uuid)
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
thread_id UUID REFERENCES message_threads(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT TIMEZONE('utc'::text, NOW()) NOT NULL
|
||||
);
|
||||
Loading…
Add table
Reference in a new issue