Back to Projects
๐ŸŒ Case Study

SocialNetwork.Social

A private, invite-only social network built for a close circle of friends โ€” combining AI-powered image generation, end-to-end encrypted messaging, and intelligent post recommendations.

Year 2025
Role Solo Engineer & Designer
Stack Flask ยท React ยท PostgreSQL
Status Live โ€” Private Beta

Why I Built It

I built SocialNetwork.Social for my closest friends โ€” a private social network they could actually trust. Public social platforms are noisy, algorithmically manipulative, and increasingly hostile to genuine connection. I wanted something different: a small, curated space where real friends could share, react, and communicate without ads, surveillance, or strangers.

The project became an opportunity to explore what modern social infrastructure looks like when you're not optimizing for engagement at scale, but for depth and quality of interaction within a small trusted group. I leveraged Cursor and a suite of AI coding tools to write code faster and more effectively, dramatically compressing the development timeline.

What It Can Do

Rather than a stripped-down MVP, I built a genuinely feature-rich platform โ€” each capability chosen because it solves a real frustration with existing social apps.

๐ŸŽ™๏ธ
Ampersounds
Re-usable audio snippets embedded inline using &username.soundname syntax โ€” anywhere in a post or comment.
๐Ÿ–ผ๏ธ
AI Image Generation (Flux)
Generate 1024ร—1024 images via FLUX-1-schnell on DeepInfra. Images are classified by a Gemma multimodal model and stored on Cloudflare R2 โ€” for โ‰ˆ1/20ยข each.
โœจ
Smart Feed Algorithm
A weighted feed-scoring formula โ€” balancing recency, relevance, popularity, and engagement โ€” surfaces posts to each user based on their personal interest graph.
๐Ÿค
Friend Systems
Full friend request, accept, reject, and cancel flows with post privacy controls (Public / Friends-only). The feed automatically scopes visibility to your social graph.
๐Ÿ”’
E2EE Messaging
X3DH-inspired pairwise key agreement with AES-GCM encryption. Keys live exclusively in the browser's IndexedDB. The server stores only opaque ciphertext.

System Overview

The platform is a Flask REST API backed by PostgreSQL, with a React SPA frontend built with Vite. Media and generated images are stored on Cloudflare R2 via the S3-compatible boto3 SDK. Real-time messaging is delivered through SpacetimeDB, which acts as the live transport layer for the E2EE chat subsystem.

Backend
Flask + Flask-Restful
REST API, session auth (Flask-Login), rate limiting (Flask-Limiter), migrations (Flask-Migrate)
Database
PostgreSQL + SQLAlchemy
Posts, users, interests, category scores, chat devices, prekeys, friendships
Frontend
React + Vite
SPA with component-level state, Cypress E2E test suite, IndexedDB for E2EE key storage
Media Storage
Cloudflare R2
All uploaded and AI-generated images. boto3 S3-compatible client with custom domain CDN URL
Real-time
SpacetimeDB
Multiplayer database used as the live transport layer for encrypted chat messages
AI Models
DeepInfra API
FLUX-1-schnell for image generation; Gemma 3 (text + multimodal) for content classification

AI Image Generation โ€” FLUX-1-schnell

Image generation is handled by black-forest-labs/FLUX-1-schnell accessed through DeepInfra's OpenAI-compatible endpoint. The request goes through the standard openai.images.generate call, so swapping the base URL is the only change needed to point at DeepInfra rather than OpenAI directly โ€” making it trivially cheap.

resources/image_generation.py # OpenAI SDK pointed at DeepInfra โ€” drop-in model swap openai_client = OpenAI( api_key=openai_api_key, base_url="https://api.deepinfra.com/v1/openai", ) response = openai_client.images.generate( prompt=prompt, model="black-forest-labs/FLUX-1-schnell", n=1, size="1024x1024", response_format="b64_json", # receive as bytes, not a URL )

After generation, the pipeline immediately classifies the image using Gemma multimodal (also on DeepInfra) to generate a set of category scores. These scores power the feed recommendation system โ€” a generated image can appear in the feeds of users who are interested in its detected topics, just like any other post.

