|
"""
|
|
Merged Rant Community and Chat Application
|
|
Environment variables (from .env):
|
|
- DB_DSN: Database connection string
|
|
- SECRET_KEY: Secret key for security
|
|
- SESSION_COOKIE_NAME: Name of session cookie
|
|
- PWA_SCOPE: PWA scope path
|
|
- UPLOAD_DIR: Upload directory path
|
|
- APP_ID: Application ID
|
|
"""
|
|
|
|
from fastapi import FastAPI, HTTPException, Depends, Form, File, UploadFile, Request, Response, WebSocket, WebSocketDisconnect
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from pydantic import BaseModel, EmailStr
|
|
from typing import Optional, List, Dict, Any, Literal
|
|
from datetime import datetime, timedelta
|
|
import hashlib
|
|
import secrets
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import asyncio
|
|
from ads import AsyncDataSet
|
|
from dotenv import load_dotenv
|
|
from contextlib import asynccontextmanager
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
|
|
# Configuration from environment
|
|
DB_DSN = os.getenv('DB_DSN', 'rant_community.db')
|
|
SECRET_KEY = os.getenv('SECRET_KEY', secrets.token_urlsafe(32))
|
|
SESSION_COOKIE_NAME = os.getenv('SESSION_COOKIE_NAME', 'auth_token')
|
|
PWA_SCOPE = os.getenv('PWA_SCOPE', '/')
|
|
UPLOAD_DIR = Path(os.getenv('UPLOAD_DIR', 'uploads'))
|
|
APP_ID = int(os.getenv('APP_ID', '3'))
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
await init_db()
|
|
Path("templates").mkdir(exist_ok=True)
|
|
Path("static").mkdir(exist_ok=True)
|
|
yield
|
|
|
|
app = FastAPI(title="Rant Community API", lifespan=lifespan)
|
|
|
|
# Enable CORS
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# Database setup
|
|
UPLOAD_DIR.mkdir(exist_ok=True)
|
|
|
|
# Template setup
|
|
templates = Jinja2Templates(directory="templates")
|
|
|
|
# Initialize AsyncDataSet
|
|
db = AsyncDataSet(DB_DSN)
|
|
|
|
# WebSocket connection manager
|
|
class ConnectionManager:
|
|
def __init__(self):
|
|
self.active_connections: Dict[str, List[WebSocket]] = {}
|
|
self.user_sockets: Dict[str, str] = {}
|
|
|
|
async def connect(self, websocket: WebSocket, user_id: str):
|
|
await websocket.accept()
|
|
if user_id not in self.active_connections:
|
|
self.active_connections[user_id] = []
|
|
self.active_connections[user_id].append(websocket)
|
|
socket_id = str(id(websocket))
|
|
self.user_sockets[socket_id] = user_id
|
|
|
|
def disconnect(self, websocket: WebSocket, user_id: str):
|
|
if user_id in self.active_connections:
|
|
self.active_connections[user_id].remove(websocket)
|
|
if not self.active_connections[user_id]:
|
|
del self.active_connections[user_id]
|
|
socket_id = str(id(websocket))
|
|
if socket_id in self.user_sockets:
|
|
del self.user_sockets[socket_id]
|
|
|
|
async def send_personal_message(self, message: str, user_id: str):
|
|
if user_id in self.active_connections:
|
|
for connection in self.active_connections[user_id]:
|
|
await connection.send_text(message)
|
|
|
|
async def broadcast_to_channel(self, message: str, channel_id: str, exclude_user: str = None):
|
|
members = await db.find("channel_members", {"channel_id": channel_id})
|
|
for member in members:
|
|
if member["user_id"] != exclude_user:
|
|
await self.send_personal_message(message, member["user_id"])
|
|
|
|
manager = ConnectionManager()
|
|
|
|
async def init_db():
|
|
# Main app tables (from main.py)
|
|
await db.create_table("users", {
|
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
"username": "TEXT NOT NULL",
|
|
"email": "TEXT NOT NULL",
|
|
"password_hash": "TEXT NOT NULL",
|
|
"score": "INTEGER DEFAULT 0",
|
|
"about": "TEXT DEFAULT ''",
|
|
"location": "TEXT DEFAULT ''",
|
|
"skills": "TEXT DEFAULT ''",
|
|
"github": "TEXT DEFAULT ''",
|
|
"website": "TEXT DEFAULT ''",
|
|
"created_time": "INTEGER NOT NULL",
|
|
"avatar_b": "TEXT DEFAULT '7bc8a4'",
|
|
"avatar_i": "TEXT",
|
|
"status": "TEXT DEFAULT 'offline'",
|
|
"last_seen": "TEXT"
|
|
}, ["UNIQUE(username)", "UNIQUE(email)"])
|
|
|
|
await db.create_table("auth_tokens", {
|
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
"user_id": "INTEGER NOT NULL",
|
|
"token_key": "TEXT NOT NULL",
|
|
"expire_time": "INTEGER NOT NULL"
|
|
}, ["UNIQUE(token_key)", "FOREIGN KEY (user_id) REFERENCES users (id)"])
|
|
|
|
await db.create_table("rants", {
|
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
"user_id": "INTEGER NOT NULL",
|
|
"text": "TEXT NOT NULL",
|
|
"score": "INTEGER DEFAULT 0",
|
|
"created_time": "INTEGER NOT NULL",
|
|
"attached_image": "TEXT DEFAULT ''",
|
|
"tags": "TEXT DEFAULT ''",
|
|
"edited": "INTEGER DEFAULT 0",
|
|
"type": "INTEGER DEFAULT 1"
|
|
}, ["FOREIGN KEY (user_id) REFERENCES users (id)"])
|
|
|
|
await db.create_table("comments", {
|
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
"rant_id": "INTEGER NOT NULL",
|
|
"user_id": "INTEGER NOT NULL",
|
|
"body": "TEXT NOT NULL",
|
|
"score": "INTEGER DEFAULT 0",
|
|
"created_time": "INTEGER NOT NULL",
|
|
"attached_image": "TEXT DEFAULT ''",
|
|
"edited": "INTEGER DEFAULT 0"
|
|
}, ["FOREIGN KEY (rant_id) REFERENCES rants (id)", "FOREIGN KEY (user_id) REFERENCES users (id)"])
|
|
|
|
await db.create_table("votes", {
|
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
"user_id": "INTEGER NOT NULL",
|
|
"target_id": "INTEGER NOT NULL",
|
|
"target_type": "TEXT NOT NULL",
|
|
"vote": "INTEGER NOT NULL",
|
|
"reason": "INTEGER"
|
|
}, ["UNIQUE(user_id, target_id, target_type)", "FOREIGN KEY (user_id) REFERENCES users (id)"])
|
|
|
|
await db.create_table("favorites", {
|
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
"user_id": "INTEGER NOT NULL",
|
|
"rant_id": "INTEGER NOT NULL"
|
|
}, ["UNIQUE(user_id, rant_id)", "FOREIGN KEY (user_id) REFERENCES users (id)", "FOREIGN KEY (rant_id) REFERENCES rants (id)"])
|
|
|
|
await db.create_table("notifications", {
|
|
"id": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
"user_id": "INTEGER NOT NULL",
|
|
"type": "TEXT NOT NULL",
|
|
"rant_id": "INTEGER",
|
|
"comment_id": "INTEGER",
|
|
"from_user_id": "INTEGER",
|
|
"created_time": "INTEGER NOT NULL",
|
|
"read": "INTEGER DEFAULT 0"
|
|
}, ["FOREIGN KEY (user_id) REFERENCES users (id)"])
|
|
|
|
# Chat tables (merged from chat.py)
|
|
await db.create_table("channels", {
|
|
"name": "TEXT UNIQUE NOT NULL",
|
|
"description": "TEXT",
|
|
"created_by": "TEXT NOT NULL",
|
|
"is_dm": "INTEGER DEFAULT 0"
|
|
})
|
|
|
|
await db.create_table("channel_members", {
|
|
"channel_id": "TEXT NOT NULL",
|
|
"user_id": "TEXT NOT NULL",
|
|
"joined_at": "TEXT",
|
|
"last_read_message_id": "TEXT"
|
|
}, ["UNIQUE(channel_id, user_id)"])
|
|
|
|
await db.create_table("messages", {
|
|
"channel_id": "TEXT NOT NULL",
|
|
"user_id": "TEXT NOT NULL",
|
|
"content": "TEXT NOT NULL",
|
|
"edited": "INTEGER DEFAULT 0",
|
|
"edited_at": "TEXT"
|
|
})
|
|
|
|
await db.create_table("dm_channels", {
|
|
"user_id_1": "TEXT NOT NULL",
|
|
"user_id_2": "TEXT NOT NULL",
|
|
"channel_id": "TEXT NOT NULL"
|
|
}, ["UNIQUE(user_id_1, user_id_2)"])
|
|
|
|
# Create default channels
|
|
default_channels = ["general", "random", "development", "design"]
|
|
for channel_name in default_channels:
|
|
existing = await db.get("channels", {"name": channel_name})
|
|
if not existing:
|
|
await db.insert("channels", {
|
|
"name": channel_name,
|
|
"description": f"#{channel_name} channel",
|
|
"created_by": "system"
|
|
})
|
|
|
|
# Pydantic models (combined from both apps)
|
|
class UserRegister(BaseModel):
|
|
email: EmailStr
|
|
username: str
|
|
password: str
|
|
|
|
class UserLogin(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
class RantCreate(BaseModel):
|
|
rant: str
|
|
tags: str
|
|
type: int = 1
|
|
|
|
class CommentCreate(BaseModel):
|
|
comment: str
|
|
|
|
class VoteRequest(BaseModel):
|
|
vote: Literal[-1, 0, 1]
|
|
reason: Optional[int] = None
|
|
|
|
class ChannelCreate(BaseModel):
|
|
name: str
|
|
description: Optional[str] = None
|
|
|
|
class MessageCreate(BaseModel):
|
|
content: str
|
|
channel_id: Optional[str] = None
|
|
dm_user_id: Optional[str] = None
|
|
|
|
# Helper functions
|
|
def hash_password(password: str) -> str:
|
|
return hashlib.sha256(password.encode()).hexdigest()
|
|
|
|
def generate_token() -> str:
|
|
return secrets.token_urlsafe(32)
|
|
|
|
def format_time(timestamp):
|
|
date = datetime.fromtimestamp(timestamp)
|
|
now = datetime.now()
|
|
diff = (now - date).total_seconds()
|
|
|
|
if diff < 60: return 'just now'
|
|
if diff < 3600: return f'{int(diff / 60)}m ago'
|
|
if diff < 86400: return f'{int(diff / 3600)}h ago'
|
|
if diff < 604800: return f'{int(diff / 86400)}d ago'
|
|
|
|
return date.strftime('%Y-%m-%d')
|
|
|
|
templates.env.globals['format_time'] = format_time
|
|
|
|
def escape_html(text):
|
|
return text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''')
|
|
|
|
templates.env.globals['escape_html'] = escape_html
|
|
|
|
# Authentication helpers
|
|
async def get_current_user_from_cookie(request: Request):
|
|
auth_token = request.cookies.get(SESSION_COOKIE_NAME)
|
|
if not auth_token:
|
|
return None
|
|
|
|
try:
|
|
token_data = json.loads(auth_token)
|
|
token = await db.get("auth_tokens", {
|
|
"id": token_data['id'],
|
|
"token_key": token_data['key'],
|
|
"user_id": token_data['user_id']
|
|
})
|
|
|
|
if not token or token['expire_time'] <= int(datetime.now().timestamp()):
|
|
return None
|
|
|
|
user = await db.get("users", {"id": token_data['user_id']})
|
|
if user:
|
|
user['token_id'] = token_data['id']
|
|
user['token_key'] = token_data['key']
|
|
return user
|
|
except:
|
|
return None
|
|
|
|
async def format_rant(rant_row, user_row, current_user_id=None):
|
|
comment_count = await db.count("comments", {"rant_id": rant_row['id']})
|
|
|
|
vote_state = 0
|
|
if current_user_id:
|
|
vote = await db.get("votes", {
|
|
"user_id": current_user_id,
|
|
"target_id": rant_row['id'],
|
|
"target_type": "rant"
|
|
})
|
|
if vote:
|
|
vote_state = vote['vote']
|
|
|
|
tags = json.loads(rant_row['tags']) if rant_row['tags'] else []
|
|
|
|
return {
|
|
"id": rant_row['id'],
|
|
"text": rant_row['text'],
|
|
"score": rant_row['score'],
|
|
"created_time": rant_row['created_time'],
|
|
"attached_image": rant_row['attached_image'],
|
|
"num_comments": comment_count,
|
|
"tags": tags,
|
|
"vote_state": vote_state,
|
|
"edited": bool(rant_row['edited']),
|
|
"rt": rant_row['type'],
|
|
"rc": 1,
|
|
"user_id": user_row['id'],
|
|
"user_username": user_row['username'],
|
|
"user_score": user_row['score'],
|
|
"user_avatar": {
|
|
"b": user_row['avatar_b'],
|
|
"i": user_row['avatar_i'] or ""
|
|
},
|
|
"user_avatar_lg": {
|
|
"b": user_row['avatar_b'],
|
|
"i": user_row['avatar_i'] or ""
|
|
}
|
|
}
|
|
|
|
async def format_comment(comment_row, user_row, current_user_id=None):
|
|
vote_state = 0
|
|
if current_user_id:
|
|
vote = await db.get("votes", {
|
|
"user_id": current_user_id,
|
|
"target_id": comment_row['id'],
|
|
"target_type": "comment"
|
|
})
|
|
if vote:
|
|
vote_state = vote['vote']
|
|
|
|
return {
|
|
"id": comment_row['id'],
|
|
"rant_id": comment_row['rant_id'],
|
|
"body": comment_row['body'],
|
|
"score": comment_row['score'],
|
|
"created_time": comment_row['created_time'],
|
|
"vote_state": vote_state,
|
|
"user_id": user_row['id'],
|
|
"user_username": user_row['username'],
|
|
"user_score": user_row['score'],
|
|
"user_avatar": {
|
|
"b": user_row['avatar_b'],
|
|
"i": user_row['avatar_i'] or ""
|
|
}
|
|
}
|
|
|
|
# SSR Routes from main.py
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def home(request: Request, sort: str = "recent"):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
current_user_id = current_user['id'] if current_user else None
|
|
|
|
order_by = "r.created_time DESC" if sort == "recent" else "r.score DESC"
|
|
rows = await db.query_raw(
|
|
f"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
|
|
u.avatar_b, u.avatar_i
|
|
FROM rants r
|
|
JOIN users u ON r.user_id = u.id
|
|
ORDER BY {order_by}
|
|
LIMIT 50 OFFSET 0""",
|
|
()
|
|
)
|
|
|
|
rants = []
|
|
for row in rows:
|
|
rant_data = {
|
|
'id': row['id'],
|
|
'text': row['text'],
|
|
'score': row['score'],
|
|
'created_time': row['created_time'],
|
|
'attached_image': row['attached_image'],
|
|
'tags': row['tags'],
|
|
'edited': row['edited'],
|
|
'type': row['type']
|
|
}
|
|
user_data = {
|
|
'id': row['user_id'],
|
|
'username': row['username'],
|
|
'score': row['user_score'],
|
|
'avatar_b': row['avatar_b'],
|
|
'avatar_i': row['avatar_i']
|
|
}
|
|
|
|
rants.append(await format_rant(rant_data, user_data, current_user_id))
|
|
|
|
notif_count = 0
|
|
if current_user:
|
|
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
|
|
|
|
return templates.TemplateResponse("index.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"notif_count": notif_count,
|
|
"rants": rants,
|
|
"current_sort": sort,
|
|
"escape_html": escape_html,
|
|
"format_time": format_time
|
|
})
|
|
|
|
# Chat route - ONLY /chat is valid, NOT /chat/
|
|
@app.get("/chat", response_class=HTMLResponse)
|
|
async def chat_page(request: Request):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
return RedirectResponse("/login", status_code=302)
|
|
|
|
# Calculate notification count
|
|
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
|
|
|
|
return templates.TemplateResponse("chat.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"notif_count": notif_count,
|
|
"escape_html": escape_html,
|
|
"format_time": format_time
|
|
})
|
|
|
|
|
|
@app.get("/rant/{rant_id}", response_class=HTMLResponse)
|
|
async def rant_detail(request: Request, rant_id: int):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
current_user_id = current_user['id'] if current_user else None
|
|
|
|
rant_row = await db.query_one(
|
|
"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
|
|
u.avatar_b, u.avatar_i
|
|
FROM rants r
|
|
JOIN users u ON r.user_id = u.id
|
|
WHERE r.id = ?""",
|
|
(rant_id,)
|
|
)
|
|
|
|
if not rant_row:
|
|
raise HTTPException(status_code=404, detail="Rant not found")
|
|
|
|
rant_data = {
|
|
'id': rant_row['id'],
|
|
'text': rant_row['text'],
|
|
'score': rant_row['score'],
|
|
'created_time': rant_row['created_time'],
|
|
'attached_image': rant_row['attached_image'],
|
|
'tags': rant_row['tags'],
|
|
'edited': rant_row['edited'],
|
|
'type': rant_row['type']
|
|
}
|
|
user_data = {
|
|
'id': rant_row['user_id'],
|
|
'username': rant_row['username'],
|
|
'score': rant_row['user_score'],
|
|
'avatar_b': rant_row['avatar_b'],
|
|
'avatar_i': rant_row['avatar_i']
|
|
}
|
|
|
|
rant = await format_rant(rant_data, user_data, current_user_id)
|
|
|
|
comment_rows = await db.query_raw(
|
|
"""SELECT c.*, u.id as user_id, u.username, u.score as user_score,
|
|
u.avatar_b, u.avatar_i
|
|
FROM comments c
|
|
JOIN users u ON c.user_id = u.id
|
|
WHERE c.rant_id = ?
|
|
ORDER BY c.created_time ASC""",
|
|
(rant_id,)
|
|
)
|
|
|
|
comments = []
|
|
for row in comment_rows:
|
|
comment_data = {
|
|
'id': row['id'],
|
|
'rant_id': row['rant_id'],
|
|
'body': row['body'],
|
|
'score': row['score'],
|
|
'created_time': row['created_time']
|
|
}
|
|
user_data = {
|
|
'id': row['user_id'],
|
|
'username': row['username'],
|
|
'score': row['user_score'],
|
|
'avatar_b': row['avatar_b'],
|
|
'avatar_i': row['avatar_i']
|
|
}
|
|
|
|
comments.append(await format_comment(comment_data, user_data, current_user_id))
|
|
|
|
subscribed = 0
|
|
if current_user_id:
|
|
if await db.exists("favorites", {"user_id": current_user_id, "rant_id": rant_id}):
|
|
subscribed = 1
|
|
|
|
notif_count = 0
|
|
if current_user:
|
|
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
|
|
|
|
return templates.TemplateResponse("rant_detail.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"notif_count": notif_count,
|
|
"rant": rant,
|
|
"comments": comments,
|
|
"subscribed": subscribed,
|
|
"escape_html": escape_html,
|
|
"format_time": format_time
|
|
})
|
|
|
|
@app.get("/profile/{user_id}", response_class=HTMLResponse)
|
|
async def profile(request: Request, user_id: int, tab: str = "rants"):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
current_user_id = current_user['id'] if current_user else None
|
|
|
|
user = await db.get("users", {"id": user_id})
|
|
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
|
|
rant_rows = await db.query_raw(
|
|
"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
|
|
u.avatar_b, u.avatar_i
|
|
FROM rants r
|
|
JOIN users u ON r.user_id = u.id
|
|
WHERE r.user_id = ?
|
|
ORDER BY r.created_time DESC
|
|
LIMIT 50""",
|
|
(user_id,)
|
|
)
|
|
|
|
rants = []
|
|
for row in rant_rows:
|
|
rant_data = {
|
|
'id': row['id'],
|
|
'text': row['text'],
|
|
'score': row['score'],
|
|
'created_time': row['created_time'],
|
|
'attached_image': row['attached_image'],
|
|
'tags': row['tags'],
|
|
'edited': row['edited'],
|
|
'type': row['type']
|
|
}
|
|
user_data = {
|
|
'id': row['user_id'],
|
|
'username': row['username'],
|
|
'score': row['user_score'],
|
|
'avatar_b': row['avatar_b'],
|
|
'avatar_i': row['avatar_i']
|
|
}
|
|
|
|
rants.append(await format_rant(rant_data, user_data, current_user_id))
|
|
|
|
comment_rows = await db.query_raw(
|
|
"""SELECT c.*, u.id as user_id, u.username, u.score as user_score,
|
|
u.avatar_b, u.avatar_i
|
|
FROM comments c
|
|
JOIN users u ON c.user_id = u.id
|
|
WHERE c.user_id = ?
|
|
ORDER BY c.created_time DESC
|
|
LIMIT 50""",
|
|
(user_id,)
|
|
)
|
|
|
|
comments = []
|
|
for row in comment_rows:
|
|
comment_data = {
|
|
'id': row['id'],
|
|
'rant_id': row['rant_id'],
|
|
'body': row['body'],
|
|
'score': row['score'],
|
|
'created_time': row['created_time']
|
|
}
|
|
user_data = {
|
|
'id': row['user_id'],
|
|
'username': row['username'],
|
|
'score': row['user_score'],
|
|
'avatar_b': row['avatar_b'],
|
|
'avatar_i': row['avatar_i']
|
|
}
|
|
|
|
comments.append(await format_comment(comment_data, user_data, current_user_id))
|
|
|
|
favorite_rows = await db.query_raw(
|
|
"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
|
|
u.avatar_b, u.avatar_i
|
|
FROM rants r
|
|
JOIN users u ON r.user_id = u.id
|
|
JOIN favorites f ON f.rant_id = r.id
|
|
WHERE f.user_id = ?
|
|
ORDER BY f.id DESC
|
|
LIMIT 50""",
|
|
(user_id,)
|
|
)
|
|
|
|
favorites = []
|
|
for row in favorite_rows:
|
|
rant_data = {
|
|
'id': row['id'],
|
|
'text': row['text'],
|
|
'score': row['score'],
|
|
'created_time': row['created_time'],
|
|
'attached_image': row['attached_image'],
|
|
'tags': row['tags'],
|
|
'edited': row['edited'],
|
|
'type': row['type']
|
|
}
|
|
user_data = {
|
|
'id': row['user_id'],
|
|
'username': row['username'],
|
|
'score': row['user_score'],
|
|
'avatar_b': row['avatar_b'],
|
|
'avatar_i': row['avatar_i']
|
|
}
|
|
|
|
favorites.append(await format_rant(rant_data, user_data, current_user_id))
|
|
|
|
notif_count = 0
|
|
if current_user:
|
|
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
|
|
|
|
profile_data = {
|
|
"username": user['username'],
|
|
"score": user['score'],
|
|
"about": user['about'],
|
|
"location": user['location'],
|
|
"created_time": user['created_time'],
|
|
"skills": user['skills'],
|
|
"github": user['github'],
|
|
"website": user['website'],
|
|
"avatar": {
|
|
"b": user['avatar_b'],
|
|
"i": user['avatar_i'] or ""
|
|
}
|
|
}
|
|
|
|
return templates.TemplateResponse("profile.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"notif_count": notif_count,
|
|
"profile": profile_data,
|
|
"profile_user_id": user_id,
|
|
"rants": rants,
|
|
"comments": comments,
|
|
"favorites": favorites,
|
|
"active_tab": tab,
|
|
"escape_html": escape_html,
|
|
"format_time": format_time
|
|
})
|
|
|
|
@app.get("/search", response_class=HTMLResponse)
|
|
async def search_page(request: Request, term: str = None):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
current_user_id = current_user['id'] if current_user else None
|
|
|
|
results = []
|
|
if term:
|
|
rows = await db.query_raw(
|
|
"""SELECT r.*, u.id as user_id, u.username, u.score as user_score,
|
|
u.avatar_b, u.avatar_i
|
|
FROM rants r
|
|
JOIN users u ON r.user_id = u.id
|
|
WHERE r.text LIKE ? OR r.tags LIKE ?
|
|
ORDER BY r.score DESC
|
|
LIMIT 50""",
|
|
(f'%{term}%', f'%{term}%')
|
|
)
|
|
|
|
for row in rows:
|
|
rant_data = {
|
|
'id': row['id'],
|
|
'text': row['text'],
|
|
'score': row['score'],
|
|
'created_time': row['created_time'],
|
|
'attached_image': row['attached_image'],
|
|
'tags': row['tags'],
|
|
'edited': row['edited'],
|
|
'type': row['type']
|
|
}
|
|
user_data = {
|
|
'id': row['user_id'],
|
|
'username': row['username'],
|
|
'score': row['user_score'],
|
|
'avatar_b': row['avatar_b'],
|
|
'avatar_i': row['avatar_i']
|
|
}
|
|
|
|
results.append(await format_rant(rant_data, user_data, current_user_id))
|
|
|
|
notif_count = 0
|
|
if current_user:
|
|
notif_count = await db.count("notifications", {"user_id": current_user['id'], "read": 0})
|
|
|
|
return templates.TemplateResponse("search.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"notif_count": notif_count,
|
|
"search_term": term,
|
|
"results": results,
|
|
"escape_html": escape_html,
|
|
"format_time": format_time
|
|
})
|
|
|
|
@app.get("/notifications", response_class=HTMLResponse)
|
|
async def notifications(request: Request):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
return RedirectResponse("/", status_code=302)
|
|
|
|
rows = await db.query_raw(
|
|
"""SELECT n.*, u.username
|
|
FROM notifications n
|
|
LEFT JOIN users u ON n.from_user_id = u.id
|
|
WHERE n.user_id = ?
|
|
ORDER BY n.created_time DESC
|
|
LIMIT 50""",
|
|
(current_user['id'],)
|
|
)
|
|
|
|
items = []
|
|
for row in rows:
|
|
item = {
|
|
"type": row['type'],
|
|
"rant_id": row['rant_id'],
|
|
"comment_id": row['comment_id'],
|
|
"created_time": row['created_time'],
|
|
"read": row['read'],
|
|
"uid": row['from_user_id'],
|
|
"username": row['username'] or ""
|
|
}
|
|
items.append(item)
|
|
|
|
if rows:
|
|
await db.update("notifications", {"read": 1}, {"user_id": current_user['id']})
|
|
|
|
return templates.TemplateResponse("notifications.html", {
|
|
"request": request,
|
|
"current_user": current_user,
|
|
"notif_count": 0,
|
|
"items": items,
|
|
"format_time": format_time
|
|
})
|
|
|
|
@app.get("/classic", response_class=HTMLResponse)
|
|
async def classic():
|
|
return FileResponse("classic.html")
|
|
|
|
# Authentication endpoints
|
|
@app.get("/login", response_class=HTMLResponse)
|
|
async def login_page(request: Request):
|
|
return templates.TemplateResponse("login.html", {"request": request})
|
|
|
|
@app.post("/login", response_class=HTMLResponse)
|
|
async def login_form(
|
|
request: Request,
|
|
username: str = Form(...),
|
|
password: str = Form(...)
|
|
):
|
|
user = await db.query_one(
|
|
"SELECT * FROM users WHERE username = ? OR email = ?",
|
|
(username, username)
|
|
)
|
|
|
|
if not user or user['password_hash'] != hash_password(password):
|
|
return templates.TemplateResponse("login.html", {
|
|
"request": request,
|
|
"error": "Invalid login credentials entered. Please try again."
|
|
})
|
|
|
|
token_key = generate_token()
|
|
expire_time = int((datetime.now() + timedelta(days=30)).timestamp())
|
|
|
|
token_id = await db.insert("auth_tokens", {
|
|
"user_id": user['id'],
|
|
"token_key": token_key,
|
|
"expire_time": expire_time
|
|
}, return_id=True)
|
|
|
|
auth_token = {
|
|
"id": token_id,
|
|
"key": token_key,
|
|
"expire_time": expire_time,
|
|
"user_id": user['id']
|
|
}
|
|
|
|
response = RedirectResponse("/", status_code=302)
|
|
response.set_cookie(SESSION_COOKIE_NAME, json.dumps(auth_token), max_age=30*24*60*60)
|
|
|
|
return response
|
|
|
|
@app.get("/logout")
|
|
async def logout(response: Response):
|
|
response = RedirectResponse("/", status_code=302)
|
|
response.delete_cookie(SESSION_COOKIE_NAME)
|
|
return response
|
|
|
|
# API Endpoints (all preserved from main.py)
|
|
@app.post("/api/users")
|
|
async def register_user(
|
|
email: str = Form(...),
|
|
username: str = Form(...),
|
|
password: str = Form(...),
|
|
type: int = Form(1),
|
|
app: int = Form(3)
|
|
):
|
|
if len(username) < 4 or len(username) > 15:
|
|
return {
|
|
"success": False,
|
|
"error": "Your username must be between 4 and 15 characters.",
|
|
"error_field": "username"
|
|
}
|
|
|
|
if await db.exists("users", {"username": username}):
|
|
return {
|
|
"success": False,
|
|
"error": "Username already taken.",
|
|
"error_field": "username"
|
|
}
|
|
|
|
if await db.exists("users", {"email": email}):
|
|
return {
|
|
"success": False,
|
|
"error": "Email already registered.",
|
|
"error_field": "email"
|
|
}
|
|
|
|
password_hash = hash_password(password)
|
|
created_time = int(datetime.now().timestamp())
|
|
|
|
try:
|
|
user_id = await db.insert("users", {
|
|
"username": username,
|
|
"email": email,
|
|
"password_hash": password_hash,
|
|
"created_time": created_time
|
|
}, return_id=True)
|
|
|
|
# Add user to default channels
|
|
default_channels = await db.find("channels", {"created_by": "system"})
|
|
for channel in default_channels:
|
|
await db.insert("channel_members", {
|
|
"channel_id": channel["uid"],
|
|
"user_id": str(user_id),
|
|
"joined_at": datetime.now().isoformat()
|
|
})
|
|
|
|
return {"success": True}
|
|
except Exception as e:
|
|
return {"success": False, "error": str(e)}
|
|
|
|
@app.post("/api/rant/rants")
|
|
async def create_rant(
|
|
request: Request,
|
|
rant: str = Form(...),
|
|
tags: str = Form(...),
|
|
type: int = Form(1),
|
|
app: int = Form(3),
|
|
image: Optional[UploadFile] = File(None)
|
|
):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
return {"success": False, "error": "Authentication required"}
|
|
|
|
current_user_id = current_user['id']
|
|
|
|
# Check for duplicate rant
|
|
recent_time = int(datetime.now().timestamp()) - 300
|
|
duplicate = await db.query_one(
|
|
"SELECT id FROM rants WHERE user_id = ? AND text = ? AND created_time > ?",
|
|
(current_user_id, rant, recent_time)
|
|
)
|
|
|
|
if duplicate:
|
|
return {
|
|
"success": False,
|
|
"error": "It looks like you just posted this same rant! Your connection might have timed out while posting so you might have seen an error, but sometimes the rant still gets posted and in this case it seems it did, so please check :) If this was not the case please contact info@rant.io. Thanks!"
|
|
}
|
|
|
|
# Handle image upload
|
|
image_path = ""
|
|
if image:
|
|
file_ext = os.path.splitext(image.filename)[1]
|
|
file_name = f"{secrets.token_hex(16)}{file_ext}"
|
|
file_path = UPLOAD_DIR / file_name
|
|
|
|
with open(file_path, "wb") as f:
|
|
f.write(await image.read())
|
|
|
|
image_path = f"/uploads/{file_name}"
|
|
|
|
# Create rant
|
|
created_time = int(datetime.now().timestamp())
|
|
tags_list = [tag.strip() for tag in tags.split(',') if tag.strip()]
|
|
|
|
rant_id = await db.insert("rants", {
|
|
"user_id": current_user_id,
|
|
"text": rant,
|
|
"created_time": created_time,
|
|
"attached_image": image_path,
|
|
"tags": json.dumps(tags_list),
|
|
"type": type
|
|
}, return_id=True)
|
|
|
|
return {"success": True, "rant_id": rant_id}
|
|
|
|
# Chat API endpoints
|
|
@app.get("/api/channels")
|
|
async def get_channels(request: Request):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
memberships = await db.find("channel_members", {"user_id": str(current_user["id"])})
|
|
channel_ids = [m["channel_id"] for m in memberships]
|
|
|
|
channels = []
|
|
for channel_id in channel_ids:
|
|
channel = await db.get("channels", {"uid": channel_id})
|
|
if channel and not channel.get("is_dm"):
|
|
last_read = next((m["last_read_message_id"] for m in memberships
|
|
if m["channel_id"] == channel_id), None)
|
|
|
|
unread = 0
|
|
if last_read:
|
|
messages = await db.query_raw(
|
|
"SELECT COUNT(*) as count FROM messages WHERE channel_id = ? AND uid > ?",
|
|
(channel_id, last_read)
|
|
)
|
|
unread = messages[0]["count"] if messages else 0
|
|
|
|
channels.append({
|
|
"id": channel["uid"],
|
|
"name": channel["name"],
|
|
"type": "channel",
|
|
"unread": unread
|
|
})
|
|
|
|
return channels
|
|
|
|
@app.post("/api/channels")
|
|
async def create_channel(channel: ChannelCreate, request: Request):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
existing = await db.get("channels", {"name": channel.name})
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="Channel already exists")
|
|
|
|
channel_id = await db.insert("channels", {
|
|
"name": channel.name.lower().replace(" ", "-"),
|
|
"description": channel.description,
|
|
"created_by": str(current_user["id"])
|
|
})
|
|
|
|
await db.insert("channel_members", {
|
|
"channel_id": channel_id,
|
|
"user_id": str(current_user["id"]),
|
|
"joined_at": datetime.now().isoformat()
|
|
})
|
|
|
|
return {"id": channel_id, "name": channel.name}
|
|
|
|
@app.get("/api/dms")
|
|
async def get_dms(request: Request):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
user_id_str = str(current_user["id"])
|
|
dms = await db.query_raw(
|
|
"""
|
|
SELECT dc.*, c.uid as channel_uid, u.username, u.avatar_b, u.status
|
|
FROM dm_channels dc
|
|
JOIN channels c ON dc.channel_id = c.uid
|
|
JOIN users u ON (
|
|
CASE
|
|
WHEN dc.user_id_1 = ? THEN dc.user_id_2 = CAST(u.id AS TEXT)
|
|
ELSE dc.user_id_1 = CAST(u.id AS TEXT)
|
|
END
|
|
)
|
|
WHERE dc.user_id_1 = ? OR dc.user_id_2 = ?
|
|
""",
|
|
(user_id_str, user_id_str, user_id_str)
|
|
)
|
|
|
|
result = []
|
|
for dm in dms:
|
|
membership = await db.get("channel_members", {
|
|
"channel_id": dm["channel_uid"],
|
|
"user_id": user_id_str
|
|
})
|
|
|
|
unread = 0
|
|
if membership and membership.get("last_read_message_id"):
|
|
messages = await db.query_raw(
|
|
"SELECT COUNT(*) as count FROM messages WHERE channel_id = ? AND uid > ?",
|
|
(dm["channel_uid"], membership["last_read_message_id"])
|
|
)
|
|
unread = messages[0]["count"] if messages else 0
|
|
|
|
result.append({
|
|
"id": dm["channel_uid"],
|
|
"name": dm["username"],
|
|
"type": "dm",
|
|
"user": {
|
|
"username": dm["username"],
|
|
"status": dm["status"],
|
|
"avatar_color": dm["avatar_b"]
|
|
},
|
|
"unread": unread
|
|
})
|
|
|
|
return result
|
|
|
|
@app.post("/api/dms")
|
|
async def create_dm(data: dict, request: Request):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
other_user_id = data.get("user_id")
|
|
if not other_user_id:
|
|
raise HTTPException(status_code=400, detail="User ID required")
|
|
|
|
user_id_1 = min(str(current_user["id"]), str(other_user_id))
|
|
user_id_2 = max(str(current_user["id"]), str(other_user_id))
|
|
|
|
existing = await db.get("dm_channels", {
|
|
"user_id_1": user_id_1,
|
|
"user_id_2": user_id_2
|
|
})
|
|
|
|
if existing:
|
|
return {"channel_id": existing["channel_id"]}
|
|
|
|
channel_id = await db.insert("channels", {
|
|
"name": f"dm_{user_id_1}_{user_id_2}",
|
|
"created_by": str(current_user["id"]),
|
|
"is_dm": 1
|
|
})
|
|
|
|
await db.insert("dm_channels", {
|
|
"user_id_1": user_id_1,
|
|
"user_id_2": user_id_2,
|
|
"channel_id": channel_id
|
|
})
|
|
|
|
for user_id in [user_id_1, user_id_2]:
|
|
await db.insert("channel_members", {
|
|
"channel_id": channel_id,
|
|
"user_id": user_id,
|
|
"joined_at": datetime.now().isoformat()
|
|
})
|
|
|
|
return {"channel_id": channel_id}
|
|
|
|
@app.get("/api/messages/{channel_id}")
|
|
async def get_messages(
|
|
channel_id: str,
|
|
request: Request,
|
|
limit: int = 50,
|
|
offset: int = 0
|
|
):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
membership = await db.get("channel_members", {
|
|
"channel_id": channel_id,
|
|
"user_id": str(current_user["id"])
|
|
})
|
|
|
|
if not membership:
|
|
raise HTTPException(status_code=403, detail="Not a member of this channel")
|
|
|
|
messages = await db.query_raw(
|
|
"""
|
|
SELECT m.*, u.username, u.avatar_b
|
|
FROM messages m
|
|
JOIN users u ON CAST(m.user_id AS INTEGER) = u.id
|
|
WHERE m.channel_id = ?
|
|
ORDER BY m.created_at DESC
|
|
LIMIT ? OFFSET ?
|
|
""",
|
|
(channel_id, limit, offset)
|
|
)
|
|
|
|
if messages:
|
|
await db.update("channel_members", {
|
|
"last_read_message_id": messages[0]["uid"]
|
|
}, {
|
|
"channel_id": channel_id,
|
|
"user_id": str(current_user["id"])
|
|
})
|
|
|
|
return list(reversed(messages))
|
|
|
|
@app.post("/api/messages")
|
|
async def send_message(message: MessageCreate, request: Request):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
if not message.channel_id and not message.dm_user_id:
|
|
raise HTTPException(status_code=400, detail="Channel ID or DM user ID required")
|
|
|
|
channel_id = message.channel_id
|
|
|
|
if message.dm_user_id:
|
|
user_id_1 = min(str(current_user["id"]), str(message.dm_user_id))
|
|
user_id_2 = max(str(current_user["id"]), str(message.dm_user_id))
|
|
|
|
dm_channel = await db.get("dm_channels", {
|
|
"user_id_1": user_id_1,
|
|
"user_id_2": user_id_2
|
|
})
|
|
|
|
if dm_channel:
|
|
channel_id = dm_channel["channel_id"]
|
|
else:
|
|
channel_id = await db.insert("channels", {
|
|
"name": f"dm_{user_id_1}_{user_id_2}",
|
|
"created_by": str(current_user["id"]),
|
|
"is_dm": 1
|
|
})
|
|
|
|
await db.insert("dm_channels", {
|
|
"user_id_1": user_id_1,
|
|
"user_id_2": user_id_2,
|
|
"channel_id": channel_id
|
|
})
|
|
|
|
for user_id in [user_id_1, user_id_2]:
|
|
await db.insert("channel_members", {
|
|
"channel_id": channel_id,
|
|
"user_id": user_id,
|
|
"joined_at": datetime.now().isoformat()
|
|
})
|
|
|
|
membership = await db.get("channel_members", {
|
|
"channel_id": channel_id,
|
|
"user_id": str(current_user["id"])
|
|
})
|
|
|
|
if not membership:
|
|
raise HTTPException(status_code=403, detail="Not a member of this channel")
|
|
|
|
message_id = await db.insert("messages", {
|
|
"channel_id": channel_id,
|
|
"user_id": str(current_user["id"]),
|
|
"content": message.content
|
|
})
|
|
|
|
msg_data = {
|
|
"id": message_id,
|
|
"channel_id": channel_id,
|
|
"content": message.content,
|
|
"author": {
|
|
"id": str(current_user["id"]),
|
|
"username": current_user["username"],
|
|
"avatar_color": current_user["avatar_b"]
|
|
},
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
|
|
await manager.broadcast_to_channel(
|
|
json.dumps({"type": "new_message", "data": msg_data}),
|
|
channel_id,
|
|
exclude_user=str(current_user["id"])
|
|
)
|
|
|
|
return msg_data
|
|
|
|
@app.get("/api/users/search")
|
|
async def search_users(q: str, request: Request):
|
|
current_user = await get_current_user_from_cookie(request)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="Authentication required")
|
|
|
|
if len(q) < 2:
|
|
return []
|
|
|
|
users = await db.query_raw(
|
|
"SELECT id, username, avatar_b, status FROM users WHERE username LIKE ? AND id != ? LIMIT 10",
|
|
(f"%{q}%", current_user["id"])
|
|
)
|
|
|
|
return [{
|
|
"id": str(user["id"]),
|
|
"username": user["username"],
|
|
"avatar_color": user["avatar_b"],
|
|
"status": user["status"]
|
|
} for user in users]
|
|
|
|
# WebSocket endpoint
|
|
@app.websocket("/ws/{user_id}")
|
|
async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
|
await manager.connect(websocket, user_id)
|
|
|
|
await db.update("users", {"status": "online"}, {"id": int(user_id)})
|
|
|
|
try:
|
|
while True:
|
|
data = await websocket.receive_text()
|
|
message_data = json.loads(data)
|
|
|
|
if message_data.get("type") == "typing":
|
|
channel_id = message_data.get("channel_id")
|
|
if channel_id:
|
|
user = await db.get("users", {"id": int(user_id)})
|
|
await manager.broadcast_to_channel(
|
|
json.dumps({
|
|
"type": "typing",
|
|
"data": {
|
|
"channel_id": channel_id,
|
|
"username": user["username"]
|
|
}
|
|
}),
|
|
channel_id,
|
|
exclude_user=user_id
|
|
)
|
|
|
|
except WebSocketDisconnect:
|
|
manager.disconnect(websocket, user_id)
|
|
await db.update("users", {
|
|
"status": "offline",
|
|
"last_seen": datetime.now().isoformat()
|
|
}, {"id": int(user_id)})
|
|
|
|
# Serve uploaded files
|
|
@app.get("/uploads/{filename}")
|
|
async def get_upload(filename: str):
|
|
file_path = UPLOAD_DIR / filename
|
|
if file_path.exists():
|
|
return FileResponse(file_path)
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
|
|
# Static files
|
|
Path("static").mkdir(exist_ok=True)
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8111)
|