Compare commits

...

81 Commits

Author SHA1 Message Date
dffec9b189 fix: enhance top friends display logic in FeedPage
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m37s
2025-09-14 21:40:48 +02:00
e2494135d6 fix: add redirect to login for unauthorized access in FeedPage
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m38s
2025-09-14 21:30:12 +02:00
d6c42afaec fix: integrate js-cookie for install prompt dismissal handling
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m35s
2025-09-09 04:51:29 +02:00
e376f584c7 fix: update frontend API URL to use proxy for server-side requests
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 28s
2025-09-09 04:47:31 +02:00
75c5adf346 fix: reorganize Traefik labels and network configuration in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 29s
2025-09-09 04:45:24 +02:00
878ebf1541 fix: add Traefik network labels for API and web routers in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 17s
2025-09-09 04:43:58 +02:00
c9775293c0 fix: clean up commented-out network and labels configuration in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 19s
2025-09-09 04:39:35 +02:00
93b90b85b6 fix: adjust network configuration for backend and frontend services in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 18s
2025-09-09 04:33:53 +02:00
58e51cb028 fix: enhance Traefik routing for API and web services in Docker Compose
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 17s
2025-09-09 04:27:02 +02:00
5282376860 fix: simplify CMD instruction in Dockerfile by removing redundant parameters
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m20s
2025-09-09 04:19:40 +02:00
082f11a3e9 fix: update Docker Compose deployment command and configure server to listen on all interfaces
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 2m0s
2025-09-09 04:13:51 +02:00
ec73a0c373 fix: update healthcheck command for frontend service and install curl in Dockerfile
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 1m50s
2025-09-09 04:09:14 +02:00
29afc2e92e fix: update Dockerfiles to install necessary packages without recommendations
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 3m4s
2025-09-09 04:03:14 +02:00
cbca1058a2 fix: add health checks for backend and frontend services in docker-compose
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 58s
2025-09-09 03:56:06 +02:00
8536e52590 Revert "fix: correct proxy_pass configuration for API requests in nginx"
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 6s
This reverts commit 247c6ad955.
2025-09-09 03:53:41 +02:00
247c6ad955 fix: correct proxy_pass configuration for API requests in nginx
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 6s
2025-09-09 03:51:37 +02:00
c6f7dfe225 feat: add health check endpoint to nginx configuration
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 7s
2025-09-09 03:49:19 +02:00
0ba3b79185 fix: remove default nginx configuration before copying custom config
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 7s
2025-09-09 03:47:24 +02:00
64806f8bd4 feat: implement pagination for feed retrieval and update frontend components
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 2m7s
2025-09-09 03:43:06 +02:00
4ea4f3149f feat: add user count endpoint and integrate it into frontend components
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 19s
2025-09-09 03:07:48 +02:00
d92c9a747e feat: implement pagination for user retrieval and update feed fetching logic
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 2m30s
2025-09-09 02:53:24 +02:00
863bc90c6f feat: add endpoint to retrieve a public list of all users
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m13s
2025-09-09 02:28:00 +02:00
d15339cf4a fix: remove debugging step that dumped POSTGRES_USER secret
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 17s
2025-09-09 02:14:45 +02:00
916dbe0245 feat: add step to dump POSTGRES_USER secret for debugging
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 17s
2025-09-09 02:09:58 +02:00
7889137cd8 fix: remove copying of .env.example to .env in Dockerfile
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 1m12s
2025-09-09 02:02:58 +02:00
4e38c1133e fix: remove debugging step that dumped environment variables
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 6s
2025-09-09 02:02:16 +02:00
86eb059f3e fix: update debugging step to display specific environment variables
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 5s
2025-09-09 02:00:35 +02:00
84f2423343 feat: add step to dump environment variables for debugging
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 6s
2025-09-09 01:59:50 +02:00
9207572f07 fix: remove redundant volume mapping for proxy service
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 5s
2025-09-09 01:44:01 +02:00
1c52bf3ea4 feat: update Docker setup to use custom proxy image and remove redundant steps
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 7s
2025-09-09 01:43:21 +02:00
327e671571 fix: update Nginx volume path to use GITHUB_WORKSPACE variable
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 5s
2025-09-09 01:40:26 +02:00
36e12d1d96 feat: add step to dump environment variables for debugging
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 6s
2025-09-09 01:39:59 +02:00
452ea5625f fix: update Nginx volume path to use GITEA_WORKSPACE variable
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 7s
2025-09-09 01:38:31 +02:00
bc8941d910 feat: add step to list files in workspace during deployment
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 6s
2025-09-09 01:33:22 +02:00
01d7a837f8 refactor: streamline Docker Compose configuration and remove unnecessary build steps
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 6s
2025-09-09 01:18:28 +02:00
71048f0060 feat: add Docker BuildKit environment variable for improved build performance
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 4s
2025-09-09 01:17:00 +02:00
f278a44d8f feat: add Docker version check step and fix DATABASE_URL formatting in production compose file
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 4s
2025-09-09 01:15:57 +02:00
aa4be7e05b feat: specify build targets for backend and frontend in Docker Compose
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 4s
2025-09-09 01:12:11 +02:00
5bc4337447 feat: update deployment workflow to use master branch and add production Docker Compose configuration
Some checks failed
Build and Deploy Thoughts / build-and-deploy-local (push) Failing after 10s
2025-09-09 01:10:07 +02:00
b50b7bcc73 feat: add GitHub Actions workflow for building and deploying Thoughts 2025-09-09 01:07:59 +02:00
9b2a1139b5 feat: add author display name to thought schemas and update related components 2025-09-07 22:54:34 +02:00
2083f3bb16 feat: refactor author username assignment in ThoughtSchema 2025-09-07 22:37:12 +02:00
08213133be feat: update environment configuration, enhance Dockerfiles, and refactor API handling 2025-09-07 19:55:49 +02:00
5f8cf49ec9 feat: simplify error handling in login and registration pages, add install prompt component, and update favicon and icons 2025-09-07 18:43:56 +02:00
c6f5bab1eb feat: update background image format and remove unused SVG files 2025-09-07 18:11:53 +02:00
72b4cb0851 feat: add confetti animation on thought submission and update dependencies 2025-09-07 17:43:17 +02:00
dd279a1434 feat: add popular tags section to FeedPage and update LandingPage text 2025-09-07 17:36:32 +02:00
6efab333f3 Remove federation functionality and related tests
- Deleted the `federation.rs` module and its associated functionality for federating thoughts to followers.
- Removed the `well_known.rs` module and its WebFinger discovery functionality.
- Eliminated references to federation in the `thought.rs` router and removed the spawning of background tasks for federating thoughts.
- Deleted tests related to WebFinger and user inbox interactions in `activitypub.rs`.
- Updated `Cargo.toml` to remove the `activitypub_federation` dependency.
2025-09-07 17:22:58 +02:00
1a405500ca feat: update top friends display condition to require more than 8 friends 2025-09-07 15:16:18 +02:00
3d25ffca4f feat: add visibility check for tagging in thought creation 2025-09-07 15:15:24 +02:00
5ce6d9f2da feat: refactor thought threads handling to improve structure and efficiency 2025-09-07 15:09:45 +02:00
40695b7ad3 feat: implement thought thread retrieval with replies and visibility filtering 2025-09-07 14:47:30 +02:00
b337184a59 feat: add API keys management page, including API key creation and deletion functionality 2025-09-07 14:06:28 +02:00
862974bb35 feat: update ApiKeySchema and ApiKeyListSchema with proper serde renaming for keyPrefix and createdAt 2025-09-07 13:48:20 +02:00
8b14ab06a2 feat: update bio length validation in UpdateUserParams to allow up to 4000 characters 2025-09-07 13:37:46 +02:00
e1b5a2aaa0 feat: enhance profile and feed pages with friends display logic, update TopFriends component to support mode, and extend bio length in profile schema 2025-09-07 13:37:39 +02:00
c9b8bd7b07 feat: implement search functionality with results display, add search input component, and update API for search results 2025-09-07 12:54:39 +02:00
69eb225c1e feat: implement full-text search functionality with API integration, add search router and persistence logic, and create related schemas and tests 2025-09-07 12:36:03 +02:00
c3539cfc11 feat: add Frutiger font, enhance UI with glass effect and shadows, and improve component styling 2025-09-07 01:12:09 +02:00
f1e891413a feat: enhance user interface with improved styling and responsiveness
- Updated UserAvatar component to accept additional className for better customization.
- Refined ProfilePage layout with responsive avatar styling.
- Enhanced Header component with improved background and text styles.
- Improved PopularTags and TopFriends components with better spacing and text shadows.
- Updated ThoughtCard and ThoughtThread components for better visual hierarchy and responsiveness.
- Enhanced UI components (Button, Badge, Card, DropdownMenu, Input, Popover, Separator, Skeleton, Textarea) with new styles and effects.
- Added a new background image for visual enhancement.
2025-09-07 00:16:51 +02:00
c520690f1e feat: add TopFriendsCombobox component for selecting top friends, update edit profile form to use it, and implement getFriends API 2025-09-06 22:37:06 +02:00
8ddbf45a09 feat: add followers and following pages with API integration, enhance profile page with follower/following counts 2025-09-06 22:22:44 +02:00
dc92945962 feat: implement friends API with routes to get friends list and update thought visibility logic 2025-09-06 22:14:47 +02:00
bf7c6501c6 feat: update JSON keys in user profile and top friends API for consistency 2025-09-06 22:04:38 +02:00
85e3425d4b feat: implement settings layout and navigation, add tag and thought pages with API integration 2025-09-06 21:56:41 +02:00
5344e0d6a8 feat: update layout and components for improved user experience, add theme toggle and main navigation 2025-09-06 21:44:52 +02:00
8b82a5e48e feat: add Header and UserNav components, update layout to include Header and enhance profile page with settings link 2025-09-06 21:21:53 +02:00
bf2e280cdd feat: implement threaded replies and enhance feed layout with ThoughtThread component 2025-09-06 21:02:46 +02:00
8a4c07b3f6 feat: update parameter serialization for CreateThoughtParams and UpdateUserParams 2025-09-06 20:44:21 +02:00
19520c832f feat: implement EditProfile functionality with form validation and update user profile API integration 2025-09-06 20:22:40 +02:00
fc7dacc6fb feat: add PopularTags and TopFriends components, update profile and feed layouts to include them 2025-09-06 19:58:53 +02:00
7348433b9c feat: add follow/unfollow functionality with FollowButton component and update user profile to display follow status 2025-09-06 19:47:29 +02:00
8552858c8c feat: add user following and followers endpoints, update user profile response structure 2025-09-06 19:43:46 +02:00
c7cb3f537d feat: implement authentication layout and pages, including login and registration forms, with validation and API integration 2025-09-06 19:19:20 +02:00
e7cf76a0d8 feat: rename fields in ApiKeyResponse and ThoughtSchema for consistency with API specifications 2025-09-06 19:19:14 +02:00
38e107ad59 feat: add UI components including Skeleton, Slider, Toaster, Switch, Table, Tabs, Textarea, Toggle Group, Toggle, Tooltip, and User Avatar
- Implemented Skeleton component for loading states.
- Added Slider component using Radix UI for customizable sliders.
- Created Toaster component for notifications with theme support.
- Developed Switch component for toggle functionality.
- Introduced Table component with subcomponents for structured data display.
- Built Tabs component for tabbed navigation.
- Added Textarea component for multi-line text input.
- Implemented Toggle Group and Toggle components for grouped toggle buttons.
- Created Tooltip component for displaying additional information on hover.
- Added User Avatar component for displaying user images with fallback.
- Implemented useIsMobile hook for responsive design.
- Created API utility functions for user and thought data fetching.
- Added utility function for class name merging.
- Updated package.json with new dependencies for UI components and utilities.
- Added TypeScript configuration for path aliasing.
2025-09-06 18:48:53 +02:00
6aef739438 feat: add API key management and tag discovery functionality with corresponding schemas and routes 2025-09-06 17:49:07 +02:00
82c6de8da8 feat: add visibility feature to thoughts, including new enum, database migration, and update related endpoints and tests 2025-09-06 17:42:50 +02:00
0abd275946 feat: add reply functionality to thoughts, including database migration and tests 2025-09-06 16:58:11 +02:00
728bf0e231 feat: enhance user registration and follow functionality, add popular tags endpoint, and update tests 2025-09-06 16:49:38 +02:00
508f218fc0 feat(api_key): implement API key management with creation, retrieval, and deletion endpoints 2025-09-06 16:18:32 +02:00
202 changed files with 12282 additions and 1360 deletions

7
.env
View File

@@ -1,3 +1,10 @@
POSTGRES_USER=thoughts_user POSTGRES_USER=thoughts_user
POSTGRES_PASSWORD=postgres POSTGRES_PASSWORD=postgres
POSTGRES_DB=thoughts_db POSTGRES_DB=thoughts_db
HOST=0.0.0.0
PORT=8000
DATABASE_URL="postgresql://thoughts_user:postgres@database/thoughts_db"
PREFORK=1
AUTH_SECRET=secret
BASE_URL=http://0.0.0.0

View File

@@ -0,0 +1,41 @@
name: Build and Deploy Thoughts
on:
push:
branches:
- master
workflow_dispatch:
jobs:
build-and-deploy-local:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Create .env file
run: |
echo "POSTGRES_USER=${{ secrets.POSTGRES_USER }}" >> .env
echo "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" >> .env
echo "POSTGRES_DB=${{ secrets.POSTGRES_DB }}" >> .env
echo "AUTH_SECRET=${{ secrets.AUTH_SECRET }}" >> .env
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" >> .env
- name: Build Docker Images Manually
run: |
docker build --target runtime -t thoughts-backend:latest ./thoughts-backend
docker build --target release -t thoughts-frontend:latest --build-arg NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }} ./thoughts-frontend
docker build -t custom-proxy:latest ./nginx
- name: Deploy with Docker Compose
run: |
docker compose -f compose.prod.yml down
POSTGRES_USER=${{ secrets.POSTGRES_USER }} \
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} \
POSTGRES_DB=${{ secrets.POSTGRES_DB }} \
AUTH_SECRET=${{ secrets.AUTH_SECRET }} \
docker compose -f compose.prod.yml up -d
docker image prune -f

91
compose.prod.yml Normal file
View File

@@ -0,0 +1,91 @@
services:
database:
image: postgres:15-alpine
container_name: thoughts-db
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
backend:
container_name: thoughts-backend
image: thoughts-backend:latest
restart: unless-stopped
environment:
- RUST_LOG=info
- RUST_BACKTRACE=1
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database/${POSTGRES_DB}
- HOST=0.0.0.0
- PORT=8000
- PREFORK=1
- AUTH_SECRET=${AUTH_SECRET}
- BASE_URL=https://thoughts.gabrielkaszewski.dev
depends_on:
database:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
frontend:
container_name: thoughts-frontend
image: thoughts-frontend:latest
restart: unless-stopped
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 10s
timeout: 5s
retries: 5
environment:
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
- PORT=3000
- HOSTNAME=0.0.0.0
networks:
- internal
proxy:
container_name: thoughts-proxy
image: custom-proxy:latest
restart: unless-stopped
depends_on:
frontend:
condition: service_healthy
backend:
condition: service_healthy
networks:
- internal
- traefik
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.thoughts.rule=Host(`thoughts.gabrielkaszewski.dev`)"
- "traefik.http.routers.thoughts.entrypoints=web,websecure"
- "traefik.http.routers.thoughts.tls.certresolver=letsencrypt"
- "traefik.http.routers.thoughts.service=thoughts"
- "traefik.http.services.thoughts.loadbalancer.server.port=80"
volumes:
postgres_data:
driver: local
networks:
traefik:
name: traefik
external: true
internal:
driver: bridge

View File

@@ -25,6 +25,9 @@ services:
restart: unless-stopped restart: unless-stopped
env_file: env_file:
- .env - .env
environment:
- RUST_LOG=info
- RUST_BACKTRACE=1
depends_on: depends_on:
database: database:
condition: service_healthy condition: service_healthy
@@ -34,9 +37,13 @@ services:
build: build:
context: ./thoughts-frontend context: ./thoughts-frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: http://localhost/api
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- backend - backend
environment:
- NEXT_PUBLIC_SERVER_SIDE_API_URL=http://proxy/api
proxy: proxy:
container_name: thoughts-proxy container_name: thoughts-proxy

