Compare commits

...

93 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
b83b7acf1c feat: Refactor user and thought models to use UUIDs instead of integers
- Updated user and thought models to utilize UUIDs for primary keys.
- Modified persistence functions to accommodate UUIDs for user and thought IDs.
- Implemented tag functionality with new Tag and ThoughtTag models.
- Added migration scripts to create new tables for tags and thought-tag relationships.
- Enhanced thought creation to parse hashtags and link them to thoughts.
- Updated tests to reflect changes in user and thought ID types.
2025-09-06 15:29:38 +02:00
c9e99e6f23 feat: add user profile management with update and retrieval endpoints, enhance database setup for testing 2025-09-06 14:24:27 +02:00
6e63dca513 feat: add environment configuration for database and authentication, update router setup 2025-09-06 01:55:59 +02:00
3dd6c0f64b feat(activitypub): implement user outbox endpoint and federate thoughts to followers 2025-09-06 01:46:11 +02:00
e9c4088e68 feat(activitypub): implement user inbox for receiving follow activities and add corresponding tests 2025-09-06 01:37:23 +02:00
c7c573f3f4 feat: Implement WebFinger discovery and ActivityPub user actor endpoint
- Added a new router for handling well-known endpoints, specifically WebFinger.
- Implemented the `webfinger` function to respond to WebFinger queries.
- Created a new route for WebFinger in the router.
- Refactored user retrieval logic to support both user ID and username in a single endpoint.
- Updated user router to use the new `get_user_by_param` function.
- Added tests for WebFinger discovery and ActivityPub user actor endpoint.
- Updated dependencies in Cargo.toml files to include necessary libraries.
2025-09-06 01:18:04 +02:00
3d73c7f198 feat(auth): implement user registration and login with JWT authentication
- Added `bcrypt`, `jsonwebtoken`, and `once_cell` dependencies to manage password hashing and JWT handling.
- Created `Claims` struct for JWT claims and implemented token generation in the login route.
- Implemented user registration and authentication logic in the `auth` module.
- Updated error handling to include validation errors.
- Created new routes for user registration and login, and integrated them into the main router.
- Added tests for the authentication flow, including registration and login scenarios.
- Updated user model to include a password hash field.
- Refactored user creation logic to include password validation.
- Adjusted feed and user routes to utilize JWT for authentication.
2025-09-06 00:06:30 +02:00
d70015c887 feat: update API endpoints and enhance feed retrieval logic, add CORS support 2025-09-05 22:26:39 +02:00
0e6c072387 feat: enhance error handling and user follow functionality, update tests for user context 2025-09-05 21:44:46 +02:00
decf81e535 feat: implement user follow/unfollow functionality and thought retrieval by user
- Added follow and unfollow endpoints for users.
- Implemented logic to retrieve thoughts by a specific user.
- Updated user error handling to include cases for already following and not following.
- Created persistence layer for follow relationships.
- Enhanced user and thought schemas to support new features.
- Added tests for follow/unfollow endpoints and thought retrieval.
- Updated frontend to display thoughts and allow posting new thoughts.
2025-09-05 19:08:37 +02:00
912259ef54 Refactor blog module and remove blog-related functionality
- Removed blog router and associated API endpoints.
- Deleted blog persistence functions and related query parameters.
- Removed blog schemas and models from the codebase.
- Introduced common crate for shared types, including DateTimeWithTimeZoneWrapper.
- Added Thought and Follow models with corresponding migrations.
- Updated dependencies in Cargo.toml files to reflect changes.
- Adjusted tests to remove references to the blog module.
2025-09-05 18:10:58 +02:00
e5747eaaf3 feat: initialize thoughts-frontend with Next.js, TypeScript, and ESLint
- Add ESLint configuration for Next.js and TypeScript support.
- Create Next.js configuration file with standalone output option.
- Initialize package.json with scripts for development, build, and linting.
- Set up PostCSS configuration for Tailwind CSS.
- Add SVG assets for UI components.
- Create TypeScript configuration for strict type checking and module resolution.
2025-09-05 17:14:45 +02:00
251 changed files with 22170 additions and 2 deletions

9
.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

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
POSTGRES_USER=thoughts_user
POSTGRES_PASSWORD=postgres
POSTGRES_DB=thoughts_db

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

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
backend-codebase.txt backend-codebase.txt
frontend-codebase.txt frontend-codebase.txt
.env

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
@@ -50,6 +57,21 @@ services:
- frontend - frontend
- backend - backend
db_test:
image: postgres:15-alpine
container_name: thoughts-db-test
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- "5434:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
postgres_data: postgres_data:
driver: local driver: local

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

@@ -0,0 +1,6 @@
# Ignore build artifacts
target/
# Ignore git directory
.git/
# Ignore local environment files
.env

8
thoughts-backend/.env Normal file
View File

@@ -0,0 +1,8 @@
HOST=0.0.0.0
PORT=8000
#DATABASE_URL="sqlite://dev.db"
DATABASE_URL="postgresql://postgres:postgres@localhost/thoughts"
#DATABASE_URL=postgres://thoughts_user:postgres@database:5432/thoughts_db
PREFORK=0
AUTH_SECRET=your_secret_key_here
BASE_URL=http://0.0.0.0

View File

@@ -0,0 +1,6 @@
HOST=0.0.0.0
PORT=3000
DATABASE_URL="postgresql://postgres:postgres@localhost/clean-axum"
PREFORK=1
AUTH_SECRET=your_secret_key_here
BASE_URL=http://localhost:3000

2
thoughts-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
.env

5093
thoughts-backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
[package]
name = "thoughts-backend"
version = "0.1.0"
edition = "2021"
publish = false
# docs
authors = ["Gabriel Kaszewski <gabrielkaszewski@gmail.com>"]
description = "Thoughts backend"
license = "MIT"
readme = "README.md"
[workspace]
members = ["api", "app", "doc", "models", "migration", "utils"]
[workspace.dependencies]
tower = { version = "0.5.2", default-features = false }
axum = { version = "0.8.4", default-features = false }
sea-orm = { version = "1.1.12" }
sea-query = { version = "0.32.6" } # Added sea-query dependency
serde = { version = "1.0.219", features = ["derive"] }
serde_json = { version = "1.0.140", features = ["raw_value"] }
tracing = "0.1.41"
utoipa = { version = "5.4.0", features = ["macros", "chrono", "uuid"] }
validator = { version = "0.20.0", default-features = false }
chrono = { version = "0.4.41", features = ["serde"] }
tokio = { version = "1.45.1", features = ["full"] }
[dependencies]
api = { path = "api" }
utils = { path = "utils" }
doc = { path = "doc" }
sea-orm = { workspace = true }
# logging
tracing = { workspace = true }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
# runtime
axum = { workspace = true, features = ["tokio", "http1", "http2"] }
prefork = { version = "0.6.0", default-features = false, optional = true }
tokio = { version = "1.45.1", features = ["full"] }
# shuttle runtime
shuttle-axum = { version = "0.55.0", optional = true }
shuttle-runtime = { version = "0.55.0", optional = true }
shuttle-shared-db = { version = "0.55.0", features = [
"postgres",
], optional = true }
[dev-dependencies]
app = { path = "app" }
models = { path = "models" }
http-body-util = "0.1.3"
serde_json = { workspace = true }
[features]
default = ["prefork"]
prefork = ["prefork/tokio"]
shuttle = ["shuttle-axum", "shuttle-runtime", "shuttle-shared-db"]

View File

@@ -0,0 +1,44 @@
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
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY api/Cargo.toml ./api/
COPY app/Cargo.toml ./app/
COPY common/Cargo.toml ./common/
COPY doc/Cargo.toml ./doc/
COPY migration/Cargo.toml ./migration/
COPY models/Cargo.toml ./models/
COPY utils/Cargo.toml ./utils/
RUN mkdir -p src && echo "fn main() {}" > src/main.rs
RUN cargo chef prepare --recipe-path recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin thoughts-backend
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 && \
useradd --system --uid 1001 --gid appgroup appuser
WORKDIR /app
COPY --from=builder /app/target/release/thoughts-backend .
RUN chown -R appuser:appgroup /app
USER appuser
EXPOSE 8000
CMD ["./thoughts-backend"]

21
thoughts-backend/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Weiliang Li
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

129
thoughts-backend/README.md Normal file
View File

