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:
Nixyi 2024-12-18 16:08:06 -03:00
parent ca8e649932
commit 8b27c6b140
48 changed files with 736 additions and 445 deletions

256
README.md
View file

@ -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 its 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:
[![wakatime](https://wakatime.com/badge/user/e0979afa-f854-452d-b8a8-56f9d69eaa3b/project/eea66021-88c7-4467-8434-937fabc8149a.svg)](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

View file

@ -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
View 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
View 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&apos;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&apos;s &quot;security&quot;
</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&apos;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&apos;t be able to decrypt previous messages.
You can generate a new key pair, but you&apos;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&apos;t even delete chats, imagine messages lmao.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-4">
<AccordionTrigger>How do I verify a user&apos;s identity?</AccordionTrigger>
<AccordionContent>
Each user has a unique SUUID (Short UUID) that can be shared and verified.
You can verify a user&apos;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&apos;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>
);
}

View file

@ -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) {

View file

@ -1,4 +1,3 @@
// app/api/user/keys/update/route.ts
import {createClient} from "@/lib/supabase/server";
import {NextResponse} from "next/server";

View file

@ -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>;
}

View file

@ -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

View file

@ -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>

View file

@ -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>

View file

@ -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]);
}

View file

@ -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
}

View file

@ -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>

View file

@ -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>

View 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}

View file

@ -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

View file

@ -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));
}

View file

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

116
supabase/main.py Normal file
View 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)

View file

@ -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));

View 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;

View file

@ -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;

View file

@ -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() ) );

View 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;

View 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;

View file

@ -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;

View file

@ -0,0 +1 @@
ALTER TABLE public.users REPLICA IDENTITY FULL; ALTER PUBLICATION supabase_realtime ADD TABLE public.users;

View file

@ -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 $$;

View 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;

View 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;

View file

@ -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 ) );

View file

@ -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));

View 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;

View 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;

View file

@ -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;

View 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;

View 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;

View 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;

View file

@ -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);

View 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);

View 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 );

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -1 +0,0 @@
SELECT * FROM public.search_users('chCzlx84');

View file

@ -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
);