5
nginx/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM nginx:stable-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -10,6 +10,11 @@ server {
listen 80; listen 80;
server_name localhost; server_name localhost;
location /health {
return 200 "OK";
access_log off;
}
proxy_connect_timeout 300s; proxy_connect_timeout 300s;
proxy_send_timeout 300s; proxy_send_timeout 300s;
proxy_read_timeout 300s; proxy_read_timeout 300s;

View File

@@ -5,3 +5,4 @@ DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db #DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
PREFORK=0 PREFORK=0
AUTH_SECRET=your_secret_key_here AUTH_SECRET=your_secret_key_here
BASE_URL=http://0.0.0.0

View File

@@ -1,7 +1,6 @@
HOST=0.0.0.0 HOST=0.0.0.0
PORT=3000 PORT=3000
DATABASE_URL="sqlite://dev.db" DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
# DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
PREFORK=1 PREFORK=1
AUTH_SECRET=your_secret_key_here AUTH_SECRET=your_secret_key_here
BASE_URL=http://localhost:3000 BASE_URL=http://localhost:3000

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,14 @@
FROM rust:1.89-slim AS builder FROM rust:1.89-slim AS builder
RUN apt-get update && apt-get install -y libssl-dev pkg-config && rm -rf /var/lib/apt/lists/*
RUN cargo install cargo-chef --locked RUN cargo install cargo-chef --locked
WORKDIR /app WORKDIR /app
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY api/Cargo.toml ./api/ COPY api/Cargo.toml ./api/
COPY app/Cargo.toml ./app/ COPY app/Cargo.toml ./app/
COPY common/Cargo.toml ./common/
COPY doc/Cargo.toml ./doc/ COPY doc/Cargo.toml ./doc/
COPY migration/Cargo.toml ./migration/ COPY migration/Cargo.toml ./migration/
COPY models/Cargo.toml ./models/ COPY models/Cargo.toml ./models/
@@ -22,6 +26,8 @@ RUN cargo build --release --bin thoughts-backend
FROM debian:13-slim AS runtime FROM debian:13-slim AS runtime
RUN apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/*
RUN groupadd --system --gid 1001 appgroup && \ RUN groupadd --system --gid 1001 appgroup && \
useradd --system --uid 1001 --gid appgroup appuser useradd --system --uid 1001 --gid appgroup appuser
@@ -29,8 +35,6 @@ WORKDIR /app
COPY --from=builder /app/target/release/thoughts-backend . COPY --from=builder /app/target/release/thoughts-backend .
COPY .env.example .env
RUN chown -R appuser:appgroup /app RUN chown -R appuser:appgroup /app
USER appuser USER appuser

View File

@@ -38,6 +38,5 @@ tower-http = { version = "0.6.6", features = ["fs", "cors"] }
tower-cookies = "0.11.0" tower-cookies = "0.11.0"
anyhow = "1.0.98" anyhow = "1.0.98"
dotenvy = "0.15.7" dotenvy = "0.15.7"
activitypub_federation = "0.6.5"
url = "2.5.7"
[dev-dependencies] [dev-dependencies]

View File

@@ -8,7 +8,7 @@ use once_cell::sync::Lazy;
use sea_orm::prelude::Uuid; use sea_orm::prelude::Uuid;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use app::state::AppState; use app::{persistence::api_key, state::AppState};
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct Claims { pub struct Claims {
@@ -28,14 +28,24 @@ impl FromRequestParts<AppState> for AuthUser {
async fn from_request_parts( async fn from_request_parts(
parts: &mut Parts, parts: &mut Parts,
_state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> Result<Self, Self::Rejection> {
// --- Test User ID (Keep for testing) ---
if let Some(user_id_header) = parts.headers.get("x-test-user-id") { if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
let user_id_str = user_id_header.to_str().unwrap_or("0"); let user_id_str = user_id_header.to_str().unwrap_or("0");
let user_id = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil()); let user_id = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
return Ok(AuthUser { id: user_id }); return Ok(AuthUser { id: user_id });
} }
// --- API Key Authentication ---
if let Some(api_key) = get_api_key_from_header(&parts.headers) {
return match api_key::validate_api_key(&state.conn, &api_key).await {
Ok(user) => Ok(AuthUser { id: user.id }),
Err(_) => Err((StatusCode::UNAUTHORIZED, "Invalid API Key")),
};
}
// --- JWT Authentication (Fallback) ---
let token = get_token_from_header(&parts.headers) let token = get_token_from_header(&parts.headers)
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?; .ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
@@ -56,3 +66,11 @@ fn get_token_from_header(headers: &HeaderMap) -> Option<String> {
.and_then(|header| header.strip_prefix("Bearer ")) .and_then(|header| header.strip_prefix("Bearer "))
.map(|token| token.to_owned()) .map(|token| token.to_owned())
} }
fn get_api_key_from_header(headers: &HeaderMap) -> Option<String> {
headers
.get("Authorization")
.and_then(|header| header.to_str().ok())
.and_then(|header| header.strip_prefix("ApiKey "))
.map(|key| key.to_owned())
}

View File

@@ -1,8 +1,10 @@
mod auth; mod auth;
mod json; mod json;
mod optional_auth;
mod valid; mod valid;
pub use auth::AuthUser; pub use auth::AuthUser;
pub use auth::Claims; pub use auth::Claims;
pub use json::Json; pub use json::Json;
pub use optional_auth::OptionalAuthUser;
pub use valid::Valid; pub use valid::Valid;

View File

@@ -0,0 +1,21 @@
use super::AuthUser;
use crate::error::ApiError;
use app::state::AppState;
use axum::{extract::FromRequestParts, http::request::Parts};
pub struct OptionalAuthUser(pub Option<AuthUser>);
impl FromRequestParts<AppState> for OptionalAuthUser {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
match AuthUser::from_request_parts(parts, state).await {
Ok(user) => Ok(OptionalAuthUser(Some(user))),
// If the user is not authenticated for any reason, we just treat them as a guest.
Err(_) => Ok(OptionalAuthUser(None)),
}
}
}

View File

@@ -1,70 +0,0 @@
use app::{
persistence::{follow, user},
state::AppState,
};
use models::domains::thought;
use serde_json::json;
// This function handles pushing a new thought to all followers.
pub async fn federate_thought(
state: AppState,
thought: thought::Model,
author: models::domains::user::Model,
) {
// Find all followers of the author
let follower_ids = match follow::get_follower_ids(&state.conn, author.id).await {
Ok(ids) => ids,
Err(e) => {
tracing::error!("Failed to get followers for federation: {}", e);
return;
}
};
if follower_ids.is_empty() {
println!("No followers to federate to for user {}", author.username);
return;
}
let thought_url = format!("{}/thoughts/{}", &state.base_url, thought.id);
let author_url = format!("{}/users/{}", &state.base_url, author.username);
// Construct the "Create" activity containing the "Note" object
let activity = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": format!("{}/activity", thought_url),
"type": "Create",
"actor": author_url,
"object": {
"id": thought_url,
"type": "Note",
"attributedTo": author_url,
"content": thought.content,
"published": thought.created_at.to_rfc3339(),
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": [format!("{}/followers", author_url)]
}
});
// Get the inbox URLs for all followers
// In a real federated app, you would store remote users' full inbox URLs.
// For now, we assume followers are local and construct their inbox URLs.
let followers = match user::get_users_by_ids(&state.conn, follower_ids).await {
Ok(users) => users,
Err(e) => {
tracing::error!("Failed to get follower user objects: {}", e);
return;
}
};
let client = reqwest::Client::new();
for follower in followers {
let inbox_url = format!("{}/users/{}/inbox", &state.base_url, follower.username);
tracing::info!("Federating post {} to {}", thought.id, inbox_url);
let res = client.post(&inbox_url).json(&activity).send().await;
if let Err(e) = res {
tracing::error!("Failed to federate to {}: {}", inbox_url, e);
}
}
}

View File

@@ -8,7 +8,6 @@ use app::state::AppState;
use crate::routers::create_router; use crate::routers::create_router;
// TODO: middleware, logging, authentication
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router { pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
create_router(AppState { create_router(AppState {
conn, conn,

View File

@@ -1,6 +1,5 @@
mod error; mod error;
mod extractor; mod extractor;
mod federation;
mod init; mod init;
mod validation; mod validation;

View File

@@ -0,0 +1,93 @@
use crate::{
error::ApiError,
extractor::{AuthUser, Json},
models::ApiErrorResponse,
};
use app::{persistence::api_key, state::AppState};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get},
Router,
};
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse};
use sea_orm::prelude::Uuid;
#[utoipa::path(
get,
path = "",
responses(
(status = 200, description = "List of API keys", body = ApiKeyListSchema),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("bearerAuth" = [])
)
)]
async fn get_keys(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let keys = api_key::get_api_keys_for_user(&state.conn, auth_user.id).await?;
Ok(Json(ApiKeyListSchema::from(keys)))
}
#[utoipa::path(
post,
path = "",
request_body = ApiKeyRequest,
responses(
(status = 201, description = "API key created", body = ApiKeyResponse),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("bearerAuth" = [])
)
)]
async fn create_key(
State(state): State<AppState>,
auth_user: AuthUser,
Json(params): Json<ApiKeyRequest>,
) -> Result<impl IntoResponse, ApiError> {
let (key_model, plaintext_key) =
api_key::create_api_key(&state.conn, auth_user.id, params.name).await?;
let response = ApiKeyResponse::from_parts(key_model, Some(plaintext_key));
Ok((StatusCode::CREATED, Json(response)))
}
#[utoipa::path(
delete,
path = "/{key_id}",
responses(
(status = 204, description = "API key deleted"),
(status = 401, description = "Unauthorized", body = ApiErrorResponse),
(status = 404, description = "API key not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
params(
("key_id" = Uuid, Path, description = "The ID of the API key to delete")
),
security(
("bearerAuth" = [])
)
)]
async fn delete_key(
State(state): State<AppState>,
auth_user: AuthUser,
Path(key_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
api_key::delete_api_key(&state.conn, key_id, auth_user.id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn create_api_key_router() -> Router<AppState> {
Router::new()
.route("/", get(get_keys).post(create_key))
.route("/{key_id}", delete(delete_key))
}

View File

@@ -1,18 +1,27 @@
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router}; use axum::{
extract::{Query, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use app::{ use app::{
persistence::{follow::get_followed_ids, thought::get_feed_for_user}, persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self_paginated},
state::AppState, state::AppState,
}; };
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; use models::{
queries::pagination::PaginationQuery,
schemas::{pagination::PaginatedResponse, thought::ThoughtSchema},
};
use crate::{error::ApiError, extractor::AuthUser}; use crate::{error::ApiError, extractor::AuthUser};
#[utoipa::path( #[utoipa::path(
get, get,
path = "", path = "",
params(PaginationQuery),
responses( responses(
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema) (status = 200, description = "Authenticated user's feed", body = PaginatedResponse<ThoughtSchema>)
), ),
security( security(
("api_key" = []), ("api_key" = []),
@@ -22,19 +31,35 @@ use crate::{error::ApiError, extractor::AuthUser};
async fn feed_get( async fn feed_get(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?; let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
let mut thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?; let (thoughts_with_authors, total_items) = get_feed_for_users_and_self_paginated(
&state.conn,
let own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?; auth_user.id,
thoughts_with_authors.extend(own_thoughts); following_ids,
&pagination,
)
.await?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter() .into_iter()
.map(ThoughtSchema::from) .map(ThoughtSchema::from)
.collect(); .collect();
Ok(Json(ThoughtListSchema::from(thoughts_schema))) let page = pagination.page();
let page_size = pagination.page_size();
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
let response = PaginatedResponse {
items: thoughts_schema,
total_items,
total_pages,
page,
page_size,
};
Ok(Json(response))
} }
pub fn create_feed_router() -> Router<AppState> { pub fn create_feed_router() -> Router<AppState> {

View File

@@ -0,0 +1,24 @@
use crate::{error::ApiError, extractor::AuthUser};
use app::{persistence::user, state::AppState};
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
use models::schemas::user::UserListSchema;
#[utoipa::path(
get,
path = "",
responses(
(status = 200, description = "List of authenticated user's friends", body = UserListSchema)
),
security(("bearer_auth" = []))
)]
async fn get_friends_list(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let friends = user::get_friends(&state.conn, auth_user.id).await?;
Ok(Json(UserListSchema::from(friends)))
}
pub fn create_friends_router() -> Router<AppState> {
Router::new().route("/", get(get_friends_list))
}

View File

@@ -1,14 +1,16 @@
use axum::Router; use axum::Router;
pub mod api_key;
pub mod auth; pub mod auth;
pub mod feed; pub mod feed;
pub mod friends;
pub mod root; pub mod root;
pub mod search;
pub mod tag; pub mod tag;
pub mod thought; pub mod thought;
pub mod user; pub mod user;
pub mod well_known;
use crate::routers::{auth::create_auth_router, well_known::create_well_known_router}; use crate::routers::auth::create_auth_router;
use app::state::AppState; use app::state::AppState;
use root::create_root_router; use root::create_root_router;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
@@ -21,12 +23,13 @@ pub fn create_router(state: AppState) -> Router {
Router::new() Router::new()
.merge(create_root_router()) .merge(create_root_router())
.nest("/.well-known", create_well_known_router())
.nest("/auth", create_auth_router()) .nest("/auth", create_auth_router())
.nest("/users", create_user_router()) .nest("/users", create_user_router())
.nest("/thoughts", create_thought_router()) .nest("/thoughts", create_thought_router())
.nest("/feed", create_feed_router()) .nest("/feed", create_feed_router())
.nest("/tags", tag::create_tag_router()) .nest("/tags", tag::create_tag_router())
.nest("/friends", friends::create_friends_router())
.nest("/search", search::create_search_router())
.with_state(state) .with_state(state)
.layer(cors) .layer(cors)
} }

View File

@@ -1,4 +1,4 @@
use axum::{extract::State, routing::get, Router}; use axum::{extract::State, http::StatusCode, routing::get, Router};
use sea_orm::{ConnectionTrait, Statement}; use sea_orm::{ConnectionTrait, Statement};
use app::state::AppState; use app::state::AppState;
@@ -25,6 +25,12 @@ async fn root_get(state: State<AppState>) -> Result<String, ApiError> {
result.unwrap().try_get_by(0).map_err(|e| e.into()) result.unwrap().try_get_by(0).map_err(|e| e.into())
} }
pub fn create_root_router() -> Router<AppState> { async fn health_check() -> StatusCode {
Router::new().route("/", get(root_get)) StatusCode::OK
}
pub fn create_root_router() -> Router<AppState> {
Router::new()
.route("/", get(root_get))
.route("/health", get(health_check))
} }

View File

@@ -0,0 +1,53 @@
use crate::{error::ApiError, extractor::OptionalAuthUser};
use app::{persistence::search, state::AppState};
use axum::{
extract::{Query, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use models::schemas::{
search::SearchResultsSchema,
thought::{ThoughtListSchema, ThoughtSchema},
user::UserListSchema,
};
use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Deserialize, IntoParams)]
pub struct SearchQuery {
q: String,
}
#[utoipa::path(
get,
path = "",
params(SearchQuery),
responses((status = 200, body = SearchResultsSchema))
)]
async fn search_all(
State(state): State<AppState>,
viewer: OptionalAuthUser,
Query(query): Query<SearchQuery>,
) -> Result<impl IntoResponse, ApiError> {
let viewer_id = viewer.0.map(|u| u.id);
let (users, thoughts) = tokio::try_join!(
search::search_users(&state.conn, &query.q),
search::search_thoughts(&state.conn, &query.q, viewer_id)
)?;
let thought_schemas: Vec<ThoughtSchema> =
thoughts.into_iter().map(ThoughtSchema::from).collect();
let response = SearchResultsSchema {
users: UserListSchema::from(users),
thoughts: ThoughtListSchema::from(thought_schemas),
};
Ok(Json(response))
}
pub fn create_search_router() -> Router<AppState> {
Router::new().route("/", get(search_all))
}

View File

@@ -1,5 +1,8 @@
use crate::error::ApiError; use crate::{error::ApiError, extractor::OptionalAuthUser};
use app::{persistence::thought::get_thoughts_by_tag_name, state::AppState}; use app::{
persistence::{tag, thought::get_thoughts_by_tag_name},
state::AppState,
};
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
response::IntoResponse, response::IntoResponse,
@@ -17,22 +20,32 @@ use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
async fn get_thoughts_by_tag( async fn get_thoughts_by_tag(
State(state): State<AppState>, State(state): State<AppState>,
Path(tag_name): Path<String>, Path(tag_name): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await; let thoughts_with_authors =
println!( get_thoughts_by_tag_name(&state.conn, &tag_name, viewer.0.map(|u| u.id)).await;
"Result from get_thoughts_by_tag_name: {:?}",
thoughts_with_authors
);
let thoughts_with_authors = thoughts_with_authors?; let thoughts_with_authors = thoughts_with_authors?;
println!("Thoughts with authors: {:?}", thoughts_with_authors);
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter() .into_iter()
.map(ThoughtSchema::from) .map(ThoughtSchema::from)
.collect(); .collect();
println!("Thoughts schema: {:?}", thoughts_schema);
Ok(Json(ThoughtListSchema::from(thoughts_schema))) Ok(Json(ThoughtListSchema::from(thoughts_schema)))
} }
pub fn create_tag_router() -> Router<AppState> { #[utoipa::path(
Router::new().route("/{tag_name}", get(get_thoughts_by_tag)) get,
path = "/popular",
responses((status = 200, description = "List of popular tags", body = Vec<String>))
)]
async fn get_popular_tags(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let tags = tag::get_popular_tags(&state.conn).await;
println!("Fetched popular tags: {:?}", tags);
let tags = tags?;
Ok(Json(tags))
}
pub fn create_tag_router() -> Router<AppState> {
Router::new()
.route("/{tag_name}", get(get_thoughts_by_tag))
.route("/popular", get(get_popular_tags))
} }

View File

@@ -2,7 +2,7 @@ use axum::{
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::{delete, post}, routing::{get, post},
Router, Router,
}; };
@@ -11,16 +11,47 @@ use app::{
persistence::thought::{create_thought, delete_thought, get_thought}, persistence::thought::{create_thought, delete_thought, get_thought},
state::AppState, state::AppState,
}; };
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema}; use models::{
params::thought::CreateThoughtParams,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
};
use sea_orm::prelude::Uuid; use sea_orm::prelude::Uuid;
use crate::{ use crate::{
error::ApiError, error::ApiError,
extractor::{AuthUser, Json, Valid}, extractor::{AuthUser, Json, OptionalAuthUser, Valid},
federation,
models::{ApiErrorResponse, ParamsErrorResponse}, models::{ApiErrorResponse, ParamsErrorResponse},
}; };
#[utoipa::path(
get,
path = "/{id}",
params(
("id" = Uuid, Path, description = "Thought ID")
),
responses(
(status = 200, description = "Thought found", body = ThoughtSchema),
(status = 404, description = "Not Found", body = ApiErrorResponse)
)
)]
async fn get_thought_by_id(
State(state): State<AppState>,
Path(id): Path<Uuid>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
let viewer_id = viewer.0.map(|u| u.id);
let thought = get_thought(&state.conn, id, viewer_id)
.await?
.ok_or(UserError::NotFound)?;
let author = app::persistence::user::get_user(&state.conn, thought.author_id)
.await?
.ok_or(UserError::NotFound)?;
let schema = ThoughtSchema::from_models(&thought, &author);
Ok(Json(schema))
}
#[utoipa::path( #[utoipa::path(
post, post,
path = "", path = "",
@@ -45,13 +76,6 @@ async fn thoughts_post(
.await? .await?
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid .ok_or(UserError::NotFound)?; // Should not happen if auth is valid
// Spawn a background task to handle federation without blocking the response
tokio::spawn(federation::federate_thought(
state.clone(),
thought.clone(),
author.clone(),
));
let schema = ThoughtSchema::from_models(&thought, &author); let schema = ThoughtSchema::from_models(&thought, &author);
Ok((StatusCode::CREATED, Json(schema))) Ok((StatusCode::CREATED, Json(schema)))
} }
@@ -77,7 +101,7 @@ async fn thoughts_delete(
auth_user: AuthUser, auth_user: AuthUser,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let thought = get_thought(&state.conn, id) let thought = get_thought(&state.conn, id, Some(auth_user.id))
.await? .await?
.ok_or(UserError::NotFound)?; .ok_or(UserError::NotFound)?;
@@ -89,8 +113,33 @@ async fn thoughts_delete(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
#[utoipa::path(
get,
path = "/{id}/thread",
params(
("id" = Uuid, Path, description = "Thought ID")
),
responses(
(status = 200, description = "Thought thread found", body = ThoughtThreadSchema),
(status = 404, description = "Not Found", body = ApiErrorResponse)
)
)]
async fn get_thought_thread(
State(state): State<AppState>,
Path(id): Path<Uuid>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
let viewer_id = viewer.0.map(|u| u.id);
let thread = app::persistence::thought::get_thought_with_replies(&state.conn, id, viewer_id)
.await?
.ok_or(UserError::NotFound)?;
Ok(Json(thread))
}
pub fn create_thought_router() -> Router<AppState> { pub fn create_thought_router() -> Router<AppState> {
Router::new() Router::new()
.route("/", post(thoughts_post)) .route("/", post(thoughts_post))
.route("/{id}", delete(thoughts_delete)) .route("/{id}/thread", get(get_thought_thread))
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
} }

View File

@@ -11,17 +11,28 @@ use serde_json::{json, Value};
use app::persistence::{ use app::persistence::{
follow, follow,
thought::get_thoughts_by_user, thought::get_thoughts_by_user,
user::{get_user, search_users, update_user_profile}, user::{
get_all_users, get_followers, get_following, get_user, search_users, update_user_profile,
},
}; };
use app::state::AppState; use app::state::AppState;
use app::{error::UserError, persistence::user::get_user_by_username}; use app::{error::UserError, persistence::user::get_user_by_username};
use models::schemas::user::{UserListSchema, UserSchema}; use models::{
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema}; params::user::UpdateUserParams,
schemas::{pagination::PaginatedResponse, thought::ThoughtListSchema},
};
use models::{
queries::pagination::PaginationQuery,
schemas::user::{MeSchema, UserListSchema, UserSchema},
};
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema}; use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
use crate::extractor::{Json, Valid};
use crate::models::ApiErrorResponse;
use crate::{error::ApiError, extractor::AuthUser}; use crate::{error::ApiError, extractor::AuthUser};
use crate::{extractor::OptionalAuthUser, models::ApiErrorResponse};
use crate::{
extractor::{Json, Valid},
routers::api_key::create_api_key_router,
};
#[utoipa::path( #[utoipa::path(
get, get,
@@ -60,12 +71,14 @@ async fn users_get(
async fn user_thoughts_get( async fn user_thoughts_get(
State(state): State<AppState>, State(state): State<AppState>,
Path(username): Path<String>, Path(username): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let user = get_user_by_username(&state.conn, &username) let user = get_user_by_username(&state.conn, &username)
.await? .await?
.ok_or(UserError::NotFound)?; .ok_or(UserError::NotFound)?;
let thoughts_with_authors = get_thoughts_by_user(&state.conn, user.id).await?; let thoughts_with_authors =
get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter() .into_iter()
@@ -245,7 +258,12 @@ async fn get_user_by_param(
} }
} else { } else {
match get_user_by_username(&state.conn, &username).await { match get_user_by_username(&state.conn, &username).await {
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(), Ok(Some(user)) => {
let top_friends = app::persistence::user::get_top_friends(&state.conn, user.id)
.await
.unwrap_or_default();
Json(UserSchema::from((user, top_friends))).into_response()
}
Ok(None) => ApiError::from(UserError::NotFound).into_response(), Ok(None) => ApiError::from(UserError::NotFound).into_response(),
Err(e) => ApiError::from(e).into_response(), Err(e) => ApiError::from(e).into_response(),
} }
@@ -264,12 +282,13 @@ async fn get_user_by_param(
async fn user_outbox_get( async fn user_outbox_get(
State(state): State<AppState>, State(state): State<AppState>,
Path(username): Path<String>, Path(username): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let user = get_user_by_username(&state.conn, &username) let user = get_user_by_username(&state.conn, &username)
.await? .await?
.ok_or(UserError::NotFound)?; .ok_or(UserError::NotFound)?;
let thoughts = get_thoughts_by_user(&state.conn, user.id).await?; let thoughts = get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?;
// Format the outbox as an ActivityPub OrderedCollection // Format the outbox as an ActivityPub OrderedCollection
let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username); let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username);
@@ -316,7 +335,7 @@ async fn user_outbox_get(
get, get,
path = "/me", path = "/me",
responses( responses(
(status = 200, description = "Authenticated user's profile", body = UserSchema) (status = 200, description = "Authenticated user's full profile", body = MeSchema)
), ),
security( security(
("bearer_auth" = []) ("bearer_auth" = [])
@@ -329,7 +348,23 @@ async fn get_me(
let user = get_user(&state.conn, auth_user.id) let user = get_user(&state.conn, auth_user.id)
.await? .await?
.ok_or(UserError::NotFound)?; .ok_or(UserError::NotFound)?;
Ok(axum::Json(UserSchema::from(user))) let top_friends = app::persistence::user::get_top_friends(&state.conn, auth_user.id).await?;
let following = get_following(&state.conn, auth_user.id).await?;
let response = MeSchema {
id: user.id,
username: user.username,
display_name: user.display_name,
bio: user.bio,
avatar_url: user.avatar_url,
header_url: user.header_url,
custom_css: user.custom_css,
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
joined_at: user.created_at.into(),
following: following.into_iter().map(UserSchema::from).collect(),
};
Ok(axum::Json(response))
} }
#[utoipa::path( #[utoipa::path(
@@ -354,12 +389,84 @@ async fn update_me(
Ok(axum::Json(UserSchema::from(updated_user))) Ok(axum::Json(UserSchema::from(updated_user)))
} }
#[utoipa::path(
get,
path = "/{username}/following",
responses((status = 200, body = UserListSchema))
)]
async fn get_user_following(
State(state): State<AppState>,
Path(username): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
let following_list = get_following(&state.conn, user.id).await?;
Ok(Json(UserListSchema::from(following_list)))
}
#[utoipa::path(
get,
path = "/{username}/followers",
responses((status = 200, body = UserListSchema))
)]
async fn get_user_followers(
State(state): State<AppState>,
Path(username): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
let followers_list = get_followers(&state.conn, user.id).await?;
Ok(Json(UserListSchema::from(followers_list)))
}
#[utoipa::path(
get,
path = "/all",
params(PaginationQuery),
responses(
(status = 200, description = "A public, paginated list of all users", body = PaginatedResponse<UserSchema>)
),
tag = "user"
)]
async fn get_all_users_public(
State(state): State<AppState>,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> {
let (users, total_items) = get_all_users(&state.conn, &pagination).await?;
let page = pagination.page();
let page_size = pagination.page_size();
let total_pages = (total_items as f64 / page_size as f64).ceil() as u64;
let response = PaginatedResponse {
items: users.into_iter().map(UserSchema::from).collect(),
page,
page_size,
total_pages,
total_items,
};
Ok(Json(response))
}
async fn get_all_users_count(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
let count = app::persistence::user::get_all_users_count(&state.conn).await?;
Ok(Json(json!({ "count": count })))
}
pub fn create_user_router() -> Router<AppState> { pub fn create_user_router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(users_get)) .route("/", get(users_get))
.route("/all", get(get_all_users_public))
.route("/count", get(get_all_users_count))
.route("/me", get(get_me).put(update_me)) .route("/me", get(get_me).put(update_me))
.nest("/me/api-keys", create_api_key_router())
.route("/{param}", get(get_user_by_param)) .route("/{param}", get(get_user_by_param))
.route("/{username}/thoughts", get(user_thoughts_get)) .route("/{username}/thoughts", get(user_thoughts_get))
.route("/{username}/followers", get(get_user_followers))
.route("/{username}/following", get(get_user_following))
.route( .route(
"/{username}/follow", "/{username}/follow",
post(user_follow_post).delete(user_follow_delete), post(user_follow_post).delete(user_follow_delete),

View File

@@ -1,70 +0,0 @@
use app::state::AppState;
use axum::{
extract::{Query, State},
response::{IntoResponse, Json},
};
use serde::{Deserialize, Serialize};
use url::Url;
#[derive(Deserialize)]
pub struct WebFingerQuery {
resource: String,
}
#[derive(Serialize)]
pub struct WebFingerLink {
rel: String,
#[serde(rename = "type")]
type_: String,
href: Url,
}
#[derive(Serialize)]
pub struct WebFingerResponse {
subject: String,
links: Vec<WebFingerLink>,
}
pub async fn webfinger(
State(state): State<AppState>,
Query(query): Query<WebFingerQuery>,
) -> Result<impl IntoResponse, impl IntoResponse> {
if let Some((scheme, account_info)) = query.resource.split_once(':') {
if scheme != "acct" {
return Err((
axum::http::StatusCode::BAD_REQUEST,
"Invalid resource scheme",
));
}
let account_parts: Vec<&str> = account_info.split('@').collect();
let username = account_parts[0];
let user = match app::persistence::user::get_user_by_username(&state.conn, username).await {
Ok(Some(user)) => user,
_ => return Err((axum::http::StatusCode::NOT_FOUND, "User not found")),
};
let user_url = Url::parse(&format!("{}/users/{}", &state.base_url, user.username)).unwrap();
let response = WebFingerResponse {
subject: query.resource,
links: vec![WebFingerLink {
rel: "self".to_string(),
type_: "application/activity+json".to_string(),
href: user_url,
}],
};
Ok(Json(response))
} else {
Err((
axum::http::StatusCode::BAD_REQUEST,
"Invalid resource format",
))
}
}
pub fn create_well_known_router() -> axum::Router<AppState> {
axum::Router::new().route("/webfinger", axum::routing::get(webfinger))
}

View File

@@ -12,4 +12,6 @@ path = "src/lib.rs"
bcrypt = "0.17.1" bcrypt = "0.17.1"
models = { path = "../models" } models = { path = "../models" }
validator = "0.20" validator = "0.20"
rand = "0.8.5"
sea-orm = { version = "1.1.12" } sea-orm = { version = "1.1.12" }
chrono = { workspace = true }

View File

@@ -0,0 +1,93 @@
use bcrypt::{hash, verify, DEFAULT_COST};
use models::domains::{api_key, user};
use rand::distributions::{Alphanumeric, DistString};
use sea_orm::{
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
};
use crate::error::UserError;
const KEY_PREFIX: &str = "th_";
const KEY_RANDOM_LENGTH: usize = 32;
const KEY_LOOKUP_PREFIX_LENGTH: usize = 8;
fn generate_key() -> String {
let random_part = Alphanumeric.sample_string(&mut rand::thread_rng(), KEY_RANDOM_LENGTH);
format!("{}{}", KEY_PREFIX, random_part)
}
pub async fn create_api_key(
db: &DbConn,
user_id: Uuid,
name: String,
) -> Result<(api_key::Model, String), UserError> {
let plaintext_key = generate_key();
let key_hash =
hash(&plaintext_key, DEFAULT_COST).map_err(|e| UserError::Internal(e.to_string()))?;
let key_prefix = plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH].to_string();
let new_key = api_key::ActiveModel {
user_id: Set(user_id),
name: Set(name),
key_hash: Set(key_hash),
key_prefix: Set(key_prefix),
..Default::default()
}
.insert(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
Ok((new_key, plaintext_key))
}
pub async fn validate_api_key(db: &DbConn, plaintext_key: &str) -> Result<user::Model, UserError> {
if !plaintext_key.starts_with(KEY_PREFIX)
|| plaintext_key.len() != KEY_PREFIX.len() + KEY_RANDOM_LENGTH
{
return Err(UserError::Validation("Invalid API key format".to_string()));
}
let key_prefix = &plaintext_key[..KEY_LOOKUP_PREFIX_LENGTH];
let candidate_keys = api_key::Entity::find()
.filter(api_key::Column::KeyPrefix.eq(key_prefix))
.all(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
for key in candidate_keys {
if verify(plaintext_key, &key.key_hash).unwrap_or(false) {
return super::user::get_user(db, key.user_id)
.await
.map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound);
}
}
Err(UserError::Validation("Invalid API key".to_string()))
}
pub async fn get_api_keys_for_user(
db: &DbConn,
user_id: Uuid,
) -> Result<Vec<api_key::Model>, DbErr> {
api_key::Entity::find()
.filter(api_key::Column::UserId.eq(user_id))
.all(db)
.await
}
pub async fn delete_api_key(db: &DbConn, key_id: Uuid, user_id: Uuid) -> Result<(), UserError> {
let result = api_key::Entity::delete_many()
.filter(api_key::Column::Id.eq(key_id))
.filter(api_key::Column::UserId.eq(user_id)) // Ensure user owns the key
.exec(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
if result.rows_affected == 0 {
Err(UserError::NotFound)
} else {
Ok(())
}
}

View File

@@ -13,7 +13,6 @@ fn hash_password(password: &str) -> Result<String, BcryptError> {
} }
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> { pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
// Validate the parameters
params params
.validate() .validate()
.map_err(|e| UserError::Validation(e.to_string()))?; .map_err(|e| UserError::Validation(e.to_string()))?;
@@ -22,8 +21,10 @@ pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::
hash_password(&params.password).map_err(|e| UserError::Internal(e.to_string()))?; hash_password(&params.password).map_err(|e| UserError::Internal(e.to_string()))?;
let new_user = user::ActiveModel { let new_user = user::ActiveModel {
username: Set(params.username), username: Set(params.username.clone()),
password_hash: Set(Some(hashed_password)), password_hash: Set(Some(hashed_password)),
email: Set(Some(params.email)),
display_name: Set(Some(params.username)),
..Default::default() ..Default::default()
}; };

View File

@@ -7,7 +7,7 @@ use models::domains::follow;
pub async fn add_follower( pub async fn add_follower(
db: &DbConn, db: &DbConn,
followed_id: Uuid, following_id: Uuid,
follower_actor_id: &str, follower_actor_id: &str,
) -> Result<(), UserError> { ) -> Result<(), UserError> {
let follower_username = follower_actor_id let follower_username = follower_actor_id
@@ -20,21 +20,21 @@ pub async fn add_follower(
.map_err(|e| UserError::Internal(e.to_string()))? .map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound)?; .ok_or(UserError::NotFound)?;
follow_user(db, follower.id, followed_id) follow_user(db, follower.id, following_id)
.await .await
.map_err(|e| UserError::Internal(e.to_string()))?; .map_err(|e| UserError::Internal(e.to_string()))?;
Ok(()) Ok(())
} }
pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> Result<(), DbErr> { pub async fn follow_user(db: &DbConn, follower_id: Uuid, following_id: Uuid) -> Result<(), DbErr> {
if follower_id == followed_id { if follower_id == following_id {
return Err(DbErr::Custom("Users cannot follow themselves".to_string())); return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
} }
let follow = follow::ActiveModel { let follow = follow::ActiveModel {
follower_id: Set(follower_id), follower_id: Set(follower_id),
followed_id: Set(followed_id), following_id: Set(following_id),
}; };
follow.insert(db).await?; follow.insert(db).await?;
@@ -44,11 +44,11 @@ pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> R
pub async fn unfollow_user( pub async fn unfollow_user(
db: &DbConn, db: &DbConn,
follower_id: Uuid, follower_id: Uuid,
followed_id: Uuid, following_id: Uuid,
) -> Result<(), UserError> { ) -> Result<(), UserError> {
let deleted_result = follow::Entity::delete_many() let deleted_result = follow::Entity::delete_many()
.filter(follow::Column::FollowerId.eq(follower_id)) .filter(follow::Column::FollowerId.eq(follower_id))
.filter(follow::Column::FollowedId.eq(followed_id)) .filter(follow::Column::FollowingId.eq(following_id))
.exec(db) .exec(db)
.await .await
.map_err(|e| UserError::Internal(e.to_string()))?; .map_err(|e| UserError::Internal(e.to_string()))?;
@@ -60,19 +60,32 @@ pub async fn unfollow_user(
Ok(()) Ok(())
} }
pub async fn get_followed_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> { pub async fn get_following_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let followed_users = follow::Entity::find() let followed_users = follow::Entity::find()
.filter(follow::Column::FollowerId.eq(user_id)) .filter(follow::Column::FollowerId.eq(user_id))
.all(db) .all(db)
.await?; .await?;
Ok(followed_users.into_iter().map(|f| f.followed_id).collect()) Ok(followed_users.into_iter().map(|f| f.following_id).collect())
} }
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> { pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let followers = follow::Entity::find() let followers = follow::Entity::find()
.filter(follow::Column::FollowedId.eq(user_id)) .filter(follow::Column::FollowingId.eq(user_id))
.all(db) .all(db)
.await?; .await?;
Ok(followers.into_iter().map(|f| f.follower_id).collect()) Ok(followers.into_iter().map(|f| f.follower_id).collect())
} }
pub async fn get_friend_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let following = get_following_ids(db, user_id).await?;
let followers = get_follower_ids(db, user_id).await?;
let following_set: std::collections::HashSet<Uuid> = following.into_iter().collect();
let followers_set: std::collections::HashSet<Uuid> = followers.into_iter().collect();
Ok(following_set
.intersection(&followers_set)
.cloned()
.collect())
}

View File

@@ -1,5 +1,7 @@
pub mod api_key;
pub mod auth; pub mod auth;
pub mod follow; pub mod follow;
pub mod search;
pub mod tag; pub mod tag;
pub mod thought; pub mod thought;
pub mod user; pub mod user;

View File

@@ -0,0 +1,66 @@
use models::{
domains::{thought, user},
schemas::thought::ThoughtWithAuthor,
};
use sea_orm::{
prelude::{Expr, Uuid},
DatabaseConnection, DbErr, EntityTrait, JoinType, QueryFilter, QuerySelect, RelationTrait,
Value,
};
use crate::persistence::follow;
fn is_visible(
author_id: Uuid,
viewer_id: Option<Uuid>,
friend_ids: &[Uuid],
visibility: &thought::Visibility,
) -> bool {
match visibility {
thought::Visibility::Public => true,
thought::Visibility::Private => viewer_id.map_or(false, |v| v == author_id),
thought::Visibility::FriendsOnly => {
viewer_id.map_or(false, |v| v == author_id || friend_ids.contains(&author_id))
}
}
}
pub async fn search_thoughts(
db: &DatabaseConnection,
query: &str,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut friend_ids = Vec::new();
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer).await?;
}
// We must join with the user table to get the author's username
let thoughts_with_authors = thought::Entity::find()
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(Expr::cust_with_values(
"thought.search_document @@ websearch_to_tsquery('english', $1)",
[Value::from(query)],
))
.into_model::<ThoughtWithAuthor>() // Convert directly in the query
.all(db)
.await?;
// Apply visibility filtering in Rust after the search
Ok(thoughts_with_authors
.into_iter()
.filter(|t| is_visible(t.author_id, viewer_id, &friend_ids, &t.visibility))
.collect())
}
pub async fn search_users(db: &DatabaseConnection, query: &str) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.filter(Expr::cust_with_values(
"\"user\".search_document @@ websearch_to_tsquery('english', $1)",
[Value::from(query)],
))
.all(db)
.await
}

View File

@@ -1,6 +1,8 @@
use models::domains::{tag, thought_tag}; use chrono::{Duration, Utc};
use models::domains::{tag, thought, thought_tag};
use sea_orm::{ use sea_orm::{
sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter, Set, prelude::Expr, sea_query::Alias, sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr,
EntityTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
}; };
use std::collections::HashSet; use std::collections::HashSet;
@@ -84,3 +86,35 @@ where
thought_tag::Entity::insert_many(links).exec(db).await?; thought_tag::Entity::insert_many(links).exec(db).await?;
Ok(()) Ok(())
} }
pub async fn get_popular_tags<C>(db: &C) -> Result<Vec<String>, DbErr>
where
C: ConnectionTrait,
{
let seven_days_ago = Utc::now() - Duration::days(7);
let popular_tags = tag::Entity::find()
.select_only()
.column(tag::Column::Name)
.column_as(Expr::col((tag::Entity, tag::Column::Id)).count(), "count")
.join(
sea_orm::JoinType::InnerJoin,
tag::Relation::ThoughtTag.def(),
)
.join(
sea_orm::JoinType::InnerJoin,
thought_tag::Relation::Thought.def(),
)
.filter(thought::Column::CreatedAt.gte(seven_days_ago))
.filter(thought::Column::Visibility.eq(thought::Visibility::Public))
.group_by(tag::Column::Name)
.group_by(tag::Column::Id)
.order_by_desc(Expr::col(Alias::new("count")))
.order_by_asc(tag::Column::Name)
.limit(10)
.into_tuple::<(String, i64)>()
.all(db)
.await?;
Ok(popular_tags.into_iter().map(|(name, _)| name).collect())
}

View File

@@ -1,17 +1,22 @@
use sea_orm::{ use sea_orm::{
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait, EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
Set, TransactionTrait,
}; };
use models::{ use models::{
domains::{tag, thought, thought_tag, user}, domains::{tag, thought, thought_tag, user},
params::thought::CreateThoughtParams, params::thought::CreateThoughtParams,
schemas::thought::ThoughtWithAuthor, queries::pagination::PaginationQuery,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
}; };
use crate::{ use crate::{
error::UserError, error::UserError,
persistence::tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags}, persistence::{
follow,
tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags},
},
}; };
pub async fn create_thought( pub async fn create_thought(
@@ -24,23 +29,55 @@ pub async fn create_thought(
let new_thought = thought::ActiveModel { let new_thought = thought::ActiveModel {
author_id: Set(author_id), author_id: Set(author_id),
content: Set(params.content.clone()), content: Set(params.content.clone()),
reply_to_id: Set(params.reply_to_id),
visibility: Set(params.visibility.unwrap_or(thought::Visibility::Public)),
..Default::default() ..Default::default()
} }
.insert(&txn) .insert(&txn)
.await?; .await?;
let tag_names = parse_hashtags(&params.content); if new_thought.visibility == thought::Visibility::Public {
if !tag_names.is_empty() { let tag_names = parse_hashtags(&params.content);
let tags = find_or_create_tags(&txn, tag_names).await?; if !tag_names.is_empty() {
link_tags_to_thought(&txn, new_thought.id, tags).await?; let tags = find_or_create_tags(&txn, tag_names).await?;
link_tags_to_thought(&txn, new_thought.id, tags).await?;
}
} }
txn.commit().await?; txn.commit().await?;
Ok(new_thought) Ok(new_thought)
} }
pub async fn get_thought(db: &DbConn, thought_id: Uuid) -> Result<Option<thought::Model>, DbErr> { pub async fn get_thought(
thought::Entity::find_by_id(thought_id).one(db).await db: &DbConn,
thought_id: Uuid,
viewer_id: Option<Uuid>,
) -> Result<Option<thought::Model>, DbErr> {
let thought = thought::Entity::find_by_id(thought_id).one(db).await?;
match thought {
Some(t) => {
if t.visibility == thought::Visibility::Public {
return Ok(Some(t));
}
if let Some(viewer) = viewer_id {
if t.author_id == viewer {
return Ok(Some(t));
}
if t.visibility == thought::Visibility::FriendsOnly {
let author_friends = follow::get_friend_ids(db, t.author_id).await?;
if author_friends.contains(&viewer) {
return Ok(Some(t));
}
}
}
Ok(None)
}
None => Ok(None),
}
} }
pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> { pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr> {
@@ -51,15 +88,25 @@ pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> Result<(), DbErr>
pub async fn get_thoughts_by_user( pub async fn get_thoughts_by_user(
db: &DbConn, db: &DbConn,
user_id: Uuid, user_id: Uuid,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> { ) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut friend_ids = vec![];
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer).await?;
}
thought::Entity::find() thought::Entity::find()
.select_only() .select_only()
.column(thought::Column::Id) .column(thought::Column::Id)
.column(thought::Column::Content) .column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt) .column(thought::Column::CreatedAt)
.column(thought::Column::AuthorId) .column(thought::Column::AuthorId)
.column(thought::Column::Visibility)
.column_as(user::Column::DisplayName, "author_display_name")
.column_as(user::Column::Username, "author_username") .column_as(user::Column::Username, "author_username")
.join(JoinType::InnerJoin, thought::Relation::User.def()) .join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(apply_visibility_filter(user_id, viewer_id, &friend_ids))
.filter(thought::Column::AuthorId.eq(user_id)) .filter(thought::Column::AuthorId.eq(user_id))
.order_by_desc(thought::Column::CreatedAt) .order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>() .into_model::<ThoughtWithAuthor>()
@@ -69,21 +116,40 @@ pub async fn get_thoughts_by_user(
pub async fn get_feed_for_user( pub async fn get_feed_for_user(
db: &DbConn, db: &DbConn,
followed_ids: Vec<Uuid>, following_ids: Vec<Uuid>,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, UserError> { ) -> Result<Vec<ThoughtWithAuthor>, UserError> {
if followed_ids.is_empty() { if following_ids.is_empty() {
return Ok(vec![]); return Ok(vec![]);
} }
let mut friend_ids = vec![];
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
}
thought::Entity::find() thought::Entity::find()
.select_only() .select_only()
.column(thought::Column::Id) .column(thought::Column::Id)
.column(thought::Column::Content) .column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt) .column(thought::Column::CreatedAt)
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId) .column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username") .column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def()) .join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(thought::Column::AuthorId.is_in(followed_ids)) .filter(
Condition::any().add(following_ids.iter().fold(
Condition::all(),
|cond, &author_id| {
cond.add(apply_visibility_filter(author_id, viewer_id, &friend_ids))
},
)),
)
.filter(thought::Column::AuthorId.is_in(following_ids))
.order_by_desc(thought::Column::CreatedAt) .order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>() .into_model::<ThoughtWithAuthor>()
.all(db) .all(db)
@@ -91,17 +157,93 @@ pub async fn get_feed_for_user(
.map_err(|e| UserError::Internal(e.to_string())) .map_err(|e| UserError::Internal(e.to_string()))
} }
pub async fn get_thoughts_by_tag_name( pub async fn get_feed_for_users_and_self(
db: &DbConn, db: &DbConn,
tag_name: &str, user_id: Uuid,
following_ids: Vec<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> { ) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut authors_to_include = following_ids;
authors_to_include.push(user_id);
thought::Entity::find() thought::Entity::find()
.select_only() .select_only()
.column(thought::Column::Id) .column(thought::Column::Id)
.column(thought::Column::Content) .column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt) .column(thought::Column::CreatedAt)
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId) .column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username") .column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(thought::Column::AuthorId.is_in(authors_to_include))
.filter(
Condition::any()
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
)
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
}
pub async fn get_feed_for_users_and_self_paginated(
db: &DbConn,
user_id: Uuid,
following_ids: Vec<Uuid>,
pagination: &PaginationQuery,
) -> Result<(Vec<ThoughtWithAuthor>, u64), DbErr> {
let mut authors_to_include = following_ids;
authors_to_include.push(user_id);
let paginator = thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::Visibility)
.column(thought::Column::AuthorId)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(thought::Column::AuthorId.is_in(authors_to_include))
.filter(
Condition::any()
.add(thought::Column::Visibility.eq(thought::Visibility::Public))
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)),
)
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.paginate(db, pagination.page_size());
let total_items = paginator.num_items().await?;
let thoughts = paginator.fetch_page(pagination.page() - 1).await?;
Ok((thoughts, total_items))
}
pub async fn get_thoughts_by_tag_name(
db: &DbConn,
tag_name: &str,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut friend_ids = Vec::new();
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer).await?;
}
let thoughts = thought::Entity::find()
.select_only()
.column(thought::Column::Id)
.column(thought::Column::Content)
.column(thought::Column::ReplyToId)
.column(thought::Column::CreatedAt)
.column(thought::Column::AuthorId)
.column(thought::Column::Visibility)
.column_as(user::Column::Username, "author_username")
.column_as(user::Column::DisplayName, "author_display_name")
.join(JoinType::InnerJoin, thought::Relation::User.def()) .join(JoinType::InnerJoin, thought::Relation::User.def())
.join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def()) .join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def())
.join(JoinType::InnerJoin, thought_tag::Relation::Tag.def()) .join(JoinType::InnerJoin, thought_tag::Relation::Tag.def())
@@ -109,5 +251,136 @@ pub async fn get_thoughts_by_tag_name(
.order_by_desc(thought::Column::CreatedAt) .order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>() .into_model::<ThoughtWithAuthor>()
.all(db) .all(db)
.await .await?;
let visible_thoughts = thoughts
.into_iter()
.filter(|thought| {
let mut condition = thought.visibility == thought::Visibility::Public;
if let Some(viewer) = viewer_id {
if thought.author_id == viewer {
condition = true;
}
if thought.visibility == thought::Visibility::FriendsOnly
&& friend_ids.contains(&thought.author_id)
{
condition = true;
}
}
condition
})
.collect();
Ok(visible_thoughts)
}
pub fn apply_visibility_filter(
user_id: Uuid,
viewer_id: Option<Uuid>,
friend_ids: &[Uuid],
) -> SimpleExpr {
let mut condition =
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
if let Some(viewer) = viewer_id {
if user_id == viewer {
condition = condition
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
} else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
condition =
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
}
}
condition.into()
}
pub async fn get_thought_with_replies(
db: &DbConn,
thought_id: Uuid,
viewer_id: Option<Uuid>,
) -> Result<Option<ThoughtThreadSchema>, DbErr> {
let root_thought = match get_thought(db, thought_id, viewer_id).await? {
Some(t) => t,
None => return Ok(None),
};
let mut all_thoughts_in_thread = vec![root_thought.clone()];
let mut ids_to_fetch = vec![root_thought.id];
let mut friend_ids = vec![];
if let Some(viewer) = viewer_id {
friend_ids = follow::get_friend_ids(db, viewer).await?;
}
while !ids_to_fetch.is_empty() {
let replies = thought::Entity::find()
.filter(thought::Column::ReplyToId.is_in(ids_to_fetch))
.all(db)
.await?;
if replies.is_empty() {
break;
}
ids_to_fetch = replies.iter().map(|r| r.id).collect();
all_thoughts_in_thread.extend(replies);
}
let mut thought_schemas = vec![];
for thought in all_thoughts_in_thread {
if let Some(author) = user::Entity::find_by_id(thought.author_id).one(db).await? {
let is_visible = match thought.visibility {
thought::Visibility::Public => true,
thought::Visibility::Private => viewer_id.map_or(false, |v| v == thought.author_id),
thought::Visibility::FriendsOnly => viewer_id.map_or(false, |v| {
v == thought.author_id || friend_ids.contains(&thought.author_id)
}),
};
if is_visible {
thought_schemas.push(ThoughtSchema::from_models(&thought, &author));
}
}
}
fn build_thread(
thought_id: Uuid,
schemas_map: &std::collections::HashMap<Uuid, ThoughtSchema>,
replies_map: &std::collections::HashMap<Uuid, Vec<Uuid>>,
) -> Option<ThoughtThreadSchema> {
schemas_map.get(&thought_id).map(|thought_schema| {
let replies = replies_map
.get(&thought_id)
.unwrap_or(&vec![])
.iter()
.filter_map(|reply_id| build_thread(*reply_id, schemas_map, replies_map))
.collect();
ThoughtThreadSchema {
id: thought_schema.id,
author_username: thought_schema.author_username.clone(),
author_display_name: thought_schema.author_display_name.clone(),
content: thought_schema.content.clone(),
visibility: thought_schema.visibility.clone(),
reply_to_id: thought_schema.reply_to_id,
created_at: thought_schema.created_at.clone(),
replies,
}
})
}
let schemas_map: std::collections::HashMap<Uuid, ThoughtSchema> =
thought_schemas.into_iter().map(|s| (s.id, s)).collect();
let mut replies_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
std::collections::HashMap::new();
for thought in schemas_map.values() {
if let Some(parent_id) = thought.reply_to_id {
if schemas_map.contains_key(&parent_id) {
replies_map.entry(parent_id).or_default().push(thought.id);
}
}
}
Ok(build_thread(root_thought.id, &schemas_map, &replies_map))
} }

View File

@@ -1,6 +1,8 @@
use models::queries::pagination::PaginationQuery;
use sea_orm::prelude::Uuid; use sea_orm::prelude::Uuid;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set, TransactionTrait, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
}; };
use models::domains::{top_friends, user}; use models::domains::{top_friends, user};
@@ -8,6 +10,7 @@ use models::params::user::{CreateUserParams, UpdateUserParams};
use models::queries::user::UserQuery; use models::queries::user::UserQuery;
use crate::error::UserError; use crate::error::UserError;
use crate::persistence::follow::{get_follower_ids, get_following_ids, get_friend_ids};
pub async fn create_user( pub async fn create_user(
db: &DbConn, db: &DbConn,
@@ -127,3 +130,57 @@ pub async fn update_user_profile(
.await .await
.map_err(|e| UserError::Internal(e.to_string())) .map_err(|e| UserError::Internal(e.to_string()))
} }
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.join(
JoinType::InnerJoin,
top_friends::Relation::Friend.def().rev(),
)
.filter(top_friends::Column::UserId.eq(user_id))
.order_by_asc(top_friends::Column::Position)
.all(db)
.await
}
pub async fn get_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
let friend_ids = get_friend_ids(db, user_id).await?;
if friend_ids.is_empty() {
return Ok(vec![]);
}
get_users_by_ids(db, friend_ids).await
}
pub async fn get_following(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
let following_ids = get_following_ids(db, user_id).await?;
if following_ids.is_empty() {
return Ok(vec![]);
}
get_users_by_ids(db, following_ids).await
}
pub async fn get_followers(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
let follower_ids = get_follower_ids(db, user_id).await?;
if follower_ids.is_empty() {
return Ok(vec![]);
}
get_users_by_ids(db, follower_ids).await
}
pub async fn get_all_users(
db: &DbConn,
pagination: &PaginationQuery,
) -> Result<(Vec<user::Model>, u64), DbErr> {
let paginator = user::Entity::find()
.order_by_desc(user::Column::CreatedAt)
.paginate(db, pagination.page_size());
let total_items = paginator.num_items().await?;
let users = paginator.fetch_page(pagination.page() - 1).await?;
Ok((users, total_items))
}
pub async fn get_all_users_count(db: &DbConn) -> Result<u64, DbErr> {
user::Entity::find().count(db).await
}

View File

@@ -3,6 +3,10 @@ name = "common"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[lib]
name = "common"
path = "src/lib.rs"
[dependencies] [dependencies]
serde = { workspace = true } serde = { workspace = true }
utoipa = { workspace = true } utoipa = { workspace = true }

View File

@@ -5,7 +5,7 @@ use sea_query::ValueTypeErr;
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema; use utoipa::ToSchema;
#[derive(Serialize, ToSchema, Debug)] #[derive(Serialize, ToSchema, Debug, Clone)]
#[schema(example = "2025-09-05T12:34:56Z")] #[schema(example = "2025-09-05T12:34:56Z")]
pub struct DateTimeWithTimeZoneWrapper(String); pub struct DateTimeWithTimeZoneWrapper(String);

View File

@@ -10,6 +10,7 @@ path = "src/lib.rs"
[dependencies] [dependencies]
axum = { workspace = true } axum = { workspace = true }
tracing = { workspace = true }
utoipa = { workspace = true, features = ["axum_extras"] } utoipa = { workspace = true, features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = [ utoipa-swagger-ui = { version = "9.0.2", features = [
"axum", "axum",
@@ -19,5 +20,5 @@ utoipa-scalar = { version = "0.3.0", features = [
"axum", "axum",
], default-features = false } ], default-features = false }
api = { path = "../api" } # api = { path = "../api" }
models = { path = "../models" } models = { path = "../models" }

View File

@@ -0,0 +1,16 @@
use api::{models::ApiErrorResponse, routers::api_key::*};
use models::schemas::api_key::{ApiKeyListSchema, ApiKeyRequest, ApiKeyResponse, ApiKeySchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(get_keys, create_key, delete_key),
components(schemas(
ApiKeySchema,
ApiKeyListSchema,
ApiKeyRequest,
ApiKeyResponse,
ApiErrorResponse,
))
)]
pub(super) struct ApiKeyApi;

View File

@@ -0,0 +1,12 @@
use utoipa::OpenApi;
use api::models::{ApiErrorResponse, ParamsErrorResponse};
use api::routers::friends::*;
use models::schemas::user::{UserListSchema, UserSchema};
#[derive(OpenApi)]
#[openapi(
paths(get_friends_list,),
components(schemas(UserListSchema, ApiErrorResponse, ParamsErrorResponse, UserSchema))
)]
pub(super) struct FriendsApi;

View File

@@ -6,27 +6,17 @@ use utoipa::{
use utoipa_scalar::{Scalar, Servable as ScalarServable}; use utoipa_scalar::{Scalar, Servable as ScalarServable};
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
mod auth;
mod feed;
mod root;
mod thought;
mod user;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
nest(
(path = "/", api = root::RootApi),
(path = "/auth", api = auth::AuthApi),
(path = "/users", api = user::UserApi),
(path = "/thoughts", api = thought::ThoughtApi),
(path = "/feed", api = feed::FeedApi),
),
tags( tags(
(name = "root", description = "Root API"), (name = "root", description = "Root API"),
(name = "auth", description = "Authentication API"), (name = "auth", description = "Authentication API"),
(name = "user", description = "User & Social API"), (name = "user", description = "User & Social API"),
(name = "thought", description = "Thoughts API"), (name = "thought", description = "Thoughts API"),
(name = "feed", description = "Feed API"), (name = "feed", description = "Feed API"),
(name = "tag", description = "Tag Discovery API"),
(name = "friends", description = "Friends API"),
(name = "search", description = "Search API"),
), ),
modifiers(&SecurityAddon), modifiers(&SecurityAddon),
)] )]
@@ -47,12 +37,14 @@ impl Modify for SecurityAddon {
} }
} }
pub trait ApiDoc { pub trait ApiDocExt {
fn attach_doc(self) -> Self; fn attach_doc(self) -> Self;
} }
impl ApiDoc for Router { impl ApiDocExt for Router {
fn attach_doc(self) -> Self { fn attach_doc(self) -> Self {
tracing::info!("Attaching API documentation");
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi())) self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi())) .merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
} }

View File

@@ -0,0 +1,21 @@
use api::{models::ApiErrorResponse, routers::search::*};
use models::schemas::{
search::SearchResultsSchema,
thought::{ThoughtListSchema, ThoughtSchema},
user::{UserListSchema, UserSchema},
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(search_all),
components(schemas(
SearchResultsSchema,
ApiErrorResponse,
ThoughtSchema,
ThoughtListSchema,
UserSchema,
UserListSchema
))
)]
pub(super) struct SearchApi;

View File

@@ -0,0 +1,12 @@
// in thoughts-backend/doc/src/tag.rs
use api::{models::ApiErrorResponse, routers::tag::*};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(get_thoughts_by_tag, get_popular_tags),
components(schemas(ThoughtSchema, ThoughtListSchema, ApiErrorResponse))
)]
pub(super) struct TagApi;

View File

@@ -2,15 +2,19 @@ use api::{
models::{ApiErrorResponse, ParamsErrorResponse}, models::{ApiErrorResponse, ParamsErrorResponse},
routers::thought::*, routers::thought::*,
}; };
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema}; use models::{
params::thought::CreateThoughtParams,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
};
use utoipa::OpenApi; use utoipa::OpenApi;
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
paths(thoughts_post, thoughts_delete), paths(thoughts_post, thoughts_delete, get_thought_by_id, get_thought_thread),
components(schemas( components(schemas(
CreateThoughtParams, CreateThoughtParams,
ThoughtSchema, ThoughtSchema,
ThoughtThreadSchema,
ApiErrorResponse, ApiErrorResponse,
ParamsErrorResponse ParamsErrorResponse
)) ))

View File

@@ -2,7 +2,7 @@ use utoipa::OpenApi;
use api::models::{ApiErrorResponse, ParamsErrorResponse}; use api::models::{ApiErrorResponse, ParamsErrorResponse};
use api::routers::user::*; use api::routers::user::*;
use models::params::user::CreateUserParams; use models::params::user::{CreateUserParams, UpdateUserParams};
use models::schemas::{ use models::schemas::{
thought::{ThoughtListSchema, ThoughtSchema}, thought::{ThoughtListSchema, ThoughtSchema},
user::{UserListSchema, UserSchema}, user::{UserListSchema, UserSchema},
@@ -19,11 +19,14 @@ use models::schemas::{
user_inbox_post, user_inbox_post,
user_outbox_get, user_outbox_get,
get_me, get_me,
update_me update_me,
get_user_followers,
get_user_following
), ),
components(schemas( components(schemas(
CreateUserParams, CreateUserParams,
UserListSchema, UserListSchema,
UpdateUserParams,
UserSchema, UserSchema,
ThoughtSchema, ThoughtSchema,
ThoughtListSchema, ThoughtListSchema,

View File

@@ -12,7 +12,4 @@ path = "src/lib.rs"
models = { path = "../models" } models = { path = "../models" }
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] } async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
sea-orm-migration = { version = "1.1.12", features = [ sea-orm-migration = { version = "1.1.12", features = ["sqlx-postgres"] }
"sqlx-sqlite",
"sqlx-postgres",
] }

View File

@@ -4,6 +4,10 @@ mod m20240101_000001_init;
mod m20250905_000001_init; mod m20250905_000001_init;
mod m20250906_100000_add_profile_fields; mod m20250906_100000_add_profile_fields;
mod m20250906_130237_add_tags; mod m20250906_130237_add_tags;
mod m20250906_134056_add_api_keys;
mod m20250906_145148_add_reply_to_thoughts;
mod m20250906_145755_add_visibility_to_thoughts;
mod m20250906_231359_add_full_text_search;
pub struct Migrator; pub struct Migrator;
@@ -15,6 +19,10 @@ impl MigratorTrait for Migrator {
Box::new(m20250905_000001_init::Migration), Box::new(m20250905_000001_init::Migration),
Box::new(m20250906_100000_add_profile_fields::Migration), Box::new(m20250906_100000_add_profile_fields::Migration),
Box::new(m20250906_130237_add_tags::Migration), Box::new(m20250906_130237_add_tags::Migration),
Box::new(m20250906_134056_add_api_keys::Migration),
Box::new(m20250906_145148_add_reply_to_thoughts::Migration),
Box::new(m20250906_145755_add_visibility_to_thoughts::Migration),
Box::new(m20250906_231359_add_full_text_search::Migration),
] ]
} }
} }

View File

@@ -46,12 +46,12 @@ impl MigrationTrait for Migration {
.table(Follow::Table) .table(Follow::Table)
.if_not_exists() .if_not_exists()
.col(uuid(Follow::FollowerId).not_null()) .col(uuid(Follow::FollowerId).not_null())
.col(uuid(Follow::FollowedId).not_null()) .col(uuid(Follow::FollowingId).not_null())
// Composite Primary Key to ensure a user can only follow another once // Composite Primary Key to ensure a user can only follow another once
.primary_key( .primary_key(
Index::create() Index::create()
.col(Follow::FollowerId) .col(Follow::FollowerId)
.col(Follow::FollowedId), .col(Follow::FollowingId),
) )
.foreign_key( .foreign_key(
ForeignKey::create() ForeignKey::create()
@@ -62,8 +62,8 @@ impl MigrationTrait for Migration {
) )
.foreign_key( .foreign_key(
ForeignKey::create() ForeignKey::create()
.name("fk_follow_followed_id") .name("fk_follow_following_id")
.from(Follow::Table, Follow::FollowedId) .from(Follow::Table, Follow::FollowingId)
.to(User::Table, User::Id) .to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
@@ -97,5 +97,5 @@ pub enum Follow {
// The user who is initiating the follow // The user who is initiating the follow
FollowerId, FollowerId,
// The user who is being followed // The user who is being followed
FollowedId, FollowingId,
} }

View File

@@ -0,0 +1,69 @@
use super::m20240101_000001_init::User;
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ApiKey::Table)
.if_not_exists()
.col(
ColumnDef::new(ApiKey::Id)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(uuid(ApiKey::UserId).not_null())
.foreign_key(
ForeignKey::create()
.name("fk_api_key_user_id")
.from(ApiKey::Table, ApiKey::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.col(text(ApiKey::KeyHash).not_null().unique_key())
.col(string(ApiKey::Name).not_null())
.col(
timestamp_with_time_zone(ApiKey::CreatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.col(ColumnDef::new(ApiKey::KeyPrefix).string_len(8).not_null())
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-api_keys-key_prefix")
.table(ApiKey::Table)
.col(ApiKey::KeyPrefix)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ApiKey::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum ApiKey {
Table,
Id,
UserId,
KeyHash,
Name,
CreatedAt,
KeyPrefix,
}

View File

@@ -0,0 +1,46 @@
use sea_orm_migration::{prelude::*, schema::*};
use crate::m20250905_000001_init::Thought;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.add_column(uuid_null(ThoughtExtension::ReplyToId))
.add_foreign_key(
TableForeignKey::new()
.name("fk_thought_reply_to_id")
.from_tbl(Thought::Table)
.from_col(ThoughtExtension::ReplyToId)
.to_tbl(Thought::Table)
.to_col(Thought::Id)
.on_delete(ForeignKeyAction::SetNull),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.drop_foreign_key(Alias::new("fk_thought_reply_to_id"))
.drop_column(ThoughtExtension::ReplyToId)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum ThoughtExtension {
ReplyToId,
}

View File

@@ -0,0 +1,59 @@
use super::m20250905_000001_init::Thought;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared(
"CREATE TYPE thought_visibility AS ENUM ('public', 'friends_only', 'private')",
)
.await?;
// 2. Add the new column to the thoughts table
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.add_column(
ColumnDef::new(ThoughtExtension::Visibility)
.enumeration(
"thought_visibility",
["public", "friends_only", "private"],
)
.not_null()
.default("public"), // Default new thoughts to public
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.alter_table(
Table::alter()
.table(Thought::Table)
.drop_column(ThoughtExtension::Visibility)
.to_owned(),
)
.await?;
// Drop the ENUM type
manager
.get_connection()
.execute_unprepared("DROP TYPE thought_visibility")
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum ThoughtExtension {
Visibility,
}

View File

@@ -0,0 +1,48 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// --- Users Table ---
// Add the tsvector column for users
manager.get_connection().execute_unprepared(
"ALTER TABLE \"user\" ADD COLUMN \"search_document\" tsvector \
GENERATED ALWAYS AS (to_tsvector('english', username || ' ' || coalesce(display_name, ''))) STORED"
).await?;
// Add the GIN index for users
manager.get_connection().execute_unprepared(
"CREATE INDEX \"user_search_document_idx\" ON \"user\" USING GIN(\"search_document\")"
).await?;
// --- Thoughts Table ---
// Add the tsvector column for thoughts
manager
.get_connection()
.execute_unprepared(
"ALTER TABLE \"thought\" ADD COLUMN \"search_document\" tsvector \
GENERATED ALWAYS AS (to_tsvector('english', content)) STORED",
)
.await?;
// Add the GIN index for thoughts
manager.get_connection().execute_unprepared(
"CREATE INDEX \"thought_search_document_idx\" ON \"thought\" USING GIN(\"search_document\")"
).await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.get_connection()
.execute_unprepared("ALTER TABLE \"user\" DROP COLUMN \"search_document\"")
.await?;
manager
.get_connection()
.execute_unprepared("ALTER TABLE \"thought\" DROP COLUMN \"search_document\"")
.await?;
Ok(())
}
}

View File

@@ -13,7 +13,6 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sea-orm = { workspace = true, features = [ sea-orm = { workspace = true, features = [
"sqlx-postgres", "sqlx-postgres",
"sqlx-sqlite",
"runtime-tokio-rustls", "runtime-tokio-rustls",
"macros", "macros",
] } ] }

View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "api_key")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub user_id: Uuid,
pub key_prefix: String,
#[sea_orm(unique)]
pub key_hash: String,
pub name: String,
pub created_at: DateTimeWithTimeZone,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -6,7 +6,7 @@ pub struct Model {
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub follower_id: Uuid, pub follower_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)] #[sea_orm(primary_key, auto_increment = false)]
pub followed_id: Uuid, pub following_id: Uuid,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -21,12 +21,12 @@ pub enum Relation {
Follower, Follower,
#[sea_orm( #[sea_orm(
belongs_to = "super::user::Entity", belongs_to = "super::user::Entity",
from = "Column::FollowedId", from = "Column::FollowingId",
to = "super::user::Column::Id", to = "super::user::Column::Id",
on_update = "NoAction", on_update = "NoAction",
on_delete = "Cascade" on_delete = "Cascade"
)] )]
Followed, Following,
} }
impl Related<super::user::Entity> for Entity { impl Related<super::user::Entity> for Entity {

View File

@@ -2,6 +2,7 @@
pub mod prelude; pub mod prelude;
pub mod api_key;
pub mod follow; pub mod follow;
pub mod tag; pub mod tag;
pub mod thought; pub mod thought;

View File

@@ -1,5 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub use super::api_key::Entity as ApiKey;
pub use super::follow::Entity as Follow; pub use super::follow::Entity as Follow;
pub use super::tag::Entity as Tag; pub use super::tag::Entity as Tag;
pub use super::thought::Entity as Thought; pub use super::thought::Entity as Thought;

View File

@@ -1,4 +1,19 @@
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(
Debug, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, ToSchema,
)]
#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "thought_visibility")]
pub enum Visibility {
#[sea_orm(string_value = "public")]
Public,
#[sea_orm(string_value = "friends_only")]
FriendsOnly,
#[sea_orm(string_value = "private")]
Private,
}
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "thought")] #[sea_orm(table_name = "thought")]
@@ -7,7 +22,11 @@ pub struct Model {
pub id: Uuid, pub id: Uuid,
pub author_id: Uuid, pub author_id: Uuid,
pub content: String, pub content: String,
pub reply_to_id: Option<Uuid>,
pub visibility: Visibility,
pub created_at: DateTimeWithTimeZone, pub created_at: DateTimeWithTimeZone,
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
pub search_document: Option<String>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -19,6 +19,8 @@ pub struct Model {
pub custom_css: Option<String>, pub custom_css: Option<String>,
pub created_at: DateTimeWithTimeZone, pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone, pub updated_at: DateTimeWithTimeZone,
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
pub search_document: Option<String>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -28,6 +30,9 @@ pub enum Relation {
#[sea_orm(has_many = "super::top_friends::Entity")] #[sea_orm(has_many = "super::top_friends::Entity")]
TopFriends, TopFriends,
#[sea_orm(has_many = "super::api_key::Entity")]
ApiKey,
} }
impl ActiveModelBehavior for ActiveModel {} impl ActiveModelBehavior for ActiveModel {}

View File

@@ -6,6 +6,8 @@ use validator::Validate;
pub struct RegisterParams { pub struct RegisterParams {
#[validate(length(min = 3))] #[validate(length(min = 3))]
pub username: String, pub username: String,
#[validate(email)]
pub email: String,
#[validate(length(min = 6))] #[validate(length(min = 6))]
pub password: String, pub password: String,
} }

View File

@@ -1,7 +1,10 @@
use serde::Deserialize; use serde::Deserialize;
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate; use validator::Validate;
use crate::domains::thought::Visibility;
#[derive(Deserialize, Validate, ToSchema)] #[derive(Deserialize, Validate, ToSchema)]
pub struct CreateThoughtParams { pub struct CreateThoughtParams {
#[validate(length( #[validate(length(
@@ -10,4 +13,7 @@ pub struct CreateThoughtParams {
message = "Content must be between 1 and 128 characters" message = "Content must be between 1 and 128 characters"
))] ))]
pub content: String, pub content: String,
pub visibility: Option<Visibility>,
#[serde(rename = "replyToId")]
pub reply_to_id: Option<Uuid>,
} }

View File

@@ -14,21 +14,25 @@ pub struct CreateUserParams {
pub struct UpdateUserParams { pub struct UpdateUserParams {
#[validate(length(max = 50))] #[validate(length(max = 50))]
#[schema(example = "Frutiger Aero Fan")] #[schema(example = "Frutiger Aero Fan")]
#[serde(rename = "displayName")]
pub display_name: Option<String>, pub display_name: Option<String>,
#[validate(length(max = 160))] #[validate(length(max = 4000))]
#[schema(example = "Est. 2004")] #[schema(example = "Est. 2004")]
pub bio: Option<String>, pub bio: Option<String>,
#[validate(url)] #[validate(url)]
#[serde(rename = "avatarUrl")]
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
#[validate(url)] #[validate(url)]
#[serde(rename = "headerUrl")]
pub header_url: Option<String>, pub header_url: Option<String>,
#[serde(rename = "customCss")]
pub custom_css: Option<String>, pub custom_css: Option<String>,
#[validate(length(max = 8))] #[validate(length(max = 8))]
#[schema(example = json!(["username1", "username2"]))] #[schema(example = json!(["username1", "username2"]))]
#[serde(rename = "topFriends")]
pub top_friends: Option<Vec<String>>, pub top_friends: Option<Vec<String>>,
} }

View File

@@ -1 +1,2 @@
pub mod pagination;
pub mod user; pub mod user;

View File

@@ -0,0 +1,27 @@
use serde::Deserialize;
use utoipa::IntoParams;
const DEFAULT_PAGE: u64 = 1;
const DEFAULT_PAGE_SIZE: u64 = 20;
#[derive(Deserialize, IntoParams)]
pub struct PaginationQuery {
#[param(nullable = true, example = 1)]
page: Option<u64>,
#[param(nullable = true, example = 20)]
page_size: Option<u64>,
}
impl PaginationQuery {
pub fn page(&self) -> u64 {
self.page.unwrap_or(DEFAULT_PAGE).max(1)
}
pub fn page_size(&self) -> u64 {
self.page_size.unwrap_or(DEFAULT_PAGE_SIZE).max(1)
}
pub fn offset(&self) -> u64 {
(self.page() - 1) * self.page_size()
}
}

View File

@@ -0,0 +1,64 @@
use crate::domains::api_key;
use common::DateTimeWithTimeZoneWrapper;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Serialize, ToSchema)]
pub struct ApiKeySchema {
pub id: Uuid,
pub name: String,
#[serde(rename = "keyPrefix")]
pub key_prefix: String,
#[serde(rename = "createdAt")]
pub created_at: DateTimeWithTimeZoneWrapper,
}
#[derive(Serialize, ToSchema)]
pub struct ApiKeyResponse {
#[serde(flatten)]
pub key: ApiKeySchema,
#[serde(skip_serializing_if = "Option::is_none", rename = "plaintextKey")]
pub plaintext_key: Option<String>,
}
impl ApiKeyResponse {
pub fn from_parts(model: api_key::Model, plaintext_key: Option<String>) -> Self {
Self {
key: ApiKeySchema {
id: model.id,
name: model.name,
key_prefix: model.key_prefix,
created_at: model.created_at.into(),
},
plaintext_key,
}
}
}
#[derive(Serialize, ToSchema)]
pub struct ApiKeyListSchema {
#[serde(rename = "apiKeys")]
pub api_keys: Vec<ApiKeySchema>,
}
impl From<Vec<api_key::Model>> for ApiKeyListSchema {
fn from(keys: Vec<api_key::Model>) -> Self {
Self {
api_keys: keys
.into_iter()
.map(|k| ApiKeySchema {
id: k.id,
name: k.name,
key_prefix: k.key_prefix,
created_at: k.created_at.into(),
})
.collect(),
}
}
}
#[derive(Deserialize, ToSchema)]
pub struct ApiKeyRequest {
pub name: String,
}

View File

@@ -1,2 +1,5 @@
pub mod api_key;
pub mod pagination;
pub mod search;
pub mod thought; pub mod thought;
pub mod user; pub mod user;

View File

@@ -0,0 +1,12 @@
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
pub total_items: u64,
}

View File

@@ -0,0 +1,9 @@
use super::{thought::ThoughtListSchema, user::UserListSchema};
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct SearchResultsSchema {
pub users: UserListSchema,
pub thoughts: ThoughtListSchema,
}

View File

@@ -1,17 +1,24 @@
use crate::domains::{thought, user}; use crate::domains::{
thought::{self, Visibility},
user,
};
use common::DateTimeWithTimeZoneWrapper; use common::DateTimeWithTimeZoneWrapper;
use sea_orm::FromQueryResult; use sea_orm::FromQueryResult;
use serde::Serialize; use serde::Serialize;
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
#[derive(Serialize, ToSchema, FromQueryResult, Debug)] #[derive(Serialize, ToSchema, FromQueryResult, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtSchema { pub struct ThoughtSchema {
pub id: Uuid, pub id: Uuid,
#[schema(example = "frutiger")] #[schema(example = "frutiger")]
pub author_username: String, pub author_username: String,
pub author_display_name: Option<String>,
#[schema(example = "This is my first thought! #welcome")] #[schema(example = "This is my first thought! #welcome")]
pub content: String, pub content: String,
pub visibility: Visibility,
pub reply_to_id: Option<Uuid>,
pub created_at: DateTimeWithTimeZoneWrapper, pub created_at: DateTimeWithTimeZoneWrapper,
} }
@@ -20,13 +27,17 @@ impl ThoughtSchema {
Self { Self {
id: thought.id, id: thought.id,
author_username: author.username.clone(), author_username: author.username.clone(),
author_display_name: author.display_name.clone(),
content: thought.content.clone(), content: thought.content.clone(),
visibility: thought.visibility.clone(),
reply_to_id: thought.reply_to_id,
created_at: thought.created_at.into(), created_at: thought.created_at.into(),
} }
} }
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtListSchema { pub struct ThoughtListSchema {
pub thoughts: Vec<ThoughtSchema>, pub thoughts: Vec<ThoughtSchema>,
} }
@@ -42,8 +53,11 @@ pub struct ThoughtWithAuthor {
pub id: Uuid, pub id: Uuid,
pub content: String, pub content: String,
pub created_at: sea_orm::prelude::DateTimeWithTimeZone, pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
pub visibility: Visibility,
pub author_id: Uuid, pub author_id: Uuid,
pub author_username: String, pub author_username: String,
pub author_display_name: Option<String>,
pub reply_to_id: Option<Uuid>,
} }
impl From<ThoughtWithAuthor> for ThoughtSchema { impl From<ThoughtWithAuthor> for ThoughtSchema {
@@ -51,8 +65,24 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
Self { Self {
id: model.id, id: model.id,
author_username: model.author_username, author_username: model.author_username,
author_display_name: model.author_display_name,
content: model.content, content: model.content,
created_at: model.created_at.into(), created_at: model.created_at.into(),
reply_to_id: model.reply_to_id,
visibility: model.visibility,
} }
} }
} }
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct ThoughtThreadSchema {
pub id: Uuid,
pub author_username: String,
pub author_display_name: Option<String>,
pub content: String,
pub visibility: Visibility,
pub reply_to_id: Option<Uuid>,
pub created_at: DateTimeWithTimeZoneWrapper,
pub replies: Vec<ThoughtThreadSchema>,
}

View File

@@ -6,6 +6,7 @@ use uuid::Uuid;
use crate::domains::user; use crate::domains::user;
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UserSchema { pub struct UserSchema {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
@@ -14,12 +15,26 @@ pub struct UserSchema {
pub avatar_url: Option<String>, pub avatar_url: Option<String>,
pub header_url: Option<String>, pub header_url: Option<String>,
pub custom_css: Option<String>, pub custom_css: Option<String>,
// In a real implementation, you'd fetch and return this data. pub top_friends: Vec<String>,
// For now, we'll omit it from the schema to keep it simple.
// pub top_friends: Vec<String>,
pub joined_at: DateTimeWithTimeZoneWrapper, pub joined_at: DateTimeWithTimeZoneWrapper,
} }
impl From<(user::Model, Vec<user::Model>)> for UserSchema {
fn from((user, top_friends): (user::Model, Vec<user::Model>)) -> Self {
Self {
id: user.id,
username: user.username,
display_name: user.display_name,
bio: user.bio,
avatar_url: user.avatar_url,
header_url: user.header_url,
custom_css: user.custom_css,
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
joined_at: user.created_at.into(),
}
}
}
impl From<user::Model> for UserSchema { impl From<user::Model> for UserSchema {
fn from(user: user::Model) -> Self { fn from(user: user::Model) -> Self {
Self { Self {
@@ -30,6 +45,7 @@ impl From<user::Model> for UserSchema {
avatar_url: user.avatar_url, avatar_url: user.avatar_url,
header_url: user.header_url, header_url: user.header_url,
custom_css: user.custom_css, custom_css: user.custom_css,
top_friends: vec![],
joined_at: user.created_at.into(), joined_at: user.created_at.into(),
} }
} }
@@ -47,3 +63,18 @@ impl From<Vec<user::Model>> for UserListSchema {
} }
} }
} }
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct MeSchema {
pub id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub header_url: Option<String>,
pub custom_css: Option<String>,
pub top_friends: Vec<String>,
pub joined_at: DateTimeWithTimeZoneWrapper,
pub following: Vec<UserSchema>,
}

View File

@@ -1,5 +1,5 @@
use api::{setup_db, setup_router}; use api::{setup_db, setup_router};
use doc::ApiDoc; use doc::ApiDocExt;
use utils::migrate; use utils::migrate;
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum { pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {

View File

@@ -1,10 +1,7 @@
use api::{setup_config, setup_db, setup_router}; use api::{setup_config, setup_db, setup_router};
use doc::ApiDoc; use utils::migrate;
use utils::{create_dev_db, migrate};
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) { async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
tracing::info!("Worker {} started", child_num);
let conn = setup_db(db_url, prefork).await; let conn = setup_db(db_url, prefork).await;
if child_num == 0 { if child_num == 0 {
@@ -13,7 +10,7 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
let config = setup_config(); let config = setup_config();
let router = setup_router(conn, &config).attach_doc(); let router = setup_router(conn, &config);
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port"); let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
axum::serve(listener, router).await.expect("start server"); axum::serve(listener, router).await.expect("start server");
@@ -22,7 +19,6 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
#[cfg(feature = "prefork")] #[cfg(feature = "prefork")]
fn run_prefork(db_url: &str, listener: std::net::TcpListener) { fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str()); let db_url: &'static str = Box::leak(db_url.to_owned().into_boxed_str());
create_dev_db(db_url);
let num_of_cores = std::thread::available_parallelism().unwrap().get() as u32; let num_of_cores = std::thread::available_parallelism().unwrap().get() as u32;
let is_parent = prefork::Prefork::from_resource(listener) let is_parent = prefork::Prefork::from_resource(listener)
@@ -37,18 +33,17 @@ fn run_prefork(db_url: &str, listener: std::net::TcpListener) {
} }
fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) { fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
create_dev_db(db_url);
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(worker(0, db_url, false, listener)); rt.block_on(worker(0, db_url, false, listener));
} }
pub fn run() { pub fn run() {
tracing::info!("Starting server...");
let config = setup_config(); let config = setup_config();
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port"); let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
listener.set_nonblocking(true).expect("non blocking failed"); listener.set_nonblocking(true).expect("non blocking failed");
println!("listening on http://{}", listener.local_addr().unwrap()); tracing::info!("listening on http://{}", listener.local_addr().unwrap());
#[cfg(feature = "prefork")] #[cfg(feature = "prefork")]
if config.prefork { if config.prefork {

View File

@@ -1,149 +0,0 @@
use crate::api::main::{create_user_with_password, setup};
use axum::http::{header, StatusCode};
use http_body_util::BodyExt;
use serde_json::{json, Value};
use utils::testing::{
make_get_request, make_jwt_request, make_post_request, make_request_with_headers,
};
#[tokio::test]
async fn test_webfinger_discovery() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123").await;
// 1. Valid WebFinger lookup for existing user
let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000";
let response = make_get_request(app.router.clone(), url, None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["subject"], "acct:testuser@localhost:3000");
assert_eq!(
v["links"][0]["href"],
"http://localhost:3000/users/testuser"
);
// 2. WebFinger lookup for a non-existent user
let response = make_get_request(
app.router.clone(),
"/.well-known/webfinger?resource=acct:nobody@localhost:3000",
None,
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_user_actor_endpoint() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123").await;
let response = make_request_with_headers(
app.router.clone(),
"/users/testuser",
"GET",
None,
vec![(
header::ACCEPT,
"application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
)],
).await;
assert_eq!(response.status(), StatusCode::OK);
let content_type = response.headers().get(header::CONTENT_TYPE).unwrap();
assert_eq!(content_type, "application/activity+json");
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["type"], "Person");
assert_eq!(v["preferredUsername"], "testuser");
assert_eq!(v["id"], "http://localhost:3000/users/testuser");
}
#[tokio::test]
async fn test_user_inbox_follow() {
let app = setup().await;
// user1 will be followed
let user1 = create_user_with_password(&app.db, "user1", "password123").await;
// user2 will be the follower
let user2 = create_user_with_password(&app.db, "user2", "password123").await;
// Construct a follow activity from user2, targeting user1
let follow_activity = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": "http://localhost:3000/some-unique-id",
"type": "Follow",
"actor": "http://localhost:3000/users/user2", // The actor is user2
"object": "http://localhost:3000/users/user1"
})
.to_string();
// POST the activity to user1's inbox
let response = make_post_request(
app.router.clone(),
"/users/user1/inbox",
follow_activity,
None,
)
.await;
assert_eq!(response.status(), StatusCode::ACCEPTED);
// Verify that user2 is now following user1 in the database
let followers = app::persistence::follow::get_followed_ids(&app.db, user2.id)
.await
.unwrap();
assert!(
followers.contains(&user1.id),
"User2 should be following user1"
);
let following = app::persistence::follow::get_followed_ids(&app.db, user1.id)
.await
.unwrap();
assert!(
!following.contains(&user2.id),
"User1 should now be followed by user2"
);
assert!(following.is_empty(), "User1 should not be following anyone");
}
#[tokio::test]
async fn test_user_outbox_get() {
let app = setup().await;
create_user_with_password(&app.db, "testuser", "password123").await;
let token = super::main::login_user(app.router.clone(), "testuser", "password123").await;
// Create a thought first
let thought_body = json!({ "content": "This is a federated thought!" }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(thought_body),
&token,
)
.await;
// Now, fetch the outbox
let response = make_request_with_headers(
app.router.clone(),
"/users/testuser/outbox",
"GET",
None,
vec![(header::ACCEPT, "application/activity+json")],
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["type"], "OrderedCollection");
assert_eq!(v["totalItems"], 1);
assert_eq!(v["orderedItems"][0]["type"], "Create");
assert_eq!(
v["orderedItems"][0]["object"]["content"],
"This is a federated thought!"
);
}

View File

@@ -0,0 +1,85 @@
use crate::api::main::{create_user_with_password, login_user, setup};
use axum::http::{header, HeaderName, StatusCode};
use http_body_util::BodyExt;
use serde_json::{json, Value};
use utils::testing::{make_jwt_request, make_request_with_headers};
#[tokio::test]
async fn test_api_key_flow() {
let app = setup().await;
let _ = create_user_with_password(
&app.db,
"apikey_user",
"password123",
"apikey_user@example.com",
)
.await;
let jwt = login_user(app.router.clone(), "apikey_user", "password123").await;
// 1. Create a new API key using JWT auth
let create_body = json!({ "name": "My Test Key" }).to_string();
let response = make_jwt_request(
app.router.clone(),
"/users/me/api-keys",
"POST",
Some(create_body),
&jwt,
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
let plaintext_key = v["plaintextKey"]
.as_str()
.expect("Plaintext key not found")
.to_string();
let key_id = v["id"].as_str().expect("Key ID not found").to_string();
assert!(plaintext_key.starts_with("th_"));
// 2. Use the new API key to post a thought
let thought_body = json!({ "content": "Posting with an API key!" }).to_string();
let key = plaintext_key.clone();
let api_key_header = format!("ApiKey {}", key);
let content_type = "application/json";
let headers: Vec<(HeaderName, &str)> = vec![
(header::AUTHORIZATION, &api_key_header),
(header::CONTENT_TYPE, content_type),
];
let response = make_request_with_headers(
app.router.clone(),
"/thoughts",
"POST",
Some(thought_body),
headers,
)
.await;
assert_eq!(response.status(), StatusCode::CREATED);
// 3. Delete the API key using JWT auth
let response = make_jwt_request(
app.router.clone(),
&format!("/users/me/api-keys/{}", key_id),
"DELETE",
None,
&jwt,
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
// 4. Try to use the deleted key again, expecting failure
let body = json!({ "content": "This should fail" }).to_string();
let headers: Vec<(HeaderName, &str)> = vec![
(header::AUTHORIZATION, &api_key_header),
(header::CONTENT_TYPE, content_type),
];
let response =
make_request_with_headers(app.router.clone(), "/thoughts", "POST", Some(body), headers)
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}

View File

@@ -11,6 +11,7 @@ async fn test_auth_flow() {
let register_body = json!({ let register_body = json!({
"username": "testuser", "username": "testuser",
"email": "testuser@example.com",
"password": "password123" "password": "password123"
}) })
.to_string(); .to_string();
@@ -26,6 +27,7 @@ async fn test_auth_flow() {
"/auth/register", "/auth/register",
json!({ json!({
"username": "testuser", "username": "testuser",
"email": "testuser@example.com",
"password": "password456" "password": "password456"
}) })
.to_string(), .to_string(),
@@ -48,6 +50,7 @@ async fn test_auth_flow() {
let bad_login_body = json!({ let bad_login_body = json!({
"username": "testuser", "username": "testuser",
"email": "testuser@example.com",
"password": "wrongpassword" "password": "wrongpassword"
}) })
.to_string(); .to_string();

View File

@@ -1,15 +1,18 @@
use std::time::Duration;
use super::main::{create_user_with_password, setup}; use super::main::{create_user_with_password, setup};
use axum::http::StatusCode; use axum::http::StatusCode;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde_json::json; use serde_json::{json, Value};
use tokio::time::sleep;
use utils::testing::make_jwt_request; use utils::testing::make_jwt_request;
#[tokio::test] #[tokio::test]
async fn test_feed_and_user_thoughts() { async fn test_feed_and_user_thoughts() {
let app = setup().await; let app = setup().await;
create_user_with_password(&app.db, "user1", "password1").await; create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
create_user_with_password(&app.db, "user2", "password2").await; create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
create_user_with_password(&app.db, "user3", "password3").await; create_user_with_password(&app.db, "user3", "password3", "user3@example.com").await;
// As user1, post a thought // As user1, post a thought
let token = super::main::login_user(app.router.clone(), "user1", "password1").await; let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
@@ -59,9 +62,9 @@ async fn test_feed_and_user_thoughts() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["thoughts"].as_array().unwrap().len(), 1); assert_eq!(v["items"].as_array().unwrap().len(), 1);
assert_eq!(v["thoughts"][0]["author_username"], "user1"); assert_eq!(v["items"][0]["authorUsername"], "user1");
assert_eq!(v["thoughts"][0]["content"], "A thought from user1"); assert_eq!(v["items"][0]["content"], "A thought from user1");
// 3. user1 follows user2 // 3. user1 follows user2
make_jwt_request( make_jwt_request(
@@ -78,9 +81,193 @@ async fn test_feed_and_user_thoughts() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["thoughts"].as_array().unwrap().len(), 2); assert_eq!(v["items"].as_array().unwrap().len(), 2);
assert_eq!(v["thoughts"][0]["author_username"], "user2"); assert_eq!(v["items"][0]["authorUsername"], "user2");
assert_eq!(v["thoughts"][0]["content"], "user2 was here"); assert_eq!(v["items"][0]["content"], "user2 was here");
assert_eq!(v["thoughts"][1]["author_username"], "user1"); assert_eq!(v["items"][1]["authorUsername"], "user1");
assert_eq!(v["thoughts"][1]["content"], "A thought from user1"); assert_eq!(v["items"][1]["content"], "A thought from user1");
}
#[tokio::test]
async fn test_feed_strict_chronological_order() {
let app = setup().await;
create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await;
create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await;
create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await;
let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await;
let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await;
let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await;
make_jwt_request(
app.router.clone(),
"/users/user2/follow",
"POST",
None,
&token1,
)
.await;
make_jwt_request(
app.router.clone(),
"/users/user3/follow",
"POST",
None,
&token1,
)
.await;
let body_t1 = json!({ "content": "Thought 1 from user2" }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(body_t1),
&token2,
)
.await;
sleep(Duration::from_millis(10)).await;
let body_t2 = json!({ "content": "Thought 2 from user3" }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(body_t2),
&token3,
)
.await;
sleep(Duration::from_millis(10)).await;
let body_t3 = json!({ "content": "Thought 3 from user2" }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(body_t3),
&token2,
)
.await;
let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token1).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let thoughts = v["items"].as_array().unwrap();
assert_eq!(
thoughts.len(),
3,
"Feed should contain 3 thoughts from followed users and self"
);
assert_eq!(thoughts[0]["content"], "Thought 3 from user2");
assert_eq!(thoughts[1]["content"], "Thought 2 from user3");
assert_eq!(thoughts[2]["content"], "Thought 1 from user2");
}
#[tokio::test]
async fn test_feed_pagination() {
let app = setup().await;
// 1. Setup users
create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await;
create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await;
create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await;
let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await;
let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await;
let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await;
// 2. user1 follows user2 and user3
make_jwt_request(
app.router.clone(),
"/users/user2/follow",
"POST",
None,
&token1,
)
.await;
make_jwt_request(
app.router.clone(),
"/users/user3/follow",
"POST",
None,
&token1,
)
.await;
// 3. Create 25 thoughts from the followed users to test pagination
// user1's feed also includes their own thoughts.
let mut last_thought_content = String::new();
for i in 0..25 {
let content = format!("Thought number {}", i);
// Alternate who posts to mix up the feed
let token_to_use = match i % 3 {
0 => &token2,
1 => &token3,
_ => &token1,
};
let body = json!({ "content": &content }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(body),
token_to_use,
)
.await;
if i == 24 {
last_thought_content = content;
}
// Small delay to ensure created_at timestamps are distinct
sleep(Duration::from_millis(5)).await;
}
// 4. Request the first page (default size 20)
let response_p1 = make_jwt_request(
app.router.clone(),
"/feed?page=1&page_size=20",
"GET",
None,
&token1,
)
.await;
assert_eq!(response_p1.status(), StatusCode::OK);
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
assert_eq!(
v_p1["items"].as_array().unwrap().len(),
20,
"First page should have 20 items"
);
assert_eq!(v_p1["page"], 1);
assert_eq!(v_p1["pageSize"], 20);
assert_eq!(v_p1["totalPages"], 2);
assert_eq!(v_p1["totalItems"], 25);
// Verify the newest thought is first on the first page
assert_eq!(v_p1["items"][0]["content"], last_thought_content);
// 5. Request the second page
let response_p2 = make_jwt_request(
app.router.clone(),
"/feed?page=2&page_size=20",
"GET",
None,
&token1,
)
.await;
assert_eq!(response_p2.status(), StatusCode::OK);
let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes();
let v_p2: Value = serde_json::from_slice(&body_p2).unwrap();
assert_eq!(
v_p2["items"].as_array().unwrap().len(),
5,
"Second page should have the remaining 5 items"
);
assert_eq!(v_p2["page"], 2);
} }

View File

@@ -1,14 +1,18 @@
use crate::api::main::login_user;
use super::main::{create_user_with_password, setup}; use super::main::{create_user_with_password, setup};
use axum::http::StatusCode; use axum::http::StatusCode;
use utils::testing::make_jwt_request; use http_body_util::BodyExt;
use serde_json::Value;
use utils::testing::{make_get_request, make_jwt_request};
#[tokio::test] #[tokio::test]
async fn test_follow_endpoints() { async fn test_follow_endpoints() {
std::env::set_var("AUTH_SECRET", "test-secret"); std::env::set_var("AUTH_SECRET", "test-secret");
let app = setup().await; let app = setup().await;
create_user_with_password(&app.db, "user1", "password1").await; create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
create_user_with_password(&app.db, "user2", "password2").await; create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
let token = super::main::login_user(app.router.clone(), "user1", "password1").await; let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
@@ -67,3 +71,92 @@ async fn test_follow_endpoints() {
.await; .await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }
#[tokio::test]
async fn test_follow_lists() {
let app = setup().await;
let user_a = create_user_with_password(&app.db, "userA", "password123", "a@a.com").await;
let user_b = create_user_with_password(&app.db, "userB", "password123", "b@b.com").await;
let user_c = create_user_with_password(&app.db, "userC", "password123", "c@c.com").await;
// A follows B, C follows A
app::persistence::follow::follow_user(&app.db, user_a.id, user_b.id)
.await
.unwrap();
app::persistence::follow::follow_user(&app.db, user_c.id, user_a.id)
.await
.unwrap();
// 1. Check user A's lists
let response_following =
make_get_request(app.router.clone(), "/users/userA/following", None).await;
let body_following = response_following
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
let v: Value = serde_json::from_slice(&body_following).unwrap();
assert_eq!(v["users"].as_array().unwrap().len(), 1);
assert_eq!(v["users"][0]["username"], "userB");
let response_followers =
make_get_request(app.router.clone(), "/users/userA/followers", None).await;
let body_followers = response_followers
.into_body()
.collect()
.await
.unwrap()
.to_bytes();
let v: Value = serde_json::from_slice(&body_followers).unwrap();
assert_eq!(v["users"].as_array().unwrap().len(), 1);
assert_eq!(v["users"][0]["username"], "userC");
// 2. Check user A's /me endpoint
let jwt_a = login_user(app.router.clone(), "userA", "password123").await;
let response_me = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &jwt_a).await;
let body_me = response_me.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body_me).unwrap();
assert_eq!(v["username"], "userA");
assert_eq!(v["following"].as_array().unwrap().len(), 1);
assert_eq!(v["following"][0]["username"], "userB");
}
#[tokio::test]
async fn test_get_friends_list() {
let app = setup().await;
let user_a = create_user_with_password(&app.db, "userA", "password123", "a@a.com").await;
let user_b = create_user_with_password(&app.db, "userB", "password123", "b@b.com").await;
let user_c = create_user_with_password(&app.db, "userC", "password123", "c@c.com").await;
// --- Create relationships ---
// A and B are friends (reciprocal follow)
app::persistence::follow::follow_user(&app.db, user_a.id, user_b.id)
.await
.unwrap();
app::persistence::follow::follow_user(&app.db, user_b.id, user_a.id)
.await
.unwrap();
// A follows C, but C does not follow A back
app::persistence::follow::follow_user(&app.db, user_a.id, user_c.id)
.await
.unwrap();
// --- Test as user_a ---
let jwt_a = login_user(app.router.clone(), "userA", "password123").await;
let response = make_jwt_request(app.router.clone(), "/friends", "GET", None, &jwt_a).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
let friends_list = v["users"].as_array().unwrap();
assert_eq!(friends_list.len(), 1, "User A should only have one friend");
assert_eq!(
friends_list[0]["username"], "userB",
"User B should be in User A's friend list"
);
}

View File

@@ -38,10 +38,12 @@ pub async fn create_user_with_password(
db: &DatabaseConnection, db: &DatabaseConnection,
username: &str, username: &str,
password: &str, password: &str,
email: &str,
) -> user::Model { ) -> user::Model {
let params = RegisterParams { let params = RegisterParams {
username: username.to_string(), username: username.to_string(),
password: password.to_string(), password: password.to_string(),
email: email.to_string(),
}; };
app::persistence::auth::register_user(db, params) app::persistence::auth::register_user(db, params)
.await .await

View File

@@ -1,8 +1,9 @@
mod activitypub; mod api_key;
mod auth; mod auth;
mod feed; mod feed;
mod follow; mod follow;
mod main; mod main;
mod search;
mod tag; mod tag;
mod thought; mod thought;
mod user; mod user;

View File

@@ -0,0 +1,198 @@
use crate::api::main::{create_user_with_password, login_user, setup};
use axum::http::StatusCode;
use http_body_util::BodyExt;
use serde_json::{json, Value};
use utils::testing::{make_get_request, make_jwt_request};
#[tokio::test]
async fn test_search_all() {
let app = setup().await;
// 1. Setup users and data
let user1 =
create_user_with_password(&app.db, "search_user1", "password123", "s1@test.com").await;
let user2 =
create_user_with_password(&app.db, "search_user2", "password123", "s2@test.com").await;
let _user3 =
create_user_with_password(&app.db, "stranger_user", "password123", "s3@test.com").await;
// Make user1 and user2 friends
app::persistence::follow::follow_user(&app.db, user1.id, user2.id)
.await
.unwrap();
app::persistence::follow::follow_user(&app.db, user2.id, user1.id)
.await
.unwrap();
let token1 = login_user(app.router.clone(), "search_user1", "password123").await;
let token2 = login_user(app.router.clone(), "search_user2", "password123").await;
let token3 = login_user(app.router.clone(), "stranger_user", "password123").await;
// User1 posts thoughts with different visibilities
let thought_public =
json!({ "content": "A very public thought about Rust.", "visibility": "Public" })
.to_string();
let thought_friends =
json!({ "content": "A friendly thought, just for pals.", "visibility": "FriendsOnly" })
.to_string();
let thought_private =
json!({ "content": "A private thought, for my eyes only.", "visibility": "Private" })
.to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(thought_public),
&token1,
)
.await;
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(thought_friends),
&token1,
)
.await;
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(thought_private),
&token1,
)
.await;
// 2. Run search tests
// -- User Search --
let response = make_get_request(app.router.clone(), "/search?q=search_user1", None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["users"]["users"].as_array().unwrap().len(), 1);
assert_eq!(v["users"]["users"][0]["username"], "search_user1");
// -- Thought Search (Public) --
let response = make_get_request(app.router.clone(), "/search?q=public", None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["thoughts"]["thoughts"].as_array().unwrap().len(),
1,
"Guest should find public thought"
);
assert!(v["thoughts"]["thoughts"][0]["content"]
.as_str()
.unwrap()
.contains("public"));
// -- Thought Search (FriendsOnly) --
let response = make_jwt_request(
app.router.clone(),
"/search?q=friendly",
"GET",
None,
&token1,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["thoughts"]["thoughts"].as_array().unwrap().len(),
1,
"Author should find friends thought"
);
let response = make_jwt_request(
app.router.clone(),
"/search?q=friendly",
"GET",
None,
&token2,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["thoughts"]["thoughts"].as_array().unwrap().len(),
1,
"Friend should find friends thought"
);
let response = make_jwt_request(
app.router.clone(),
"/search?q=friendly",
"GET",
None,
&token3,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["thoughts"]["thoughts"].as_array().unwrap().len(),
0,
"Stranger should NOT find friends thought"
);
let response = make_get_request(app.router.clone(), "/search?q=friendly", None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["thoughts"]["thoughts"].as_array().unwrap().len(),
0,
"Guest should NOT find friends thought"
);
// -- Thought Search (Private) --
let response = make_jwt_request(
app.router.clone(),
"/search?q=private",
"GET",
None,
&token1,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["thoughts"]["thoughts"].as_array().unwrap().len(),
1,
"Author should find private thought"
);
let response = make_jwt_request(
app.router.clone(),
"/search?q=private",
"GET",
None,
&token2,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["thoughts"]["thoughts"].as_array().unwrap().len(),
0,
"Friend should NOT find private thought"
);
let response = make_get_request(app.router.clone(), "/search?q=private", None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
v["thoughts"]["thoughts"].as_array().unwrap().len(),
0,
"Guest should NOT find private thought"
);
}

View File

@@ -1,4 +1,4 @@
use crate::api::main::{create_user_with_password, login_user, setup}; use crate::api::main::{create_user_with_password, login_user, setup, TestApp};
use axum::http::StatusCode; use axum::http::StatusCode;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -7,7 +7,8 @@ use utils::testing::{make_get_request, make_jwt_request};
#[tokio::test] #[tokio::test]
async fn test_hashtag_flow() { async fn test_hashtag_flow() {
let app = setup().await; let app = setup().await;
let user = create_user_with_password(&app.db, "taguser", "password123").await; let user =
create_user_with_password(&app.db, "taguser", "password123", "taguser@example.com").await;
let token = login_user(app.router.clone(), "taguser", "password123").await; let token = login_user(app.router.clone(), "taguser", "password123").await;
// 1. Post a thought with hashtags // 1. Post a thought with hashtags
@@ -25,7 +26,6 @@ async fn test_hashtag_flow() {
// 3. Fetch thoughts by tag "rustlang" // 3. Fetch thoughts by tag "rustlang"
let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await; let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await;
println!("Response: {:?}", response);
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body_bytes = response.into_body().collect().await.unwrap().to_bytes(); let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body_bytes).unwrap(); let v: Value = serde_json::from_slice(&body_bytes).unwrap();
@@ -49,3 +49,43 @@ async fn test_hashtag_flow() {
assert_eq!(thoughts.len(), 1); assert_eq!(thoughts.len(), 1);
assert_eq!(thoughts[0]["id"], thought_id); assert_eq!(thoughts[0]["id"], thought_id);
} }
#[tokio::test]
async fn test_popular_tags() {
let app = setup().await;
let _ = create_user_with_password(&app.db, "poptag_user", "password123", "poptag@example.com")
.await;
let token = login_user(app.router.clone(), "poptag_user", "password123").await;
// Helper async function to post a thought
async fn post_thought(app: &TestApp, token: &str, content: &str) {
let body = json!({ "content": content }).to_string();
let response =
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), token).await;
assert_eq!(response.status(), StatusCode::CREATED);
}
// 1. Post thoughts to create tag usage data
// Expected counts: rust (3), web (2), axum (2), testing (1)
post_thought(&app, &token, "My first post about #rust and the #web").await;
post_thought(&app, &token, "Another post about #rust and #axum").await;
post_thought(&app, &token, "I'm really enjoying #rust lately").await;
post_thought(&app, &token, "Let's talk about #axum and the #web").await;
post_thought(&app, &token, "Don't forget about #testing").await;
// 2. Fetch the popular tags
let response = make_get_request(app.router.clone(), "/tags/popular", None).await;
println!("Response: {:?}", response);
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Vec<String> = serde_json::from_slice(&body).unwrap();
// 3. Assert the results
assert_eq!(v.len(), 4, "Should return the 4 unique tags used");
assert_eq!(
v,
vec!["rust", "axum", "web", "testing"],
"Tags should be ordered by popularity, then alphabetically"
);
}

View File

@@ -1,17 +1,20 @@
use crate::api::main::create_user_with_password; use crate::api::main::{create_user_with_password, login_user};
use super::main::setup; use super::main::setup;
use axum::http::StatusCode; use app::persistence::follow;
use axum::{http::StatusCode, Router};
use http_body_util::BodyExt; use http_body_util::BodyExt;
use sea_orm::prelude::Uuid; use sea_orm::prelude::Uuid;
use serde_json::json; use serde_json::{json, Value};
use utils::testing::{make_delete_request, make_post_request}; use utils::testing::{make_delete_request, make_get_request, make_jwt_request, make_post_request};
#[tokio::test] #[tokio::test]
async fn test_thought_endpoints() { async fn test_thought_endpoints() {
let app = setup().await; let app = setup().await;
let user1 = create_user_with_password(&app.db, "user1", "password123").await; // AuthUser is ID 1 let user1 =
let _user2 = create_user_with_password(&app.db, "user2", "password123").await; // Other user is ID 2 create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await; // AuthUser is ID 1
let _user2 =
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await; // Other user is ID 2
// 1. Post a new thought as user 1 // 1. Post a new thought as user 1
let body = json!({ "content": "My first thought!" }).to_string(); let body = json!({ "content": "My first thought!" }).to_string();
@@ -20,7 +23,7 @@ async fn test_thought_endpoints() {
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["content"], "My first thought!"); assert_eq!(v["content"], "My first thought!");
assert_eq!(v["author_username"], "user1"); assert_eq!(v["authorUsername"], "user1");
let thought_id = v["id"].as_str().unwrap().to_string(); let thought_id = v["id"].as_str().unwrap().to_string();
// 2. Post a thought with invalid content // 2. Post a thought with invalid content
@@ -46,3 +49,273 @@ async fn test_thought_endpoints() {
.await; .await;
assert_eq!(response.status(), StatusCode::NO_CONTENT); assert_eq!(response.status(), StatusCode::NO_CONTENT);
} }
#[tokio::test]
async fn test_thought_replies() {
let app = setup().await;
let user1 =
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
let user2 =
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
// 1. User 1 posts an original thought
let body = json!({ "content": "This is the original post!" }).to_string();
let response = make_post_request(app.router.clone(), "/thoughts", body, Some(user1.id)).await;
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let original_thought: Value = serde_json::from_slice(&body).unwrap();
let original_thought_id = original_thought["id"].as_str().unwrap();
// 2. User 2 replies to the original thought
let reply_body = json!({
"content": "This is a reply.",
"replyToId": original_thought_id
})
.to_string();
let response =
make_post_request(app.router.clone(), "/thoughts", reply_body, Some(user2.id)).await;
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let reply_thought: Value = serde_json::from_slice(&body).unwrap();
// 3. Verify the reply is linked correctly
assert_eq!(reply_thought["replyToId"], original_thought_id);
assert_eq!(reply_thought["authorUsername"], "user2");
}
#[tokio::test]
async fn test_thought_visibility() {
let app = setup().await;
let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await;
let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await;
let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await;
// Make author and friend follow each other
follow::follow_user(&app.db, author.id, friend.id)
.await
.unwrap();
follow::follow_user(&app.db, friend.id, author.id)
.await
.unwrap();
let author_jwt = login_user(app.router.clone(), "author", "password123").await;
let friend_jwt = login_user(app.router.clone(), "friend", "password123").await;
let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await;
// Author posts one of each visibility
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(json!({"content": "public", "visibility": "Public"}).to_string()),
&author_jwt,
)
.await;
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(json!({"content": "friends", "visibility": "FriendsOnly"}).to_string()),
&author_jwt,
)
.await;
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(json!({"content": "private", "visibility": "Private"}).to_string()),
&author_jwt,
)
.await;
// Helper to get thoughts and count them
async fn get_thought_count(router: Router, jwt: Option<&str>) -> usize {
let response = if let Some(token) = jwt {
make_jwt_request(router, "/users/author/thoughts", "GET", None, token).await
} else {
make_get_request(router, "/users/author/thoughts", None).await
};
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
v["thoughts"].as_array().unwrap().len()
}
// Assertions
assert_eq!(
get_thought_count(app.router.clone(), Some(&author_jwt)).await,
3,
"Author should see all their posts"
);
assert_eq!(
get_thought_count(app.router.clone(), Some(&friend_jwt)).await,
2,
"Friend should see public and friends_only posts"
);
assert_eq!(
get_thought_count(app.router.clone(), Some(&stranger_jwt)).await,
1,
"Stranger should see only public posts"
);
assert_eq!(
get_thought_count(app.router.clone(), None).await,
1,
"Unauthenticated guest should see only public posts"
);
}
async fn post_thought_and_get_id(
router: &Router,
content: &str,
visibility: &str,
token: &str,
) -> String {
let body = json!({ "content": content, "visibility": visibility }).to_string();
let response = make_jwt_request(router.clone(), "/thoughts", "POST", Some(body), token).await;
assert_eq!(response.status(), StatusCode::CREATED);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
v["id"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn test_get_thought_by_id_visibility() {
let app = setup().await;
let author = create_user_with_password(&app.db, "author", "password123", "a@a.com").await;
let friend = create_user_with_password(&app.db, "friend", "password123", "f@f.com").await;
let _stranger = create_user_with_password(&app.db, "stranger", "password123", "s@s.com").await;
// Make author and friend follow each other
follow::follow_user(&app.db, author.id, friend.id)
.await
.unwrap();
follow::follow_user(&app.db, friend.id, author.id)
.await
.unwrap();
let author_jwt = login_user(app.router.clone(), "author", "password123").await;
let friend_jwt = login_user(app.router.clone(), "friend", "password123").await;
let stranger_jwt = login_user(app.router.clone(), "stranger", "password123").await;
// Author posts one of each visibility
let public_id = post_thought_and_get_id(&app.router, "public", "Public", &author_jwt).await;
let friends_id =
post_thought_and_get_id(&app.router, "friends", "FriendsOnly", &author_jwt).await;
let private_id = post_thought_and_get_id(&app.router, "private", "Private", &author_jwt).await;
// --- Test Assertions ---
// 1. Public thought
let public_url = format!("/thoughts/{}", public_id);
assert_eq!(
make_get_request(app.router.clone(), &public_url, None)
.await
.status(),
StatusCode::OK,
"Guest should see public thought"
);
// 2. Friends-only thought
let friends_url = format!("/thoughts/{}", friends_id);
assert_eq!(
make_jwt_request(app.router.clone(), &friends_url, "GET", None, &friend_jwt)
.await
.status(),
StatusCode::OK,
"Friend should see friends-only thought"
);
assert_eq!(
make_jwt_request(app.router.clone(), &friends_url, "GET", None, &stranger_jwt)
.await
.status(),
StatusCode::NOT_FOUND,
"Stranger should NOT see friends-only thought"
);
// 3. Private thought
let private_url = format!("/thoughts/{}", private_id);
assert_eq!(
make_jwt_request(app.router.clone(), &private_url, "GET", None, &author_jwt)
.await
.status(),
StatusCode::OK,
"Author should see their private thought"
);
assert_eq!(
make_jwt_request(app.router.clone(), &private_url, "GET", None, &friend_jwt)
.await
.status(),
StatusCode::NOT_FOUND,
"Friend should NOT see private thought"
);
}
#[tokio::test]
async fn test_get_thought_thread() {
let app = setup().await;
let _user1 =
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
let _user2 =
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
let user3 =
create_user_with_password(&app.db, "user3", "password123", "user3@example.com").await;
let token1 = login_user(app.router.clone(), "user1", "password123").await;
let token2 = login_user(app.router.clone(), "user2", "password123").await;
// 1. user1 posts a root thought
let root_id = post_thought_and_get_id(&app.router, "Root thought", "Public", &token1).await;
// 2. user2 replies to the root thought
let reply1_body = json!({ "content": "First reply", "replyToId": root_id }).to_string();
let response = make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(reply1_body),
&token2,
)
.await;
let body = response.into_body().collect().await.unwrap().to_bytes();
let reply1: Value = serde_json::from_slice(&body).unwrap();
let reply1_id = reply1["id"].as_str().unwrap().to_string();
// 3. user1 replies to user2's reply
let reply2_body =
json!({ "content": "Reply to the reply", "replyToId": reply1_id }).to_string();
make_jwt_request(
app.router.clone(),
"/thoughts",
"POST",
Some(reply2_body),
&token1,
)
.await;
// 4. Fetch the entire thread
let response = make_get_request(
app.router.clone(),
&format!("/thoughts/{}/thread", root_id),
Some(user3.id), // Fetch as a third user to test visibility
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let thread: Value = serde_json::from_slice(&body).unwrap();
// 5. Assert the structure
assert_eq!(thread["content"], "Root thought");
assert_eq!(thread["authorUsername"], "user1");
assert_eq!(thread["replies"].as_array().unwrap().len(), 1);
let reply_level_1 = &thread["replies"][0];
assert_eq!(reply_level_1["content"], "First reply");
assert_eq!(reply_level_1["authorUsername"], "user2");
assert_eq!(reply_level_1["replies"].as_array().unwrap().len(), 1);
let reply_level_2 = &reply_level_1["replies"][0];
assert_eq!(reply_level_2["content"], "Reply to the reply");
assert_eq!(reply_level_2["authorUsername"], "user1");
assert!(reply_level_2["replies"].as_array().unwrap().is_empty());
}

View File

@@ -12,7 +12,8 @@ use crate::api::main::{create_user_with_password, login_user, setup};
async fn test_post_users() { async fn test_post_users() {
let app = setup().await; let app = setup().await;
let body = r#"{"username": "test", "password": "password123"}"#.to_owned(); let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
.to_owned();
let response = make_post_request(app.router, "/auth/register", body, None).await; let response = make_post_request(app.router, "/auth/register", body, None).await;
assert_eq!(response.status(), StatusCode::CREATED); assert_eq!(response.status(), StatusCode::CREATED);
@@ -21,14 +22,15 @@ async fn test_post_users() {
let v: Value = serde_json::from_slice(&body).unwrap(); let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["username"], "test"); assert_eq!(v["username"], "test");
assert!(v["display_name"].is_null()); assert!(v["displayName"].is_string());
} }
#[tokio::test] #[tokio::test]
pub(super) async fn test_post_users_error() { pub(super) async fn test_post_users_error() {
let app = setup().await; let app = setup().await;
let body = r#"{"username": "1", "password": "password123"}"#.to_owned(); let body =
r#"{"username": "1", "email": "test@example.com", "password": "password123"}"#.to_owned();
let response = make_post_request(app.router, "/auth/register", body, None).await; let response = make_post_request(app.router, "/auth/register", body, None).await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
@@ -43,7 +45,8 @@ pub(super) async fn test_post_users_error() {
pub async fn test_get_users() { pub async fn test_get_users() {
let app = setup().await; let app = setup().await;
let body = r#"{"username": "test", "password": "password123"}"#.to_owned(); let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
.to_owned();
make_post_request(app.router.clone(), "/auth/register", body, None).await; make_post_request(app.router.clone(), "/auth/register", body, None).await;
let response = make_get_request(app.router, "/users", None).await; let response = make_get_request(app.router, "/users", None).await;
@@ -65,6 +68,7 @@ async fn test_me_endpoints() {
// 1. Register a new user // 1. Register a new user
let register_body = json!({ let register_body = json!({
"username": "me_user", "username": "me_user",
"email": "me_user@example.com",
"password": "password123" "password": "password123"
}) })
.to_string(); .to_string();
@@ -82,13 +86,13 @@ async fn test_me_endpoints() {
let v: Value = serde_json::from_slice(&body).unwrap(); let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["username"], "me_user"); assert_eq!(v["username"], "me_user");
assert!(v["bio"].is_null()); assert!(v["bio"].is_null());
assert!(v["display_name"].is_null()); assert!(v["displayName"].is_string());
// 4. PUT /users/me to update the profile // 4. PUT /users/me to update the profile
let update_body = json!({ let update_body = json!({
"display_name": "Me User", "displayName": "Me User",
"bio": "This is my updated bio.", "bio": "This is my updated bio.",
"avatar_url": "https://example.com/avatar.png" "avatarUrl": "https://example.com/avatar.png"
}) })
.to_string(); .to_string();
let response = make_jwt_request( let response = make_jwt_request(
@@ -102,7 +106,7 @@ async fn test_me_endpoints() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v_updated: Value = serde_json::from_slice(&body).unwrap(); let v_updated: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v_updated["display_name"], "Me User"); assert_eq!(v_updated["displayName"], "Me User");
assert_eq!(v_updated["bio"], "This is my updated bio."); assert_eq!(v_updated["bio"], "This is my updated bio.");
// 5. GET /users/me again to verify the update was persisted // 5. GET /users/me again to verify the update was persisted
@@ -110,7 +114,7 @@ async fn test_me_endpoints() {
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes(); let body = response.into_body().collect().await.unwrap().to_bytes();
let v_verify: Value = serde_json::from_slice(&body).unwrap(); let v_verify: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v_verify["display_name"], "Me User"); assert_eq!(v_verify["displayName"], "Me User");
assert_eq!(v_verify["bio"], "This is my updated bio."); assert_eq!(v_verify["bio"], "This is my updated bio.");
} }
@@ -119,17 +123,21 @@ async fn test_update_me_top_friends() {
let app = setup().await; let app = setup().await;
// 1. Create users for the test // 1. Create users for the test
let user_me = create_user_with_password(&app.db, "me_user", "password123").await; let user_me =
let friend1 = create_user_with_password(&app.db, "friend1", "password123").await; create_user_with_password(&app.db, "me_user", "password123", "me_user@example.com").await;
let friend2 = create_user_with_password(&app.db, "friend2", "password123").await; let friend1 =
let _friend3 = create_user_with_password(&app.db, "friend3", "password123").await; create_user_with_password(&app.db, "friend1", "password123", "friend1@example.com").await;
let friend2 =
create_user_with_password(&app.db, "friend2", "password123", "friend2@example.com").await;
let _friend3 =
create_user_with_password(&app.db, "friend3", "password123", "friend3@example.com").await;
// 2. Log in as "me_user" // 2. Log in as "me_user"
let token = login_user(app.router.clone(), "me_user", "password123").await; let token = login_user(app.router.clone(), "me_user", "password123").await;
// 3. Update profile to set top friends // 3. Update profile to set top friends
let update_body = json!({ let update_body = json!({
"top_friends": ["friend1", "friend2"] "topFriends": ["friend1", "friend2"]
}) })
.to_string(); .to_string();
@@ -158,7 +166,7 @@ async fn test_update_me_top_friends() {
// 5. Update again with a different list to test replacement // 5. Update again with a different list to test replacement
let update_body_2 = json!({ let update_body_2 = json!({
"top_friends": ["friend2"] "topFriends": ["friend2"]
}) })
.to_string(); .to_string();
@@ -183,3 +191,122 @@ async fn test_update_me_top_friends() {
assert_eq!(top_friends_list_2[0].friend_id, friend2.id); assert_eq!(top_friends_list_2[0].friend_id, friend2.id);
assert_eq!(top_friends_list_2[0].position, 1); assert_eq!(top_friends_list_2[0].position, 1);
} }
#[tokio::test]
async fn test_update_me_css_and_images() {
let app = setup().await;
// 1. Create and log in as a user
let _ =
create_user_with_password(&app.db, "css_user", "password123", "css_user@example.com").await;
let token = login_user(app.router.clone(), "css_user", "password123").await;
// 2. Attempt to update with an invalid avatar URL
let invalid_body = json!({
"avatarUrl": "not-a-valid-url"
})
.to_string();
let response = make_jwt_request(
app.router.clone(),
"/users/me",
"PUT",
Some(invalid_body),
&token,
)
.await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
// 3. Update profile with valid URLs and custom CSS
let valid_body = json!({
"avatarUrl": "https://example.com/new-avatar.png",
"headerUrl": "https://example.com/new-header.jpg",
"customCss": "body { color: blue; }"
})
.to_string();
let response = make_jwt_request(
app.router.clone(),
"/users/me",
"PUT",
Some(valid_body),
&token,
)
.await;
assert_eq!(response.status(), StatusCode::OK);
// 4. Verify the changes were persisted by fetching the profile again
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["avatarUrl"], "https://example.com/new-avatar.png");
assert_eq!(v["headerUrl"], "https://example.com/new-header.jpg");
assert_eq!(v["customCss"], "body { color: blue; }");
}
#[tokio::test]
async fn test_get_all_users_paginated() {
let app = setup().await;
for i in 0..25 {
create_user_with_password(
&app.db,
&format!("user{}", i),
"password123",
&format!("u{}@e.com", i),
)
.await;
}
let response_p1 = make_get_request(app.router.clone(), "/users/all", None).await;
assert_eq!(response_p1.status(), StatusCode::OK);
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
assert_eq!(
v_p1["items"].as_array().unwrap().len(),
20,
"First page should have 20 items"
);
assert_eq!(v_p1["page"], 1);
assert_eq!(v_p1["pageSize"], 20);
assert_eq!(v_p1["totalPages"], 2);
assert_eq!(v_p1["totalItems"], 25);
let response_p2 = make_get_request(app.router.clone(), "/users/all?page=2", None).await;
assert_eq!(response_p2.status(), StatusCode::OK);
let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes();
let v_p2: Value = serde_json::from_slice(&body_p2).unwrap();
assert_eq!(
v_p2["items"].as_array().unwrap().len(),
5,
"Second page should have 5 items"
);
assert_eq!(v_p2["page"], 2);
assert_eq!(v_p2["totalPages"], 2);
}
#[tokio::test]
async fn test_get_all_users_count() {
let app = setup().await;
for i in 0..25 {
create_user_with_password(
&app.db,
&format!("user{}", i),
"password123",
&format!("u{}@e.com", i),
)
.await;
}
let response = make_get_request(app.router.clone(), "/users/count", None).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["count"], 25);
}

View File

@@ -1,22 +0,0 @@
use std::process::Command;
fn touch(file_name: &str) {
if cfg!(target_os = "windows") {
Command::new("cmd")
.args(["/C", &format!("type nul >> {}", file_name)])
.output()
.expect("failed to execute touch");
} else {
Command::new("touch")
.arg(file_name)
.output()
.expect("failed to execute touch");
}
}
pub fn create_dev_db(db_url: &str) {
let prefix = "sqlite://";
if let Some(file_name) = db_url.strip_prefix(prefix) {
touch(file_name);
}
}

View File

@@ -1,6 +1,4 @@
mod db; mod db;
mod file;
pub mod testing; pub mod testing;
pub use db::migrate; pub use db::migrate;
pub use file::create_dev_db;

View File

@@ -1,30 +1,29 @@
FROM oven/bun:1 AS base FROM node:22-slim AS builder
WORKDIR /app WORKDIR /app
FROM base AS install ARG NEXT_PUBLIC_API_URL
RUN mkdir -p /temp/dev ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
COPY package.json bun.lock /temp/dev/
RUN cd /temp/dev && bun install --frozen-lockfile
RUN mkdir -p /temp/prod # Install dependencies with Bun for speed
COPY package.json bun.lock /temp/prod/ COPY --chown=node:node package.json bun.lock ./
RUN cd /temp/prod && bun install --frozen-lockfile --production RUN npm install -g bun
RUN bun install --frozen-lockfile
FROM base AS prerelease # Copy the rest of the app and build with Node's Next.js runtime
COPY --from=install /temp/dev/node_modules node_modules COPY --chown=node:node . .
COPY . .
ENV NODE_ENV=production ENV NODE_ENV=production
RUN bun run build RUN bun run build
FROM base AS release FROM node:22-slim AS release
COPY --from=prerelease /app/public ./public RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY --from=prerelease /app/.next/standalone ./
COPY --from=prerelease /app/.next/static ./.next/static COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER bun
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
CMD ["bun", "run", "server.js"] CMD ["node", "server.js"]

View File

@@ -0,0 +1,12 @@
// app/(auth)/layout.tsx
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-center min-h-screen">
{children}
</div>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { LoginSchema, loginUser } from "@/lib/api";
import { useAuth } from "@/hooks/use-auth";
import { useState } from "react";
export default function LoginPage() {
const router = useRouter();
const { setToken } = useAuth();
const [error, setError] = useState<string | null>(null);
const form = useForm<z.infer<typeof LoginSchema>>({
resolver: zodResolver(LoginSchema),
defaultValues: { username: "", password: "" },
});
async function onSubmit(values: z.infer<typeof LoginSchema>) {
try {
setError(null);
const { token } = await loginUser(values);
setToken(token);
router.push("/"); // Redirect to homepage on successful login
} catch {
setError("Invalid username or password.");
}
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>
Enter your credentials to access your account.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* ... Form fields for username and password ... */}
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="frutiger" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<p className="text-sm font-medium text-destructive">{error}</p>
)}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "Logging in..." : "Login"}
</Button>
</form>
</Form>
<p className="mt-4 text-center text-sm text-gray-600">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-blue-600 hover:underline"
>
Register
</Link>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RegisterSchema, registerUser } from "@/lib/api";
import { useState } from "react";
export default function RegisterPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const form = useForm<z.infer<typeof RegisterSchema>>({
resolver: zodResolver(RegisterSchema),
defaultValues: { username: "", email: "", password: "" },
});
async function onSubmit(values: z.infer<typeof RegisterSchema>) {
try {
setError(null);
await registerUser(values);
// You can automatically log the user in here or just redirect them
router.push("/login");
} catch {
setError("Username or email may already be taken.");
}
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Create an Account</CardTitle>
<CardDescription>Enter your details to register.</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* ... Form fields for username, email, and password ... */}
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="frutiger" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="aero@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<p className="text-sm font-medium text-destructive">{error}</p>
)}
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? "Creating account..." : "Register"}
</Button>
</form>
</Form>
<p className="mt-4 text-center text-sm text-gray-600">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-blue-600 hover:underline"
>
Login
</Link>
</p>
</CardContent>
</Card>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -1,26 +1,314 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
:root { @custom-variant dark (&:is(.dark *));
--background: #ffffff;
--foreground: #171717;
}
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--background-start: var(--color-sky-blue);
--background-end: var(--color-lime-300);
/* Frutiger Aero Gradients */
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%;
--gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
--gradient-fa-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%,
hsl(var(--card)) 100%;
--gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%,
rgba(255, 255, 255, 0) 100%;
--shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
--shadow-fa-lg: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05);
--fa-inner: inset 0 1px 2px rgba(0, 0, 0, 0.1);
--text-shadow-default: 0 1px 1px rgba(0, 0, 0, 0.2);
--text-shadow-sm: 0 1px 0px rgba(255, 255, 255, 0.4);
--text-shadow-md: 0 2px 2px rgba(0, 0, 0, 0.2);
--text-shadow-lg: 0 4px 4px rgba(0, 0, 0, 0.2);
--font-display: var(--font-frutiger), "Arial", "Helvetica", "sans-serif";
} }
@media (prefers-color-scheme: dark) { :root {
:root { --destructive: oklch(0.577 0.245 27.325);
--background: #0a0a0a; --chart-1: oklch(0.646 0.222 41.116);
--foreground: #ededed; --chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: hsl(0 0% 98%); /* Light off-white */
--foreground: hsl(222.2 47.4% 11.2%);
--muted: hsl(210 20% 96.1%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(222.2 47.4% 11.2%);
--card: hsl(0 0% 100%); /* Pure white for a crisp look */
--card-foreground: hsl(222.2 47.4% 11.2%);
--border: hsl(214.3 31.8% 91.4%);
--input: hsl(214.3 31.8% 91.4%);
--ring: hsl(222.2 47.4% 11.2%);
--primary: hsl(217 91% 60%); /* Vibrant Blue */
--primary-foreground: hsl(210 40% 98%);
--secondary: hsl(155 70% 55%); /* Vibrant Green */
--secondary-foreground: hsl(210 40% 98%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(210 40% 98%);
--accent: hsl(210 20% 96.1%);
--accent-foreground: hsl(222.2 47.4% 11.2%);
--radius: 0.75rem; /* Larger border radius */
}
.dark {
--destructive: oklch(0.704 0.191 22.216);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: hsl(222.2 47.4% 11.2%);
--foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.4% 14.8%);
--muted-foreground: hsl(215 20.2% 65.1%);
--popover: hsl(222.2 47.4% 11.2%);
--popover-foreground: hsl(210 40% 98%);
--card: hsl(217.2 32.4% 14.8%);
--card-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.4% 14.8%);
--input: hsl(217.2 32.4% 14.8%);
--ring: hsl(212.7 26.8% 83.9%);
--primary: hsl(217 91% 60%); /* Vibrant Blue (same as light) */
--primary-foreground: hsl(210 40% 98%);
--secondary: hsl(155 70% 55%); /* Vibrant Green (same as light) */
--secondary-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--accent: hsl(217.2 32.4% 14.8%);
--accent-foreground: hsl(210 40% 98%);
/* Frutiger Aero Gradients for dark mode (slightly adjusted) */
--color-fa-gradient-blue: linear-gradient(
135deg,
hsl(217 91% 45%) 0%,
hsl(200 90% 55%) 100%
);
--color-fa-gradient-green: linear-gradient(
135deg,
hsl(155 70% 40%) 0%,
hsl(170 80% 50%) 100%
);
--color-fa-gradient-card: linear-gradient(
180deg,
hsl(var(--card)) 0%,
hsl(var(--card)) 90%,
hsl(var(--card)) 100%
);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
background-image: url("/background.avif");
background-size: cover;
background-position: center;
background-attachment: fixed;
background-repeat: no-repeat;
}
.glossy-effect::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
border-radius: var(--radius); /* Inherit parent's border radius */
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0.1) 100%
);
opacity: 0.8;
pointer-events: none; /* Allow clicks to pass through */
z-index: 1; /* Ensure it's above the background but below content */
}
.glossy-effect.bottom::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30%;
border-radius: var(--radius);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0) 100%
);
pointer-events: none;
z-index: 1;
}
.fa-gradient-blue {
background: linear-gradient(var(--gradient-fa-blue));
}
.fa-gradient-green {
background: linear-gradient(var(--gradient-fa-green));
}
.fa-gradient-card {
background: linear-gradient(var(--gradient-fa-card));
}
.fa-gloss {
position: relative;
}
.fa-gloss::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 50%;
border-radius: var(--radius);
background: linear-gradient(var(--gradient-fa-gloss));
opacity: 0.8;
pointer-events: none;
z-index: 1;
}
.fa-gloss.bottom::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30%;
border-radius: var(--radius);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0.1) 0%,
rgba(0, 0, 0, 0) 100%
);
pointer-events: none;
z-index: 1;
} }
} }
body { @layer components {
background: var(--background); .shadow-fa-sm {
color: var(--foreground); box-shadow: var(--shadow-fa-sm), var(--fa-inner);
font-family: Arial, Helvetica, sans-serif; }
.shadow-fa-md {
box-shadow: var(--shadow-fa-md), var(--fa-inner);
}
.shadow-fa-lg {
box-shadow: var(--shadow-fa-lg), var(--fa-inner);
}
.text-shadow-default {
text-shadow: var(--text-shadow-default);
}
.text-shadow-sm {
text-shadow: var(--text-shadow-sm);
}
.text-shadow-md {
text-shadow: var(--text-shadow-md);
}
.text-shadow-lg {
text-shadow: var(--text-shadow-lg);
}
.glass-effect {
@apply bg-card/70 backdrop-blur-lg border border-white/20 shadow-fa-lg;
}
.gloss-highlight::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 60%;
border-radius: inherit; /* This is key for matching the parent's border radius */
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.5) 0%,
rgba(255, 255, 255, 0) 100%
);
pointer-events: none;
z-index: 1;
}
} }