@@ -0,0 +1,129 @@
# clean-axum
Axum scaffold with clean architecture.
You probably don't need [Rust on Rails](https://github.com/loco-rs/loco).
Refer to [this post](https://kigawas.me/posts/rustacean-clean-architecture-approach/) for rationale and background.
## Features
- [Axum](https://github.com/tokio-rs/axum) framework
- [SeaORM](https://github.com/SeaQL/sea-orm) domain models
- Completely separated API routers and DB-related logic (named "persistence" layer)
- Completely separated input parameters, queries and output schemas
- OpenAPI documentation ([Swagger UI](https://clean-axum.shuttleapp.rs/docs) and [Scalar](https://clean-axum.shuttleapp.rs/scalar)) powered by [Utoipa](https://github.com/juhaku/utoipa)
- Error handling with [Anyhow](https://github.com/dtolnay/anyhow)
- Custom parameter validation with [validator](https://github.com/Keats/validator)
- Optional [Shuttle](https://www.shuttle.rs/) runtime
- Optional [prefork](https://docs.rs/prefork/latest/prefork/) workers for maximizing performance on Linux
## Module hierarchy
### API logic
- `api::routers`: Axum endpoints
- `api::error`: Models and traits for error handling
- `api::extractor` Custom Axum extractors
- `api::extractor::json`: `Json` for bodies and responses
- `api::extractor::valid`: `Valid` for JSON body validation
- `api::validation`: JSON validation model based on `validator`
- `api::models`: Non domain model API models
- `api::models::response`: JSON error response
### OpenAPI documentation
- `doc`: Utoipa doc declaration
### API-agonistic application logic
Main concept: Web framework is replaceable.
All modules here should not include any specific API web framework logic.
- `app::persistence`: DB manipulation (CRUD) functions
- `app::config`: DB or API server configuration
- `app::state`: APP state, e.g. DB connection
- `app::error`: APP errors used by `api::error`. e.g. "User not found"
### DB/API-agnostic domain models
Main concept: Database (Sqlite/MySQL/PostgreSQL) is replaceable.
Except `models::domains` and `migration`, all modules are ORM library agnostic.
- `models::domains`: SeaORM domain models
- `models::params`: Serde input parameters for creating/updating domain models in DB
- `models::schemas`: Serde output schemas for combining different domain models
- `models::queries`: Serde queries for filtering domain models
- `migration`: SeaORM migration files
### Unit and integration tests
- `tests::api`: API integration tests. Hierarchy is the same as `api::routers`
- `tests::app::persistence`: DB/ORM-related unit tests. Hierarchy is the same as `app::persistence`
### Others
- `utils`: Utility functions
- `main`: Tokio and Shuttle conditional entry point
## Run
### Start server
```bash
cp .env.example .env
# touch dev.db
# cargo install sea-orm-cli
# sea-orm-cli migrate up
cargo run
# or for production
cargo run --release
```
### Call API
```bash
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"username":"aaa"}'
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"username":"abc"}'
curl http://localhost:3000/users\?username\=a
```
### OpenAPI doc (Swagger UI/Scalar)
```bash
open http://localhost:3000/docs
open http://localhost:3000/scalar
```
## Start Shuttle local server
```bash
# cargo install cargo-shuttle
cargo shuttle run
```
Make sure docker engine is running, otherwise:
```bash
brew install colima docker
colima start
sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock
```
## Shuttle deployment
```bash
cargo shuttle login
cargo shuttle deploy
```
## Benchmark
```bash
# edit .env to use Postgres
cargo run --release
wrk --latency -t20 -c50 -d10s http://localhost:3000/users\?username\=
```

View File

@@ -0,0 +1,42 @@
[package]
name = "api"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "api"
path = "src/lib.rs"
[dependencies]
axum = { workspace = true, features = ["macros", "query"] }
serde = { workspace = true }
tower = { workspace = true }
tracing = { workspace = true }
validator = { workspace = true, features = ["derive"] }
bcrypt = "0.17.1"
jsonwebtoken = "9.3.1"
once_cell = "1.21.3"
# db
sea-orm = { workspace = true }
# doc
utoipa = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
# local dependencies
app = { path = "../app" }
models = { path = "../models" }
reqwest = { version = "0.12.23", features = ["json"] }
tower-http = { version = "0.6.6", features = ["fs", "cors"] }
tower-cookies = "0.11.0"
anyhow = "1.0.98"
dotenvy = "0.15.7"
[dev-dependencies]

View File

@@ -0,0 +1,41 @@
use axum::{extract::rejection::JsonRejection, http::StatusCode};
use sea_orm::DbErr;
use app::error::UserError;
use super::traits::HTTPError;
impl HTTPError for JsonRejection {
fn to_status_code(&self) -> StatusCode {
match self {
JsonRejection::JsonSyntaxError(_) => StatusCode::BAD_REQUEST,
_ => StatusCode::BAD_REQUEST,
}
}
}
impl HTTPError for DbErr {
fn to_status_code(&self) -> StatusCode {
match self {
DbErr::ConnectionAcquire(_) => StatusCode::INTERNAL_SERVER_ERROR,
DbErr::UnpackInsertId => StatusCode::CONFLICT,
DbErr::RecordNotFound(_) => StatusCode::NOT_FOUND,
DbErr::Custom(s) if s == "Users cannot follow themselves" => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR, // TODO:: more granularity
}
}
}
impl HTTPError for UserError {
fn to_status_code(&self) -> StatusCode {
match self {
UserError::NotFound => StatusCode::NOT_FOUND,
UserError::NotFollowing => StatusCode::NOT_FOUND,
UserError::Forbidden => StatusCode::FORBIDDEN,
UserError::UsernameTaken => StatusCode::BAD_REQUEST,
UserError::AlreadyFollowing => StatusCode::BAD_REQUEST,
UserError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

View File

@@ -0,0 +1,10 @@
pub struct ApiError(pub(super) anyhow::Error);
impl<E> From<E> for ApiError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

View File

@@ -0,0 +1,36 @@
use axum::{
extract::rejection::JsonRejection,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use sea_orm::DbErr;
use app::error::UserError;
use super::{ApiError, HTTPError};
use crate::models::ApiErrorResponse;
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let err = self.0;
let (status, message) = if let Some(err) = err.downcast_ref::<DbErr>() {
tracing::error!(%err, "error from db:");
(err.to_status_code(), "DB error".to_string()) // hide the detail
} else if let Some(err) = err.downcast_ref::<UserError>() {
(err.to_status_code(), err.to_string())
} else if let Some(err) = err.downcast_ref::<JsonRejection>() {
tracing::error!(%err, "error from extractor:");
(err.to_status_code(), err.to_string())
} else {
tracing::error!(%err, "error from other source:");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Unknown error".to_string(),
)
};
(status, Json(ApiErrorResponse { message })).into_response()
}
}

View File

@@ -0,0 +1,7 @@
mod adapter;
mod core;
mod handler;
mod traits;
pub use core::ApiError;
pub use traits::HTTPError;

View File

@@ -0,0 +1,5 @@
use axum::http::StatusCode;
pub trait HTTPError {
fn to_status_code(&self) -> StatusCode;
}

View File

@@ -0,0 +1,76 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, HeaderMap, StatusCode},
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use once_cell::sync::Lazy;
use sea_orm::prelude::Uuid;
use serde::{Deserialize, Serialize};
use app::{persistence::api_key, state::AppState};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: Uuid,
pub exp: usize,
}
static JWT_SECRET: Lazy<String> =
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
pub struct AuthUser {
pub id: Uuid,
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
// --- Test User ID (Keep for testing) ---
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 = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
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)
.ok_or((StatusCode::UNAUTHORIZED, "Missing or invalid token"))?;
let decoding_key = DecodingKey::from_secret(JWT_SECRET.as_ref());
let claims = decode::<Claims>(&token, &decoding_key, &Validation::default())
.map(|data| data.claims)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;
Ok(AuthUser { id: claims.sub })
}
}
fn get_token_from_header(headers: &HeaderMap) -> Option<String> {
headers
.get("Authorization")
.and_then(|header| header.to_str().ok())
.and_then(|header| header.strip_prefix("Bearer "))
.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

@@ -0,0 +1,26 @@
use axum::{
extract::FromRequest,
response::{IntoResponse, Response},
};
use validator::Validate;
use crate::error::ApiError;
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
pub struct Json<T>(pub T);
impl<T> IntoResponse for Json<T>
where
axum::Json<T>: IntoResponse,
{
fn into_response(self) -> Response {
axum::Json(self.0).into_response()
}
}
impl<T: Validate> Validate for Json<T> {
fn validate(&self) -> Result<(), validator::ValidationErrors> {
self.0.validate()
}
}

View File

@@ -0,0 +1,10 @@
mod auth;
mod json;
mod optional_auth;
mod valid;
pub use auth::AuthUser;
pub use auth::Claims;
pub use json::Json;
pub use optional_auth::OptionalAuthUser;
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

@@ -0,0 +1,23 @@
use axum::extract::{FromRequest, Request};
use validator::Validate;
use crate::validation::ValidRejection;
#[derive(Debug, Clone, Copy, Default)]
pub struct Valid<T>(pub T);
impl<State, Extractor> FromRequest<State> for Valid<Extractor>
where
State: Send + Sync,
Extractor: Validate + FromRequest<State>,
{
type Rejection = ValidRejection<<Extractor as FromRequest<State>>::Rejection>;
async fn from_request(req: Request, state: &State) -> Result<Self, Self::Rejection> {
let inner = Extractor::from_request(req, state)
.await
.map_err(ValidRejection::Extractor)?;
inner.validate()?;
Ok(Valid(inner))
}
}

View File

@@ -0,0 +1,34 @@
use std::time::Duration;
use axum::Router;
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use app::config::Config;
use app::state::AppState;
use crate::routers::create_router;
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
create_router(AppState {
conn,
base_url: config.base_url.clone(),
})
}
pub fn setup_config() -> Config {
dotenvy::dotenv().ok();
Config::from_env()
}
pub async fn setup_db(db_url: &str, prefork: bool) -> DatabaseConnection {
let mut opt = ConnectOptions::new(db_url);
opt.max_lifetime(Duration::from_secs(60));
if !prefork {
opt.min_connections(10).max_connections(100);
}
Database::connect(opt)
.await
.expect("Database connection failed")
}

View File

@@ -0,0 +1,9 @@
mod error;
mod extractor;
mod init;
mod validation;
pub mod models;
pub mod routers;
pub use init::{setup_config, setup_db, setup_router};

View File

@@ -0,0 +1,3 @@
mod response;
pub use response::{ApiErrorResponse, ParamsErrorResponse, ValidationErrorResponse};

View File

@@ -0,0 +1,27 @@
use std::collections::HashMap;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
pub struct ApiErrorResponse {
pub message: String,
}
#[derive(Serialize, ToSchema)]
pub struct ValidationErrorResponse<T> {
pub message: String,
pub details: T,
}
pub type ParamsErrorResponse =
ValidationErrorResponse<HashMap<String, Vec<HashMap<String, String>>>>;
impl<T> From<T> for ValidationErrorResponse<T> {
fn from(t: T) -> Self {
Self {
message: "Validation error".to_string(),
details: t,
}
}
}

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

@@ -0,0 +1,93 @@
use axum::{
debug_handler, extract::State, http::StatusCode, response::IntoResponse, routing::post, Router,
};
use jsonwebtoken::{encode, EncodingKey, Header};
use once_cell::sync::Lazy;
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};
use utoipa::ToSchema;
use crate::{
error::ApiError,
extractor::{Claims, Json, Valid},
models::{ApiErrorResponse, ParamsErrorResponse},
};
use app::{persistence::auth, state::AppState};
use models::{
params::auth::{LoginParams, RegisterParams},
schemas::user::UserSchema,
};
static JWT_SECRET: Lazy<String> =
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
#[derive(Serialize, ToSchema)]
pub struct TokenResponse {
token: String,
}
#[utoipa::path(
post,
path = "/register",
request_body = RegisterParams,
responses(
(status = 201, description = "User registered", body = UserSchema),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 409, description = "Username already exists", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ParamsErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
)]
#[axum::debug_handler]
async fn register(
State(state): State<AppState>,
Valid(Json(params)): Valid<Json<RegisterParams>>,
) -> Result<impl IntoResponse, ApiError> {
let user = auth::register_user(&state.conn, params).await?;
Ok((StatusCode::CREATED, Json(UserSchema::from(user))))
}
#[utoipa::path(
post,
path = "/login",
request_body = LoginParams,
responses(
(status = 200, description = "User logged in", body = TokenResponse),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 401, description = "Invalid credentials", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ParamsErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
)]
#[debug_handler]
async fn login(
state: State<AppState>,
Valid(Json(params)): Valid<Json<LoginParams>>,
) -> Result<impl IntoResponse, ApiError> {
let user = auth::authenticate_user(&state.conn, params).await?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = Claims {
sub: user.id,
exp: (now + 3600 * 24) as usize,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(JWT_SECRET.as_ref()),
)
.map_err(|e| ApiError::from(app::error::UserError::Internal(e.to_string())))?;
Ok((StatusCode::OK, Json(TokenResponse { token })))
}
pub fn create_auth_router() -> Router<AppState> {
Router::new()
.route("/register", post(register))
.route("/login", post(login))
}

View File

@@ -0,0 +1,67 @@
use axum::{
extract::{Query, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use app::{
persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self_paginated},
state::AppState,
};
use models::{
queries::pagination::PaginationQuery,
schemas::{pagination::PaginatedResponse, thought::ThoughtSchema},
};
use crate::{error::ApiError, extractor::AuthUser};
#[utoipa::path(
get,
path = "",
params(PaginationQuery),
responses(
(status = 200, description = "Authenticated user's feed", body = PaginatedResponse<ThoughtSchema>)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn feed_get(
State(state): State<AppState>,
auth_user: AuthUser,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> {
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
let (thoughts_with_authors, total_items) = get_feed_for_users_and_self_paginated(
&state.conn,
auth_user.id,
following_ids,
&pagination,
)
.await?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter()
.map(ThoughtSchema::from)
.collect();
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> {
Router::new().route("/", get(feed_get))
}

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

@@ -0,0 +1,35 @@
use axum::Router;
pub mod api_key;
pub mod auth;
pub mod feed;
pub mod friends;
pub mod root;
pub mod search;
pub mod tag;
pub mod thought;
pub mod user;
use crate::routers::auth::create_auth_router;
use app::state::AppState;
use root::create_root_router;
use tower_http::cors::CorsLayer;
use user::create_user_router;
use crate::routers::{feed::create_feed_router, thought::create_thought_router};
pub fn create_router(state: AppState) -> Router {
let cors = CorsLayer::permissive();
Router::new()
.merge(create_root_router())
.nest("/auth", create_auth_router())
.nest("/users", create_user_router())
.nest("/thoughts", create_thought_router())
.nest("/feed", create_feed_router())
.nest("/tags", tag::create_tag_router())
.nest("/friends", friends::create_friends_router())
.nest("/search", search::create_search_router())
.with_state(state)
.layer(cors)
}

View File

@@ -0,0 +1,36 @@
use axum::{extract::State, http::StatusCode, routing::get, Router};
use sea_orm::{ConnectionTrait, Statement};
use app::state::AppState;
use crate::error::ApiError;
#[utoipa::path(
get,
path = "",
responses(
(status = 200, description = "Hello world", body = String)
)
)]
async fn root_get(state: State<AppState>) -> Result<String, ApiError> {
let result = state
.conn
.query_one(Statement::from_string(
state.conn.get_database_backend(),
"SELECT 'Hello, World from DB!'",
))
.await
.map_err(ApiError::from)?;
result.unwrap().try_get_by(0).map_err(|e| e.into())
}
async fn health_check() -> StatusCode {
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

@@ -0,0 +1,51 @@
use crate::{error::ApiError, extractor::OptionalAuthUser};
use app::{
persistence::{tag, thought::get_thoughts_by_tag_name},
state::AppState,
};
use axum::{
extract::{Path, State},
response::IntoResponse,
routing::get,
Json, Router,
};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
#[utoipa::path(
get,
path = "{tagName}",
params(("tagName" = String, Path, description = "Tag name")),
responses((status = 200, description = "List of thoughts with a specific tag", body = ThoughtListSchema))
)]
async fn get_thoughts_by_tag(
State(state): State<AppState>,
Path(tag_name): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
let thoughts_with_authors =
get_thoughts_by_tag_name(&state.conn, &tag_name, viewer.0.map(|u| u.id)).await;
let thoughts_with_authors = thoughts_with_authors?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter()
.map(ThoughtSchema::from)
.collect();
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
}
#[utoipa::path(
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

@@ -0,0 +1,145 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Router,
};
use app::{
error::UserError,
persistence::thought::{create_thought, delete_thought, get_thought},
state::AppState,
};
use models::{
params::thought::CreateThoughtParams,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
};
use sea_orm::prelude::Uuid;
use crate::{
error::ApiError,
extractor::{AuthUser, Json, OptionalAuthUser, Valid},
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(
post,
path = "",
request_body = CreateThoughtParams,
responses(
(status = 201, description = "Thought created", body = ThoughtSchema),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ParamsErrorResponse)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn thoughts_post(
State(state): State<AppState>,
auth_user: AuthUser,
Valid(Json(params)): Valid<Json<CreateThoughtParams>>,
) -> Result<impl IntoResponse, ApiError> {
let thought = create_thought(&state.conn, auth_user.id, params).await?;
let author = app::persistence::user::get_user(&state.conn, auth_user.id)
.await?
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid
let schema = ThoughtSchema::from_models(&thought, &author);
Ok((StatusCode::CREATED, Json(schema)))
}
#[utoipa::path(
delete,
path = "/{id}",
params(
("id" = i32, Path, description = "Thought ID")
),
responses(
(status = 204, description = "Thought deleted"),
(status = 403, description = "Forbidden", body = ApiErrorResponse),
(status = 404, description = "Not Found", body = ApiErrorResponse)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn thoughts_delete(
State(state): State<AppState>,
auth_user: AuthUser,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let thought = get_thought(&state.conn, id, Some(auth_user.id))
.await?
.ok_or(UserError::NotFound)?;
if thought.author_id != auth_user.id {
return Err(UserError::Forbidden.into());
}
delete_thought(&state.conn, id).await?;
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> {
Router::new()
.route("/", post(thoughts_post))
.route("/{id}/thread", get(get_thought_thread))
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
}

View File

@@ -0,0 +1,476 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use sea_orm::prelude::Uuid;
use serde_json::{json, Value};
use app::persistence::{
follow,
thought::get_thoughts_by_user,
user::{
get_all_users, get_followers, get_following, get_user, search_users, update_user_profile,
},
};
use app::state::AppState;
use app::{error::UserError, persistence::user::get_user_by_username};
use models::{
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 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(
get,
path = "",
params(
UserQuery
),
responses(
(status = 200, description = "List users", body = UserListSchema),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
)]
async fn users_get(
state: State<AppState>,
query: Query<UserQuery>,
) -> Result<impl IntoResponse, ApiError> {
let Query(query) = query;
let users = search_users(&state.conn, query)
.await
.map_err(ApiError::from)?;
Ok(Json(UserListSchema::from(users)))
}
#[utoipa::path(
get,
path = "/{username}/thoughts",
params(
("username" = String, Path, description = "Username")
),
responses(
(status = 200, description = "List of user's thoughts", body = ThoughtListSchema),
(status = 404, description = "User not found", body = ApiErrorResponse)
)
)]
async fn user_thoughts_get(
State(state): State<AppState>,
Path(username): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
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
.into_iter()
.map(ThoughtSchema::from)
.collect();
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
}
#[utoipa::path(
post,
path = "/{username}/follow",
params(
("username" = String, Path, description = "Username to follow")
),
responses(
(status = 204, description = "User followed successfully"),
(status = 404, description = "User not found", body = ApiErrorResponse),
(status = 409, description = "Already following", body = ApiErrorResponse)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn user_follow_post(
State(state): State<AppState>,
auth_user: AuthUser,
Path(username): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let user_to_follow = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
let result = follow::follow_user(&state.conn, auth_user.id, user_to_follow.id).await;
match result {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e)
if matches!(
e.sql_err(),
Some(sea_orm::SqlErr::UniqueConstraintViolation { .. })
) =>
{
Err(UserError::AlreadyFollowing.into())
}
Err(e) => Err(e.into()),
}
}
#[utoipa::path(
delete,
path = "/{username}/follow",
params(
("username" = String, Path, description = "Username to unfollow")
),
responses(
(status = 204, description = "User unfollowed successfully"),
(status = 404, description = "User not found or not being followed", body = ApiErrorResponse)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn user_follow_delete(
State(state): State<AppState>,
auth_user: AuthUser,
Path(username): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let user_to_unfollow = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
follow::unfollow_user(&state.conn, auth_user.id, user_to_unfollow.id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post,
path = "/{username}/inbox",
request_body = Object,
description = "The ActivityPub inbox for receiving activities.",
responses(
(status = 202, description = "Activity accepted"),
(status = 400, description = "Bad Request"),
(status = 404, description = "User not found")
)
)]
async fn user_inbox_post(
State(state): State<AppState>,
Path(username): Path<String>,
Json(activity): Json<Value>,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
let activity_type = activity["type"].as_str().unwrap_or_default();
let actor_id = activity["actor"].as_str().unwrap_or_default();
tracing::debug!(target: "activitypub", "Received activity '{}' from actor '{}' in {}'s inbox", activity_type, actor_id, username);
// For now, we only handle the "Follow" activity
if activity_type == "Follow" {
follow::add_follower(&state.conn, user.id, actor_id).await?;
}
// Per the ActivityPub spec, we should return a 202 Accepted status
Ok(StatusCode::ACCEPTED)
}
#[utoipa::path(
get,
path = "/{param}",
params(
("param" = String, Path, description = "User ID or username")
),
responses(
(status = 200, description = "User profile or ActivityPub actor", body = UserSchema, content_type = "application/json"),
(status = 200, description = "ActivityPub actor", body = Object, content_type = "application/activity+json"),
(status = 404, description = "User not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn get_user_by_param(
State(state): State<AppState>,
headers: axum::http::HeaderMap,
Path(param): Path<String>,
) -> Response {
// First, try to handle it as a numeric ID.
if let Ok(id) = param.parse::<Uuid>() {
return match get_user(&state.conn, id).await {
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
Err(db_err) => ApiError::from(db_err).into_response(),
};
}
// If it's not a number, treat it as a username and perform content negotiation.
let username = param;
let is_activitypub_request = headers
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.map_or(false, |s| s.contains("application/activity+json"));
if is_activitypub_request {
// This is the logic from `user_actor_get`.
match get_user_by_username(&state.conn, &username).await {
Ok(Some(user)) => {
let user_url = format!("{}/users/{}", &state.base_url, user.username);
let actor = json!({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
"id": user_url,
"type": "Person",
"preferredUsername": user.username,
"inbox": format!("{}/inbox", user_url),
"outbox": format!("{}/outbox", user_url),
});
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
"application/activity+json".parse().unwrap(),
);
(headers, Json(actor)).into_response()
}
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
Err(e) => ApiError::from(e).into_response(),
}
} else {
match get_user_by_username(&state.conn, &username).await {
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(),
Err(e) => ApiError::from(e).into_response(),
}
}
}
#[utoipa::path(
get,
path = "/{username}/outbox",
description = "The ActivityPub outbox for sending activities.",
responses(
(status = 200, description = "Activity collection", body = Object),
(status = 404, description = "User not found")
)
)]
async fn user_outbox_get(
State(state): State<AppState>,
Path(username): Path<String>,
viewer: OptionalAuthUser,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
let thoughts = get_thoughts_by_user(&state.conn, user.id, viewer.0.map(|u| u.id)).await?;
// Format the outbox as an ActivityPub OrderedCollection
let outbox_url = format!("{}/users/{}/outbox", &state.base_url, username);
let items: Vec<Value> = thoughts
.into_iter()
.map(|thought| {
let thought_url = format!("{}/thoughts/{}", &state.base_url, thought.id);
let author_url = format!("{}/users/{}", &state.base_url, thought.author_username);
json!({
"id": format!("{}/activity", thought_url),
"type": "Create",
"actor": author_url,
"published": thought.created_at,
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"object": {
"id": thought_url,
"type": "Note",
"attributedTo": author_url,
"content": thought.content,
"published": thought.created_at,
}
})
})
.collect();
let outbox = json!({
"@context": "https://www.w3.org/ns/activitystreams",
"id": outbox_url,
"type": "OrderedCollection",
"totalItems": items.len(),
"orderedItems": items,
});
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
"application/activity+json".parse().unwrap(),
);
Ok((headers, Json(outbox)))
}
#[utoipa::path(
get,
path = "/me",
responses(
(status = 200, description = "Authenticated user's full profile", body = MeSchema)
),
security(
("bearer_auth" = [])
)
)]
async fn get_me(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user(&state.conn, auth_user.id)
.await?
.ok_or(UserError::NotFound)?;
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(
put,
path = "/me",
request_body = UpdateUserParams,
responses(
(status = 200, description = "Profile updated", body = UserSchema),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ApiErrorResponse)
),
security(
("bearer_auth" = [])
)
)]
async fn update_me(
State(state): State<AppState>,
auth_user: AuthUser,
Valid(Json(params)): Valid<Json<UpdateUserParams>>,
) -> Result<impl IntoResponse, ApiError> {
let updated_user = update_user_profile(&state.conn, auth_user.id, params).await?;
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> {
Router::new()
.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))
.nest("/me/api-keys", create_api_key_router())
.route("/{param}", get(get_user_by_param))
.route("/{username}/thoughts", get(user_thoughts_get))
.route("/{username}/followers", get(get_user_followers))
.route("/{username}/following", get(get_user_following))
.route(
"/{username}/follow",
post(user_follow_post).delete(user_follow_delete),
)
.route("/{username}/inbox", post(user_inbox_post))
.route("/{username}/outbox", get(user_outbox_get))
}