1
Rate limit check โ€” UserImageGenerationStats enforces a cap of 20 generations per user per day before any API call is made.
2
Generate โ€” FLUX-1-schnell returns the image as base64 JSON. Decoded to bytes in memory โ€” no temporary files on disk.
3
Upload to R2 โ€” The image bytes stream directly to Cloudflare R2 via s3_client.upload_fileobj(). A CDN-backed public URL is constructed from the app config's custom domain.
4
Multimodal classification โ€” The same image bytes are passed to Gemma multimodal on DeepInfra, which returns a JSON dict of { "Technology": 0.85, "Art": 0.4, โ€ฆ } category scores.
5
Persist โ€” A Post row is created with the R2 URL, and one PostCategoryScore row is inserted per category โ€” enabling relevance-based feed ranking.
โ‰ˆ$0.005
Cost per 1024ร—1024 image
20/day
Per-user daily rate limit
Zero disk
Images streamed in-memory โ€” no temp files

Ampersounds โ€” Inline Audio Snippets

Ampersounds are re-usable audio clips that any member can embed in posts or comments using a simple &username.soundname syntax. A server-side regex pass over post content resolves each tag against the database and injects a data--attributed span that the React frontend transforms into a playable audio button.

The entire text is HTML-escaped first, before any tag replacement โ€” ensuring that user-supplied content can never inject raw HTML. Only DB-verified values are placed into element attributes, preventing stored XSS.

utils.py โ€” format_text_with_ampersounds() # XSS-safe: escape the full string BEFORE resolving tags escaped_text = html.escape(text_content) # Regex supports both &user.sound and bare &sound forms pattern = r"&([a-zA-Z0-9_][a-zA-Z0-9_-]*)\.([a-zA-Z0-9_][a-zA-Z0-9_-]+)|&([a-zA-Z0-9_][a-zA-Z0-9_-]+)" def replace_tag(match): # Look up &user.sound in DB โ€” only trusted DB values go into HTML attrs entry = Ampersound.query.join(User).filter( User.username == target_username, Ampersound.name == sound_name ).first() if entry: safe_user = html.escape(entry.user.username, quote=True) safe_sound = html.escape(entry.name, quote=True) return f'<span class="ampersound-tag" data-username="{safe_user}" data-soundname="{safe_sound}">โ€ฆ</span>' return original_escaped_tag # leave as plain text if not found

Audio files can also be imported directly from YouTube URLs using yt-dlp. The server extracts just the audio track, trims it to a reasonable length, stores it on R2, and enters it into the moderation queue. An admin approval step runs before any sound becomes publicly searchable โ€” preventing abuse of the ingestion pipeline.

&user.sound
Inline syntax โ€” works anywhere in post text
yt-dlp
YouTube โ†’ audio import pipeline
Admin gate
Moderation queue before sounds go public

Feed Recommendation Algorithm

Every post on the platform is run through Gemma 3 (via DeepInfra) when it's created, producing a JSON object of category-to-score mappings โ€” e.g. {"Technology": 0.9, "Science": 0.4}. These are persisted as PostCategoryScore rows. Over time, as users engage, a parallel UserInterest table accumulates category affinity scores for each person.

At feed-render time, a single SQL query computes a feed score for every visible post by joining PostCategoryScore against the user's UserInterest weights and folding in four normalized signals:

feed_score = R_WEIGHT ร— relevance(ฮฃ post_score ร— user_weight) / (ฮฃ + K_rel) + P_WEIGHT ร— popularity = comments / (comments + K_comments) + D_WEIGHT ร— recency = 1 / (1 + hours_since_post) + E_WEIGHT ร— engagement = likes / (likes + K_likes) โˆ’ S_PENALTY if post.user_id == current_user.id Default weights: R=0.20 P=0.05 D=0.70 E=0.05 S=0.20

Recency (D_WEIGHT=0.70) is the dominant signal by design โ€” the network is small and real-time, so stale posts should decay quickly. Relevance (0.20) personalizes results using the user's top-5 interest categories. Each signal is normalized with a "softplus" denominator (K constants) to prevent any single viral post from monopolizing the feed.

Posts whose detected categories overlap with a configurable blocked categories list are filtered out after pagination โ€” allowing admins to suppress entire topic areas without changing the core algorithm.

resources/feed.py โ€” feed score SQL expression (SQLAlchemy) feed_query = db.session.query( Post.id.label('post_id'), ( R_WEIGHT * ( func.coalesce(func.sum(PostCategoryScore.score * weight_subq.c.weight), 0) / (func.coalesce(func.sum(PostCategoryScore.score * weight_subq.c.weight), 0) + K_RELEVANCE) ) + D_WEIGHT * (1.0 / (1.0 + func.extract('epoch', func.now() - Post.timestamp) / 3600.0)) + P_WEIGHT * (Post.comments_count / (Post.comments_count + K_COMMENTS)) + E_WEIGHT * (Post.likes_count / (Post.likes_count + K_LIKES)) - case((Post.user_id == current_user.id, S_PENALTY_WEIGHT), else_=0) ).label('feed_score') ).select_from(Post).outerjoin(PostCategoryScore).outerjoin(weight_subq).group_by(Post.id)