View File

@@ -1,22 +1,32 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "@/hooks/use-auth";
const geistSans = Geist({ import { Toaster } from "@/components/ui/sonner";
variable: "--font-geist-sans", import { Header } from "@/components/header";
subsets: ["latin"], import localFont from "next/font/local";
}); import InstallPrompt from "@/components/install-prompt";
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Thoughts",
description: "Generated by create next app", description: "A social network for sharing thoughts",
}; };
const frutiger = localFont({
src: [
{
path: "./frutiger.woff",
weight: "normal",
style: "normal",
},
{
path: "./frutiger-bold.woff",
weight: "bold",
style: "normal",
},
],
variable: "--font-frutiger",
});
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@@ -24,10 +34,13 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body <body className={`${frutiger.className} antialiased`}>
className={`${geistSans.variable} ${geistMono.variable} antialiased`} <AuthProvider>
> <Header />
{children} <main className="flex-1">{children}</main>
<InstallPrompt />
<Toaster />
</AuthProvider>
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,25 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Thoughts',
short_name: 'Thoughts',
description: 'A social network for sharing thoughts',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [
{
src: '/icon-192x192.webp',
sizes: '192x192',
type: 'image/webp',
},
{
src: '/icon.avif',
sizes: '512x512',
type: 'image/avif',
},
],
}
}