View File

@@ -0,0 +1,3 @@
mod rejection;
pub use rejection::ValidRejection;

View File

@@ -0,0 +1,58 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use validator::ValidationErrors;
use crate::models::ValidationErrorResponse;
#[derive(Debug)]
pub enum ValidationRejection<V, E> {
Validator(V), // Validation errors
Extractor(E), // Extraction errors, e.g. axum's JsonRejection
}
impl<V: std::fmt::Display, E: std::fmt::Display> std::fmt::Display for ValidationRejection<V, E> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationRejection::Validator(v) => write!(f, "{v}"),
ValidationRejection::Extractor(e) => write!(f, "{e}"),
}
}
}
impl<V: std::error::Error + 'static, E: std::error::Error + 'static> std::error::Error
for ValidationRejection<V, E>
{
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ValidationRejection::Validator(v) => Some(v),
ValidationRejection::Extractor(e) => Some(e),
}
}
}
impl<V: serde::Serialize + std::error::Error, E: IntoResponse> IntoResponse
for ValidationRejection<V, E>
{
fn into_response(self) -> Response {
match self {
ValidationRejection::Validator(v) => {
tracing::error!("Validation error: {v}");
(
StatusCode::UNPROCESSABLE_ENTITY,
axum::Json(ValidationErrorResponse::from(v)),
)
.into_response()
}
// logged by ApiError
ValidationRejection::Extractor(e) => e.into_response(),
}
}
}
pub type ValidRejection<E> = ValidationRejection<ValidationErrors, E>;
impl<E> From<ValidationErrors> for ValidRejection<E> {
fn from(v: ValidationErrors) -> Self {
Self::Validator(v)
}
}