End-to-End Encrypted Messaging

The chat system implements a full X3DH-style key agreement protocol โ€” the same cryptographic approach used by Signal. Every registered device has an identity key pair, a signed prekey (rotated periodically), and a pool of one-time prekeys, all generated in the browser using the Web Crypto API and stored exclusively in IndexedDB. The server never receives private key material.

When Alice sends Bob a message for the first time, her client fetches Bob's public key bundle from the server, runs three ECDH agreements (identity ร— signed-prekey, identity ร— one-time-prekey, etc.), and feeds the concatenated shared secrets through HKDF to derive a symmetric session key. All subsequent messages in the conversation use AES-GCM with that derived key and a freshly generated nonce โ€” authenticated with associated data (AAD) that binds the ciphertext to its conversation and epoch.

frontend/src/chat/crypto/directMessageService.js โ€” session key derivation // Three ECDH agreements โ†’ HKDF โ†’ AES-GCM session key const sharedSecrets = [ await prekeyService.deriveAgreementBytes({ localPrivateKeyJwk: localDevice.identityKey.privateKeyJwk, remotePublicKey: targetDevice.identityKeyPublic, // DH1 }), await prekeyService.deriveAgreementBytes({ localPrivateKeyJwk: localDevice.identityKey.privateKeyJwk, remotePublicKey: targetDevice.signedPrekeyPublic, // DH2 }), ]; if (targetDevice.oneTimePrekey?.publicKey) { // Optional DH3 with one-time prekey โ€” ratchet forward-secrecy sharedSecrets.push(await prekeyService.deriveAgreementBytes({ โ€ฆ })); } const keyMaterial = await prekeyService.deriveSessionKey({ sharedSecrets, salt: PAIRWISE_SESSION_SALT, // "llm-social-network:dm-session:v1" info: buildSessionInfo({ senderUserId, senderDeviceId, recipientUserId, recipientDeviceId }), });

Each encrypted message payload is wrapped in a signed envelope that carries the nonce, AAD, session type, sender identity key, and prekey IDs. The server receives this opaque envelope and routes it to the recipient over SpacetimeDB โ€” a multiplayer database that acts as the real-time transport layer. The server never holds the session key and cannot decrypt any message.

The system also supports multi-device linking: a secondary device (e.g. a second browser tab) can request a link from an approved primary device. The primary device re-encrypts recent message history and ships it to the new device during the link ceremony โ€” ensuring the linked device has chat history without the server ever decrypting anything.

X3DH
Key agreement protocol (Signal-style)
AES-GCM
Symmetric encryption per message
IndexedDB
Keys stored client-side only โ€” never on server
Multi-device
Secure device linking with history backfill
models.py โ€” ChatDevice & ChatOneTimePrekey (server-side key registry) class ChatDevice(db.Model): device_id = db.Column(db.String(64), unique=True) identity_key_public = db.Column(db.Text, nullable=False) # public only signed_prekey_id = db.Column(db.Integer, nullable=False) signed_prekey_public= db.Column(db.Text, nullable=False) signed_prekey_sig = db.Column(db.Text, nullable=False) # Private keys are NEVER sent to or stored on the server class ChatOneTimePrekey(db.Model): chat_device_id = db.Column(db.Integer, ForeignKey('chat_device.id')) prekey_id = db.Column(db.Integer, nullable=False) public_key = db.Column(db.Text, nullable=False) # Consumed one-at-a-time on first contact; never reused

Full Stack & Tools

I used Cursor as my primary IDE alongside Claude, ChatGPT, and Gemini to write complex subsystems โ€” especially the E2EE crypto layer and the SQL feed algorithm โ€” significantly faster than I could alone. The AI tooling was most valuable for researching cryptographic protocols, generating test cases for the Cypress E2E suite, and scaffolding the SpacetimeDB reducers.

Flask Flask-Restful Flask-SQLAlchemy Flask-Limiter PostgreSQL React + Vite SpacetimeDB Cloudflare R2 (boto3) FLUX-1-schnell Gemma 3 (DeepInfra) Web Crypto API IndexedDB yt-dlp Cypress Cursor
Next Project
PieBot Chess Engine โ†’
View Case Study