View File

@@ -1,158 +1,183 @@
"use client"; import { cookies } from "next/headers";
import {
getFeed,
getFriends,
getMe,
getUserProfile,
Me,
User,
} from "@/lib/api";
import { PostThoughtForm } from "@/components/post-thought-form";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { PopularTags } from "@/components/popular-tags";
import { ThoughtThread } from "@/components/thought-thread";
import { buildThoughtThreads } from "@/lib/utils";
import { TopFriends } from "@/components/top-friends";
import { UsersCount } from "@/components/users-count";
import { useState, useEffect, FormEvent } from "react"; import {
Pagination,
PaginationContent,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { redirect } from "next/navigation";
interface Thought { export default async function Home({
id: number; searchParams,
author_id: number; }: {
content: string; searchParams: { page?: string };
created_at: string; }) {
const token = (await cookies()).get("auth_token")?.value ?? null;
if (token) {
return <FeedPage token={token} searchParams={searchParams} />;
} else {
return <LandingPage />;
}
} }
export default function Home() { async function FeedPage({
// State to store the list of thoughts for the feed token,
const [thoughts, setThoughts] = useState<Thought[]>([]); searchParams,
// State for the content of the new thought being typed }: {
const [newThoughtContent, setNewThoughtContent] = useState(""); token: string;
// State to manage loading status searchParams: { page?: string };
const [isLoading, setIsLoading] = useState(true); }) {
// State to hold any potential errors during API calls const page = parseInt(searchParams.page ?? "1", 10);
const [error, setError] = useState<string | null>(null);
// Function to fetch the feed from the backend API const [feedData, me] = await Promise.all([
const fetchFeed = async () => { getFeed(token, page).catch(() => null),
try { getMe(token).catch(() => null) as Promise<Me | null>,
setError(null); ]);
const response = await fetch("http://localhost:8000/feed");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// The API returns { thoughts: [...] }, so we access the nested array
setThoughts(data.thoughts || []);
} catch (e: unknown) {
console.error("Failed to fetch feed:", e);
setError(
"Could not load the feed. The backend might be busy. Please try refreshing."
);
} finally {
setIsLoading(false);
}
};
// useEffect hook to fetch the feed when the component first loads if (!feedData || !me) {
useEffect(() => { redirect("/login");
fetchFeed(); }
}, []);
// Handler for submitting the new thought form const { items: allThoughts, totalPages } = feedData!;
const handleSubmitThought = async (e: FormEvent) => { const thoughtThreads = buildThoughtThreads(allThoughts);
e.preventDefault();
if (!newThoughtContent.trim()) return; // Prevent empty posts
try { const authors = [...new Set(allThoughts.map((t) => t.authorUsername))];
const response = await fetch("http://localhost:8000/thoughts", { const userProfiles = await Promise.all(
method: "POST", authors.map((username) => getUserProfile(username, token).catch(() => null))
headers: { );
"Content-Type": "application/json",
},
// We are hardcoding author_id: 1 as we don't have auth yet
body: JSON.stringify({ content: newThoughtContent, author_id: 1 }),
});
if (!response.ok) { const authorDetails = new Map<string, { avatarUrl?: string | null }>(
throw new Error(`HTTP error! status: ${response.status}`); userProfiles
} .filter((u): u is User => !!u)
.map((user) => [user.username, { avatarUrl: user.avatarUrl }])
);
// Clear the input box const friends = (await getFriends(token)).users.map((user) => user.username);
setNewThoughtContent(""); const shouldDisplayTopFriends =
// Refresh the feed to show the new post token && me?.topFriends && me.topFriends.length > 8;
fetchFeed();
} catch (e: unknown) { console.log("Should display top friends:", shouldDisplayTopFriends);
console.error("Failed to post thought:", e);
setError("Failed to post your thought. Please try again.");
}
};
return ( return (
<div className="font-sans bg-gradient-to-br from-sky-200 via-teal-100 to-green-200 min-h-screen text-gray-800"> <div className="container mx-auto max-w-6xl p-4 sm:p-6">
<div className="container mx-auto max-w-2xl p-4 sm:p-6"> <div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Header */} <aside className="hidden lg:block lg:col-span-1">
<header className="text-center my-6"> <div className="sticky top-20 space-y-6 glass-effect glossy-effect bottom rounded-md p-4">
<h1 <h2 className="text-lg font-semibold">Filters & Sorting</h2>
className="text-5xl font-bold text-white" <p className="text-sm text-muted-foreground">Coming soon...</p>
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.2)" }} </div>
> </aside>
Thoughts
</h1>
<p className="text-white/80 mt-2">
Your space on the decentralized web.
</p>
</header>
{/* New Thought Form */} <main className="col-span-1 lg:col-span-2 space-y-6">
<div className="bg-white/70 backdrop-blur-lg rounded-xl shadow-lg p-5 mb-8"> <header className="mb-6">
<form onSubmit={handleSubmitThought}> <h1 className="text-3xl font-bold text-shadow-sm">Your Feed</h1>
<textarea </header>
value={newThoughtContent} <PostThoughtForm />
onChange={(e) => setNewThoughtContent(e.target.value)}
className="w-full h-24 p-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-sky-400 focus:outline-none resize-none transition-shadow"
placeholder="What's on your mind?"
maxLength={128}
/>
<div className="flex justify-between items-center mt-3">
<span className="text-sm text-gray-500">
{128 - newThoughtContent.length} characters remaining
</span>
<button
type="submit"
className="px-6 py-2 bg-sky-500 text-white font-semibold rounded-full shadow-md hover:bg-sky-600 active:scale-95 transition-all duration-150 ease-in-out disabled:bg-gray-400"
disabled={!newThoughtContent.trim()}
>
Post
</button>
</div>
</form>
</div>
{/* Feed Section */} <div className="block lg:hidden space-y-6">
<main> <PopularTags />
{isLoading ? ( {shouldDisplayTopFriends && (
<p className="text-center text-gray-600">Loading feed...</p> <TopFriends mode="top-friends" usernames={me.topFriends} />
) : error ? ( )}
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg text-center"> {!shouldDisplayTopFriends && token && friends.length > 0 && (
<p>{error}</p> <TopFriends mode="friends" usernames={friends || []} />
</div> )}
) : thoughts.length === 0 ? ( <UsersCount />
<p className="text-center text-gray-600"> </div>
The feed is empty. Follow some users to see their thoughts!
</p> <div className="space-y-6">
) : ( {thoughtThreads.map((thought) => (
<div className="space-y-4"> <ThoughtThread
{thoughts.map((thought) => ( key={thought.id}
<div thought={thought}
key={thought.id} authorDetails={authorDetails}
className="bg-white/80 backdrop-blur-lg rounded-xl shadow-lg p-4 transition-transform hover:scale-[1.02]" currentUser={me}
> />
<div className="flex items-center mb-2"> ))}
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-300 to-sky-400 flex items-center justify-center font-bold text-white mr-3"> {thoughtThreads.length === 0 && (
{/* Placeholder for avatar */} <p className="text-center text-muted-foreground pt-8">
{thought.author_id} Your feed is empty. Follow some users to see their thoughts!
</div> </p>
<div> )}
<p className="font-bold">User {thought.author_id}</p> </div>
<p className="text-xs text-gray-500"> <Pagination className="mt-8">
{new Date(thought.created_at).toLocaleString()} <PaginationContent>
</p> <PaginationItem>
</div> <PaginationPrevious
</div> href={page > 1 ? `/?page=${page - 1}` : "#"}
<p className="text-gray-800 break-words">{thought.content}</p> aria-disabled={page <= 1}
</div> />
))} </PaginationItem>
</div> <PaginationItem>
)} <PaginationNext
href={page < totalPages ? `/?page=${page + 1}` : "#"}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</main> </main>
<aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6">
<PopularTags />
{shouldDisplayTopFriends && (
<TopFriends mode="top-friends" usernames={me.topFriends} />
)}
{!shouldDisplayTopFriends && token && friends.length > 0 && (
<TopFriends mode="friends" usernames={friends || []} />
)}
<UsersCount />
</div>
</aside>
</div> </div>
</div> </div>
); );
} }
function LandingPage() {
return (
<>
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center">
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center glass-effect glossy-effect bottom rounded-md shadow-fa-lg">
<h1
className="text-5xl font-bold"
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
>
Welcome to Thoughts
</h1>
<p className="text-muted-foreground mt-2">
Throwback to the golden age of microblogging.
</p>
<div className="mt-8 flex justify-center gap-4">
<Button asChild>
<Link href="/login">Login</Link>
</Button>
<Button variant="secondary" asChild>
<Link href="/register">Register</Link>
</Button>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,76 @@
import { cookies } from "next/headers";
import { getMe, search, User } from "@/lib/api";
import { UserListCard } from "@/components/user-list-card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ThoughtList } from "@/components/thought-list";
interface SearchPageProps {
searchParams: { q?: string };
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
const query = searchParams.q || "";
const token = (await cookies()).get("auth_token")?.value ?? null;
if (!query) {
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center">
<h1 className="text-2xl font-bold mt-8">Search Thoughts</h1>
<p className="text-muted-foreground">
Find users and thoughts across the platform.
</p>
</div>
);
}
const [results, me] = await Promise.all([
search(query, token).catch(() => null),
token ? getMe(token).catch(() => null) : null,
]);
const authorDetails = new Map<string, { avatarUrl?: string | null }>();
if (results) {
results.users.users.forEach((user: User) => {
authorDetails.set(user.username, { avatarUrl: user.avatarUrl });
});
}
return (
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
<header className="my-6">
<h1 className="text-3xl font-bold">Search Results</h1>
<p className="text-muted-foreground">
Showing results for: &quot;{query}&quot;
</p>
</header>
<main>
{results ? (
<Tabs defaultValue="thoughts" className="w-full">
<TabsList>
<TabsTrigger value="thoughts">
Thoughts ({results.thoughts.thoughts.length})
</TabsTrigger>
<TabsTrigger value="users">
Users ({results.users.users.length})
</TabsTrigger>
</TabsList>
<TabsContent value="thoughts">
<ThoughtList
thoughts={results.thoughts.thoughts}
authorDetails={authorDetails}
currentUser={me}
/>
</TabsContent>
<TabsContent value="users">
<UserListCard users={results.users.users} />
</TabsContent>
</Tabs>
) : (
<p className="text-center text-muted-foreground pt-8">
No results found or an error occurred.
</p>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getApiKeys } from "@/lib/api";
import { ApiKeyList } from "@/components/api-keys-list";
export default async function ApiKeysPage() {
const token = (await cookies()).get("auth_token")?.value;
if (!token) {
redirect("/login");
}
const initialApiKeys = await getApiKeys(token).catch(() => ({
apiKeys: [],
}));
return (
<div className="space-y-6">
<div className="glass-effect glossy-effect bottom rounded-md shadow-fa-lg p-4">
<h3 className="text-lg font-medium">API Keys</h3>
<p className="text-sm text-muted-foreground">
Manage API keys for third-party applications.
</p>
</div>
<ApiKeyList initialApiKeys={initialApiKeys.apiKeys} />
</div>
);
}

View File

@@ -0,0 +1,38 @@
// app/settings/layout.tsx
import { SettingsNav } from "@/components/settings-nav";
import { Separator } from "@/components/ui/separator";
const sidebarNavItems = [
{
title: "Profile",
href: "/settings/profile",
},
{
title: "API Keys",
href: "/settings/api-keys",
},
];
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="container mx-auto max-w-5xl space-y-6 p-10 pb-16">
<div className="space-y-0.5 p-4 glass-effect rounded-md shadow-fa-lg">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">
Manage your account settings and profile.
</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SettingsNav items={sidebarNavItems} />
</aside>
<div className="flex-1 lg:max-w-2xl">{children}</div>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More