View File

@@ -0,0 +1,17 @@
[package]
name = "app"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "app"
path = "src/lib.rs"
[dependencies]
bcrypt = "0.17.1"
models = { path = "../models" }
validator = "0.20"
rand = "0.8.5"
sea-orm = { version = "1.1.12" }
chrono = { workspace = true }

View File

@@ -0,0 +1,3 @@
# app
No axum or api dependencies should be introduced into this folder.

View File

@@ -0,0 +1,28 @@
pub struct Config {
pub db_url: String,
pub host: String,
pub port: u32,
pub prefork: bool,
pub auth_secret: String,
pub base_url: String,
}
impl Config {
pub fn from_env() -> Config {
Config {
db_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is not set in .env file"),
host: std::env::var("HOST").expect("HOST is not set in .env file"),
port: std::env::var("PORT")
.expect("PORT is not set in .env file")
.parse()
.expect("PORT is not a number"),
prefork: std::env::var("PREFORK").is_ok_and(|v| v == "1"),
auth_secret: std::env::var("AUTH_SECRET").expect("AUTH_SECRET is not set in .env file"),
base_url: std::env::var("BASE_URL").expect("BASE_URL is not set in .env file"),
}
}
pub fn get_server_url(&self) -> String {
format!("{}:{}", self.host, self.port)
}
}

