Compare commits

..

47 Commits

Author SHA1 Message Date
zhenyi c6f99fff47 refactor: extract GitksServer / GitksConfig as library, thin main.rs
- Add GitksConfig struct with from_env() for explicit config
- Add GitksServer / GitksServerBuilder to lib.rs
- Move tracing init, disk cache, metrics, hooks setup into builder
- Expose serve() / serve_with_shutdown() for embedding
- main.rs now only handles etcd overlay and delegates to library
2026-06-12 21:36:57 +08:00
zhenyi 96b391ff2d chore: add AGENTS.md 2026-06-12 17:20:41 +08:00
zhenyi 0e13f90834 style(format): reformat code with consistent line breaks and spacing 2026-06-12 15:11:32 +08:00
zhenyi 6f40921576 refactor(metrics): simplify response building with unwrap_or_else
- Replace explicit match statement with unwrap_or_else for cleaner error handling
- Maintain same error logging behavior when response building fails
- Reduce code complexity and improve readability
- Keep identical fallback response creation on builder errors
2026-06-12 15:10:00 +08:00
zhenyi 44fe5a519b chore(copyright): add copyright headers to all source files 2026-06-12 15:06:39 +08:00
zhenyi 70f2f7d63d feat(config): add centralized configuration constants and infrastructure tests
- Introduce config.rs with all magic numbers and resource limits defined as constants
- Add comprehensive test suite covering metrics rendering, rate limiting, and cache operations
- Include tests for configuration constant validation and sanitization functions
- Add pack protocol tests for index_pack and pack_objects functionality
- Implement remote repository discovery tests with security validations
- Support runtime overrides via environment variables for all configurable values
2026-06-12 15:04:29 +08:00
zhenyi 10a4398e81 refactor(bare): enhance security and performance optimizations
- Remove unnecessary sorting in advertise_refs for deterministic output
- Add path traversal detection and validation in bare_dir construction
- Implement symlink resolution checks to prevent security vulnerabilities
- Refactor cache system with CRC validation and improved metrics
- Integrate repo-specific cache invalidation using indexed keys
- Add comprehensive unit tests for commit operations and diff functionality
- Move configuration constants to centralized config module
- Optimize string operations in disk cache random value generation
- Enhance license detection algorithm with cleaner matching logic
- Streamline argument processing in various git operations
- Update dependencies including crc32fast and flate2 for performance
- Add signal handling capability to tokio runtime configuration
2026-06-12 15:04:12 +08:00
zhenyi e386f44ee2 feat(git): add size limits for git operations
- Added MAX_CHERRY_PICK_PATCH_BYTES limit of 100MB for cherry-pick operations
- Added MAX_ACTION_CONTENT_BYTES limit of 100MB for commit action content
- Added MAX_COMMIT_MESSAGE_BYTES limit of 10MB for commit messages
- Added MAX_CHECK_REVISIONS limit of 10,000 for revision checks
- Added MAX_REBASE_COMMITS limit of 10,000 for rebase operations
- Added MAX_REBASE_PATCH_BYTES limit of 100MB for rebase patches
- Added MAX_RESOLUTION_CONTENT_BYTES limit of 100MB for merge conflict resolutions
- Added MAX_REVERT_PATCH_BYTES limit of 100MB for revert operations
- Return InvalidArgument error when size limits are exceeded with descriptive messages
2026-06-12 12:59:50 +08:00
zhenyi 293102e5f2 feat(git): add size limits for git operations
- Added MAX_CHERRY_PICK_PATCH_BYTES limit of 100MB for cherry-pick operations
- Added MAX_ACTION_CONTENT_BYTES limit of 100MB for commit action content
- Added MAX_COMMIT_MESSAGE_BYTES limit of 10MB for commit messages
- Added MAX_CHECK_REVISIONS limit of 10,000 for revision checks
- Added MAX_REBASE_COMMITS limit of 10,000 for rebase operations
- Added MAX_REBASE_PATCH_BYTES limit of 100MB for rebase patches
- Added MAX_RESOLUTION_CONTENT_BYTES limit of 100MB for merge conflict resolutions
- Added MAX_REVERT_PATCH_BYTES limit of 100MB for revert operations
- Return InvalidArgument error when size limits are exceeded with descriptive messages
2026-06-12 12:59:47 +08:00
zhenyi 934858bebf refactor(cache): redesign cache system with structured keys and improved performance
- Add repo_path parameter to cached_response and cached_vec_response functions
- Implement structured cache key format with namespace, repo_path, and request proto
- Replace global cache with Moka in-memory cache using weight-based eviction
- Set 256MB memory cap with 10-minute TTL and 2-minute TTI policy
- Add metrics collection for cache operations and evictions
- Implement efficient repo-scoped invalidation using key structure
- Add detailed documentation comments explaining cache architecture
- Remove outdated dependencies and update dependency versions
- Add error handling for encoding failures in cache operations
- Optimize Vec responses with length-delimited encoding and pre-allocation
2026-06-12 12:53:23 +08:00
zhenyi a40da90ef9 refactor(build): reformat code and add tonic health dependency
- Reformatted build script with proper indentation and line breaks
- Added tonic-health dependency to Cargo.toml and updated lock file
- Improved error handling in disk cache with concurrent deletion checks
- Refactored conditional chains using && and let expressions
- Reformatted struct initialization and function parameter lists
- Added proper spacing and alignment in language stats processing
- Improved assertion formatting in test cases
- Reorganized import statements and code layout in multiple files
- Updated metrics functions with better parameter handling and formatting
2026-06-11 13:56:15 +08:00
zhenyi c32a7cad2f feat(cluster): implement Raft consensus with tracing and HTTP support
- Add Raft log and snapshot mechanisms for distributed consensus
- Integrate hyper HTTP server and client libraries for network communication
- Enhance tracing capabilities with structured logging and spans
- Add dependency tracking for new consensus-related crates
- Implement snapshot storage with serialization and persistence
- Add remote repository synchronization via Raft commands
- Include comprehensive tracing instrumentation across services
2026-06-10 18:33:42 +08:00
zhenyi 0207cde234 chore(disk-cache): remove unnecessary doc comment on random_value 2026-06-10 18:32:52 +08:00
zhenyi 2dd384f7be fix(server): add periodic route cache cleanup
Add cleanup_route_cache() and a 120s background cleanup task to
prevent unbounded DashMap growth from stale route entries.
Fix init_tracing to return WorkerGuard so the file appender stays
alive for the program lifetime.
2026-06-10 18:32:47 +08:00
zhenyi 0782a9fe6d fix(refs): write stdin data to git update-ref subprocess
Use spawn + write_all + wait_with_output instead of output() so that
the constructed ref update commands are actually written to the
subprocess stdin.
2026-06-10 18:32:42 +08:00
zhenyi 1dca8b3b78 fix(raft): add boundary checks and parse warnings to message deserialization
Add offset+length bounds checking to AppendEntriesRequest.from_bytes
to prevent slice panic. Cap entry count to 10000. Emit tracing::warn
when critical term fields fail to parse in Election and RoleChanged
messages.
2026-06-10 18:32:37 +08:00
zhenyi 49c1675e01 fix(raft): truncate log on AppendEntries term conflict
Use truncate_from() instead of skip when a follower detects a term
mismatch, correctly deleting conflicting entries and all that follow
per the Raft protocol.
2026-06-10 18:32:33 +08:00
zhenyi bcd750b905 feat(raft): add log truncation for AppendEntries conflict resolution
Add truncate_from() method to RaftLog for removing entries from a given
index onwards, as required by the Raft protocol when a follower detects
a term mismatch during AppendEntries.
2026-06-10 18:32:22 +08:00
zhenyi c8729d38bc fix(rate-limit): avoid over-admission when updating max_concurrent
Only replace semaphores that have no active permits (idle repos).
Previously all semaphores were replaced, allowing active permits from
old semaphores plus full permits from new ones simultaneously.
2026-06-10 18:32:16 +08:00
zhenyi c3017a255f fix(search): use fixed-string matching to prevent ReDoS
Add -F flag to git grep to disable regex interpretation, preventing
catastrophic backtracking from malicious query patterns.
2026-06-10 18:32:10 +08:00
zhenyi c9c1a739fd fix(hooks): shell-escape script values and use glob iteration
Add shell_escape() to safely embed values in generated shell scripts.
Replace '$(ls ...)' anti-pattern with glob iteration to correctly
handle filenames containing spaces.
2026-06-10 18:32:05 +08:00
zhenyi 45c00b2dee fix(metrics): escape Prometheus label values and remove section dividers
Add prom_escape() to sanitize label values per Prometheus text format spec.
Remove '// ──' section dividers per project style guidelines.
2026-06-10 18:31:59 +08:00
zhenyi e582b269f1 fix(server): validate branch name in set_default_branch
Call validate_ref_name on the user-provided branch name before
constructing the symbolic-ref argument to prevent command injection.
2026-06-10 18:31:54 +08:00
zhenyi 0665772079 fix(remote): validate remote URL, name and refspecs
Call validate_remote_url, validate_ref_name, and validate_refspec before
passing user input to git subprocess in update_remote_mirror, fetch_remote,
and create_repository_from_url.
2026-06-10 18:31:50 +08:00
zhenyi 1c22700769 feat(sanitize): add remote URL and refspec validation
Add validate_remote_url() to reject non-transport schemes (file://, ext::)
and validate_refspec() to reject shell metacharacters in refspec strings.
2026-06-10 18:31:46 +08:00
zhenyi e4080dcbc7 chore: remove .env from git tracking
Add .env to .gitignore, unstage tracked .env, and create .env.example
as a template for local development.
2026-06-10 18:31:36 +08:00
zhenyi 939931acad feat(repository): add language statistics analysis feature
- Remove data directory from gitignore to include language data
- Add build script to parse linguist languages.yml and generate static mappings
- Include serde and serde_yml dependencies for YAML parsing
- Add lang_stats module with language detection and statistics calculation
- Generate protobuf definitions for language statistics API endpoints
- Implement GetLanguageStats RPC endpoint in repository server
- Add comprehensive test suite for language statistics functionality
- Include extension and filename based language detection logic
- Implement binary file classification and group resolution features
2026-06-10 13:06:59 +08:00
zhenyi 9a0c26e5f6 refactor(actor): implement Raft consensus algorithm for cluster leader election
- Add voting mechanism with term tracking and vote persistence
- Implement election triggering logic with majority vote counting
- Add primary/replica role transition handling with state management
- Integrate health check failure detection for automatic elections
- Refactor actor messaging system for distributed coordination
- Update repository registration to query cluster for existing primary
- Add broadcast mechanism for role change notifications
- Implement proper term comparison and duplicate request filtering
- Upgrade dependency versions including tokio-util for async utilities
- Optimize code formatting and line wrapping for improved readability
- Remove redundant blank lines and improve code structure consistency
- Enhance error logging and trace information for debugging purposes
2026-06-10 12:35:10 +08:00
zhenyi ab32e8826e feat(pack): add raw advertise refs and stateless protocol support
- Add raw flag to AdvertiseRefsRequest to enable raw pkt-line output
- Implement advertise_refs_raw function that calls git upload-pack/receive-pack with --advertise-refs
- Add stateless flag to GitProtocolFeatures for HTTP smart protocol support
- Modify upload_pack and receive_pack to accept stateless parameter
- Update command construction to include --stateless-rpc flag when enabled
- Add raw_data field to AdvertiseRefsResponse for raw output
- Update pack cache key computation to include raw service differentiation
- Initialize raw field to false in all request creation calls
2026-06-08 21:46:31 +08:00
zhenyi eeb4d9f902 feat(system): add systemd service and installation script
- Add gitks systemd service unit file with security sandboxing
- Create environment configuration template for gitks service
- Add logrotate configuration for gitks application logs
- Implement installation script with service user creation
- Set up proper directory permissions and file ownership
- Configure automatic service startup and systemd integration
2026-06-08 21:27:54 +08:00
zhenyi c2487ec0b6 feat(charts): add Helm chart for gitks Git bare repository service
- Create Chart.yaml with application metadata and keywords
- Add _helpers.tpl with name, fullname, labels, and DNS template functions
- Generate ConfigMap with all gitks configuration environment variables
- Implement StatefulSet with persistent volume claims for repository data
- Create headless service for pod DNS and cluster communication
- Add gRPC service for client connections and metrics service
- Include HorizontalPodAutoscaler for dynamic scaling
- Add PodDisruptionBudget to maintain cluster availability
- Create ServiceAccount with proper security context
- Add test connection pod using grpcurl for health checks
- Generate NOTES.txt with installation instructions and quick start guide
- Create .helmignore file to exclude common development files
- Configure persistence, resource limits, and security settings
- Add support for cluster mode with etcd service discovery
2026-06-08 21:21:15 +08:00
zhenyi f5044fb099 refactor(docker): optimize Docker build process and update configurations
- Replace direct Rust build with cargo-chef multi-stage build pattern
- Switch base image from debian:bookworm-slim to ubuntu:26.04
- Add .codegraph to .dockerignore and data to .gitignore
- Introduce Dockerfile.fast for faster builds without optimization
- Add comprehensive .env configuration file with cluster settings
- Create docker-compose.yaml for multi-node cluster setup
- Add cluster routing test case for distributed operations
- Remove unnecessary success status checks in repository maintenance
- Fix error handling in git command executions by properly propagating errors
- Add repository move protection to prevent self-destruct operations
- Simplify conditional logic in actor message validation
- Update remote pack client calls with proper error handling parameters
2026-06-08 18:52:22 +08:00
zhenyi 66afd932ed feat(api): extend commit and diff services with new functionality
- Add FindCommit, ListCommitsByOid, CommitIsAncestor RPCs to CommitService
- Add CheckObjectsExist, CommitsByMessage, GetCommitStats RPCs to CommitService
- Add LastCommitForPath, CountCommits, CountDivergingCommits RPCs to CommitService
- Add RawDiff, RawPatch, FindChangedPaths RPCs to DiffService
- Add FindMergeBase, WriteRef, SearchFilesByContent RPCs to RepositoryService
- Add SearchFilesByName, ObjectsSize, RepositorySize RPCs to RepositoryService
- Add FindLicense, OptimizeRepository, GetRawChanges RPCs to RepositoryService
- Add FetchRemote, CreateRepositoryFromURL RPCs to RepositoryService
- Implement server handlers for all new RPC methods
- Add new modules for commit counting, finding, and querying features
- Add new modules for diff changed paths and raw operations
- Add new modules for refs and remote operations
- Remove unnecessary comments from various source files
- Update proto definitions with new message types and service methods
2026-06-08 15:37:08 +08:00
zhenyi 8f472a0443 feat(cluster): implement distributed clustering with etcd coordination
- Integrate etcd-client for distributed coordination and leader election
- Add remote client macros with proper formatting for all services
- Implement RequestMetrics for tracking RPC performance and errors
- Add rate limiting mechanism across all service endpoints
- Create ElectionRequest and ElectionResult message types for leader election
- Add role management with primary/replica switching capabilities
- Implement health checker with automatic failover detection
- Add repository count metrics for cluster monitoring
- Update Cargo.toml with etcd-client and dashmap dependencies
- Modify RepoEntry to include read_only flag for replica handling
- Implement should_accept_election logic to prevent duplicate elections
- Add RoleChangedEvent handling for cluster role updates
2026-06-08 14:31:29 +08:00
zhenyi d243dce027 refactor(server): replace custom remote clients with macro-based implementation
- Replaced manual remote client functions with remote_client! macro for archive, blame, branch, commit, and diff services
- Simplified remote client creation logic using declarative macro approach
- Maintained same functionality while reducing code duplication across services

security(bare): enhance path traversal protection with comprehensive validation

- Added early relative_path validation to prevent path traversal attacks
- Implemented unified path validation to avoid TOCTOU race conditions
- Enhanced canonicalization checks for both existing and non-existent paths
- Added detailed logging for path traversal detection attempts

feat(cache): migrate from CLruCache to Moka with TTL and invalidation support

- Replaced clru dependency with moka for improved caching capabilities
- Added 300-second time-to-live for cache entries
- Implemented repository-specific cache invalidation mechanism
- Enhanced cache operations with thread-safe async support

refactor(commit): improve security validation for commit operations

- Added ref name validation to prevent command injection in cherry_pick_commit
- Implemented revision validation for commit selectors
- Added comprehensive input validation for create_commit parameters
- Enhanced file path validation to prevent traversal
2026-06-08 09:43:57 +08:00
zhenyi 8c95eb230d refactor(actor): implement replica sync and ref update notification system
- Add is_write parameter to remote clients for read/write routing distinction
- Introduce RepoEntry struct with role tracking (primary/replica) for repositories
- Replace HashSet with HashMap for repository storage with role metadata
- Add ROLE_PRIMARY and ROLE_REPLICA constants for node role identification
- Implement FindPrimary and FindReplica RPC methods for role-based routing
- Add RefUpdateEvent message type for propagating reference updates
- Create sync module with BundleApplicator for handling replica synchronization
- Implement notify_ref_update calls after branch/tag/commit operations
- Add broadcast_ref_update function to propagate events across cluster nodes
- Modify route_repository to prioritize primary for writes and replicas for reads
- Update actor message handling to support role-based repository discovery
- Implement sync_from_primary function using pack protocol for incremental updates
2026-06-08 01:54:08 +08:00
zhenyi 5c99b27421 feat(gateway): implement remote service forwarding for distributed git operations
- Add remote client functions for archive, blame, and branch services
- Implement fallback logic to forward requests to remote storage nodes
- Add logging for forwarding operations with route details
- Update Cargo.lock with new dependencies including ractor cluster libraries
- Extend .gitignore with IDE and build system files
- Remove outdated comments from bare repository implementation
2026-06-08 01:21:20 +08:00
zhenyi 5b740eecd7 chore(deps): update dependencies and migrate to tonic-prost
- Replace tonic-build with tonic-prost-build in build dependencies
- Update clru from 0.6.3 to 0.6
- Update serde from 1.0.228 to 1.0
- Update gix from 0.84.0 to 0.84
- Update gix-archive from 0.33.0 to 0.33
- Update duct from 1.1.1 to 1.0
- Update tracing from 0.1.32 to 0.1
- Update tokio-stream from 0.1.18 to 0.1
- Update thiserror from 2.0.18 to 2.0
- Update prost from 0.13 to 0.14
- Update prost-types from 0.13 to 0.14
- Update tonic from 0.12 to 0.14 with transport feature
- Add tonic-prost dependency at version 0.14
- Update tonic-prost-build in build dependencies from 0.12 to 0.14
- Remove async-stream and async-stream-impl dependencies
- Update axum from 0.7.9 to 0.8.9
- Update various other transitive dependencies including getrandom, indexmap, hashbrown, socket2, log, matchit, petgraph, tower, and windows-sys related packages
2026-06-04 18:07:17 +08:00
zhenyi a815f63927 chore: update gitignore and remove ide configuration files
- Add .idea directory to .gitignore
- Remove .idea/.gitignore file
- Remove .idea/gitks.iml module file
- Remove .idea/modules.xml project modules configuration
- Remove .idea/vcs.xml version control system mapping
- Clean up IDE-specific configuration files from repository
2026-06-04 17:48:17 +08:00
zhenyi 7631e57f69 test(bare): add comprehensive tests for GitBare functionality
- Add test_from_header_valid to verify valid repository header parsing
- Add test_from_header_empty_path to handle empty path scenarios
- Add test_from_header_relative_storage_path to validate absolute paths
- Add test_from_header_relative_path_without_storage for missing storage
- Add test_from_header_nonexistent_repo to check repo existence
- Add test_from_header_path_traversal to prevent directory traversal
- Add test_from_header_not_a_directory for file instead of directory
- Add test_from_header_dir_without_head to verify bare repository format
- Add test_object_format to validate object format detection
- Add test_oid_to_pb to verify OID conversion functionality
- Add test_oid_to_pb_invalid_hex to handle invalid hex input gracefully

test(error): add comprehensive error handling tests

- Add test_error_display_variants to verify error message formatting
- Add test_error_is_debug to
2026-06-04 15:33:33 +08:00
zhenyi cc202d6d1f feat(server): add tracing spans and caching to archive and blame services
- Add tracing spans with repo labels for archive and blame operations
- Implement caching for archive list entries when using OID selectors
- Implement caching for blame operations when using OID selectors
- Add detailed
2026-06-04 15:33:16 +08:00
zhenyi 729604f13b feat(server): add repository prefix path configuration and service struct
- Add REPO_PREFIX_PATH environment variable support in Dockerfile and main.rs
- Introduce GitksService struct with repo_prefix field to manage repository paths
- Implement resolve and resolve_for_init methods for repository path handling
- Add path traversal protection and validation for repository operations
- Update all service implementations to use self.resolve instead of global resolve
- Modify serve function to accept repo_prefix parameter and pass to GitksService
- Remove global resolve functions and integrate them into GitksService struct
- Add proper initialization of repo directory from environment variable
2026-06-04 14:18:12 +08:00
zhenyi 4a87ea475d feat(build): add Docker support for gitks application
- Add .dockerignore file to exclude unnecessary files from Docker build context
- Create multi-stage Dockerfile with rust builder and debian runtime stages
- Configure environment variables for host and port settings
- Set up proper EXPOSE and ENTRYPOINT instructions
- Optimize build with cargo release and binary stripping
- Install only required dependencies in production stage
2026-06-04 14:11:55 +08:00
zhenyi f0a443932a feat(server): add gitks gRPC server with environment configuration
- Add dotenvy dependency for environment variable loading
- Integrate tracing-subscriber for structured logging
- Create main.rs entry point with async server initialization
- Implement environment-based host and port configuration
- Set default address to 0.0.0:50051 for gRPC server
- Add proper error handling with Box<dyn std::error::Error>
2026-06-04 14:10:29 +08:00
zhenyi 998f393ed0 feat(server): add comprehensive Git repository services with test coverage
- Implement ArchiveService for repository archive operations
- Add BlameService for Git blame functionality
- Create BranchService with full branch management capabilities
- Integrate CommitService for commit operations and history
- Add DiffService for generating diffs and patches
- Implement MergeService with conflict resolution features
- Add PackService for Git packfile operations
- Create TagService for Git tag management
- Add TreeService for Git tree operations
- Implement comprehensive repository management functions
- Add repository statistics and health checking capabilities
- Include garbage collection and repacking operations
- Add repository configuration management
- Implement error handling and status conversion utilities
- Add test suite covering all repository operations
- Create utility functions for Git command execution
- Add streaming response support for large data operations
- Implement request resolution and validation helpers
2026-06-04 14:10:21 +08:00
zhenyi 737e934043 feat(tree): add recent commit metadata and LFS support to file metadata
- Added RecentCommit message definition with oid, subject and timestamp fields
- Extended TreeEntry, Tree, and FileMetadata messages with is_lfs and recent_commit fields
- Updated get_file_metadata function to include recent commit information
- Added tree module import and recent_commit lookup functionality
- Updated protobuf definitions to include new metadata fields
- Enhanced file metadata response with LFS status and commit history
2026-06-04 13:47:46 +08:00
zhenyi dcb0fb74c5 feat(core): implement Git repository operations with gRPC services
- Add advertise_refs functionality for Git protocol communication
- Implement archive service with TAR/ZIP format support and streaming
- Create blame service for Git file annotation with line tracking
- Add branch management including create, delete, rename and compare operations
- Implement merge checking with conflict detection and fast-forward handling
- Add cherry-pick functionality for applying commits between branches
- Integrate gix library for Git repository operations and object handling
- Add comprehensive test suite covering all Git operations
- Implement proper error handling and repository validation
- Add pagination support for large result sets
- Create protobuf definitions for all Git operations and data structures
- Add build system for gRPC code generation and dependency management
2026-06-04 13:05:38 +08:00
877 changed files with 43783 additions and 121789 deletions
View File
-1
View File
@@ -1,5 +1,4 @@
.codegraph .codegraph
.claude
target target
.git .git
.idea .idea
+23 -120
View File
@@ -1,120 +1,23 @@
# HTTP Server REPO_PREFIX_PATH=/home/zhenyi/RustroverProjects/gitks/data
APP_HTTP_HOST=0.0.0.0 GITKS_HOST=0.0.0.0
APP_HTTP_PORT=8000 GITKS_PORT=50051
APP_HTTP_WORKERS=4 GITKS_ADVERTISE_ADDR=http://gitks-node1:50051
APP_HTTP_JSON_LIMIT_BYTES=10485760 GITKS_METRICS_PORT=9100
GITKS_DISK_CACHE_ENABLED=false
# App GITKS_DISK_CACHE_MAX_AGE=300
APP_URL=http://localhost:8000 GITKS_PACK_CACHE_ENABLED=true
APP_MAIN_DOMAIN=localhost GITKS_PACK_CACHE_BACKPRESSURE=true
GITKS_RATE_LIMIT_MAX_CONCURRENT=100
# Session GITKS_HOOKS_ENABLED=true
APP_SESSION_SECRET=change-me-to-a-secure-random-string-at-least-32-bytes GITKS_HOOK_TIMEOUT=30
APP_SESSION_COOKIE_NAME=sid GITKS_ALLOW_CUSTOM_HOOKS=true
APP_SESSION_COOKIE_SECURE=false #GITKS_SERVER_HOOKS_DIR=/etc/gitks/hooks
APP_SESSION_COOKIE_HTTP_ONLY=true GITKS_HOOK_CALLBACK_ADDR=http://localhost:50052
APP_SESSION_COOKIE_SAME_SITE=Lax GITKS_ETCD_ENDPOINTS=http://localhost:2379
APP_SESSION_COOKIE_PATH=/ GITKS_CLUSTER_PORT=4697
APP_SESSION_COOKIE_DOMAIN= GITKS_CLUSTER_COOKIE=gitks-default-cookie
APP_SESSION_TTL_SECS=86400 GITKS_LEASE_TTL=15
APP_SESSION_MAX_AGE_SECS=86400 GITKS_ETCD_CONNECT_TIMEOUT=5000
GITKS_HEALTH_CHECK_INTERVAL=1
# PostgreSQL GITKS_MAX_HEALTH_FAILURES=10
DATABASE_URL=postgres://appks:appks@localhost:5432/appks STORAGE_NAME=default
APP_DATABASE_URL=postgres://appks:appks@localhost:5432/appks
APP_DATABASE_MAX_CONNECTIONS=10
APP_DATABASE_MIN_CONNECTIONS=2
APP_DATABASE_IDLE_TIMEOUT=600
APP_DATABASE_MAX_LIFETIME=3600
APP_DATABASE_CONNECTION_TIMEOUT=8
APP_DATABASE_SCHEMA_SEARCH_PATH=public
APP_DATABASE_READ_WRITE_SPLIT=false
APP_DATABASE_RETRY_ATTEMPTS=3
APP_DATABASE_RETRY_DELAY=5
# Redis
# Single-node mode (set APP_REDIS_CLUSTER_ENABLED=false)
APP_REDIS_URL=redis://localhost:6379/0
# Cluster mode (set APP_REDIS_CLUSTER_ENABLED=true)
APP_REDIS_CLUSTER_ENABLED=true
APP_REDIS_CLUSTER_NODES=redis://localhost:6379,redis://localhost:6380,redis://localhost:6381,redis://localhost:6382,redis://localhost:6383,redis://localhost:6384
APP_REDIS_READ_FROM_REPLICAS=false
APP_REDIS_USERNAME=
APP_REDIS_PASSWORD=
APP_REDIS_MAX_CONNECTIONS=20
APP_REDIS_MIN_CONNECTIONS=2
APP_REDIS_IDLE_TIMEOUT=300
APP_REDIS_CONNECTION_TIMEOUT=5
APP_REDIS_MAX_RETRIES=3
APP_REDIS_RETRY_DELAY_MS=100
APP_REDIS_TLS_ENABLED=false
APP_REDIS_KEY_PREFIX=appks:
# etcd
APP_ETCD_ENDPOINTS=http://localhost:2379
APP_ETCD_KEY_PREFIX=/appks/
APP_ETCD_CONNECT_TIMEOUT=5
APP_ETCD_REQUEST_TIMEOUT=10
APP_ETCD_KEEP_ALIVE_INTERVAL=10
APP_ETCD_LEASE_TTL=15
APP_ETCD_MAX_RETRIES=3
APP_ETCD_REGISTER_SELF=false
# NATS
APP_NATS_URL=nats://localhost:4222
APP_NATS_CONNECTION_TIMEOUT=5
APP_NATS_PING_INTERVAL=20
APP_NATS_RECONNECT_DELAY=2
APP_NATS_MAX_RECONNECTS=60
APP_NATS_STREAM_PREFIX=APPKS
APP_NATS_ACK_WAIT_SECS=30
APP_NATS_MAX_DELIVER=5
# S3 / MinIO
APP_S3_ENDPOINT=http://localhost:9000
APP_S3_REGION=us-east-1
APP_S3_ACCESS_KEY=admin
APP_S3_SECRET_KEY=mysecret123
APP_S3_BUCKET=appks
APP_S3_PATH_STYLE=true
APP_S3_FORCE_PATH_STYLE=true
APP_S3_PUBLIC_URL=http://localhost:9000/appks
APP_S3_MAX_CONNECTIONS=50
APP_S3_IDLE_TIMEOUT=90
APP_S3_CONNECTION_TIMEOUT=10
APP_S3_MAX_RETRIES=3
APP_S3_UPLOAD_PART_SIZE=8388608
APP_S3_MAX_UPLOAD_SIZE=104857600
APP_S3_PRESIGNED_URL_EXPIRY=3600
# LRU Cache
APP_LRU_DEFAULT_CAPACITY=1000
APP_LRU_DEFAULT_TTL_SECS=300
APP_LRU_CLEANUP_INTERVAL_SECS=60
# gRPC Server
APP_RPC_SELF_HOST=0.0.0.0
APP_RPC_SELF_PORT=50049
APP_RPC_SELF_REFLECTION=false
APP_RPC_SELF_SERVICE_NAME=appks
APP_RPC_DEFAULT_TIMEOUT_SECS=10
# AI Provider
APP_AI_PROVIDER_API_KEY=
APP_AI_PROVIDER_URL=
# Qdrant
APP_QDRANT_URL=http://localhost:6334
APP_QDRANT_COLLECTION=appks_embeddings
APP_QDRANT_VECTOR_SIZE=1536
APP_QDRANT_DISTANCE=Cosine
APP_QDRANT_MAX_CONNECTIONS=10
APP_QDRANT_IDLE_TIMEOUT=300
APP_QDRANT_CONNECTION_TIMEOUT=10
APP_QDRANT_MAX_RETRIES=3
APP_QDRANT_TLS_ENABLED=false
APP_QDRANT_SEARCH_LIMIT=10
APP_QDRANT_SCORE_THRESHOLD=0.7
# Email RPC
APP_EMAIL_RPC_ADDR=http://localhost:50050
+5 -5
View File
@@ -1,8 +1,8 @@
/target /target
.idea .idea
.codegraph .codegraph
.claude .classpath
.env* .project
!.env.example .settings
AGENT.md .DS_Store
CLAUDE.md .env
-10
View File
@@ -1,10 +0,0 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+87 -580
View File
@@ -1,469 +1,121 @@
# AGENTS.md — 开发规范 / Development Guidelines # AGENTS.md — Development Guidelines
> 本文件为所有 AI 编码助手(Claude Code、pi、Cursor 等)提供统一的开发指导。 > Unified development guidelines for all AI coding assistants (Claude Code, Cursor, etc.)
> This file provides unified development guidelines for all AI coding assistants.
**最后更新 / Last Updated**: 2026-06-10 **Last Updated**: 2026-06-11
--- ---
## 目录 / Table of Contents ## 1. Language
1. [语言 / Language](#1-语言--language) Always respond in **Chinese (中文)**. Code, commands, and technical terms remain in English.
2. [代码风格 / Code Style](#2-代码风格--code-style)
3. [禁止模式 / Forbidden Patterns](#3-禁止模式--forbidden-patterns)
4. [错误处理 / Error Handling](#4-错误处理--error-handling)
5. [安全规范 / Security](#5-安全规范--security)
6. [数据库规范 / Database](#6-数据库规范--database)
7. [API 设计规范 / API Design](#7-api-设计规范--api-design)
8. [日志与可观测性 / Logging & Observability](#8-日志与可观测性--logging--observability)
9. [性能规范 / Performance](#9-性能规范--performance)
10. [测试规范 / Testing](#10-测试规范--testing)
11. [Git 规范 / Git Workflow](#11-git-规范--git-workflow)
12. [工作流程 / Workflow](#12-工作流程--workflow)
13. [架构决策记录 / ADR](#13-架构决策记录--adr)
14. [审查清单 / Review Checklist](#14-审查清单--review-checklist)
--- ---
## 1. 语言 / Language ## 2. Code Style
**Always respond in Chinese (中文).** Use the user's language for all conversations and explanations. Code, commands, and technical terms can remain in English. ### 2.1 Basic Principles
始终使用中文回复。代码、命令和技术术语可以保留英文。 - Follow existing project conventions
- Use meaningful variable names
- Keep functions under **50 lines**
- Maximum nesting depth: **3 levels**
- Add comments for complex logic only
--- ### 2.2 Rust Best Practices
## 2. 代码风格 / Code Style - Use `?` operator; never use `unwrap()` in non-test code
- Avoid `unsafe`; if necessary, add `// SAFETY:` comment
- Minimize `clone()`; prefer references
- No magic numbers; use named constants
- No hardcoded strings; use enums or constants
### 2.1 基本原则 / Basic Principles ### 2.3 Import Order
| 规则 / Rule | 说明 / Description |
|-----------|-----------------------------------------------------------------------------------------|
| 遵循现有风格 | Follow existing project conventions |
| 有意义命名 | Use meaningful variable names; avoid single-letter names except loop counters |
| 函数长度 | Keep functions under **50 lines**; split complex logic into smaller functions |
| 嵌套深度 | Maximum nesting depth: **3 levels**; use early returns to flatten logic |
| 圈复杂度 | Function cyclomatic complexity should not exceed **10** |
| 注释 | Add comments for complex logic only; prefer self-documenting code |
| 文档注释 | Public items must have `///` doc comments; private items only when logic is non-obvious |
### 2.2 Rust 最佳实践 / Rust Best Practices
```rust ```rust
// ✅ 正确 / Correct // std → third-party crates → local modules
fn get_user(id: i64) -> AppResult<User> {
let user = db.find_user(id).await?; // 使用 ? 传播错误
Ok(user)
}
// ❌ 错误 / Incorrect
fn get_user(id: i64) -> User {
db.find_user(id).await.unwrap() // 禁止 unwrap()
}
```
| 规则 / Rule | 说明 / Description |
|-----------|---------------------------------------------------------------------------------------------|
| 错误传播 | Use `?` operator for error propagation; never use `unwrap()` or `expect()` in non-test code |
| `unsafe` | Avoid `unsafe` blocks; if necessary, add a `// SAFETY:` comment explaining why |
| `clone()` | Minimize `clone()` usage; prefer references or `Rc`/`Arc` for shared ownership |
| 魔法数字 | No magic numbers; define named constants with `const` |
| 硬编码字符串 | No hardcoded strings for config/status; use enums or constants |
| 死代码 | Remove dead code; don't leave commented-out code blocks |
| 未完成代码 | Don't commit `unimplemented!()`, `todo!()`, or `FIXME` without a tracking issue |
### 2.3 导入规范 / Import Guidelines
```rust
// 标准库 → 第三方 crate → 本地模块
// stdlib → third-party crates → local modules
use std::collections::HashMap; use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use crate::error::{AppError, AppResult}; use crate::error::{AppError, AppResult};
use crate::models::common::Status;
``` ```
--- ---
## 3. 禁止模式 / Forbidden Patterns ## 3. Forbidden Patterns
以下代码模式在项目中严格禁止: - `// ── xxxx ──────────` — no divider comments
- `unwrap()` / `expect()` in non-test code — use `?` instead
The following code patterns are strictly forbidden in this project: - `panic!()` / `unreachable!()` — use error types instead
- Untracked `todo!()` — must have a corresponding issue
| 禁止项 / Forbidden | 说明 / Reason | - Commented-out code — use Git history instead
|-------------------------------|------------------------------------------------| - Nesting depth ≥ 4 — flatten with early return
| `// ── xxxx ──────────` | 禁止使用此类分隔线注释;使用 `// Section: xxx` 格式替代 | - Functions > 50 lines — split into smaller functions
| `unwrap()` / `expect()` (非测试) | 在非测试代码中禁止使用;使用 `?``unwrap_or` 等安全替代 | - Magic numbers — define named `const`
| `panic!()` / `unreachable!()` | 除极少数不可能到达的分支外禁止使用;使用 `AppError` 替代 | - Hardcoded strings — use enums or constants
| 未处理的 `todo!()` | 不得提交包含 `todo!()` 的代码,除非有对应的 issue 追踪 |
| 注释掉的代码 | 不得提交被注释的代码块;使用 Git 历史追溯 |
| 过深嵌套 (≥4层) | 使用 early return、`match``map`/`and_then` 扁平化逻辑 |
| 过长函数 (>50行) | 拆分为更小的、职责单一的函数 |
| 魔法数字 | 使用 `const` 定义命名常量 |
| 硬编码字符串 | 使用枚举或常量定义配置值/状态值 |
| 死代码 | 删除未使用的代码、导入和变量 |
--- ---
## 4. 错误处理 / Error Handling ## 4. Error Handling
### 4.1 错误类型体系 / Error Type System ### 4.1 Principles
- Handle all errors explicitly; no silent failures
- Log internal errors; keep user-facing messages helpful
- Add context with `.context()` or `.map_err()`
### 4.2 Log Format
```rust ```rust
// 统一使用 AppError 和 AppResult
// Use AppError and AppResult consistently
use crate::error::{AppError, AppResult};
pub async fn create_user(req: CreateUserReq) -> AppResult<User> {
// ...
}
```
- **AppError**: 统一错误枚举,包含领域错误和外部库包装
- **AppResult<T>**: `Result<T, AppError>` 的类型别名
### 4.2 错误处理原则 / Error Handling Principles
| 原则 / Principle | 说明 / Description |
|----------------|---------------------------------------------------------------------------------|
| 显式处理 | Handle all errors explicitly; no silent failures |
| 用户友好 | Internal errors are logged and masked; user-facing messages should be helpful |
| 错误上下文 | Use `.context()` or `.map_err()` to add meaningful context to errors |
| 错误分类 | Domain errors (UserNotFound) vs Infrastructure errors (DatabaseError) |
| Postgres 映射 | Map Postgres error codes (23505 unique, 23503 FK, 23514 check) to HTTP statuses |
### 4.3 错误日志格式 / Error Logging Format
```rust
// 记录错误时包含完整上下文
// Log errors with full context
tracing::error!( tracing::error!(
error = %err, error = %err,
user_id = %user_id, operation = "operation_name",
operation = "create_user", "Failed to perform operation"
"Failed to create user"
);
```
### 4.4 错误恢复策略 / Error Recovery
| 场景 / Scenario | 策略 / Strategy |
|---------------|---------------|
| 数据库连接失败 | 重试 + 降级到只读模式 |
| 外部服务超时 | 断路器 + 降级响应 |
| 缓存 miss | 回退到数据库查询 |
| 队列积压 | 背压控制 + 告警 |
---
## 5. 安全规范 / Security
### 5.1 基础安全 / Basic Security
| 规则 / Rule | 说明 / Description |
|-----------|---------------------------------------------------------------|
| 密钥管理 | Never hardcode secrets or API keys; use environment variables |
| 输入验证 | Always validate and sanitize user input |
| SQL 注入 | Use parameterized queries (sqlx handles this automatically) |
| XSS 防护 | Escape output; use Content-Security-Policy headers |
| CSRF 防护 | Use CSRF tokens for state-changing operations |
| 密码安全 | Argon2 hashing with session-scoped RSA-2048 OAEP-SHA256 |
| 2FA | TOTP with HMAC-SHA1, base32 secrets, backup codes |
### 5.2 OWASP Top 10 防护 / OWASP Top 10 Protection
| 风险 / Risk | 防护措施 / Mitigation |
|-----------|------------------------------------------------------|
| 注入 | Parameterized queries, input validation |
| 失效认证 | Strong password policy, 2FA, session management |
| 敏感数据暴露 | Encryption at rest and in transit, data masking |
| XML 外部实体 | Disable XML external entity processing |
| 失效访问控制 | Role-based access control, resource ownership checks |
| 安全配置错误 | Secure defaults, environment-based config |
| XSS | Output encoding, CSP headers |
| 不安全反序列化 | Validate serialized data, use safe formats |
| 使用含漏洞组件 | Regular dependency updates, `cargo audit` |
| 日志和监控不足 | Comprehensive logging, alerting |
### 5.3 企业级安全 / Enterprise Security
| 要求 / Requirement | 说明 / Description |
|------------------|----------------------------------------------------------------------|
| 安全审计日志 | Log all sensitive operations with actor, action, resource, timestamp |
| 访问控制 | Implement RBAC/ABAC; check permissions at service layer |
| 数据脱敏 | Mask PII in logs; encrypt sensitive fields in database |
| 依赖安全 | Run `cargo audit` in CI; review new dependencies |
| 安全头 | Set HSTS, X-Frame-Options, X-Content-Type-Options, etc. |
| 速率限制 | Implement rate limiting for auth endpoints and API calls |
---
## 6. 数据库规范 / Database
### 6.1 基础规范 / Basic Rules
| 规则 / Rule | 说明 / Description |
|-----------|----------------------------------------------------------------------|
| 参数化查询 | Always use parameterized queries (sqlx does this by default) |
| 事务管理 | Use `ServiceContext::run_in_transaction()` for multi-step operations |
| 读写分离 | Use `AppDatabase` read/write pool methods appropriately |
| 迁移规范 | All schema changes must go through migration files in `migrate/` |
### 6.2 性能优化 / Performance Optimization
| 规则 / Rule | 说明 / Description |
|-----------|----------------------------------------------------------------------|
| N+1 防护 | Use `JOIN` or batch queries instead of N+1 patterns |
| 批量操作 | Use `INSERT ... ON CONFLICT`, `UPDATE ... FROM`, bulk operations |
| 索引规范 | Add indexes for frequently queried columns; document index rationale |
| 查询分析 | Use `EXPLAIN ANALYZE` to verify query plans for complex queries |
| 连接池 | Configure pool sizes based on workload; monitor connection usage |
| 慢查询 | Log queries >100ms; investigate and optimize |
### 6.3 数据一致性 / Data Consistency
| 规则 / Rule | 说明 / Description |
|-----------|-----------------------------------------------------------|
| 事务边界 | Keep transactions short; avoid long-running transactions |
| 幂等性 | Design operations to be idempotent where possible |
| 乐观锁 | Use version columns for concurrent update protection |
| 外键约束 | Use database-level foreign keys for referential integrity |
---
## 7. API 设计规范 / API Design
### 7.1 RESTful 规范 / RESTful Conventions
| 规则 / Rule | 示例 / Example |
|-----------|-----------------------------------------------------------------------------------------|
| 资源命名 | `/api/v1/workspaces/{id}/repos` (复数名词) |
| HTTP 方法 | GET (读取), POST (创建), PUT/PATCH (更新), DELETE (删除) |
| 状态码 | 200 (成功), 201 (创建), 204 (无内容), 400 (客户端错误), 401 (未认证), 403 (禁止), 404 (未找到), 500 (服务器错误) |
| 版本管理 | URL path versioning: `/api/v1/...` |
### 7.2 响应格式 / Response Format
```rust
// 统一响应类型
// Unified response types
ApiResponse<T> // 单个数据 / Single payload
ApiListResponse<T> // 分页列表 / Paginated list { data, total, page, per_page }
ApiEmptyResponse // 空响应 / Empty response
ApiErrorResponse // 错误响应 / Error response { code, message, details }
```
### 7.3 OpenAPI 文档 / OpenAPI Documentation
```rust
// 每个端点必须添加 OpenAPI 注解
// Every endpoint must have OpenAPI annotations
#[utoipa::path(
post,
path = "/api/v1/auth/login",
request_body = LoginReq,
responses(
(status = 200, description = "Login successful", body = ApiResponse<LoginResp>),
(status = 401, description = "Invalid credentials", body = ApiErrorResponse)
),
tag = "auth"
)]
pub async fn login(...) -> HttpResponse { ... }
```
### 7.4 API 治理 / API Governance
| 规则 / Rule | 说明 / Description |
|---|---|
| 请求验证 | Validate all request bodies and query parameters |
| 速率限制 | Apply rate limiting to auth and resource-intensive endpoints |
| 幂等性 | POST operations with same idempotency key should produce same result |
| 缓存策略 | Use ETag/Last-Modified for cacheable resources |
| 错误码体系 | Consistent error codes across all endpoints |
| 分页 | Default page size 20, max 100; use cursor-based pagination for large datasets |
---
## 8. 日志与可观测性 / Logging & Observability
### 8.1 日志规范 / Logging Standards
```rust
// 使用 tracing crate 进行结构化日志
// Use tracing crate for structured logging
use tracing::{info, warn, error, debug, instrument};
#[instrument(skip(db), fields(user_id = %req.user_id))]
pub async fn create_user(req: CreateUserReq) -> AppResult<User> {
info!("Creating new user");
// ...
error!(error = %err, "Failed to create user");
}
```
| 级别 / Level | 用途 / Usage |
|---|---|
| `error` | 错误需要立即关注 / Errors requiring immediate attention |
| `warn` | 异常但可恢复的情况 / Abnormal but recoverable situations |
| `info` | 关键业务操作记录 / Key business operation records |
| `debug` | 开发调试信息 / Development debugging info |
| `trace` | 详细执行路径 / Detailed execution paths |
### 8.2 敏感信息脱敏 / Data Masking
| 数据类型 / Data Type | 脱敏规则 / Masking Rule |
|---|---|
| 密码 | 完全隐藏 / Never log |
| Token/密钥 | 只显示前 4 位 / Show first 4 chars only |
| 邮箱 | `u***@example.com` |
| IP 地址 | 保留网段 / Keep subnet |
| 个人信息 | 根据最小必要原则 / Minimum necessary principle |
### 8.3 性能指标 / Metrics
| 指标 / Metric | 说明 / Description |
|---|---|
| 请求延迟 | HTTP request latency (P50, P95, P99) |
| 错误率 | Error rate by endpoint and status code |
| 吞吐量 | Requests per second |
| 数据库连接 | Active/idle connections in pool |
| 缓存命中率 | Cache hit/miss ratio |
| 队列积压 | Queue depth and processing rate |
| 内存使用 | Heap usage, allocation rate |
| 活跃会话 | Active WebSocket sessions |
### 8.4 健康检查 / Health Checks
```rust
// 端点: GET /health
// Endpoint: GET /health
{
"status": "healthy", // healthy | degraded | unhealthy
"version": "1.0.0",
"uptime": 3600,
"checks": {
"database": { "status": "up", "latency_ms": 5 },
"redis": { "status": "up", "latency_ms": 2 },
"nats": { "status": "up", "latency_ms": 1 },
"etcd": { "status": "up", "latency_ms": 3 }
}
}
```
### 8.5 告警规则 / Alerting Rules
| 条件 / Condition | 级别 / Level |
|---|---|
| 错误率 > 5% | Critical |
| P99 延迟 > 500ms | Warning |
| 数据库连接池 > 80% | Warning |
| 队列积压 > 1000 | Critical |
| 内存使用 > 85% | Warning |
| 健康检查失败 | Critical |
### 8.6 请求链路追踪 / Request Tracing
```rust
// 每个请求分配唯一 trace_id
// Each request gets a unique trace_id
tracing::info!(
trace_id = %request_id,
user_id = %session.user_id,
method = %req.method(),
path = %req.path(),
"Request started"
); );
``` ```
--- ---
## 9. 性能规范 / Performance ## 5. Security
### 9.1 SLA 目标 / SLA Targets - Never hardcode secrets or API keys
- Always validate and sanitize user input
| 指标 / Metric | 目标 / Target | - Use parameterized queries (no SQL injection)
|---|---| - Use proper password hashing (Argon2, bcrypt)
| 可用性 | 99.9% (每月宕机 <43 分钟) |
| P50 延迟 | <50ms |
| P95 延迟 | <200ms |
| P99 延迟 | <500ms |
| 错误率 | <0.1% |
| 数据库查询 | <100ms (常规查询) |
| 缓存命中率 | >90% |
### 9.2 性能原则 / Performance Principles
| 原则 / Principle | 说明 / Description |
|---|---|
| 基准测试 | Establish performance baselines before optimization |
| 测量优先 | Profile before optimizing; don't guess |
| 渐进优化 | Optimize iteratively; measure impact of each change |
| 容量规划 | Plan for 3x current load |
### 9.3 优化策略 / Optimization Strategies
| 场景 / Scenario | 策略 / Strategy |
|---|---|
| 热点查询 | Add caching (L1 + L2) |
| 大量读取 | Use read replicas |
| 批量操作 | Batch database operations |
| 高并发 | Use connection pooling, async I/O |
| 大数据量 | Use cursor-based pagination |
--- ---
## 10. 测试规范 / Testing ## 6. Workflow
### 10.1 基础要求 / Basic Requirements ### 6.1 Development Flow
| 规则 / Rule | 说明 / Description | 1. **Read before write** — understand context first
|---|---| 2. **Minimal changes** — don't refactor unrelated code
| 新功能 | All new features must have unit tests | 3. **Verify after changes** — run tests or check output
| Bug 修复 | Bug fixes must include regression tests |
| 关键路径 | Critical business logic must have integration tests |
| 测试隔离 | Tests must be independent and not depend on execution order |
### 10.2 测试命令 / Test Commands ### 6.2 AI Assistant Rules
- Always read existing code before making changes
- Make minimal changes
- Run `cargo check` or `cargo test` after changes
- Explain what you changed and why
### 6.3 Common Commands
```bash ```bash
cargo test # 运行所有测试 / Run all tests cargo build # Build
cargo test -- <test_name> # 按名称运行 / Run by name cargo check # Quick syntax check
cargo test lru::tests # 运行特定模块 / Run module tests cargo test # Run tests
cargo test -- --nocapture # 显示输出 / Show output cargo clippy # Lint
``` cargo fmt # Format
### 10.3 测试命名 / Test Naming
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_user_with_valid_input() { ... }
#[test]
fn test_create_user_with_duplicate_email_returns_error() { ... }
#[tokio::test]
async fn test_async_operation_handles_timeout() { ... }
}
``` ```
--- ---
## 11. Git 规范 / Git Workflow ## 7. Git Workflow
### 11.1 提交信息格式 / Commit Message Format ### 7.1 Commit Message Format
使用 Angular 风格,全部英文:
Use Angular style, all English:
``` ```
<type>(<scope>): <subject> <type>(<scope>): <subject>
@@ -473,182 +125,37 @@ Use Angular style, all English:
[optional footer] [optional footer]
``` ```
| Type | 说明 / Description | Types: `feat` · `fix` · `refactor` · `docs` · `test` · `chore`
|---|---|
| `feat` | 新功能 / New feature |
| `fix` | Bug 修复 / Bug fix |
| `refactor` | 重构 / Code refactoring |
| `docs` | 文档 / Documentation |
| `test` | 测试 / Tests |
| `chore` | 构建/工具 / Build/tooling |
| `perf` | 性能优化 / Performance improvement |
| `style` | 代码格式 / Code formatting |
| `ci` | CI/CD 相关 / CI/CD changes |
**示例 / Examples:** ### 7.2 Commit Principles
```
feat(auth): add 2FA login support
fix(api): resolve race condition in user creation
refactor(service): extract common validation logic
docs(readme): update API documentation
test(cache): add unit tests for LRU eviction
chore(deps): update sqlx to 0.8
```
### 11.2 提交原则 / Commit Principles - Each commit addresses one concern (atomic)
- Each commit leaves the codebase in a working state
| 原则 / Principle | 说明 / Description | - Never force push to `main`
|---|---|
| 原子提交 | Each commit should address one concern |
| 完整性 | Each commit should leave the codebase in a working state |
| 禁止强制推送 | Never force push to main branch |
| 提交前检查 | Run `cargo check` and `cargo test` before committing |
### 11.3 分支策略 / Branch Strategy
| 分支 / Branch | 用途 / Purpose |
|---|---|
| `main` | 生产就绪代码 / Production-ready code |
| `feat/*` | 功能开发 / Feature development |
| `fix/*` | Bug 修复 / Bug fixes |
| `release/*` | 发布准备 / Release preparation |
--- ---
## 12. 工作流程 / Workflow ## Appendix: Architecture Overview
### 12.1 开发流程 / Development Process ```
gitks — Git Repository Operations Service
1. **理解先于编写** — Read before write; understand context first actor/ → Actor model
2. **最小变更** — Minimal changes; don't refactor unrelated code archive/ → Archive operations
3. **验证变更** — Verify after changes; run tests or check output blame/ → Blame operations
4. **文档同步** — Update documentation when changing public APIs blob/ → Blob objects
branch/ → Branch operations
### 12.2 AI 助手工作规范 / AI Assistant Guidelines commit/ → Commit operations
diff/ → Diff operations
| 规则 / Rule | 说明 / Description | merge/ → Merge operations
|---|---| pack/ → Pack operations
| 先读后写 | Always read existing code before making changes | refs/ → Reference management
| 最小侵入 | Make minimal changes; don't refactor unrelated code | remote/ → Remote operations
| 验证结果 | Run `cargo check` or `cargo test` after changes | repository/ → Repository operations
| 解释变更 | Explain what you changed and why | server/ → gRPC server
| 询问不确定 | Ask when unsure about requirements |
### 12.3 常用命令 / Common Commands
```bash
cargo build # 构建 / Build
cargo check # 快速检查 / Quick check
cargo test # 运行测试 / Run tests
cargo clippy # Lint 检查 / Lint checks
cargo fmt # 格式化 / Format code
cargo doc --no-deps # 生成文档 / Build docs
cargo machete # 检查未使用依赖 / Check unused deps
cargo run --bin gen_openapi # 生成 OpenAPI / Generate OpenAPI
``` ```
--- ---
## 13. 架构决策记录 / ADR *For questions or suggestions, please open an issue.*
架构决策记录存放在 `docs/adr/` 目录下,使用 Markdown 格式。
Architecture Decision Records are stored in `docs/adr/` directory in Markdown format.
### 索引 / Index
| ADR | 标题 / Title | 状态 / Status |
|---|---|---|
| [ADR-001](docs/adr/001-choice-of-web-framework.md) | 选择 Actix-web 作为 Web 框架 | Accepted |
| [ADR-002](docs/adr/002-two-tier-caching.md) | 两级缓存架构 (L1 LRU + L2 Redis) | Accepted |
| [ADR-003](docs/adr/003-nats-for-messaging.md) | 使用 NATS JetStream 作为消息队列 | Accepted |
| [ADR-004](docs/adr/004-etcd-for-discovery.md) | 使用 etcd 进行服务发现 | Accepted |
| [ADR-005](docs/adr/005-error-handling-strategy.md) | 统一错误处理策略 | Accepted |
### ADR 模板 / ADR Template
```markdown
# ADR-NNN: 标题 / Title
## 状态 / Status
Accepted | Superseded | Deprecated
## 背景 / Context
描述问题背景 / Describe the context
## 决策 / Decision
描述做出的决策 / Describe the decision
## 后果 / Consequences
描述正面和负面影响 / Describe positive and negative impacts
```
---
## 14. 审查清单 / Review Checklist
### 代码审查 / Code Review
- [ ] 代码风格符合项目规范 / Code style follows project conventions
- [ ] 没有使用禁止模式 / No forbidden patterns used
- [ ] 错误处理完整 / Error handling is complete
- [ ] 安全考虑已处理 / Security considerations addressed
- [ ] 性能影响已评估 / Performance impact assessed
- [ ] 测试已添加 / Tests are added
- [ ] 文档已更新 / Documentation is updated
### PR 审查 / PR Review
- [ ] 提交信息符合 Angular 风格 / Commit messages follow Angular style
- [ ] 每个提交只关注一个问题 / Each commit addresses one concern
- [ ] 变更范围合理 / Change scope is reasonable
- [ ] 没有遗留的 TODO/FIXME / No leftover TODO/FIXME
- [ ] CI 检查通过 / CI checks pass
### 发布前审查 / Pre-release Review
- [ ] 所有测试通过 / All tests pass
- [ ] 性能测试完成 / Performance tests completed
- [ ] 安全扫描通过 / Security scan passed
- [ ] 文档完整 / Documentation is complete
- [ ] 变更日志已更新 / Changelog is updated
---
## 附录 / Appendix
### 项目架构速查 / Quick Architecture Reference
```
appks — 协作开发平台后端 / Collaborative Development Platform Backend
config/ → 环境配置 / Environment configuration
models/ → 数据模型 / Data models (sqlx FromRow)
service/ → 业务逻辑 / Business logic (AppService)
api/ → HTTP 端点 / HTTP endpoints
immediate/ → 实时 IM / Real-time IM (WebSocket)
cache/ → 两级缓存 / Two-tier cache (L1 + L2)
storage/ → 对象存储 / Object storage (S3)
queue/ → 消息队列 / Message queue (NATS)
etcd/ → 服务发现 / Service discovery
session/ → 会话管理 / Session management
pb/ → gRPC 客户端 / gRPC client stubs
proto/ → Protobuf 定义 / Protobuf definitions
migrate/ → 数据库迁移 / Database migrations
error.rs → 统一错误类型 / Unified error types
```
### 基础设施速查 / Infrastructure Quick Reference
| 服务 / Service | 用途 / Purpose | 协议 / Protocol |
|--------------|-----------------------------------------|---------------|
| Postgres | 主数据库 / Primary database | sqlx |
| Redis | 缓存/会话/限流 / Cache/sessions/rate limiting | redis + r2d2 |
| etcd | 服务发现 / Service discovery | etcd-client |
| NATS | 消息队列 / Message queue | async-nats |
| S3/MinIO | 对象存储 / Object storage | object_store |
| Qdrant | 向量数据库 / Vector DB | config only |
---
*This document is maintained by the development team. For questions or suggestions, please open an issue.*
+96
View File
@@ -0,0 +1,96 @@
# Gitks Security Best Practices
This document outlines security best practices for the gitks project.
## Input Validation
### Revision Strings
All revision strings (branch names, commit hashes, refs) are validated using `sanitize::validate_revision()`:
- Prevents command injection via `~N` and `^N` operators
- Limits revision string length to 256 characters
- Limits ancestry depth to 10000 to prevent DoS attacks
- Validates branch name characters to prevent shell metacharacter injection
### File Paths
File paths are validated using `sanitize::validate_file_path()`:
- Rejects absolute paths
- Blocks path traversal attacks (`..`)
- Prevents null byte injection
- Blocks `.git` directory access
- On Windows, blocks reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9)
### Git Configuration Keys
Configuration keys are validated using `sanitize::validate_config_key()`:
- Blocks dangerous keys that could execute arbitrary commands (core.sshCommand, core.hooksPath)
- Blocks network-related keys (http.proxy, https.proxy, remote.*.url)
- Blocks credential helpers
- Only allows alphanumeric characters, dots, hyphens, and underscores
### Relative Paths
Relative paths are validated using `sanitize::validate_relative_path()`:
- Rejects absolute paths
- Blocks path traversal attacks (`..`)
## Path Security
### TOCTOU Prevention
Path validation uses a unified approach to prevent Time-Of-Check-Time-Of-Use vulnerabilities:
1. Canonicalize the path if it exists
2. If path doesn't exist, validate parent directory and filename separately
3. Verify canonical path starts with allowed prefix
4. Reject any path that escapes the allowed directory
### Cache Invalidation
Cache entries are invalidated when repositories are modified:
- Uses precise substring matching on relative path
- Invalidates all cache keys containing the modified repository path
- Prevents stale data from being served after modifications
## Message Decoding Security
### String Decoding
The `decode_strings()` function in `actor/message.rs` includes:
- Total message size limit (50MB)
- Individual string length limit (10MB)
- Overflow protection using `checked_add()`
- Graceful degradation on malformed data
## Cluster Registration
### Primary/Replica Role Assignment
When registering repositories in a cluster:
- Single node: registers as PRIMARY
- Multiple nodes: registers as REPLICA initially
- Final role determination happens at query time via `route_repository`
- This conservative approach prevents split-brain scenarios
## Testing
All security-critical functions have comprehensive unit tests:
- `tests/sanitize_test.rs`: Input validation tests
- `tests/macro_test.rs`: Revision resolution tests
- Tests cover both valid and malicious inputs
## Code Quality
- All code passes `cargo clippy --all-targets --all-features` with zero warnings
- Code is formatted with `cargo fmt`
- All tests pass with `cargo test`
- No known security vulnerabilities in dependencies (verified with `cargo deny`)
## Recommendations for Users
1. **Never trust user input**: Always validate revisions, paths, and config keys
2. **Use the sanitize module**: All user-provided strings should go through validation
3. **Keep dependencies updated**: Run `cargo update` regularly and check for security advisories
4. **Monitor logs**: Watch for validation failures which may indicate attack attempts
5. **Limit cluster size**: The cluster registration logic assumes a reasonable number of nodes
6. **Use HTTPS**: When deploying in production, use TLS for gRPC connections
7. **Audit configuration**: Regularly review which git config keys are allowed
## Reporting Security Issues
If you discover a security vulnerability, please report it responsibly by:
1. Creating a private security advisory
2. Providing detailed reproduction steps
3. Allowing maintainers time to address the issue before public disclosure
Generated
+1194 -3621
View File
File diff suppressed because it is too large Load Diff
+39 -47
View File
@@ -1,61 +1,53 @@
[package] [package]
name = "appks" name = "gitks"
version = "0.1.0" version = "1.0.0"
edition = "2024" edition = "2024"
authors = ["gitks contributors"]
description = "A gRPC-accessible Git repository operations library for bare repositories"
repository = "https://github.com/appks/gitks"
homepage = "https://github.com/appks/gitks"
license = "PolyForm-Noncommercial-1.0.0"
keywords = ["git", "grpc", "bare-repository", "gix"]
categories = ["development-tools"]
[lib] [lib]
name = "appks"
path = "lib.rs" path = "lib.rs"
name = "gitks"
[[bin]]
name = "appks"
path = "main.rs"
[[bin]]
name = "gen_openapi"
path = "gen_openapi.rs"
[dependencies] [dependencies]
sqlx = { version = "0.9", features = ["postgres","runtime-tokio","chrono","uuid","json","migrate"] } moka = { version = "0.12", default-features = false, features = ["sync"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = [] } serde_json = "1"
chrono = { version = "0.4", features = ["serde"] } sha2 = "0.11"
uuid = { version = "1", features = ["serde","v4","v7","v5"] } uuid = { version = "1", features = ["v7"] }
reqwest = { version = "0.13", features = ["json"] } gix = { version = "0.84", default-features = false, features = ["serde", "blame", "sha256", "sha1", "tracing", "merge", "max-performance-safe", "revision"] }
tracing = { version = "0.1", features = [] } gix-archive = { version = "0.33", features = ["sha256","sha1","document-features"] }
tracing-subscriber = { version = "0.3", features = ["fmt"] } duct = { version = "1", features = [] }
dotenvy = "0.15" tracing = { version = "0.1", features = ["log"] }
thiserror = "2" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
redis = { version = "1", features = ["cluster","cluster-async","aio","tokio-comp","connection-manager"] } tracing-appender = "0.2"
dashmap = "6" tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "sync", "net", "signal"] }
object_store = { version = "0.13", features = ["tokio","aws","cloud"] } tokio-stream = { version = "0.1", features = ["full"] }
argon2 = "0.5" tokio-util = "0.7"
rsa = "0.9" thiserror = { version = "2", features = [] }
chacha20poly1305 = "0.10"
hkdf = "0.12"
sha2 = "0.10"
sha1 = "0.10"
hmac = "0.12"
jsonwebtoken = "9"
arc-swap = "1"
base64 = "0.22"
rand = "0.8"
captcha-rs = "0.5"
tonic = { version = "0.14", features = ["transport", "channel"] }
prost = "0.14" prost = "0.14"
prost-types = "0.14" prost-types = "0.14"
tonic = { version = "0.14", features = ["transport", "gzip"] }
tonic-health = "0.14"
tonic-prost = "0.14" tonic-prost = "0.14"
tonic-health = "0.14.6" tempfile = "3"
url = "2.5" dotenvy = "0.15"
etcd-client = { version = "0.18", features = ["tls"] } etcd-client = { version = "0.18", features = ["tls"] }
tokio-stream = { version = "0.1", features = ["net"] } dashmap = "6"
async-nats = "0.49" hyper = { version = "1", features = ["server", "http1"] }
futures-util = "0.3" hyper-util = { version = "0.1", features = ["tokio"] }
utoipa = { version = "5", features = ["uuid","chrono","actix_extras","decimal","macros"]} http-body-util = "0.1"
actix-web = { version = "4", features = ["secure-cookies"] } bytes = "1"
actix-multipart = "0.7" crc32fast = "1"
hex = "0.4" [[bin]]
name = "gitks"
path = "main.rs"
[build-dependencies] [build-dependencies]
tonic-prost-build = "0.14" tonic-prost-build = "0.14"
serde_yml = "0.0.12"
serde = { version = "1", features = ["derive"] }
+10 -10
View File
@@ -14,19 +14,19 @@ FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json RUN cargo chef cook --release --recipe-path recipe.json
COPY . . COPY . .
RUN cargo build --release --bin appks && \ RUN cargo build --release --bin gitks && \
strip target/release/appks strip target/release/gitks
FROM ubuntu:26.04 FROM ubuntu:26.04
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \ apt-get install -y --no-install-recommends git ca-certificates && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/appks /usr/local/bin/appks COPY --from=builder /app/target/release/gitks /usr/local/bin/gitks
ENV APP_HTTP_HOST=0.0.0.0 ENV GITKS_HOST=0.0.0.0
ENV APP_HTTP_PORT=8000 ENV GITKS_PORT=50051
ENV APP_RPC_SELF_HOST=0.0.0.0 ENV REPO_PREFIX_PATH=/data/repos
ENV APP_RPC_SELF_PORT=50049
EXPOSE 8000 50049 RUN mkdir -p /data/repos
ENTRYPOINT ["appks"] EXPOSE 50051
ENTRYPOINT ["gitks"]
+9 -8
View File
@@ -1,16 +1,17 @@
FROM ubuntu:26.04 FROM ubuntu:26.04
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends ca-certificates && \ apt-get install -y --no-install-recommends git && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY target/release/appks /usr/local/bin/appks COPY target/release/gitks /usr/local/bin/gitks
ENV APP_HTTP_HOST=0.0.0.0 ENV GITKS_HOST=0.0.0.0
ENV APP_HTTP_PORT=8000 ENV GITKS_PORT=50051
ENV APP_RPC_SELF_HOST=0.0.0.0 ENV REPO_PREFIX_PATH=/data/repos
ENV APP_RPC_SELF_PORT=50049
EXPOSE 8000 50049 RUN mkdir -p /data/repos
ENTRYPOINT ["appks"] EXPOSE 50051
ENTRYPOINT ["gitks"]
+58
View File
@@ -0,0 +1,58 @@
PolyForm Noncommercial License 1.0.0
Copyright (c) 2024 gitks contributors
License: "Noncommercial" as defined below.
"Noncommercial" means primarily intended for or directed towards the
advantage or monetary gain of a business, commercial entity, or for-profit
organization. A use is "Noncommercial" if it is not primarily intended for
or directed towards commercial advantage or monetary compensation.
1. Grant of Copyright License. Subject to the terms of this license,
Licensor grants you a worldwide, royalty-free, non-exclusive, limited
license to exercise the Licensed Rights in the Licensed Material for
Noncommercial purposes only.
2. Grant of Patent License. Subject to the terms of this license, Licensor
grants you a worldwide, royalty-free, non-exclusive, limited license
under patent claims owned or controlled by Licensor that are embodied
in the Licensed Material as furnished by Licensor, to make, use, sell,
offer for sale, have made, and import the Licensed Material for
Noncommercial purposes only.
3. Limitations. The license granted in Section 1 and Section 2 above is
expressly limited to Noncommercial purposes. You may not exercise the
Licensed Rights for the purpose of providing services to third parties,
including but not limited to:
(a) offering the Licensed Material as a hosted or managed service
where third parties access or use the Licensed Material;
(b) offering the Licensed Material as part of a product or service
that is sold, licensed, or otherwise provided for monetary gain;
(c) using the Licensed Material to provide consulting, support, or
other services for monetary gain.
4. Acceptance. Any use of the Licensed Material in violation of this
license will automatically terminate your rights under this license
for the current and all future versions of the Licensed Material.
5. Patents. If you institute patent litigation against any entity
(including a cross-claim or counterclaim in a lawsuit) alleging that
the Licensed Material constitutes direct or contributory patent
infringement, then any patent licenses granted to you under this
license for the Licensed Material shall terminate as of the date
such litigation is filed.
6. Disclaimer of Warranty. THE LICENSED MATERIAL IS PROVIDED "AS IS" AND
WITHOUT ANY WARRANTY OF ANY KIND. LICENSOR DISCLAIMS ALL WARRANTIES,
EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES
OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A
PARTICULAR PURPOSE.
7. Limitation of Liability. IN NO EVENT WILL LICENSOR BE LIABLE TO YOU
FOR ANY DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL, OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THESE TERMS OR IN CONNECTION
WITH THE USE OR INABILITY TO USE THE LICENSED MATERIAL, EVEN IF
LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
For the full license text, see: https://polyformproject.org/licenses/noncommercial/1.0.0
+7
View File
@@ -0,0 +1,7 @@
# gitks
A Git bare repository operation library based on gRPC.
## License
[PolyForm Noncommercial 1.0.0](LICENSE) — Free for noncommercial use. For commercial licenses, please contact us.
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::captcha::{CaptchaQuery, CaptchaResponse};
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/captcha",
tag = "Auth",
operation_id = "authGetCaptcha",
summary = "Get captcha image",
description = "Generate a one-time captcha image and store the plaintext captcha in the current session. Captchas are used for sensitive entry points such as login and sending registration email codes. Set rsa=true to return the current session RSA public key at the same time and reduce frontend initialization requests. The captcha is consumed after either successful or failed validation, so clients must fetch a new one after failure.",
params(
("w" = u32, Query, description = "Captcha image width; allowed range is 80..=400.", example = 160),
("h" = u32, Query, description = "Captcha image height; allowed range is 30..=200.", example = 64),
("dark" = bool, Query, description = "Whether to generate a dark-mode captcha.", example = false),
("rsa" = bool, Query, description = "Whether to include the RSA public key in the response.", example = true)
),
responses(
(status = 200, description = "Captcha generated successfully. The base64 field is image data that can be used directly as img.src.", body = ApiResponse<CaptchaResponse>),
(status = 400, description = "Invalid captcha size.", body = ApiErrorResponse),
(status = 500, description = "Session write failed or RSA initialization failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
query: web::Query<CaptchaQuery>,
) -> Result<HttpResponse, AppError> {
let data = service
.auth
.auth_captcha(&session, query.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-33
View File
@@ -1,33 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::change_password::ChangePasswordParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/password/change",
tag = "Auth",
operation_id = "authChangePassword",
request_body(content = ChangePasswordParams, description = "Password change parameters (passwords encrypted with session RSA public key)", content_type = "application/json"),
responses(
(status = 200, description = "Password changed successfully", body = ApiEmptyResponse),
(status = 400, description = "Invalid password", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn change_password(
service: web::Data<AppService>,
session: Session,
params: web::Json<ChangePasswordParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_change_password(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password changed successfully")))
}
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::totp::Disable2FAParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/2fa/disable",
tag = "Auth",
operation_id = "authDisableTwoFactor",
summary = "Disable two-factor authentication",
description = "Disable TOTP two-factor authentication for the current signed-in user. This requires verifying both the current password and a valid TOTP code or backup code. password must be encrypted with the current session RSA public key; a successfully verified backup code is consumed.",
request_body(
content = Disable2FAParams,
description = "TOTP/backup code and the current password encrypted with RSA.",
content_type = "application/json"
),
responses(
(status = 200, description = "2FA has been disabled.", body = ApiEmptyResponse),
(status = 400, description = "2FA is not enabled, the verification code is incorrect, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<Disable2FAParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_2fa_disable(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("two-factor authentication disabled")))
}
-29
View File
@@ -1,29 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::totp::Enable2FAResponse;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/2fa/enable",
tag = "Auth",
operation_id = "authPrepareTwoFactorEnable",
summary = "Initialize two-factor authentication setup",
description = "Generate a new TOTP secret, otpauth QR-code URI, and 10 one-time backup codes for the current signed-in user, and save them in a not-yet-enabled state. Clients must guide the user to scan the QR code and call /auth/2fa/verify with a dynamic code before 2FA is actually enabled. Backup codes are returned in plaintext only once in this response; frontends must remind users to store them securely.",
responses(
(status = 200, description = "2FA setup initialized successfully. Returns the secret, QR-code URI, and backup codes.", body = ApiResponse<Enable2FAResponse>),
(status = 400, description = "The current user has already enabled 2FA.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database write or backup code hashing failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_2fa_enable(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-28
View File
@@ -1,28 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::totp::Get2FAStatusResponse;
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/2fa/status",
tag = "Auth",
operation_id = "authGetTwoFactorStatus",
summary = "Get two-factor authentication status",
description = "Read the current signed-in user's TOTP two-factor authentication status, including whether it is enabled, the authentication method, and whether backup codes are still available.",
responses(
(status = 200, description = "Read successfully.", body = ApiResponse<Get2FAStatusResponse>),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_2fa_status(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-28
View File
@@ -1,28 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::email::EmailResponse;
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/email",
tag = "Auth",
operation_id = "authGetPrimaryEmail",
summary = "Get current user verified email",
description = "Return the verified primary email for the current signed-in user. If no verified email is bound to the account, the email field is null.",
responses(
(status = 200, description = "Read successfully.", body = ApiResponse<EmailResponse>),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_get_email(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::login::LoginParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/login",
tag = "Auth",
operation_id = "authLogin",
summary = "Account login",
description = "Log in using a username or verified email. password must be a Base64 ciphertext encrypted with the public key returned by /auth/rsa; the first login attempt must include captcha. If the account has TOTP enabled, the first successful password check returns 400/two-factor required and records pending verification state in the session. Then submit username, password, and totp_code again in the same session to complete login. On success, the session is renewed, the current user is bound, and temporary RSA keys are cleared.",
request_body(
content = LoginParams,
description = "Login parameters. username accepts a username or email; password is an RSA-OAEP-SHA256 encrypted ciphertext; captcha is the captcha stored in the current session; totp_code is required only during the second verification step.",
content_type = "application/json"
),
responses(
(status = 200, description = "Login succeeded. The server establishes login state through the session cookie.", body = ApiEmptyResponse),
(status = 400, description = "Captcha error, RSA decryption failure, or missing/incorrect TOTP.", body = ApiErrorResponse),
(status = 404, description = "User does not exist or password is incorrect; to reduce enumeration risk, incorrect passwords are also treated as user-not-found.", body = ApiErrorResponse),
(status = 500, description = "Database, cache, or session write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<LoginParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_login(params.into_inner(), session)
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("login successful")))
}
-26
View File
@@ -1,26 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/logout",
tag = "Auth",
operation_id = "authLogout",
summary = "Log out",
description = "Clear the user identity and all temporary authentication data from the current session, including captcha, temporary RSA keys, and pending 2FA state. This endpoint is idempotent: unauthenticated users also receive a success response.",
responses(
(status = 200, description = "Logged out successfully, or the session was already unauthenticated.", body = ApiEmptyResponse),
(status = 500, description = "Session persistence failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
service.auth.auth_logout(&session).await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("logout successful")))
}
-29
View File
@@ -1,29 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::me::ContextMe;
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/me",
tag = "Auth",
operation_id = "authGetCurrentUser",
summary = "Get current signed-in user context",
description = "Return the current user's basic profile, preferred language, timezone, and notification summary using the user_uid bound to the session. This endpoint is typically used to restore the login state when the frontend app starts.",
responses(
(status = 200, description = "The current session is authenticated. Returns the user context.", body = ApiResponse<ContextMe>),
(status = 401, description = "The current session is unauthenticated or the login state has expired.", body = ApiErrorResponse),
(status = 404, description = "The user in the session no longer exists, has been disabled, or has been deleted.", body = ApiErrorResponse),
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_me(session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-62
View File
@@ -1,62 +0,0 @@
pub mod captcha;
pub mod change_password;
pub mod disable_2fa;
pub mod enable_2fa;
pub mod get_2fa_status;
pub mod get_email;
pub mod login;
pub mod logout;
pub mod me;
pub mod regenerate_2fa_backup_codes;
pub mod register;
pub mod register_email_code;
pub mod request_email_change;
pub mod request_reset_password;
pub mod rsa;
pub mod verify_2fa;
pub mod verify_email;
pub mod verify_reset_password;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/auth")
.route("/rsa", web::get().to(rsa::handle))
.route("/captcha", web::get().to(captcha::handle))
.route("/login", web::post().to(login::handle))
.route("/logout", web::post().to(logout::handle))
.route("/me", web::get().to(me::handle))
.route(
"/register/email-code",
web::post().to(register_email_code::handle),
)
.route("/register", web::post().to(register::handle))
.route("/email", web::get().to(get_email::handle))
.route(
"/email/change",
web::post().to(request_email_change::handle),
)
.route("/email/verify", web::post().to(verify_email::handle))
.route(
"/reset-password",
web::post().to(request_reset_password::handle),
)
.route(
"/reset-password/verify",
web::post().to(verify_reset_password::handle),
)
.route("/2fa/status", web::get().to(get_2fa_status::handle))
.route("/2fa/enable", web::post().to(enable_2fa::handle))
.route("/2fa/verify", web::post().to(verify_2fa::handle))
.route("/2fa/disable", web::post().to(disable_2fa::handle))
.route(
"/2fa/backup-codes/regenerate",
web::post().to(regenerate_2fa_backup_codes::handle),
)
.route(
"/password/change",
web::post().to(change_password::change_password),
),
);
}
-54
View File
@@ -1,54 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
pub struct Regenerate2FABackupCodesRequest {
/// Current account password encrypted with the session RSA public key.
pub password: String,
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct Regenerate2FABackupCodesResponse {
/// Newly generated one-time backup codes. Old backup codes become invalid.
pub backup_codes: Vec<String>,
}
#[utoipa::path(
post,
path = "/api/v1/auth/2fa/backup-codes/regenerate",
tag = "Auth",
operation_id = "authRegenerateTwoFactorBackupCodes",
summary = "Regenerate 2FA backup codes",
description = "After verifying the current password, generate a new set of backup codes for a user with 2FA enabled and replace the old backup codes. password must be encrypted with the current session RSA public key. Backup codes are returned in plaintext only once in this response; clients must prompt users to store them securely.",
request_body(
content = Regenerate2FABackupCodesRequest,
description = "The current account password encrypted with RSA.",
content_type = "application/json"
),
responses(
(status = 200, description = "Backup codes have been regenerated; old backup codes are immediately invalidated.", body = ApiResponse<Regenerate2FABackupCodesResponse>),
(status = 400, description = "2FA is not enabled, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database write or backup code hashing failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<Regenerate2FABackupCodesRequest>,
) -> Result<HttpResponse, AppError> {
let backup_codes = service
.auth
.auth_2fa_regenerate_backup_codes(&session, params.into_inner().password)
.await?;
Ok(
HttpResponse::Ok().json(ApiResponse::new(Regenerate2FABackupCodesResponse {
backup_codes,
})),
)
}
-64
View File
@@ -1,64 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Serialize;
use uuid::Uuid;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::users::User;
use crate::service::AppService;
use crate::service::auth::register::RegisterParams;
use crate::session::Session;
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RegisterResponse {
/// Newly created user id.
pub id: Uuid,
/// Unique username used for login and profile URL.
pub username: String,
/// Display name initialized from username.
pub display_name: Option<String>,
/// Avatar URL; usually absent right after registration.
pub avatar_url: Option<String>,
}
impl From<User> for RegisterResponse {
fn from(user: User) -> Self {
Self {
id: user.id,
username: user.username,
display_name: user.display_name,
avatar_url: user.avatar_url,
}
}
}
#[utoipa::path(
post,
path = "/api/v1/auth/register",
tag = "Auth",
operation_id = "authRegister",
summary = "Register a new account",
description = "Create an account after validating username, email, password, captcha, and email verification code. password must be encrypted with the current session RSA public key; captcha and email_code are one-time credentials. On successful registration, the new user is written to the session and does not need to log in again.",
request_body(
content = RegisterParams,
description = "Registration parameters. email_code comes from /auth/register/email-code; password is a Base64 ciphertext encrypted with RSA-OAEP-SHA256.",
content_type = "application/json"
),
responses(
(status = 200, description = "Registration succeeded; the current session is automatically signed in as the new user.", body = ApiResponse<RegisterResponse>),
(status = 400, description = "Captcha error, email verification code error, weak password, RSA decryption failure, or missing required fields.", body = ApiErrorResponse),
(status = 409, description = "The username or email is already in use.", body = ApiErrorResponse),
(status = 500, description = "Database transaction, password hashing, cache, or session write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<RegisterParams>,
) -> Result<HttpResponse, AppError> {
let user = service
.auth
.auth_register(params.into_inner(), &session)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(RegisterResponse::from(user))))
}
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::register::{RegisterEmailCodeParams, RegisterEmailCodeResponse};
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/register/email-code",
tag = "Auth",
operation_id = "authSendRegisterEmailCode",
summary = "Send registration email verification code",
description = "After validating the captcha in the current session, send a 6-digit registration code to the target email address. The endpoint checks whether a verified email already exists and applies a per-email cooldown to prevent email bombing. The code is valid for 10 minutes by default.",
request_body(
content = RegisterEmailCodeParams,
description = "The target email address and captcha from the current session.",
content_type = "application/json"
),
responses(
(status = 200, description = "The verification email has been queued for delivery. Returns the code expiration time.", body = ApiResponse<RegisterEmailCodeResponse>),
(status = 400, description = "The captcha is incorrect, the email is empty, or requests are too frequent.", body = ApiErrorResponse),
(status = 409, description = "The email is already used by another verified account.", body = ApiErrorResponse),
(status = 500, description = "Cache write failed or the email service is unavailable.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<RegisterEmailCodeParams>,
) -> Result<HttpResponse, AppError> {
let data = service
.auth
.auth_register_email_code(params.into_inner(), &session)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-39
View File
@@ -1,39 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::email::EmailChangeRequest;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/email/change",
tag = "Auth",
operation_id = "authRequestEmailChange",
summary = "Request login email change",
description = "After verifying the current user password, send a confirmation link to the new email address. password must be encrypted with the current session RSA public key. The token in the confirmation link is valid for 1 hour by default; the actual email switch is completed by calling /auth/email/verify.",
request_body(
content = EmailChangeRequest,
description = "The new email address and encrypted current account password.",
content_type = "application/json"
),
responses(
(status = 200, description = "The confirmation email has been queued for delivery.", body = ApiEmptyResponse),
(status = 400, description = "The new email is empty, the password is incorrect, or RSA decryption failed.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 409, description = "The new email is already in use.", body = ApiErrorResponse),
(status = 500, description = "Cache, email service, or database read failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<EmailChangeRequest>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_email_change_request(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("email change verification sent")))
}
-34
View File
@@ -1,34 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::reset_pass::ResetPasswordRequest;
#[utoipa::path(
post,
path = "/api/v1/auth/reset-password",
tag = "Auth",
operation_id = "authRequestPasswordReset",
summary = "Request password reset email",
description = "Submit an email address to send a password reset link if it belongs to an active user. To prevent user enumeration, the business logic attempts to return success whether the email exists, rate limits are triggered, or email delivery fails. Internally, the endpoint enforces a 60-second cooldown and a daily limit of 5 requests per email.",
request_body(
content = ResetPasswordRequest,
description = "The email address that should receive the password reset link.",
content_type = "application/json"
),
responses(
(status = 200, description = "The request has been accepted; if the email exists, a reset email will be sent.", body = ApiEmptyResponse),
(status = 500, description = "Rare unrecoverable server-side error.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
params: web::Json<ResetPasswordRequest>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_reset_password_request(params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password reset request accepted")))
}
-27
View File
@@ -1,27 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::rsa::RsaResponse;
use crate::session::Session;
#[utoipa::path(
get,
path = "/api/v1/auth/rsa",
tag = "Auth",
operation_id = "authGetRsaPublicKey",
summary = "Get login form RSA public key",
description = "Generate or reuse a temporary RSA-2048 key pair for the current browser session and return the public key in PKCS#1 PEM format. Clients should use this public key to encrypt sensitive fields such as passwords with RSA-OAEP-SHA256 before submitting login, registration, password reset, or 2FA disable requests. The private key is encrypted with AEAD and stored only in the server-side session; it is never returned to clients.",
responses(
(status = 200, description = "Return the RSA public key available for the current session; if an unexpired key already exists in the session, reuse the existing public key.", body = ApiResponse<RsaResponse>),
(status = 500, description = "APP_SESSION_SECRET is missing, RSA generation failed, or session write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let data = service.auth.auth_rsa(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
}
-38
View File
@@ -1,38 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::totp::Verify2FAParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/2fa/verify",
tag = "Auth",
operation_id = "authVerifyAndEnableTwoFactor",
summary = "Verify and enable two-factor authentication",
description = "After initializing with /auth/2fa/enable, submit the 6-digit TOTP code generated by the authenticator app. On success, the current user's 2FA status is set to enabled. A small clock drift of one 30-second window before or after is allowed.",
request_body(
content = Verify2FAParams,
description = "The 6-digit TOTP code generated by the authenticator app.",
content_type = "application/json"
),
responses(
(status = 200, description = "2FA has been enabled.", body = ApiEmptyResponse),
(status = 400, description = "2FA has not been initialized, is already enabled, or the verification code is incorrect.", body = ApiErrorResponse),
(status = 401, description = "The current session is not authenticated.", body = ApiErrorResponse),
(status = 500, description = "Database write failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<Verify2FAParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_2fa_verify_and_enable(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("two-factor authentication enabled")))
}
-34
View File
@@ -1,34 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::email::EmailVerifyRequest;
#[utoipa::path(
post,
path = "/api/v1/auth/email/verify",
tag = "Auth",
operation_id = "authVerifyEmailChange",
summary = "Confirm email change",
description = "Complete an email change using the token from the confirmation email. The endpoint checks again whether the target email is already taken, then marks old emails as unverified and inserts the new verified primary email in a transaction.",
request_body(
content = EmailVerifyRequest,
description = "Email change confirmation token.",
content_type = "application/json"
),
responses(
(status = 200, description = "Email changed successfully.", body = ApiEmptyResponse),
(status = 400, description = "The token is empty.", body = ApiErrorResponse),
(status = 404, description = "The token is invalid or expired.", body = ApiErrorResponse),
(status = 409, description = "The target email was taken by another account before confirmation.", body = ApiErrorResponse),
(status = 500, description = "Database transaction failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
params: web::Json<EmailVerifyRequest>,
) -> Result<HttpResponse, AppError> {
service.auth.auth_email_verify(params.into_inner()).await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("email verified")))
}
-37
View File
@@ -1,37 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::auth::reset_pass::ResetPasswordVerifyParams;
use crate::session::Session;
#[utoipa::path(
post,
path = "/api/v1/auth/reset-password/verify",
tag = "Auth",
operation_id = "authVerifyPasswordReset",
summary = "Confirm password reset",
description = "Set a new password using the token from the password reset email. password must be encrypted with the current session RSA public key; the new password is strength-checked and rehashed with Argon2id. The token is deleted immediately after successful use; expired or missing tokens fail.",
request_body(
content = ResetPasswordVerifyParams,
description = "The reset token and new password encrypted with RSA.",
content_type = "application/json"
),
responses(
(status = 200, description = "Password reset succeeded.", body = ApiEmptyResponse),
(status = 400, description = "The token is invalid or expired, RSA decryption failed, or the password is too weak.", body = ApiErrorResponse),
(status = 500, description = "Database update or password hashing failed.", body = ApiErrorResponse)
)
)]
pub async fn handle(
service: web::Data<AppService>,
session: Session,
params: web::Json<ResetPasswordVerifyParams>,
) -> Result<HttpResponse, AppError> {
service
.auth
.auth_reset_password_verify(&session, params.into_inner())
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("password reset successful")))
}
-51
View File
@@ -1,51 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::channels::ChannelCategory;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::service::im::categories::CreateCategoryParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
}
#[utoipa::path(
post,
path = "/api/v1/im/workspaces/{workspace_name}/categories",
tag = "IM",
operation_id = "imCategoryCreate",
params(PathParams),
request_body(
content = CreateCategoryParams,
description = "Category creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Category created successfully", body = ApiResponse<ChannelCategory>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn category_create(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateCategoryParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let result = service
.im
.category_create(&im_session, &path.workspace_name, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
}
-44
View File
@@ -1,44 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub category_id: uuid::Uuid,
}
/// Delete a category
#[utoipa::path(
delete,
path = "/api/v1/im/workspaces/{workspace_name}/categories/{category_id}",
tag = "IM",
operation_id = "imCategoryDelete",
params(PathParams),
responses(
(status = 200, description = "Category deleted successfully", body = ApiEmptyResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or category not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn category_delete(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
service
.im
.category_delete(&im_session, &path.workspace_name, path.category_id)
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Category deleted")))
}
-44
View File
@@ -1,44 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::channels::ChannelCategory;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
}
/// List categories
#[utoipa::path(
get,
path = "/api/v1/im/workspaces/{workspace_name}/categories",
tag = "IM",
operation_id = "imCategoryList",
params(PathParams),
responses(
(status = 200, description = "Categories listed successfully", body = ApiResponse<Vec<ChannelCategory>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn category_list(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let result = service
.im
.category_list(&im_session, &path.workspace_name)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-58
View File
@@ -1,58 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::channels::ChannelCategory;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::service::im::categories::UpdateCategoryParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub category_id: uuid::Uuid,
}
/// Update a category
#[utoipa::path(
put,
path = "/api/v1/im/workspaces/{workspace_name}/categories/{category_id}",
tag = "IM",
operation_id = "imCategoryUpdate",
params(PathParams),
request_body(
content = UpdateCategoryParams,
description = "Category update parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Category updated successfully", body = ApiResponse<ChannelCategory>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or category not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn category_update(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateCategoryParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let result = service
.im
.category_update(
&im_session,
&path.workspace_name,
path.category_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-65
View File
@@ -1,65 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::resolve_users;
use crate::models::channels::ChannelDetail;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::service::im::channels::CreateChannelParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
}
/// Create a channel
#[utoipa::path(
post,
path = "/api/v1/im/workspaces/{workspace_name}/channels",
tag = "IM",
operation_id = "imChannelCreate",
params(PathParams),
request_body(
content = CreateChannelParams,
description = "Channel creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Channel created successfully", body = ApiResponse<ChannelDetail>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn channel_create(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateChannelParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let request_id = uuid::Uuid::now_v7();
let channel = service
.im
.channel_create(
&im_session,
&path.workspace_name,
params.into_inner(),
request_id,
)
.await?;
let db = &service.ctx.db;
let users = resolve_users(db, &[channel.created_by]).await?;
let creator = users.get(&channel.created_by).cloned().unwrap_or_default();
let detail = channel.into_detail(creator);
Ok(HttpResponse::Created().json(ApiResponse::new(detail)))
}
-50
View File
@@ -1,50 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
}
/// Delete a channel
#[utoipa::path(
delete,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}",
tag = "IM",
operation_id = "imChannelDelete",
params(PathParams),
responses(
(status = 200, description = "Channel deleted successfully", body = ApiEmptyResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn channel_delete(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let request_id = uuid::Uuid::now_v7();
service
.im
.channel_delete(
&im_session,
&path.workspace_name,
path.channel_id,
request_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Channel deleted")))
}
-52
View File
@@ -1,52 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::resolve_users;
use crate::models::channels::ChannelDetail;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
}
/// Get a channel
#[utoipa::path(
get,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}",
tag = "IM",
operation_id = "imChannelGet",
params(PathParams),
responses(
(status = 200, description = "Channel retrieved successfully", body = ApiResponse<ChannelDetail>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn channel_get(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let channel = service
.im
.channel_get(&im_session, &path.workspace_name, path.channel_id)
.await?;
let db = &service.ctx.db;
let users = resolve_users(db, &[channel.created_by]).await?;
let creator = users.get(&channel.created_by).cloned().unwrap_or_default();
let detail = channel.into_detail(creator);
Ok(HttpResponse::Ok().json(ApiResponse::new(detail)))
}
-82
View File
@@ -1,82 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::resolve_users;
use crate::models::channels::ChannelDetail;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::service::im::channels::ChannelListFilters;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub channel_type: Option<String>,
pub channel_kind: Option<String>,
pub category_id: Option<uuid::Uuid>,
pub archived: Option<bool>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List channels
#[utoipa::path(
get,
path = "/api/v1/im/workspaces/{workspace_name}/channels",
tag = "IM",
operation_id = "imChannelList",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Channels listed successfully", body = ApiResponse<Vec<ChannelDetail>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn channel_list(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let filters = ChannelListFilters {
channel_type: query.channel_type.clone(),
channel_kind: query.channel_kind.clone(),
category_id: query.category_id,
archived: query.archived,
};
let result = service
.im
.channel_list(
&im_session,
&path.workspace_name,
filters,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
let db = &service.ctx.db;
let creator_ids: Vec<Uuid> = result.iter().map(|c| c.created_by).collect();
let users = resolve_users(db, &creator_ids).await?;
let details: Vec<ChannelDetail> = result
.into_iter()
.map(|c| {
let creator = users.get(&c.created_by).cloned().unwrap_or_default();
c.into_detail(creator)
})
.collect();
Ok(HttpResponse::Ok().json(ApiResponse::new(details)))
}
-67
View File
@@ -1,67 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::resolve_users;
use crate::models::channels::ChannelDetail;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::service::im::channels::UpdateChannelParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
}
/// Update a channel
#[utoipa::path(
put,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}",
tag = "IM",
operation_id = "imChannelUpdate",
params(PathParams),
request_body(
content = UpdateChannelParams,
description = "Channel update parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Channel updated successfully", body = ApiResponse<ChannelDetail>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn channel_update(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateChannelParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let request_id = uuid::Uuid::now_v7();
let channel = service
.im
.channel_update(
&im_session,
&path.workspace_name,
path.channel_id,
params.into_inner(),
request_id,
)
.await?;
let db = &service.ctx.db;
let users = resolve_users(db, &[channel.created_by]).await?;
let creator = users.get(&channel.created_by).cloned().unwrap_or_default();
let detail = channel.into_detail(creator);
Ok(HttpResponse::Ok().json(ApiResponse::new(detail)))
}
-58
View File
@@ -1,58 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::channels::ChannelMember;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::service::im::members::InviteMemberParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
}
/// Invite a member
#[utoipa::path(
post,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members",
tag = "IM",
operation_id = "imMemberInvite",
params(PathParams),
request_body(
content = InviteMemberParams,
description = "Invitation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Member invited successfully", body = ApiResponse<ChannelMember>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn member_invite(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<InviteMemberParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let result = service
.im
.member_invite(
&im_session,
&path.workspace_name,
path.channel_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
}
-45
View File
@@ -1,45 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::channels::ChannelMember;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
}
/// Join a channel
#[utoipa::path(
post,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/join",
tag = "IM",
operation_id = "imMemberJoin",
params(PathParams),
responses(
(status = 200, description = "Joined channel successfully", body = ApiResponse<ChannelMember>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn member_join(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let result = service
.im
.member_join(&im_session, &path.workspace_name, path.channel_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-50
View File
@@ -1,50 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
pub user_id: uuid::Uuid,
}
/// Kick a member
#[utoipa::path(
delete,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members/{user_id}",
tag = "IM",
operation_id = "imMemberKick",
params(PathParams),
responses(
(status = 200, description = "Member kicked successfully", body = ApiEmptyResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace, channel or member not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn member_kick(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
service
.im
.member_kick(
&im_session,
&path.workspace_name,
path.channel_id,
path.user_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Member kicked")))
}
-44
View File
@@ -1,44 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
}
/// Leave a channel
#[utoipa::path(
post,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/leave",
tag = "IM",
operation_id = "imMemberLeave",
params(PathParams),
responses(
(status = 200, description = "Left channel successfully", body = ApiEmptyResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn member_leave(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
service
.im
.member_leave(&im_session, &path.workspace_name, path.channel_id)
.await?;
Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("Left channel")))
}
-58
View File
@@ -1,58 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::channels::ChannelMember;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List channel members
#[utoipa::path(
get,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members",
tag = "IM",
operation_id = "imMemberList",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Members listed successfully", body = ApiResponse<Vec<ChannelMember>>),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace or channel not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn member_list(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let result = service
.im
.member_list(
&im_session,
&path.workspace_name,
path.channel_id,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-60
View File
@@ -1,60 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::channels::ChannelMember;
use crate::service::AppService;
use crate::service::im::ImSession;
use crate::service::im::members::UpdateMemberParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub channel_id: uuid::Uuid,
pub user_id: uuid::Uuid,
}
/// Update member role
#[utoipa::path(
put,
path = "/api/v1/im/workspaces/{workspace_name}/channels/{channel_id}/members/{user_id}",
tag = "IM",
operation_id = "imMemberUpdate",
params(PathParams),
request_body(
content = UpdateMemberParams,
description = "Member update parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Member updated successfully", body = ApiResponse<ChannelMember>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required", body = ApiErrorResponse),
(status = 404, description = "Workspace, channel or member not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn member_update(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateMemberParams>,
) -> Result<HttpResponse, AppError> {
let user_id = session.user().ok_or(AppError::Unauthorized)?;
let im_session = ImSession::new(user_id);
let result = service
.im
.member_update(
&im_session,
&path.workspace_name,
path.channel_id,
path.user_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-77
View File
@@ -1,77 +0,0 @@
pub mod category_create;
pub mod category_delete;
pub mod category_list;
pub mod category_update;
pub mod channel_create;
pub mod channel_delete;
pub mod channel_get;
pub mod channel_list;
pub mod channel_update;
pub mod member_invite;
pub mod member_join;
pub mod member_kick;
pub mod member_leave;
pub mod member_list;
pub mod member_update;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/im/workspaces/{workspace_name}")
// Channels
.route("/channels", web::get().to(channel_list::channel_list))
.route("/channels", web::post().to(channel_create::channel_create))
.route(
"/channels/{channel_id}",
web::get().to(channel_get::channel_get),
)
.route(
"/channels/{channel_id}",
web::put().to(channel_update::channel_update),
)
.route(
"/channels/{channel_id}",
web::delete().to(channel_delete::channel_delete),
)
// Members
.route(
"/channels/{channel_id}/members",
web::get().to(member_list::member_list),
)
.route(
"/channels/{channel_id}/members",
web::post().to(member_invite::member_invite),
)
.route(
"/channels/{channel_id}/members/{user_id}",
web::put().to(member_update::member_update),
)
.route(
"/channels/{channel_id}/members/{user_id}",
web::delete().to(member_kick::member_kick),
)
.route(
"/channels/{channel_id}/join",
web::post().to(member_join::member_join),
)
.route(
"/channels/{channel_id}/leave",
web::post().to(member_leave::member_leave),
)
// Categories
.route("/categories", web::get().to(category_list::category_list))
.route(
"/categories",
web::post().to(category_create::category_create),
)
.route(
"/categories/{category_id}",
web::put().to(category_update::category_update),
)
.route(
"/categories/{category_id}",
web::delete().to(category_delete::category_delete),
),
);
}
-65
View File
@@ -1,65 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::api::response::ApiResponse;
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct IssueTokenRequest {
pub user_id: String,
pub scopes: Vec<String>,
pub ttl_hours: Option<i64>,
#[serde(default)]
pub extra: HashMap<String, String>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct IssueTokenResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_at: i64,
pub key_id: String,
}
#[utoipa::path(
post,
path = "/api/v1/internal/tokens",
tag = "Internal",
operation_id = "internalIssueToken",
request_body = IssueTokenRequest,
responses(
(status = 200, description = "JWT token issued", body = ApiResponse<IssueTokenResponse>),
(status = 401, description = "Authentication required"),
(status = 403, description = "Admin permission required"),
),
security(("session_cookie" = []))
)]
pub async fn issue_token(
session: Session,
service: web::Data<AppService>,
body: web::Json<IssueTokenRequest>,
) -> Result<HttpResponse, AppError> {
let _user_uid = session.user().ok_or(AppError::Unauthorized)?;
let ttl_secs = body.ttl_hours.unwrap_or(1) * 3600;
let tokens = service
.internal_auth
.issue_token(
&body.user_id,
ttl_secs,
body.scopes.clone(),
body.extra.clone(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(IssueTokenResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: tokens.expires_at,
key_id: tokens.key_id,
})))
}
-10
View File
@@ -1,10 +0,0 @@
pub mod issue_api_key;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/internal")
.route("/tokens", web::post().to(issue_api_key::issue_token)),
);
}
-60
View File
@@ -1,60 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueAssignee;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// User ID (UUID) to assign
pub user_id: uuid::Uuid,
}
/// Assign a user to an issue
///
/// Assigns a workspace member to the given issue.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - User is assigned to the issue
/// - Assignee is automatically subscribed to the issue
/// - Issue assignee count is incremented
///
/// Returns the created assignment record.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees/{user_id}",
tag = "Issues",
operation_id = "issueAssign",
params(PathParams),
responses(
(status = 201, description = "User assigned successfully. Returns the created assignment record.", body = ApiResponse<IssueAssignee>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or user not found", body = ApiErrorResponse),
(status = 409, description = "User is already assigned to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn assign_issue(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let assignee = service
.issue
.issue_assign(&session, &path.workspace_name, path.number, path.user_id)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(assignee)))
}
-59
View File
@@ -1,59 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabelRelation;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Label ID (UUID) to assign
pub label_id: uuid::Uuid,
}
/// Assign a label to an issue
///
/// Attaches a label to the given issue. The label must belong to a repository in the same workspace.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Label is attached to the issue
/// - Issue label count is incremented
///
/// Returns the created label relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels/{label_id}",
tag = "Issues",
operation_id = "issueAssignLabel",
params(PathParams),
responses(
(status = 200, description = "Label assigned successfully. Returns the created label relation.", body = ApiResponse<IssueLabelRelation>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or label not found", body = ApiErrorResponse),
(status = 409, description = "Label is already assigned to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn assign_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let rel = service
.issue
.issue_assign_label(&session, &path.workspace_name, path.number, path.label_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(rel)))
}
-56
View File
@@ -1,56 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Close an issue
///
/// Closes an open issue. The issue is marked as closed and the closing user is recorded.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Issue state changes to "closed"
/// - Closed by and closed at are recorded
/// - A "Closed" event is logged
///
/// Returns the closed issue with updated metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/close",
tag = "Issues",
operation_id = "issueClose",
params(PathParams),
responses(
(status = 200, description = "Issue closed successfully. Returns the closed issue with updated metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Issue is already closed", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to close this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn close(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_close(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-82
View File
@@ -1,82 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::{self, UserBaseInfo};
use crate::models::issues::IssueDetail;
use crate::service::AppService;
use crate::service::issues::core::CreateIssueParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
}
/// Create an issue
///
/// Creates a new issue in the specified workspace.
/// Requires at least Member role in the workspace.
///
/// Parameters:
/// - title: Issue title (required)
/// - body: Issue body in markdown (optional)
/// - priority: Priority level (optional, defaults to "none")
/// - visibility: Visibility setting (optional, defaults to "public")
/// - due_at: Due date (optional)
/// - repo_ids: Related repository IDs
/// - label_ids: Label IDs to apply
/// - assignee_ids: User IDs to assign
/// - milestone_id: Milestone ID to attach
///
/// Effects:
/// - Issue is created with auto-incrementing number
/// - Author is automatically subscribed
/// - Relations, labels, and assignees are attached
/// - Workspace stats are updated
///
/// Returns the created issue with full metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues",
tag = "Issues",
operation_id = "issueCreate",
params(PathParams),
request_body(
content = CreateIssueParams,
description = "Issue creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Issue created successfully. Returns the newly created issue with full metadata.", body = ApiResponse<IssueDetail>),
(status = 400, description = "Invalid parameters: empty title, invalid repository/label/milestone references", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Workspace or referenced resource not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_create(&session, &path.workspace_name, params.into_inner())
.await?;
let author_id = issue.author_id;
let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?;
let author = users
.get(&author_id)
.cloned()
.unwrap_or_else(|| UserBaseInfo::placeholder(author_id));
Ok(HttpResponse::Created().json(ApiResponse::new(issue.into_detail(author))))
}
-73
View File
@@ -1,73 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::{self, UserBaseInfo};
use crate::models::issues::IssueCommentDetail;
use crate::service::AppService;
use crate::service::issues::comments::CreateCommentParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Create a comment on an issue
///
/// Adds a new comment to an issue. Users with read access can comment unless the issue is locked
/// (in which case only users with write access can comment).
///
/// Parameters:
/// - body: Comment body in markdown format (required)
/// - reply_to_comment_id: ID of parent comment for threaded replies (optional)
///
/// Effects:
/// - Comment is created and attached to the issue
/// - Commenter is automatically subscribed to the issue
/// - Issue comment count is incremented
///
/// Returns the created comment with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments",
tag = "Issues",
operation_id = "issueCreateComment",
params(PathParams),
request_body(content = CreateCommentParams, description = "Comment creation parameters", content_type = "application/json"),
responses(
(status = 201, description = "Comment created successfully.", body = ApiResponse<IssueCommentDetail>),
(status = 400, description = "Invalid parameters: empty body or issue is locked", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (issue locked and user lacks write access)", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateCommentParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.issue
.issue_create_comment(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
let author_id = comment.author_id;
let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?;
let author = users
.get(&author_id)
.cloned()
.unwrap_or_else(|| UserBaseInfo::placeholder(author_id));
Ok(HttpResponse::Created().json(ApiResponse::new(comment.into_detail(author))))
}
-62
View File
@@ -1,62 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::service::issues::labels::CreateLabelParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
}
/// Create a label
///
/// Creates a new issue label in a repository.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - name: Label name (required, e.g., "bug", "feature")
/// - color: Hex color code (required, e.g., "#FF0000")
/// - description: Label description (optional)
///
/// Returns the created label with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels",
tag = "Issues",
operation_id = "issueCreateLabel",
params(PathParams),
request_body(content = CreateLabelParams, description = "Label creation parameters", content_type = "application/json"),
responses(
(status = 201, description = "Label created successfully.", body = ApiResponse<IssueLabel>),
(status = 400, description = "Invalid parameters: empty name or invalid color", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn create_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateLabelParams>,
) -> Result<HttpResponse, AppError> {
let label = service
.issue
.issue_create_label(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(label)))
}
-70
View File
@@ -1,70 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::service::issues::milestones::CreateMilestoneParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
/// Create a milestone
///
/// Creates a new milestone in a repository for tracking issue progress.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - title: Milestone title (required)
/// - description: Description of the milestone (optional)
/// - due_at: Target due date (optional)
///
/// Returns the created milestone with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones",
tag = "Issues",
operation_id = "issueCreateMilestone",
params(PathParams),
request_body(
content = CreateMilestoneParams,
description = "Milestone creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Milestone created successfully. Returns the newly created milestone with metadata.", body = ApiResponse<IssueMilestone>),
(status = 400, description = "Invalid parameters: empty title", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateMilestoneParams>,
) -> Result<HttpResponse, AppError> {
let milestone = service
.issue
.issue_create_milestone(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(milestone)))
}
-53
View File
@@ -1,53 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Delete an issue
///
/// Soft-deletes an issue. The issue is marked as deleted but remains in the database.
/// Requires Admin role in the workspace (or issue author).
///
/// Effects:
/// - Issue is marked as deleted (soft-delete)
/// - Workspace issue count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueDelete",
params(PathParams),
responses(
(status = 200, description = "Issue deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role or issue author)", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Issue deleted successfully".to_string())))
}
-52
View File
@@ -1,52 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
pub comment_id: uuid::Uuid,
}
/// Delete an issue comment
///
/// Soft-deletes a comment. The comment author can delete their own comments.
/// Workspace admins can delete any comment.
///
/// Effects:
/// - Comment is marked as deleted
/// - Issue comment count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments/{comment_id}",
tag = "Issues",
operation_id = "issueDeleteComment",
params(PathParams),
responses(
(status = 200, description = "Comment deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot delete other users' comments (requires admin)", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_comment(&session, &path.workspace_name, path.number, path.comment_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Comment deleted successfully".to_string())))
}
-57
View File
@@ -1,57 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
pub label_id: uuid::Uuid,
}
/// Delete a label
///
/// Permanently removes an issue label from a repository.
/// Requires Admin role in the repository.
///
/// Effects:
/// - Label is permanently deleted
/// - All issue-label relations using this label are removed
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels/{label_id}",
tag = "Issues",
operation_id = "issueDeleteLabel",
params(PathParams),
responses(
(status = 200, description = "Label deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn delete_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_label(
&session,
&path.workspace_name,
&path.repo_name,
path.label_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Label deleted successfully".to_string())))
}
-62
View File
@@ -1,62 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Milestone ID (UUID)
pub milestone_id: uuid::Uuid,
}
/// Delete a milestone
///
/// Permanently removes a milestone from the repository.
/// Requires Admin role in the repository.
///
/// Effects:
/// - Milestone is permanently deleted
/// - Issues attached to this milestone lose their milestone association
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones/{milestone_id}",
tag = "Issues",
operation_id = "issueDeleteMilestone",
params(PathParams),
responses(
(status = 200, description = "Milestone deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or milestone not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_milestone(
&session,
&path.workspace_name,
&path.repo_name,
path.milestone_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Milestone deleted".to_string())))
}
-57
View File
@@ -1,57 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::{self, UserBaseInfo};
use crate::models::issues::IssueDetail;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
/// Get an issue by number
///
/// Returns detailed information about a specific issue, identified by workspace name and issue number.
/// Requires read access to the issue (public or workspace member).
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueGet",
params(PathParams),
responses(
(status = 200, description = "Issue retrieved successfully. Returns complete issue with all metadata.", body = ApiResponse<IssueDetail>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_get(&session, &path.workspace_name, path.number)
.await?;
let author_id = issue.author_id;
let users = base_info::resolve_users(&service.ctx.db, &[author_id]).await?;
let author = users
.get(&author_id)
.cloned()
.unwrap_or_else(|| UserBaseInfo::placeholder(author_id));
Ok(HttpResponse::Ok().json(ApiResponse::new(issue.into_detail(author))))
}
-98
View File
@@ -1,98 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::{self, UserBaseInfo};
use crate::models::issues::IssueDetail;
use crate::service::AppService;
use crate::service::issues::core::IssueListFilters;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Filter by issue state ("open" or "closed")
pub state: Option<String>,
/// Filter by priority level
pub priority: Option<String>,
/// Filter by author user ID
pub author_id: Option<uuid::Uuid>,
/// Filter by assignee user ID
pub assignee_id: Option<uuid::Uuid>,
/// Filter by milestone ID
pub milestone_id: Option<uuid::Uuid>,
/// Filter by label ID
pub label_id: Option<uuid::Uuid>,
/// Maximum number of issues to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of issues to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issues in a workspace
///
/// Returns a paginated list of issues in the workspace, sorted by issue number (newest first).
/// Supports filtering by state, priority, author, assignee, milestone, and label.
/// Only returns issues visible to the authenticated user (public + workspace member access).
/// Requires authentication.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues",
tag = "Issues",
operation_id = "issueList",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Issues listed successfully. Returns filtered array of issue objects with metadata.", body = ApiResponse<Vec<IssueDetail>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let filters = IssueListFilters {
state: query.state.clone(),
priority: query.priority.clone(),
author_id: query.author_id,
assignee_id: query.assignee_id,
milestone_id: query.milestone_id,
label_id: query.label_id,
};
let issues = service
.issue
.issue_list(
&session,
&path.workspace_name,
filters,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
let user_ids: Vec<_> = issues.iter().map(|i| i.author_id).collect();
let users = base_info::resolve_users(&service.ctx.db, &user_ids).await?;
let details: Vec<IssueDetail> = issues
.into_iter()
.map(|i| {
let author = users
.get(&i.author_id)
.cloned()
.unwrap_or_else(|| UserBaseInfo::placeholder(i.author_id));
i.into_detail(author)
})
.collect();
Ok(HttpResponse::Ok().json(ApiResponse::new(details)))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueAssignee;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of assignees to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of assignees to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List assignees of an issue
///
/// Returns a paginated list of all users assigned to the given issue.
/// Shows who is assigned, when they were assigned, and who assigned them.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees",
tag = "Issues",
operation_id = "issueListAssignees",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Assignees listed successfully. Returns array of assignee objects with assignment metadata.", body = ApiResponse<Vec<IssueAssignee>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_assignees(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let assignees = service
.issue
.issue_assignees(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(assignees)))
}
-72
View File
@@ -1,72 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info::{self, UserBaseInfo};
use crate::models::issues::IssueCommentDetail;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List issue comments
///
/// Returns a paginated list of comments on an issue, sorted by creation date (oldest first).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments",
tag = "Issues",
operation_id = "issueListComments",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Comments listed successfully.", body = ApiResponse<Vec<IssueCommentDetail>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_comments(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let comments = service
.issue
.issue_comments(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
let user_ids: Vec<_> = comments.iter().map(|c| c.author_id).collect();
let users = base_info::resolve_users(&service.ctx.db, &user_ids).await?;
let details: Vec<IssueCommentDetail> = comments
.into_iter()
.map(|c| {
let author = users
.get(&c.author_id)
.cloned()
.unwrap_or_else(|| UserBaseInfo::placeholder(c.author_id));
c.into_detail(author)
})
.collect();
Ok(HttpResponse::Ok().json(ApiResponse::new(details)))
}
-67
View File
@@ -1,67 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueEvent;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of events to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of events to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issue events
///
/// Returns a chronological timeline of all events for the given issue.
/// Events include creation, updates, state changes, assignments, label changes, etc.
/// Sorted by creation date (oldest first for timeline display).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/events",
tag = "Issues",
operation_id = "issueListEvents",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Events listed successfully. Returns chronological array of event objects.", body = ApiResponse<Vec<IssueEvent>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_events(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let events = service
.issue
.issue_list_events(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(events)))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabelRelation;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of label relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of label relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List labels assigned to an issue
///
/// Returns a paginated list of all label relations for the given issue.
/// Shows which labels are attached to the issue, with assignment metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels",
tag = "Issues",
operation_id = "issueListLabelRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Label relations listed successfully. Returns array of label relation objects with metadata.", body = ApiResponse<Vec<IssueLabelRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_issue_labels(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let rels = service
.issue
.issue_label_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(rels)))
}
-46
View File
@@ -1,46 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
}
/// List labels in a repository
///
/// Returns all issue labels defined in the repository, sorted alphabetically.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels",
tag = "Issues",
operation_id = "issueListLabels",
params(PathParams),
responses(
(status = 200, description = "Labels listed successfully.", body = ApiResponse<Vec<IssueLabel>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn list_labels(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let labels = service
.issue
.issue_labels(&session, &path.workspace_name, &path.repo_name)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(labels)))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of milestones to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of milestones to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List milestones in a repository
///
/// Returns a paginated list of milestones in the repository, sorted by state (open first) then by due date.
/// Includes milestone metadata such as title, description, state, due date, and progress.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones",
tag = "Issues",
operation_id = "issueListMilestones",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Milestones listed successfully. Returns array of milestone objects with metadata.", body = ApiResponse<Vec<IssueMilestone>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_milestones(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let milestones = service
.issue
.issue_milestones(
&session,
&path.workspace_name,
&path.repo_name,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(milestones)))
}
-62
View File
@@ -1,62 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LockIssueParams {
/// Whether to lock (true) or unlock (false) the issue
pub locked: bool,
}
/// Lock or unlock an issue
///
/// Locks or unlocks conversation on an issue. When locked, only users with write access can comment.
/// Requires write access to the issue (author or workspace member).
///
/// Returns the updated issue with lock status.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/lock",
tag = "Issues",
operation_id = "issueLock",
params(PathParams),
request_body(
content = LockIssueParams,
description = "Lock/unlock parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue lock status updated successfully.", body = ApiResponse<Issue>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to manage this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn lock(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LockIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_lock(&session, &path.workspace_name, path.number, params.locked)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-185
View File
@@ -1,185 +0,0 @@
pub mod assign_issue;
pub mod assign_label;
pub mod close;
pub mod create;
pub mod create_comment;
pub mod create_label;
pub mod create_milestone;
pub mod delete;
pub mod delete_comment;
pub mod delete_label;
pub mod delete_milestone;
pub mod get;
pub mod list;
pub mod list_assignees;
pub mod list_comments;
pub mod list_events;
pub mod list_issue_labels;
pub mod list_labels;
pub mod list_milestones;
pub mod lock;
pub mod pr_relations;
pub mod reactions;
pub mod reopen;
pub mod repo_relations;
pub mod subscribers;
pub mod templates;
pub mod transfer;
pub mod unassign_issue;
pub mod unassign_label;
pub mod update;
pub mod update_comment;
pub mod update_label;
pub mod update_milestone;
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("")
// Core
.route("", web::get().to(list::list))
.route("", web::post().to(create::create))
.route("/{number}", web::get().to(get::get))
.route("/{number}", web::put().to(update::update))
.route("/{number}", web::delete().to(delete::delete))
.route("/{number}/close", web::post().to(close::close))
.route("/{number}/reopen", web::post().to(reopen::reopen))
.route("/{number}/lock", web::put().to(lock::lock))
.route("/{number}/transfer", web::post().to(transfer::transfer))
// Comments
.route(
"/{number}/comments",
web::get().to(list_comments::list_comments),
)
.route(
"/{number}/comments",
web::post().to(create_comment::create_comment),
)
.route(
"/{number}/comments/{comment_id}",
web::put().to(update_comment::update_comment),
)
.route(
"/{number}/comments/{comment_id}",
web::delete().to(delete_comment::delete_comment),
)
// Labels (issue-level)
.route(
"/{number}/labels",
web::get().to(list_issue_labels::list_issue_labels),
)
.route(
"/{number}/labels/{label_id}",
web::post().to(assign_label::assign_label),
)
.route(
"/{number}/labels/{label_id}",
web::delete().to(unassign_label::unassign_label),
)
// Assignees
.route(
"/{number}/assignees",
web::get().to(list_assignees::list_assignees),
)
.route(
"/{number}/assignees/{user_id}",
web::post().to(assign_issue::assign_issue),
)
.route(
"/{number}/assignees/{user_id}",
web::delete().to(unassign_issue::unassign_issue),
)
// Events
.route("/{number}/events", web::get().to(list_events::list_events))
// Reactions
.route(
"/{number}/reactions",
web::get().to(reactions::list_reactions),
)
.route(
"/{number}/reactions",
web::post().to(reactions::add_reaction),
)
.route(
"/{number}/reactions/{reaction_id}",
web::delete().to(reactions::remove_reaction),
)
// Subscribers
.route(
"/{number}/subscribers",
web::get().to(subscribers::list_subscribers),
)
.route(
"/{number}/subscribe",
web::post().to(subscribers::subscribe),
)
.route(
"/{number}/subscribe",
web::delete().to(subscribers::unsubscribe),
)
.route("/{number}/mute", web::put().to(subscribers::mute))
// Repo relations
.route(
"/{number}/repos",
web::get().to(repo_relations::list_repo_relations),
)
.route("/{number}/repos", web::post().to(repo_relations::link_repo))
.route(
"/{number}/repos/{relation_id}",
web::delete().to(repo_relations::unlink_repo),
)
// PR relations
.route(
"/{number}/prs",
web::get().to(pr_relations::list_pr_relations),
)
.route("/{number}/prs", web::post().to(pr_relations::link_pr))
.route(
"/{number}/prs/{relation_id}",
web::delete().to(pr_relations::unlink_pr),
),
);
}
pub fn configure_repo_level(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("")
.route("/labels", web::get().to(list_labels::list_labels))
.route("/labels", web::post().to(create_label::create_label))
.route(
"/labels/{label_id}",
web::put().to(update_label::update_label),
)
.route(
"/labels/{label_id}",
web::delete().to(delete_label::delete_label),
)
.route(
"/milestones",
web::get().to(list_milestones::list_milestones),
)
.route(
"/milestones",
web::post().to(create_milestone::create_milestone),
)
.route(
"/milestones/{milestone_id}",
web::put().to(update_milestone::update_milestone),
)
.route(
"/milestones/{milestone_id}",
web::delete().to(delete_milestone::delete_milestone),
)
.route("/templates", web::get().to(templates::list_templates))
.route("/templates", web::post().to(templates::create_template))
.route(
"/templates/{template_id}",
web::put().to(templates::update_template),
)
.route(
"/templates/{template_id}",
web::delete().to(templates::delete_template),
),
);
}
-170
View File
@@ -1,170 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssuePrRelation;
use crate::service::AppService;
use crate::service::issues::pr_relations::LinkPrParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List pull request relations for an issue
///
/// Returns a paginated list of all pull requests linked to the given issue.
/// Shows relation type (closes, references, depends_on, etc.) and link metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs",
tag = "Issues",
operation_id = "issueListPrRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "PR relations listed successfully. Returns array of PR relation objects.", body = ApiResponse<Vec<IssuePrRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_pr_relations(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let relations = service
.issue
.issue_pr_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relations)))
}
/// Link a pull request to an issue
///
/// Creates a relation between the given issue and a pull request.
/// Commonly used to mark a PR as closing or referencing an issue.
/// Requires write access to the issue.
///
/// Parameters:
/// - pull_request_id: Pull request ID (UUID) to link
/// - relation_type: Relation type ("closes", "references", "depends_on", default: "references")
///
/// Returns the created relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs",
tag = "Issues",
operation_id = "issueLinkPr",
params(PathParams),
request_body(
content = LinkPrParams,
description = "Link pull request parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Pull request linked successfully. Returns the created relation.", body = ApiResponse<IssuePrRelation>),
(status = 400, description = "Invalid parameters: invalid relation type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or pull request not found", body = ApiErrorResponse),
(status = 409, description = "Pull request is already linked to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn link_pr(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LinkPrParams>,
) -> Result<HttpResponse, AppError> {
let relation = service
.issue
.issue_link_pr(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relation)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct RelationPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Relation ID (UUID)
pub relation_id: uuid::Uuid,
}
/// Unlink a pull request from an issue
///
/// Removes a pull request relation from the given issue.
/// Requires write access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/prs/{relation_id}",
tag = "Issues",
operation_id = "issueUnlinkPr",
params(RelationPathParams),
responses(
(status = 200, description = "Pull request unlinked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "PR relation not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unlink_pr(
service: web::Data<AppService>,
session: Session,
path: web::Path<RelationPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unlink_pr(
&session,
&path.workspace_name,
path.number,
path.relation_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("PR unlinked".to_string())))
}
-169
View File
@@ -1,169 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueReaction;
use crate::service::AppService;
use crate::service::issues::reactions::CreateIssueReactionParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of reactions to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of reactions to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List reactions on an issue
///
/// Returns a paginated list of all emoji reactions on the given issue.
/// Includes reaction content, target type, and user who added each reaction.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions",
tag = "Issues",
operation_id = "issueListReactions",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Reactions listed successfully. Returns array of reaction objects.", body = ApiResponse<Vec<IssueReaction>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_reactions(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let reactions = service
.issue
.issue_reactions(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(reactions)))
}
/// Add a reaction to an issue
///
/// Adds an emoji reaction to the given issue.
/// Requires read access to the issue.
///
/// Parameters:
/// - content: Reaction content (e.g., "👍", "❤️", "🎉")
/// - target_type: Target type for the reaction (defaults to "Issue")
/// - target_id: Target ID for reactions on specific comments (optional)
///
/// Returns the created reaction.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions",
tag = "Issues",
operation_id = "issueAddReaction",
params(PathParams),
request_body(
content = CreateIssueReactionParams,
description = "Reaction creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Reaction added successfully. Returns the created reaction.", body = ApiResponse<IssueReaction>),
(status = 400, description = "Invalid parameters: empty content or invalid target type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn add_reaction(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateIssueReactionParams>,
) -> Result<HttpResponse, AppError> {
let reaction = service
.issue
.issue_add_reaction(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(reaction)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ReactionPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Reaction ID (UUID)
pub reaction_id: uuid::Uuid,
}
/// Remove a reaction from an issue
///
/// Removes a previously added reaction. Only the user who added the reaction can remove it.
/// Requires read access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reactions/{reaction_id}",
tag = "Issues",
operation_id = "issueRemoveReaction",
params(ReactionPathParams),
responses(
(status = 200, description = "Reaction removed successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot remove another user's reaction", body = ApiErrorResponse),
(status = 404, description = "Reaction not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn remove_reaction(
service: web::Data<AppService>,
session: Session,
path: web::Path<ReactionPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_remove_reaction(
&session,
&path.workspace_name,
path.number,
path.reaction_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Reaction removed".to_string())))
}
-51
View File
@@ -1,51 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
}
/// Reopen an issue
///
/// Reopens a closed issue. The issue state changes back to "open" and closed metadata is cleared.
/// Requires write access to the issue (author or workspace member).
///
/// Returns the reopened issue with updated metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/reopen",
tag = "Issues",
operation_id = "issueReopen",
params(PathParams),
responses(
(status = 200, description = "Issue reopened successfully. Returns the reopened issue with updated metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Issue is not closed", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to reopen this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn reopen(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_reopen(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-169
View File
@@ -1,169 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueRepoRelation;
use crate::service::AppService;
use crate::service::issues::repo_relations::LinkRepoParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of relations to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of relations to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List repository relations for an issue
///
/// Returns a paginated list of all repositories linked to the given issue.
/// Shows relation type (references, duplicates, blocks, etc.) and link metadata.
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos",
tag = "Issues",
operation_id = "issueListRepoRelations",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Repository relations listed successfully. Returns array of relation objects.", body = ApiResponse<Vec<IssueRepoRelation>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_repo_relations(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let relations = service
.issue
.issue_repo_relations(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relations)))
}
/// Link a repository to an issue
///
/// Creates a relation between the given issue and a repository.
/// Requires write access to the issue.
///
/// Parameters:
/// - repo_id: Repository ID (UUID) to link
/// - relation_type: Relation type ("references", "duplicates", "blocks", "depends_on", default: "references")
///
/// Returns the created relation.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos",
tag = "Issues",
operation_id = "issueLinkRepo",
params(PathParams),
request_body(
content = LinkRepoParams,
description = "Link repository parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Repository linked successfully. Returns the created relation.", body = ApiResponse<IssueRepoRelation>),
(status = 400, description = "Invalid parameters: invalid relation type", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Issue or repository not found", body = ApiErrorResponse),
(status = 409, description = "Repository is already linked to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn link_repo(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<LinkRepoParams>,
) -> Result<HttpResponse, AppError> {
let relation = service
.issue
.issue_link_repo(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(relation)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct RelationPathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Relation ID (UUID)
pub relation_id: uuid::Uuid,
}
/// Unlink a repository from an issue
///
/// Removes a repository relation from the given issue.
/// Requires write access to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/repos/{relation_id}",
tag = "Issues",
operation_id = "issueUnlinkRepo",
params(RelationPathParams),
responses(
(status = 200, description = "Repository unlinked successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Repository relation not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unlink_repo(
service: web::Data<AppService>,
session: Session,
path: web::Path<RelationPathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unlink_repo(
&session,
&path.workspace_name,
path.number,
path.relation_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Repo unlinked".to_string())))
}
-186
View File
@@ -1,186 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueSubscriber;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of subscribers to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of subscribers to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List subscribers of an issue
///
/// Returns a paginated list of all users subscribed to the given issue.
/// Shows who receives notifications and their subscription reason (author, assignee, manual).
/// Requires read access to the issue.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribers",
tag = "Issues",
operation_id = "issueListSubscribers",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Subscribers listed successfully. Returns array of subscriber objects.", body = ApiResponse<Vec<IssueSubscriber>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_subscribers(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let subscribers = service
.issue
.issue_subscribers(
&session,
&path.workspace_name,
path.number,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(subscribers)))
}
/// Subscribe to an issue
///
/// Subscribes the authenticated user to the given issue to receive notifications.
/// Requires read access to the issue.
///
/// Effects:
/// - User is added as a subscriber with "manual" reason
/// - User receives notifications for all issue activity
///
/// Returns the created subscription record.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribe",
tag = "Issues",
operation_id = "issueSubscribe",
params(PathParams),
responses(
(status = 200, description = "Subscribed successfully. Returns the subscription record.", body = ApiResponse<IssueSubscriber>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to view this issue", body = ApiErrorResponse),
(status = 404, description = "Issue not found", body = ApiErrorResponse),
(status = 409, description = "Already subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn subscribe(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let sub = service
.issue
.issue_subscribe(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(sub)))
}
/// Unsubscribe from an issue
///
/// Removes the authenticated user's subscription to the given issue.
/// Stops all notifications for this issue.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/subscribe",
tag = "Issues",
operation_id = "issueUnsubscribe",
params(PathParams),
responses(
(status = 200, description = "Unsubscribed successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Not currently subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unsubscribe(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unsubscribe(&session, &path.workspace_name, path.number)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Unsubscribed".to_string())))
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct MuteIssueParams {
/// Whether to mute (true) or unmute (false) notifications
pub muted: bool,
}
/// Mute or unmute issue notifications
///
/// Mutes or unmutes notifications for the given issue without unsubscribing.
/// Requires an active subscription to the issue.
///
/// Returns success message on completion.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/mute",
tag = "Issues",
operation_id = "issueMute",
params(PathParams),
request_body(
content = MuteIssueParams,
description = "Mute/unmute parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Mute status updated successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Not currently subscribed to this issue", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn mute(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<MuteIssueParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_mute(&session, &path.workspace_name, path.number, params.muted)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Mute status updated".to_string())))
}
-220
View File
@@ -1,220 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueTemplate;
use crate::service::AppService;
use crate::service::issues::templates::{CreateTemplateParams, UpdateTemplateParams};
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
/// Maximum number of templates to return (default: 50, max: 100)
pub limit: Option<i64>,
/// Number of templates to skip for pagination (default: 0)
pub offset: Option<i64>,
}
/// List issue templates in a repository
///
/// Returns a paginated list of all active issue templates in the repository.
/// Templates provide pre-filled content for creating new issues.
/// Sorted alphabetically by name.
/// Requires read access to the repository.
#[utoipa::path(
get,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates",
tag = "Issues",
operation_id = "issueListTemplates",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Templates listed successfully. Returns array of template objects.", body = ApiResponse<Vec<IssueTemplate>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to access this repository", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_templates(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let templates = service
.issue
.issue_templates(
&session,
&path.workspace_name,
&path.repo_name,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(templates)))
}
/// Create an issue template
///
/// Creates a new issue template in the repository.
/// Requires at least Member role in the repository.
///
/// Parameters:
/// - name: Template name (required)
/// - description: Template description (optional)
/// - title_template: Default title for issues (optional, supports placeholders)
/// - body_template: Default body content in markdown (required)
/// - labels: List of label names to auto-apply (optional)
///
/// Returns the created template with metadata.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates",
tag = "Issues",
operation_id = "issueCreateTemplate",
params(PathParams),
request_body(
content = CreateTemplateParams,
description = "Template creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Template created successfully. Returns the newly created template.", body = ApiResponse<IssueTemplate>),
(status = 400, description = "Invalid parameters: empty name or body template", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository or workspace not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<CreateTemplateParams>,
) -> Result<HttpResponse, AppError> {
let template = service
.issue
.issue_create_template(
&session,
&path.workspace_name,
&path.repo_name,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(template)))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct TemplatePathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Template ID (UUID)
pub template_id: uuid::Uuid,
}
/// Update an issue template
///
/// Updates an existing issue template's metadata and content.
/// Requires Admin role in the repository.
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated template.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates/{template_id}",
tag = "Issues",
operation_id = "issueUpdateTemplate",
params(TemplatePathParams),
request_body(
content = UpdateTemplateParams,
description = "Template update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Template updated successfully. Returns the updated template.", body = ApiResponse<IssueTemplate>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or template not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<TemplatePathParams>,
params: web::Json<UpdateTemplateParams>,
) -> Result<HttpResponse, AppError> {
let template = service
.issue
.issue_update_template(
&session,
&path.workspace_name,
&path.repo_name,
path.template_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(template)))
}
/// Delete an issue template
///
/// Permanently removes an issue template from the repository.
/// Requires Admin role in the repository.
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/templates/{template_id}",
tag = "Issues",
operation_id = "issueDeleteTemplate",
params(TemplatePathParams),
responses(
(status = 200, description = "Template deleted successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Template not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<TemplatePathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_delete_template(
&session,
&path.workspace_name,
&path.repo_name,
path.template_id,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Template deleted".to_string())))
}
-75
View File
@@ -1,75 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct TransferIssueParams {
/// Target workspace name to transfer the issue to
pub target_workspace_name: String,
}
/// Transfer an issue to another workspace
///
/// Moves an issue from the current workspace to a different workspace.
/// Requires Admin role in both the source and target workspaces.
///
/// Effects:
/// - Issue is transferred to the target workspace with a new number
/// - Source workspace issue count is decremented
/// - Target workspace issue count is incremented
///
/// Returns the transferred issue with updated workspace and number.
#[utoipa::path(
post,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/transfer",
tag = "Issues",
operation_id = "issueTransfer",
params(PathParams),
request_body(
content = TransferIssueParams,
description = "Transfer parameters",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue transferred successfully. Returns the issue with new workspace assignment.", body = ApiResponse<Issue>),
(status = 400, description = "Invalid target workspace", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions in source or target workspace", body = ApiErrorResponse),
(status = 404, description = "Workspace or issue not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn transfer(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<TransferIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_transfer(
&session,
&path.workspace_name,
path.number,
&params.target_workspace_name,
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-57
View File
@@ -1,57 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// User ID (UUID) to unassign
pub user_id: uuid::Uuid,
}
/// Unassign a user from an issue
///
/// Removes a user from the issue's assignee list.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - User is removed from the issue's assignees
/// - Issue assignee count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/assignees/{user_id}",
tag = "Issues",
operation_id = "issueUnassign",
params(PathParams),
responses(
(status = 200, description = "User unassigned successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "User is not assigned to this issue or not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unassign_issue(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unassign(&session, &path.workspace_name, path.number, path.user_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("User unassigned".to_string())))
}
-57
View File
@@ -1,57 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
/// Label ID (UUID) to unassign
pub label_id: uuid::Uuid,
}
/// Unassign a label from an issue
///
/// Removes a label from the given issue.
/// Requires write access to the issue (author or workspace member).
///
/// Effects:
/// - Label relation is removed from the issue
/// - Issue label count is decremented
///
/// Returns success message on completion.
#[utoipa::path(
delete,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/labels/{label_id}",
tag = "Issues",
operation_id = "issueUnassignLabel",
params(PathParams),
responses(
(status = 200, description = "Label unassigned successfully.", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Label is not assigned to this issue or not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn unassign_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.issue
.issue_unassign_label(&session, &path.workspace_name, path.number, path.label_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Label unassigned".to_string())))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::Issue;
use crate::service::AppService;
use crate::service::issues::core::UpdateIssueParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Issue number (unique within the workspace)
pub number: i64,
}
/// Update an issue
///
/// Updates an existing issue's metadata such as title, body, priority, visibility, due date, and milestone.
/// Requires write access to the issue (author or workspace member).
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated issue with full metadata.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}",
tag = "Issues",
operation_id = "issueUpdate",
params(PathParams),
request_body(
content = UpdateIssueParams,
description = "Issue update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Issue updated successfully. Returns the updated issue with full metadata.", body = ApiResponse<Issue>),
(status = 400, description = "Invalid parameters: invalid priority, visibility, or milestone", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions to edit this issue", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or referenced resource not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateIssueParams>,
) -> Result<HttpResponse, AppError> {
let issue = service
.issue
.issue_update(
&session,
&path.workspace_name,
path.number,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(issue)))
}
-59
View File
@@ -1,59 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueComment;
use crate::service::AppService;
use crate::service::issues::comments::UpdateCommentParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub number: i64,
pub comment_id: uuid::Uuid,
}
/// Update an issue comment
///
/// Updates the body of an existing comment. Only the comment author can update their own comments.
/// Requires read access to the issue.
///
/// Returns the updated comment with edit timestamp.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/issues/{number}/comments/{comment_id}",
tag = "Issues",
operation_id = "issueUpdateComment",
params(PathParams),
request_body(content = UpdateCommentParams, description = "Comment update parameters", content_type = "application/json"),
responses(
(status = 200, description = "Comment updated successfully.", body = ApiResponse<IssueComment>),
(status = 400, description = "Invalid parameters: empty body", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Cannot edit other users' comments", body = ApiErrorResponse),
(status = 404, description = "Workspace, issue, or comment not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_comment(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateCommentParams>,
) -> Result<HttpResponse, AppError> {
let comment = service
.issue
.issue_update_comment(
&session,
&path.workspace_name,
path.number,
path.comment_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(comment)))
}
-59
View File
@@ -1,59 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueLabel;
use crate::service::AppService;
use crate::service::issues::labels::UpdateLabelParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub workspace_name: String,
pub repo_name: String,
pub label_id: uuid::Uuid,
}
/// Update a label
///
/// Updates an existing issue label's name, color, or description.
/// Requires Admin role in the repository.
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated label.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/labels/{label_id}",
tag = "Issues",
operation_id = "issueUpdateLabel",
params(PathParams),
request_body(content = UpdateLabelParams, description = "Label update parameters (all fields optional)", content_type = "application/json"),
responses(
(status = 200, description = "Label updated successfully.", body = ApiResponse<IssueLabel>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Admin role)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or label not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(("session_cookie" = []))
)]
pub async fn update_label(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateLabelParams>,
) -> Result<HttpResponse, AppError> {
let label = service
.issue
.issue_update_label(
&session,
&path.workspace_name,
&path.repo_name,
path.label_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(label)))
}
-75
View File
@@ -1,75 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::issues::IssueMilestone;
use crate::service::AppService;
use crate::service::issues::milestones::UpdateMilestoneParams;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
/// Workspace name (unique identifier)
pub workspace_name: String,
/// Repository name (unique within the workspace)
pub repo_name: String,
/// Milestone ID (UUID)
pub milestone_id: uuid::Uuid,
}
/// Update a milestone
///
/// Updates an existing milestone's metadata. Can also close or reopen the milestone via the state field.
/// Requires at least Member role in the repository.
///
/// Updatable fields:
/// - title: Milestone title (optional)
/// - description: Description (optional)
/// - due_at: Target due date (optional)
/// - state: State ("open" or "closed") for closing/reopening the milestone (optional)
///
/// All fields are optional; only provided fields are updated.
/// Returns the updated milestone with full metadata.
#[utoipa::path(
put,
path = "/api/v1/workspaces/{workspace_name}/repos/{repo_name}/issues/milestones/{milestone_id}",
tag = "Issues",
operation_id = "issueUpdateMilestone",
params(PathParams),
request_body(
content = UpdateMilestoneParams,
description = "Milestone update parameters (all fields optional)",
content_type = "application/json"
),
responses(
(status = 200, description = "Milestone updated successfully. Returns the updated milestone with full metadata.", body = ApiResponse<IssueMilestone>),
(status = 400, description = "Invalid parameters", body = ApiErrorResponse),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "Insufficient permissions (requires Member role or higher)", body = ApiErrorResponse),
(status = 404, description = "Repository, workspace, or milestone not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn update_milestone(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
params: web::Json<UpdateMilestoneParams>,
) -> Result<HttpResponse, AppError> {
let milestone = service
.issue
.issue_update_milestone(
&session,
&path.workspace_name,
&path.repo_name,
path.milestone_id,
params.into_inner(),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(milestone)))
}
-13
View File
@@ -1,13 +0,0 @@
pub mod auth;
pub mod im;
pub mod internal;
pub mod issue;
pub mod notify;
pub mod openapi;
pub mod pr;
pub mod repo;
pub mod response;
pub mod routes;
pub mod user;
pub mod wiki;
pub mod workspace;
-29
View File
@@ -1,29 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
/// Clear all notifications (dismiss all)
#[utoipa::path(
delete,
path = "/api/v1/notifications",
tag = "Notifications",
operation_id = "notificationClearAll",
responses(
(status = 200, description = "All notifications cleared", body = ApiResponse<i64>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn clear_all_notifications(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let result = service.notify.clear_all_notifications(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-40
View File
@@ -1,40 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::notifications::NotificationBlock;
use crate::service::AppService;
use crate::service::notify::blocks::CreateBlockParams;
use crate::session::Session;
/// Create a notification block
#[utoipa::path(
post,
path = "/api/v1/notifications/blocks",
tag = "Notifications",
operation_id = "notificationCreateBlock",
request_body(
content = CreateBlockParams,
description = "Block creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Block created", body = ApiResponse<NotificationBlock>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_block(
service: web::Data<AppService>,
session: Session,
params: web::Json<CreateBlockParams>,
) -> Result<HttpResponse, AppError> {
let result = service
.notify
.create_block(&session, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
}
-40
View File
@@ -1,40 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::notifications::NotificationSubscription;
use crate::service::AppService;
use crate::service::notify::subscriptions::CreateSubscriptionParams;
use crate::session::Session;
/// Create a notification subscription
#[utoipa::path(
post,
path = "/api/v1/notifications/subscriptions",
tag = "Notifications",
operation_id = "notificationCreateSubscription",
request_body(
content = CreateSubscriptionParams,
description = "Subscription creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Subscription created", body = ApiResponse<NotificationSubscription>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_subscription(
service: web::Data<AppService>,
session: Session,
params: web::Json<CreateSubscriptionParams>,
) -> Result<HttpResponse, AppError> {
let result = service
.notify
.create_subscription(&session, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
}
-41
View File
@@ -1,41 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::notifications::NotificationTemplate;
use crate::service::AppService;
use crate::service::notify::templates::CreateTemplateParams;
use crate::session::Session;
/// Create a notification template (requires system admin)
#[utoipa::path(
post,
path = "/api/v1/notifications/templates",
tag = "Notifications",
operation_id = "notificationCreateTemplate",
request_body(
content = CreateTemplateParams,
description = "Template creation parameters",
content_type = "application/json"
),
responses(
(status = 201, description = "Template created", body = ApiResponse<NotificationTemplate>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "System admin access required", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn create_template(
service: web::Data<AppService>,
session: Session,
params: web::Json<CreateTemplateParams>,
) -> Result<HttpResponse, AppError> {
let result = service
.notify
.create_template(&session, params.into_inner())
.await?;
Ok(HttpResponse::Created().json(ApiResponse::new(result)))
}
-39
View File
@@ -1,39 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub block_id: uuid::Uuid,
}
/// Delete a notification block
#[utoipa::path(
delete,
path = "/api/v1/notifications/blocks/{block_id}",
tag = "Notifications",
operation_id = "notificationDeleteBlock",
params(PathParams),
responses(
(status = 200, description = "Block deleted", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Block not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_block(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service.notify.delete_block(&session, path.block_id).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Block deleted".to_string())))
}
-42
View File
@@ -1,42 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub notification_id: uuid::Uuid,
}
/// Delete a notification
#[utoipa::path(
delete,
path = "/api/v1/notifications/{notification_id}",
tag = "Notifications",
operation_id = "notificationDelete",
params(PathParams),
responses(
(status = 200, description = "Notification deleted", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Notification not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_notification(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.notify
.delete_notification(&session, path.notification_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Notification deleted".to_string())))
}
-42
View File
@@ -1,42 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub subscription_id: uuid::Uuid,
}
/// Delete a notification subscription
#[utoipa::path(
delete,
path = "/api/v1/notifications/subscriptions/{subscription_id}",
tag = "Notifications",
operation_id = "notificationDeleteSubscription",
params(PathParams),
responses(
(status = 200, description = "Subscription deleted", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Subscription not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_subscription(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.notify
.delete_subscription(&session, path.subscription_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Subscription deleted".to_string())))
}
-43
View File
@@ -1,43 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub template_id: uuid::Uuid,
}
/// Delete a notification template (requires system admin)
#[utoipa::path(
delete,
path = "/api/v1/notifications/templates/{template_id}",
tag = "Notifications",
operation_id = "notificationDeleteTemplate",
params(PathParams),
responses(
(status = 200, description = "Template deleted", body = ApiResponse<String>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "System admin access required", body = ApiErrorResponse),
(status = 404, description = "Template not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn delete_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
service
.notify
.delete_template(&session, path.template_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new("Template deleted".to_string())))
}
-66
View File
@@ -1,66 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info;
use crate::models::notifications::NotificationDetail;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub notification_id: uuid::Uuid,
}
/// Dismiss a notification
#[utoipa::path(
post,
path = "/api/v1/notifications/{notification_id}/dismiss",
tag = "Notifications",
operation_id = "notificationDismiss",
params(PathParams),
responses(
(status = 200, description = "Notification dismissed", body = ApiResponse<NotificationDetail>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Notification not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn dismiss_notification(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let notification = service
.notify
.dismiss_notification(&session, path.notification_id)
.await?;
let actor = match notification.actor_id {
Some(id) => base_info::resolve_users(&service.ctx.db, &[id])
.await?
.remove(&id),
None => None,
};
let workspace = match notification.workspace_id {
Some(id) => base_info::resolve_workspaces(&service.ctx.db, &[id])
.await?
.remove(&id),
None => None,
};
let repo = match notification.repo_id {
Some(id) => base_info::resolve_repos(&service.ctx.db, &[id])
.await?
.remove(&id),
None => None,
};
Ok(HttpResponse::Ok().json(ApiResponse::new(
notification.into_detail(actor, workspace, repo),
)))
}
-44
View File
@@ -1,44 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::notifications::NotificationTemplate;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub template_id: uuid::Uuid,
}
/// Get a notification template by ID (requires system admin)
#[utoipa::path(
get,
path = "/api/v1/notifications/templates/{template_id}",
tag = "Notifications",
operation_id = "notificationGetTemplate",
params(PathParams),
responses(
(status = 200, description = "Template retrieved", body = ApiResponse<NotificationTemplate>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 403, description = "System admin access required", body = ApiErrorResponse),
(status = 404, description = "Template not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get_template(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
) -> Result<HttpResponse, AppError> {
let result = service
.notify
.get_template(&session, path.template_id)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-29
View File
@@ -1,29 +0,0 @@
use actix_web::{HttpResponse, web};
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::service::AppService;
use crate::session::Session;
/// Get unread notification count for the current user
#[utoipa::path(
get,
path = "/api/v1/notifications/unread-count",
tag = "Notifications",
operation_id = "notificationUnreadCount",
responses(
(status = 200, description = "Unread count returned successfully", body = ApiResponse<i64>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn get_unread_count(
service: web::Data<AppService>,
session: Session,
) -> Result<HttpResponse, AppError> {
let result = service.notify.count_unread(&session).await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-47
View File
@@ -1,47 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::notifications::NotificationBlock;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List notification blocks for the current user
#[utoipa::path(
get,
path = "/api/v1/notifications/blocks",
tag = "Notifications",
operation_id = "notificationListBlocks",
params(QueryParams),
responses(
(status = 200, description = "Blocks listed successfully", body = ApiResponse<Vec<NotificationBlock>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_blocks(
service: web::Data<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let result = service
.notify
.list_blocks(
&session,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-47
View File
@@ -1,47 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::notifications::NotificationDelivery;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List notification deliveries for the current user
#[utoipa::path(
get,
path = "/api/v1/notifications/deliveries",
tag = "Notifications",
operation_id = "notificationListDeliveries",
params(QueryParams),
responses(
(status = 200, description = "Deliveries listed successfully", body = ApiResponse<Vec<NotificationDelivery>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_deliveries(
service: web::Data<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let result = service
.notify
.list_deliveries(
&session,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
@@ -1,55 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::notifications::NotificationDelivery;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct PathParams {
pub notification_id: uuid::Uuid,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List deliveries for a specific notification
#[utoipa::path(
get,
path = "/api/v1/notifications/{notification_id}/deliveries",
tag = "Notifications",
operation_id = "notificationListDeliveriesForNotification",
params(PathParams, QueryParams),
responses(
(status = 200, description = "Deliveries listed successfully", body = ApiResponse<Vec<NotificationDelivery>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 404, description = "Notification not found", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_deliveries_for_notification(
service: web::Data<AppService>,
session: Session,
path: web::Path<PathParams>,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let result = service
.notify
.list_deliveries_for_notification(
&session,
path.notification_id,
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
Ok(HttpResponse::Ok().json(ApiResponse::new(result)))
}
-72
View File
@@ -1,72 +0,0 @@
use actix_web::{HttpResponse, web};
use serde::Deserialize;
use utoipa::IntoParams;
use crate::api::response::{ApiErrorResponse, ApiResponse};
use crate::error::AppError;
use crate::models::base_info;
use crate::models::notifications::NotificationDetail;
use crate::service::AppService;
use crate::session::Session;
#[derive(Debug, Deserialize, IntoParams)]
pub struct QueryParams {
pub unread_only: Option<bool>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// List notifications for the current user
#[utoipa::path(
get,
path = "/api/v1/notifications",
tag = "Notifications",
operation_id = "notificationList",
params(QueryParams),
responses(
(status = 200, description = "Notifications listed successfully", body = ApiResponse<Vec<NotificationDetail>>),
(status = 401, description = "Authentication required or session expired", body = ApiErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
),
security(
("session_cookie" = [])
)
)]
pub async fn list_notifications(
service: web::Data<AppService>,
session: Session,
query: web::Query<QueryParams>,
) -> Result<HttpResponse, AppError> {
let notifications = service
.notify
.list_notifications(
&session,
query.unread_only.unwrap_or(false),
query.limit.unwrap_or(50),
query.offset.unwrap_or(0),
)
.await?;
let actor_ids: Vec<_> = notifications.iter().filter_map(|n| n.actor_id).collect();
let workspace_ids: Vec<_> = notifications
.iter()
.filter_map(|n| n.workspace_id)
.collect();
let repo_ids: Vec<_> = notifications.iter().filter_map(|n| n.repo_id).collect();
let actors = base_info::resolve_users(&service.ctx.db, &actor_ids).await?;
let workspaces = base_info::resolve_workspaces(&service.ctx.db, &workspace_ids).await?;
let repos = base_info::resolve_repos(&service.ctx.db, &repo_ids).await?;
let details: Vec<NotificationDetail> = notifications
.into_iter()
.map(|n| {
let actor = n.actor_id.and_then(|id| actors.get(&id).cloned());
let workspace = n.workspace_id.and_then(|id| workspaces.get(&id).cloned());
let repo = n.repo_id.and_then(|id| repos.get(&id).cloned());
n.into_detail(actor, workspace, repo)
})
.collect();
Ok(HttpResponse::Ok().json(ApiResponse::new(details)))
}

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