View File

@@ -0,0 +1,3 @@
mod user;
pub use user::UserError;

View File

@@ -0,0 +1,26 @@
#[derive(Debug)]
pub enum UserError {
NotFound,
NotFollowing,
Forbidden,
UsernameTaken,
AlreadyFollowing,
Validation(String), // Added Validation variant
Internal(String),
}
impl std::fmt::Display for UserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserError::NotFound => write!(f, "User not found"),
UserError::NotFollowing => write!(f, "You are not following this user"),
UserError::Forbidden => write!(f, "You do not have permission to perform this action"),
UserError::UsernameTaken => write!(f, "Username is already taken"),
UserError::AlreadyFollowing => write!(f, "You are already following this user"),
UserError::Validation(msg) => write!(f, "Validation error: {}", msg),
UserError::Internal(msg) => write!(f, "Internal server error: {}", msg),
}
}
}
impl std::error::Error for UserError {}

View File

@@ -0,0 +1,4 @@
pub mod config;
pub mod error;
pub mod persistence;
pub mod state;

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

@@ -0,0 +1,55 @@
use bcrypt::{hash, verify, BcryptError, DEFAULT_COST};
use models::{
domains::user,
params::auth::{LoginParams, RegisterParams},
};
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, EntityTrait, QueryFilter, Set};
use validator::Validate; // Import the Validate trait
use crate::error::UserError;
fn hash_password(password: &str) -> Result<String, BcryptError> {
hash(password, DEFAULT_COST)
}
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
params
.validate()
.map_err(|e| UserError::Validation(e.to_string()))?;
let hashed_password =
hash_password(&params.password).map_err(|e| UserError::Internal(e.to_string()))?;
let new_user = user::ActiveModel {
username: Set(params.username.clone()),
password_hash: Set(Some(hashed_password)),
email: Set(Some(params.email)),
display_name: Set(Some(params.username)),
..Default::default()
};
new_user.insert(db).await.map_err(|e| {
if let Some(sea_orm::SqlErr::UniqueConstraintViolation { .. }) = e.sql_err() {
UserError::UsernameTaken
} else {
UserError::Internal(e.to_string())
}
})
}
pub async fn authenticate_user(db: &DbConn, params: LoginParams) -> Result<user::Model, UserError> {
let user = user::Entity::find()
.filter(user::Column::Username.eq(params.username))
.one(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound)?;
let password_hash = user.password_hash.as_ref().ok_or(UserError::NotFound)?;
if verify(params.password, password_hash).map_err(|e| UserError::Internal(e.to_string()))? {
Ok(user)
} else {
Err(UserError::NotFound)
}
}

View File

@@ -0,0 +1,91 @@
use sea_orm::{
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set,
};
use crate::{error::UserError, persistence::user::get_user_by_username};
use models::domains::follow;
pub async fn add_follower(
db: &DbConn,
following_id: Uuid,
follower_actor_id: &str,
) -> Result<(), UserError> {
let follower_username = follower_actor_id
.split('/')
.last()
.ok_or_else(|| UserError::Internal("Invalid follower actor ID".to_string()))?;
let follower = get_user_by_username(db, follower_username)
.await
.map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound)?;
follow_user(db, follower.id, following_id)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
Ok(())
}
pub async fn follow_user(db: &DbConn, follower_id: Uuid, following_id: Uuid) -> Result<(), DbErr> {
if follower_id == following_id {
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
}
let follow = follow::ActiveModel {
follower_id: Set(follower_id),
following_id: Set(following_id),
};
follow.insert(db).await?;
Ok(())
}
pub async fn unfollow_user(
db: &DbConn,
follower_id: Uuid,
following_id: Uuid,
) -> Result<(), UserError> {
let deleted_result = follow::Entity::delete_many()
.filter(follow::Column::FollowerId.eq(follower_id))
.filter(follow::Column::FollowingId.eq(following_id))
.exec(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
if deleted_result.rows_affected == 0 {
return Err(UserError::NotFollowing);
}
Ok(())
}
pub async fn get_following_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
let followed_users = follow::Entity::find()
.filter(follow::Column::FollowerId.eq(user_id))
.all(db)
.await?;
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> {
let followers = follow::Entity::find()
.filter(follow::Column::FollowingId.eq(user_id))
.all(db)
.await?;
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

@@ -0,0 +1,7 @@
pub mod api_key;
pub mod auth;
pub mod follow;
pub mod search;
pub mod tag;
pub mod thought;
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

@@ -0,0 +1,120 @@
use chrono::{Duration, Utc};
use models::domains::{tag, thought, thought_tag};
use sea_orm::{
prelude::Expr, sea_query::Alias, sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr,
EntityTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
};
use std::collections::HashSet;
pub fn parse_hashtags(content: &str) -> Vec<String> {
content
.split_whitespace()
.filter_map(|word| {
if word.starts_with('#') && word.len() > 1 {
Some(word[1..].to_lowercase().to_string())
} else {
None
}
})
.collect::<HashSet<_>>()
.into_iter()
.collect()
}
pub async fn find_or_create_tags<C>(db: &C, names: Vec<String>) -> Result<Vec<tag::Model>, DbErr>
where
C: ConnectionTrait,
{
if names.is_empty() {
return Ok(vec![]);
}
let existing_tags = tag::Entity::find()
.filter(tag::Column::Name.is_in(names.clone()))
.all(db)
.await?;
let existing_names: HashSet<String> = existing_tags.iter().map(|t| t.name.clone()).collect();
let new_names: Vec<String> = names
.into_iter()
.filter(|n| !existing_names.contains(n))
.collect();
if !new_names.is_empty() {
let new_tags: Vec<tag::ActiveModel> = new_names
.clone()
.into_iter()
.map(|name| tag::ActiveModel {
name: Set(name),
..Default::default()
})
.collect();
tag::Entity::insert_many(new_tags).exec(db).await?;
}
tag::Entity::find()
.filter(
tag::Column::Name.is_in(
existing_names
.union(&new_names.into_iter().collect())
.cloned()
.collect::<Vec<_>>(),
),
)
.all(db)
.await
}
pub async fn link_tags_to_thought<C>(
db: &C,
thought_id: uuid::Uuid,
tags: Vec<tag::Model>,
) -> Result<(), DbErr>
where
C: ConnectionTrait,
{
if tags.is_empty() {
return Ok(());
}
let links: Vec<thought_tag::ActiveModel> = tags
.into_iter()
.map(|tag| thought_tag::ActiveModel {
thought_id: Set(thought_id),
tag_id: Set(tag.id),
})
.collect();
thought_tag::Entity::insert_many(links).exec(db).await?;
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

@@ -0,0 +1,386 @@
use sea_orm::{
prelude::Uuid, sea_query::SimpleExpr, ActiveModelTrait, ColumnTrait, Condition, DbConn, DbErr,
EntityTrait, JoinType, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait,
Set, TransactionTrait,
};
use models::{
domains::{tag, thought, thought_tag, user},
params::thought::CreateThoughtParams,
queries::pagination::PaginationQuery,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
};
use crate::{
error::UserError,
persistence::{
follow,
tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags},
},
};
pub async fn create_thought(
db: &DbConn,
author_id: Uuid,
params: CreateThoughtParams,
) -> Result<thought::Model, DbErr> {
let txn = db.begin().await?;
let new_thought = thought::ActiveModel {
author_id: Set(author_id),
content: Set(params.content.clone()),
reply_to_id: Set(params.reply_to_id),
visibility: Set(params.visibility.unwrap_or(thought::Visibility::Public)),
..Default::default()
}
.insert(&txn)
.await?;
if new_thought.visibility == thought::Visibility::Public {
let tag_names = parse_hashtags(&params.content);
if !tag_names.is_empty() {
let tags = find_or_create_tags(&txn, tag_names).await?;
link_tags_to_thought(&txn, new_thought.id, tags).await?;
}
}
txn.commit().await?;
Ok(new_thought)
}
pub async fn get_thought(
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> {
thought::Entity::delete_by_id(thought_id).exec(db).await?;
Ok(())
}
pub async fn get_thoughts_by_user(
db: &DbConn,
user_id: Uuid,
viewer_id: Option<Uuid>,
) -> 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()
.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::DisplayName, "author_display_name")
.column_as(user::Column::Username, "author_username")
.join(JoinType::InnerJoin, thought::Relation::User.def())
.filter(apply_visibility_filter(user_id, viewer_id, &friend_ids))
.filter(thought::Column::AuthorId.eq(user_id))
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
}
pub async fn get_feed_for_user(
db: &DbConn,
following_ids: Vec<Uuid>,
viewer_id: Option<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
if following_ids.is_empty() {
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()
.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(
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)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))
}
pub async fn get_feed_for_users_and_self(
db: &DbConn,
user_id: Uuid,
following_ids: Vec<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
let mut authors_to_include = following_ids;
authors_to_include.push(user_id);
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>()
.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::ThoughtTag.def())
.join(JoinType::InnerJoin, thought_tag::Relation::Tag.def())
.filter(tag::Column::Name.eq(tag_name.to_lowercase()))
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.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

@@ -0,0 +1,186 @@
use models::queries::pagination::PaginationQuery;
use sea_orm::prelude::Uuid;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
};
use models::domains::{top_friends, user};
use models::params::user::{CreateUserParams, UpdateUserParams};
use models::queries::user::UserQuery;
use crate::error::UserError;
use crate::persistence::follow::{get_follower_ids, get_following_ids, get_friend_ids};
pub async fn create_user(
db: &DbConn,
params: CreateUserParams,
) -> Result<user::ActiveModel, DbErr> {
user::ActiveModel {
username: Set(params.username),
..Default::default()
}
.save(db)
.await
}
pub async fn search_users(db: &DbConn, query: UserQuery) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.filter(user::Column::Username.contains(query.username.unwrap_or_default()))
.all(db)
.await
}
pub async fn get_user(db: &DbConn, id: Uuid) -> Result<Option<user::Model>, DbErr> {
user::Entity::find_by_id(id).one(db).await
}
pub async fn get_user_by_username(
db: &DbConn,
username: &str,
) -> Result<Option<user::Model>, DbErr> {
user::Entity::find()
.filter(user::Column::Username.eq(username))
.one(db)
.await
}
pub async fn get_users_by_ids(db: &DbConn, ids: Vec<Uuid>) -> Result<Vec<user::Model>, DbErr> {
user::Entity::find()
.filter(user::Column::Id.is_in(ids))
.all(db)
.await
}
pub async fn update_user_profile(
db: &DbConn,
user_id: Uuid,
params: UpdateUserParams,
) -> Result<user::Model, UserError> {
let mut user: user::ActiveModel = get_user(db, user_id)
.await
.map_err(|e| UserError::Internal(e.to_string()))?
.ok_or(UserError::NotFound)?
.into();
if let Some(display_name) = params.display_name {
user.display_name = Set(Some(display_name));
}
if let Some(bio) = params.bio {
user.bio = Set(Some(bio));
}
if let Some(avatar_url) = params.avatar_url {
user.avatar_url = Set(Some(avatar_url));
}
if let Some(header_url) = params.header_url {
user.header_url = Set(Some(header_url));
}
if let Some(custom_css) = params.custom_css {
user.custom_css = Set(Some(custom_css));
}
if let Some(friend_usernames) = params.top_friends {
let txn = db
.begin()
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
top_friends::Entity::delete_many()
.filter(top_friends::Column::UserId.eq(user_id))
.exec(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
let friends = user::Entity::find()
.filter(user::Column::Username.is_in(friend_usernames.clone()))
.all(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
if friends.len() != friend_usernames.len() {
return Err(UserError::Validation(
"One or more usernames in top_friends do not exist".to_string(),
));
}
let new_top_friends: Vec<top_friends::ActiveModel> = friends
.iter()
.enumerate()
.map(|(index, friend)| top_friends::ActiveModel {
user_id: Set(user_id),
friend_id: Set(friend.id),
position: Set((index + 1) as i16),
..Default::default()
})
.collect();
if !new_top_friends.is_empty() {
top_friends::Entity::insert_many(new_top_friends)
.exec(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
}
txn.commit()
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
}
user.update(db)
.await
.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

@@ -0,0 +1,7 @@
use sea_orm::DatabaseConnection;
#[derive(Clone)]
pub struct AppState {
pub conn: DatabaseConnection,
pub base_url: String,
}

View File

@@ -0,0 +1,14 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[lib]
name = "common"
path = "src/lib.rs"
[dependencies]
serde = { workspace = true }
utoipa = { workspace = true }
sea-orm = { workspace = true }
sea-query = { workspace = true }

View File

@@ -0,0 +1,53 @@
use sea_orm::prelude::DateTimeWithTimeZone;
use sea_orm::TryGetError;
use sea_orm::{sea_query::ColumnType, sea_query::Value, sea_query::ValueType, TryGetable};
use sea_query::ValueTypeErr;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema, Debug, Clone)]
#[schema(example = "2025-09-05T12:34:56Z")]
pub struct DateTimeWithTimeZoneWrapper(String);
impl From<DateTimeWithTimeZone> for DateTimeWithTimeZoneWrapper {
fn from(value: DateTimeWithTimeZone) -> Self {
DateTimeWithTimeZoneWrapper(value.to_rfc3339())
}
}
impl TryGetable for DateTimeWithTimeZoneWrapper {
fn try_get_by<I: sea_orm::ColIdx>(
res: &sea_orm::QueryResult,
index: I,
) -> Result<Self, TryGetError> {
let value: String = res.try_get_by(index)?;
Ok(DateTimeWithTimeZoneWrapper(value))
}
fn try_get(res: &sea_orm::QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
let value: String = res.try_get(pre, col)?;
Ok(DateTimeWithTimeZoneWrapper(value))
}
}
impl ValueType for DateTimeWithTimeZoneWrapper {
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
if let Value::String(Some(string)) = v {
Ok(DateTimeWithTimeZoneWrapper(*string))
} else {
Err(ValueTypeErr)
}
}
fn array_type() -> sea_query::ArrayType {
sea_query::ArrayType::String
}
fn column_type() -> ColumnType {
ColumnType::String(sea_query::StringLen::Max)
}
fn type_name() -> String {
"DateTimeWithTimeZoneWrapper".to_string()
}
}

View File

@@ -0,0 +1,24 @@
[package]
name = "doc"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "doc"
path = "src/lib.rs"
[dependencies]
axum = { workspace = true }
tracing = { workspace = true }
utoipa = { workspace = true, features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = [
"axum",
"vendored",
], default-features = false }
utoipa-scalar = { version = "0.3.0", features = [
"axum",
], default-features = false }
# api = { path = "../api" }
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,23 @@
use api::{
models::{ApiErrorResponse, ParamsErrorResponse},
routers::auth::*,
};
use models::{
params::auth::{LoginParams, RegisterParams},
schemas::user::UserSchema,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(register, login),
components(schemas(
RegisterParams,
LoginParams,
UserSchema,
TokenResponse,
ApiErrorResponse,
ParamsErrorResponse,
))
)]
pub(super) struct AuthApi;

View File

@@ -0,0 +1,10 @@
use api::{models::ApiErrorResponse, routers::feed::*};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(feed_get),
components(schemas(ThoughtSchema, ThoughtListSchema, ApiErrorResponse))
)]
pub(super) struct FeedApi;

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

@@ -0,0 +1,51 @@
use axum::Router;
use utoipa::{
openapi::security::{ApiKey, ApiKeyValue, Http, SecurityScheme},
Modify, OpenApi,
};
use utoipa_scalar::{Scalar, Servable as ScalarServable};
use utoipa_swagger_ui::SwaggerUi;
#[derive(OpenApi)]
#[openapi(
tags(
(name = "root", description = "Root API"),
(name = "auth", description = "Authentication API"),
(name = "user", description = "User & Social API"),
(name = "thought", description = "Thoughts API"),
(name = "feed", description = "Feed API"),
(name = "tag", description = "Tag Discovery API"),
(name = "friends", description = "Friends API"),
(name = "search", description = "Search API"),
),
modifiers(&SecurityAddon),
)]
struct _ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_auth",
SecurityScheme::Http(Http::new(utoipa::openapi::security::HttpAuthScheme::Bearer)),
);
components.add_security_scheme(
"api_key",
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("Authorization"))),
);
}
}
pub trait ApiDocExt {
fn attach_doc(self) -> Self;
}
impl ApiDocExt for Router {
fn attach_doc(self) -> Self {
tracing::info!("Attaching API documentation");
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
}
}

View File

@@ -0,0 +1,7 @@
use utoipa::OpenApi;
use api::routers::root::*;
#[derive(OpenApi)]
#[openapi(paths(root_get))]
pub(super) struct RootApi;

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

@@ -0,0 +1,22 @@
use api::{
models::{ApiErrorResponse, ParamsErrorResponse},
routers::thought::*,
};
use models::{
params::thought::CreateThoughtParams,
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(thoughts_post, thoughts_delete, get_thought_by_id, get_thought_thread),
components(schemas(
CreateThoughtParams,
ThoughtSchema,
ThoughtThreadSchema,
ApiErrorResponse,
ParamsErrorResponse
))
)]
pub(super) struct ThoughtApi;

View File

@@ -0,0 +1,37 @@
use utoipa::OpenApi;
use api::models::{ApiErrorResponse, ParamsErrorResponse};
use api::routers::user::*;
use models::params::user::{CreateUserParams, UpdateUserParams};
use models::schemas::{
thought::{ThoughtListSchema, ThoughtSchema},
user::{UserListSchema, UserSchema},
};
#[derive(OpenApi)]
#[openapi(
paths(
users_get,
get_user_by_param,
user_thoughts_get,
user_follow_post,
user_follow_delete,
user_inbox_post,
user_outbox_get,
get_me,
update_me,
get_user_followers,
get_user_following
),
components(schemas(
CreateUserParams,
UserListSchema,
UpdateUserParams,
UserSchema,
ThoughtSchema,
ThoughtListSchema,
ApiErrorResponse,
ParamsErrorResponse,
))
)]
pub(super) struct UserApi;

View File

@@ -0,0 +1,15 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
models = { path = "../models" }
async-std = { version = "1.13.1", features = ["attributes", "tokio1"] }
sea-orm-migration = { version = "1.1.12", features = ["sqlx-postgres"] }

View File

@@ -0,0 +1,59 @@
# Running Migrator CLI
- Generate a new migration file
```sh
cargo run -- generate MIGRATION_NAME
```
- Apply all pending migrations
```sh
cargo run
```
```sh
cargo run -- up
```
- Apply first 10 pending migrations
```sh
cargo run -- up -n 10
```
- Rollback last applied migrations
```sh
cargo run -- down
```
- Rollback last 10 applied migrations
```sh
cargo run -- down -n 10
```
- Drop all tables from the database, then reapply all migrations
```sh
cargo run -- fresh
```
- Rollback all applied migrations, then reapply all migrations
```sh
cargo run -- refresh
```
- Rollback all applied migrations
```sh
cargo run -- reset
```
- Check the status of all migrations
```sh
cargo run -- status
```

View File

@@ -0,0 +1,28 @@
pub use sea_orm_migration::prelude::*;
mod m20240101_000001_init;
mod m20250905_000001_init;
mod m20250906_100000_add_profile_fields;
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;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
Box::new(m20240101_000001_init::Migration),
Box::new(m20250905_000001_init::Migration),
Box::new(m20250906_100000_add_profile_fields::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

@@ -0,0 +1,47 @@
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
.create_table(
Table::create()
.table(User::Table)
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(
ColumnDef::new(User::Username)
.string()
.not_null()
.unique_key(),
)
.to_owned()
.col(ColumnDef::new(User::PasswordHash).string())
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(User::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub(super) enum User {
Table,
Id,
Username,
PasswordHash,
}

View File

@@ -0,0 +1,101 @@
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> {
// --- Create Thought Table ---
manager
.create_table(
Table::create()
.table(Thought::Table)
.if_not_exists()
.col(
ColumnDef::new(Thought::Id)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(uuid(Thought::AuthorId).not_null())
.foreign_key(
ForeignKey::create()
.name("fk_thought_author_id")
.from(Thought::Table, Thought::AuthorId)
.to(User::Table, User::Id)
.on_update(ForeignKeyAction::NoAction)
.on_delete(ForeignKeyAction::Cascade),
)
.col(string(Thought::Content).not_null())
.col(
timestamp_with_time_zone(Thought::CreatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
// --- Create Follow Table ---
manager
.create_table(
Table::create()
.table(Follow::Table)
.if_not_exists()
.col(uuid(Follow::FollowerId).not_null())
.col(uuid(Follow::FollowingId).not_null())
// Composite Primary Key to ensure a user can only follow another once
.primary_key(
Index::create()
.col(Follow::FollowerId)
.col(Follow::FollowingId),
)
.foreign_key(
ForeignKey::create()
.name("fk_follow_follower_id")
.from(Follow::Table, Follow::FollowerId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_follow_following_id")
.from(Follow::Table, Follow::FollowingId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Follow::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Thought::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
pub enum Thought {
Table,
Id,
AuthorId,
Content,
CreatedAt,
}
#[derive(DeriveIden)]
pub enum Follow {
Table,
// The user who is initiating the follow
FollowerId,
// The user who is being followed
FollowingId,
}

View File

@@ -0,0 +1,107 @@
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
.alter_table(
Table::alter()
.table(User::Table)
.add_column(string_null(UserExtension::Email).unique_key())
.add_column(string_null(UserExtension::DisplayName))
.add_column(string_null(UserExtension::Bio))
.add_column(text_null(UserExtension::AvatarUrl))
.add_column(text_null(UserExtension::HeaderUrl))
.add_column(text_null(UserExtension::CustomCss))
.add_column(
timestamp_with_time_zone(UserExtension::CreatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.add_column(
timestamp_with_time_zone(UserExtension::UpdatedAt)
.not_null()
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(TopFriends::Table)
.if_not_exists()
.col(uuid(TopFriends::UserId).not_null())
.col(uuid(TopFriends::FriendId).not_null())
.col(small_integer(TopFriends::Position).not_null())
.primary_key(
Index::create()
.col(TopFriends::UserId)
.col(TopFriends::FriendId),
)
.foreign_key(
ForeignKey::create()
.name("fk_top_friends_user_id")
.from(TopFriends::Table, TopFriends::UserId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_top_friends_friend_id")
.from(TopFriends::Table, TopFriends::FriendId)
.to(User::Table, User::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(TopFriends::Table).to_owned())
.await?;
manager
.alter_table(
Table::alter()
.table(User::Table)
.drop_column(UserExtension::Email)
.drop_column(UserExtension::DisplayName)
.drop_column(UserExtension::Bio)
.drop_column(UserExtension::AvatarUrl)
.drop_column(UserExtension::HeaderUrl)
.drop_column(UserExtension::CustomCss)
.drop_column(UserExtension::CreatedAt)
.drop_column(UserExtension::UpdatedAt)
.to_owned(),
)
.await
}
}
#[derive(DeriveIden)]
enum UserExtension {
Email,
DisplayName,
Bio,
AvatarUrl,
HeaderUrl,
CustomCss,
CreatedAt,
UpdatedAt,
}
#[derive(DeriveIden)]
enum TopFriends {
Table,
UserId,
FriendId,
Position,
}

View File

@@ -0,0 +1,74 @@
use super::m20250905_000001_init::Thought;
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(Tag::Table)
.if_not_exists()
.col(pk_auto(Tag::Id))
.col(string(Tag::Name).not_null().unique_key())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(ThoughtTag::Table)
.if_not_exists()
.col(uuid(ThoughtTag::ThoughtId).not_null())
.col(integer(ThoughtTag::TagId).not_null())
.primary_key(
Index::create()
.col(ThoughtTag::ThoughtId)
.col(ThoughtTag::TagId),
)
.foreign_key(
ForeignKey::create()
.name("fk_thought_tag_thought_id")
.from(ThoughtTag::Table, ThoughtTag::ThoughtId)
.to(Thought::Table, Thought::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_thought_tag_tag_id")
.from(ThoughtTag::Table, ThoughtTag::TagId)
.to(Tag::Table, Tag::Id)
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(ThoughtTag::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Tag::Table).to_owned())
.await
}
}
#[derive(DeriveIden)]
enum Tag {
Table,
Id,
Name,
}
#[derive(DeriveIden)]
enum ThoughtTag {
Table,
ThoughtId,
TagId,
}

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

@@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

View File

@@ -0,0 +1,23 @@
[package]
name = "models"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "models"
path = "src/lib.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
sea-orm = { workspace = true, features = [
"sqlx-postgres",
"runtime-tokio-rustls",
"macros",
] }
uuid = { version = "1.18.1", features = ["v4", "serde"] }
validator = { workspace = true, features = ["derive"] }
utoipa = { workspace = true }
common = { path = "../common" }

View File

@@ -0,0 +1,13 @@
# models
No axum or api dependencies should be introduced into this folder.
Only dependencies for modelling are allowed:
- serde (JSON serialization/deserialization)
- SeaORM (domain models and database)
- validator (parameter validation)
- utoipa (openapi)
## SeaORM
Write migration files first, then generate models.

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

@@ -0,0 +1,38 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "follow")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub follower_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub following_id: Uuid,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::FollowerId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Follower,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::FollowingId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Following,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Follower.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,11 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
pub mod prelude;
pub mod api_key;
pub mod follow;
pub mod tag;
pub mod thought;
pub mod thought_tag;
pub mod top_friends;
pub mod user;

View File

@@ -0,0 +1,9 @@
//! `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::tag::Entity as Tag;
pub use super::thought::Entity as Thought;
pub use super::thought_tag::Entity as ThoughtTag;
pub use super::top_friends::Entity as TopFriends;
pub use super::user::Entity as User;

View File

@@ -0,0 +1,27 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "tag")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::thought_tag::Entity")]
ThoughtTag,
}
impl Related<super::thought::Entity> for Entity {
fn to() -> RelationDef {
super::thought_tag::Relation::Thought.def()
}
fn via() -> Option<RelationDef> {
Some(super::thought_tag::Relation::Tag.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,62 @@
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)]
#[sea_orm(table_name = "thought")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub author_id: Uuid,
pub content: String,
pub reply_to_id: Option<Uuid>,
pub visibility: Visibility,
pub created_at: DateTimeWithTimeZone,
#[sea_orm(column_type = "custom(\"tsvector\")", nullable, ignore)]
pub search_document: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::AuthorId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
#[sea_orm(has_many = "super::thought_tag::Entity")]
ThoughtTag,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
super::thought_tag::Relation::Tag.def()
}
fn via() -> Option<RelationDef> {
Some(super::thought_tag::Relation::Thought.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,40 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "thought_tag")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub thought_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::thought::Entity",
from = "Column::ThoughtId",
to = "super::thought::Column::Id"
)]
Thought,
#[sea_orm(
belongs_to = "super::tag::Entity",
from = "Column::TagId",
to = "super::tag::Column::Id"
)]
Tag,
}
impl Related<super::thought::Entity> for Entity {
fn to() -> RelationDef {
Relation::Thought.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tag.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,35 @@
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "top_friends")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub friend_id: Uuid,
pub position: i16,
}
#[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,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::FriendId",
to = "super::user::Column::Id"
)]
Friend,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,38 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique)]
pub username: String,
pub password_hash: Option<String>,
#[sea_orm(unique)]
pub email: Option<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 created_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)]
pub enum Relation {
#[sea_orm(has_many = "super::thought::Entity")]
Thought,
#[sea_orm(has_many = "super::top_friends::Entity")]
TopFriends,
#[sea_orm(has_many = "super::api_key::Entity")]
ApiKey,
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,4 @@
pub mod domains;
pub mod params;
pub mod queries;
pub mod schemas;

View File

@@ -0,0 +1,21 @@
use serde::Deserialize;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Deserialize, Validate, ToSchema)]
pub struct RegisterParams {
#[validate(length(min = 3))]
pub username: String,
#[validate(email)]
pub email: String,
#[validate(length(min = 6))]
pub password: String,
}
#[derive(Deserialize, Validate, ToSchema)]
pub struct LoginParams {
#[validate(length(min = 3))]
pub username: String,
#[validate(length(min = 6))]
pub password: String,
}

View File

@@ -0,0 +1,3 @@
pub mod auth;
pub mod thought;
pub mod user;

View File

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

View File

@@ -0,0 +1,38 @@
use serde::Deserialize;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Deserialize, Validate, ToSchema)]
pub struct CreateUserParams {
#[validate(length(min = 2))]
pub username: String,
#[validate(length(min = 6))]
pub password: String,
}
#[derive(Deserialize, Validate, ToSchema, Default)]
pub struct UpdateUserParams {
#[validate(length(max = 50))]
#[schema(example = "Frutiger Aero Fan")]
#[serde(rename = "displayName")]
pub display_name: Option<String>,
#[validate(length(max = 4000))]
#[schema(example = "Est. 2004")]
pub bio: Option<String>,
#[validate(url)]
#[serde(rename = "avatarUrl")]
pub avatar_url: Option<String>,
#[validate(url)]
#[serde(rename = "headerUrl")]
pub header_url: Option<String>,
#[serde(rename = "customCss")]
pub custom_css: Option<String>,
#[validate(length(max = 8))]
#[schema(example = json!(["username1", "username2"]))]
#[serde(rename = "topFriends")]
pub top_friends: Option<Vec<String>>,
}

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