Merge pull request #1052 from trheyi/main

Add Signin API and update file management endpoints
This commit is contained in:
Max 2025-07-30 16:52:42 +08:00 committed by GitHub
commit 93291022ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 932 additions and 80 deletions

View file

@ -80,6 +80,25 @@ env:
S3_BUCKET: ${{ secrets.S3_BUCKET }} S3_BUCKET: ${{ secrets.S3_BUCKET }}
S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }} S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }}
# === Openapi Signin Configs ===
## Google
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
## Microsoft
MICROSOFT_CLIENT_ID: ${{ secrets.MICROSOFT_CLIENT_ID }}
MICROSOFT_CLIENT_SECRET: ${{ secrets.MICROSOFT_CLIENT_SECRET }}
## Apple
APPLE_SERVICE_ID: ${{ secrets.APPLE_SERVICE_ID }}
APPLE_PRIVATE_KEY_PATH: "apple/signin_client_secret_key.p8"
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
## Github
GITHUBUSER_CLIENT_ID: ${{ secrets.GITHUBUSER_CLIENT_ID }}
GITHUBUSER_CLIENT_SECRET: ${{ secrets.GITHUBUSER_CLIENT_SECRET }}
jobs: jobs:
UnitTest: UnitTest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -236,6 +255,11 @@ jobs:
with: with:
ref: ${{ env.HEAD }} ref: ${{ env.HEAD }}
- name: Setup Apple Private Key
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
- name: Start Redis - name: Start Redis
uses: supercharge/redis-github-action@1.4.0 uses: supercharge/redis-github-action@1.4.0
with: with:

View file

@ -84,6 +84,26 @@ env:
S3_BUCKET: ${{ secrets.S3_BUCKET }} S3_BUCKET: ${{ secrets.S3_BUCKET }}
S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }} S3_PUBLIC_URL: ${{ secrets.S3_PUBLIC_URL }}
# === Openapi Signin Configs ===
## Google
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
## Microsoft
MICROSOFT_CLIENT_ID: ${{ secrets.MICROSOFT_CLIENT_ID }}
MICROSOFT_CLIENT_SECRET: ${{ secrets.MICROSOFT_CLIENT_SECRET }}
## Apple
APPLE_SERVICE_ID: ${{ secrets.APPLE_SERVICE_ID }}
APPLE_PRIVATE_KEY_PATH: "apple/signin_client_secret_key.p8"
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
## Github
GITHUBUSER_CLIENT_ID: ${{ secrets.GITHUBUSER_CLIENT_ID }}
GITHUBUSER_CLIENT_SECRET: ${{ secrets.GITHUBUSER_CLIENT_SECRET }}
jobs: jobs:
unit-test: unit-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -190,6 +210,11 @@ jobs:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Apple Private Key
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ secrets.APPLE_PRIVATE_KEY_USER }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
- name: Setup Go ${{ matrix.go }} - name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:

View file

@ -322,6 +322,140 @@ openai.api_key = "your-oauth-token"
All Chat endpoints require OAuth authentication. All Chat endpoints require OAuth authentication.
## Signin API
Comprehensive authentication API for user signin, configuration management, and OAuth integration with support for multiple authentication providers.
The Signin API provides:
- **Signin Configuration**: Get public signin configuration for different locales
- **Password Authentication**: Traditional username/password signin flow
- **OAuth Integration**: Third-party authentication provider callbacks
- **Multi-Locale Support**: Localized signin configurations and messages
- **Provider Management**: Support for multiple OAuth providers (Google, GitHub, etc.)
**Key Endpoints:**
- `GET /signin` - Get signin configuration for locale
- `POST /signin` - Authenticate with username/password
- `GET /signin/authback/{id}` - OAuth authentication callback handler
**Configuration:**
Signin configurations are defined in DSL files with multi-locale support:
**[View Configuration Examples →](https://github.com/YaoApp/yao-dev-app/blob/main/openapi/signin.en.yao)**
### Get Signin Configuration
Retrieve public signin configuration for a specific locale:
```
GET /signin?locale={locale}
```
**Parameters:**
- `locale` (optional): Language locale (e.g., "en", "zh-cn")
**Example:**
```bash
curl -X GET "/v1/signin?locale=en" \
-H "Content-Type: application/json"
```
**Response:**
```json
{
"title": "Sign In",
"subtitle": "Welcome back",
"providers": [
{
"id": "google",
"name": "Google",
"icon": "google",
"enabled": true
},
{
"id": "github",
"name": "GitHub",
"icon": "github",
"enabled": true
}
],
"password_enabled": true,
"register_enabled": true,
"forgot_password_enabled": true
}
```
### Password Signin
Authenticate using username and password:
```
POST /signin
```
**Request Body:**
```json
{
"username": "user@example.com",
"password": "your_password",
"remember": true
}
```
**Response:**
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "def50200...",
"user": {
"id": "user123",
"email": "user@example.com",
"name": "John Doe"
}
}
```
### OAuth Authentication Callback
Handle OAuth provider authentication callbacks:
```
GET /signin/authback/{provider_id}
```
**Parameters:**
- `provider_id` (path): OAuth provider identifier (e.g., "google", "github")
- Standard OAuth parameters in query string (code, state, etc.)
**Example:**
```
GET /signin/authback/google?code=auth_code&state=csrf_token
```
This endpoint processes the OAuth callback and returns authentication tokens or redirects to the configured success/error URLs.
**Features:**
- **Multi-Provider Support**: Google, GitHub, Microsoft, and custom OAuth providers
- **Locale Awareness**: Configuration adapts to user's preferred language
- **Security**: CSRF protection, secure token handling, and validation
- **Customizable UI**: Configurable signin forms and provider buttons
- **Session Management**: Automatic session creation and token management
**Note:** Signin endpoints are publicly accessible for authentication purposes, but return OAuth tokens that must be used for subsequent API calls.
## File Management API ## File Management API
Comprehensive API for managing file uploads, downloads, and file operations with support for multiple storage backends. Comprehensive API for managing file uploads, downloads, and file operations with support for multiple storage backends.
@ -340,12 +474,12 @@ The File Management API provides:
**Key Endpoints:** **Key Endpoints:**
- `POST /files/{uploaderID}` - Upload files (supports chunked upload) - `POST /file/{uploaderID}` - Upload files (supports chunked upload)
- `GET /files/{uploaderID}` - List files with pagination and filters - `GET /file/{uploaderID}` - List files with pagination and filters
- `GET /files/{uploaderID}/{fileID}` - Get file metadata - `GET /file/{uploaderID}/{fileID}` - Get file metadata
- `GET /files/{uploaderID}/{fileID}/content` - Download file content - `GET /file/{uploaderID}/{fileID}/content` - Download file content
- `GET /files/{uploaderID}/{fileID}/exists` - Check file existence - `GET /file/{uploaderID}/{fileID}/exists` - Check file existence
- `DELETE /files/{uploaderID}/{fileID}` - Delete file - `DELETE /file/{uploaderID}/{fileID}` - Delete file
**Advanced Features:** **Advanced Features:**
@ -519,12 +653,40 @@ curl -X GET "/v1/chat/completions?content=Help%20me%20create%20a%20user%20model&
-H "Accept: text/event-stream" -H "Accept: text/event-stream"
``` ```
### User Authentication with Signin API
1. **Get signin configuration**:
```bash
curl -X GET "/v1/signin?locale=en" \
-H "Content-Type: application/json"
```
2. **Authenticate with password**:
```bash
curl -X POST "/v1/signin" \
-H "Content-Type: application/json" \
-d '{
"username": "user@example.com",
"password": "secure_password",
"remember": true
}'
```
3. **Use authentication token for API access**:
```bash
curl -X GET "/v1/dsl/list/model" \
-H "Authorization: Bearer {received_access_token}"
```
### File Upload and Management ### File Upload and Management
1. **Upload a file with metadata**: 1. **Upload a file with metadata**:
```bash ```bash
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {access_token}" \ -H "Authorization: Bearer {access_token}" \
-F "file=@document.pdf" \ -F "file=@document.pdf" \
-F "path=documents/reports/quarterly-report.pdf" \ -F "path=documents/reports/quarterly-report.pdf" \
@ -536,14 +698,14 @@ curl -X POST "/v1/files/default" \
2. **List and filter files**: 2. **List and filter files**:
```bash ```bash
curl -X GET "/v1/files/default?status=completed&content_type=application/pdf&page=1&page_size=10" \ curl -X GET "/v1/file/default?status=completed&content_type=application/pdf&page=1&page_size=10" \
-H "Authorization: Bearer {access_token}" -H "Authorization: Bearer {access_token}"
``` ```
3. **Download file content** (with optimized delivery): 3. **Download file content** (with optimized delivery):
```bash ```bash
curl -X GET "/v1/files/default/{file_id}/content" \ curl -X GET "/v1/file/default/{file_id}/content" \
-H "Authorization: Bearer {access_token}" \ -H "Authorization: Bearer {access_token}" \
--output downloaded-document.pdf --output downloaded-document.pdf
``` ```

View file

@ -4,7 +4,7 @@ This document describes the RESTful API for managing file uploads, downloads, an
## Base URL ## Base URL
All endpoints are prefixed with the configured base URL followed by `/files` (e.g., `/v1/files`). All endpoints are prefixed with the configured base URL followed by `/file` (e.g., `/v1/file`).
## Authentication ## Authentication
@ -28,7 +28,7 @@ The File Management API provides comprehensive file handling capabilities includ
Upload files with support for chunked uploads, compression, and metadata. Upload files with support for chunked uploads, compression, and metadata.
``` ```
POST /files/{uploaderID} POST /file/{uploaderID}
``` ```
**Parameters:** **Parameters:**
@ -57,7 +57,7 @@ POST /files/{uploaderID}
```bash ```bash
# Simple file upload # Simple file upload
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-F "file=@document.pdf" \ -F "file=@document.pdf" \
-F "path=documents/reports/quarterly-report.pdf" \ -F "path=documents/reports/quarterly-report.pdf" \
@ -66,7 +66,7 @@ curl -X POST "/v1/files/default" \
-F "gzip=true" -F "gzip=true"
# Chunked upload (first chunk) # Chunked upload (first chunk)
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-H "Content-Range: bytes 0-1023/2048" \ -H "Content-Range: bytes 0-1023/2048" \
-H "Content-Sync: chunk-upload" \ -H "Content-Sync: chunk-upload" \
@ -95,7 +95,7 @@ curl -X POST "/v1/files/default" \
List files with pagination, filtering, and sorting capabilities. List files with pagination, filtering, and sorting capabilities.
``` ```
GET /files/{uploaderID}?page={page}&page_size={page_size}&status={status}&content_type={content_type}&name={name}&order_by={order_by}&select={select} GET /file/{uploaderID}?page={page}&page_size={page_size}&status={status}&content_type={content_type}&name={name}&order_by={order_by}&select={select}
``` ```
**Parameters:** **Parameters:**
@ -116,15 +116,15 @@ GET /files/{uploaderID}?page={page}&page_size={page_size}&status={status}&conten
```bash ```bash
# List files with pagination # List files with pagination
curl -X GET "/v1/files/default?page=1&page_size=10" \ curl -X GET "/v1/file/default?page=1&page_size=10" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
# List files with filters # List files with filters
curl -X GET "/v1/files/default?status=completed&content_type=image/jpeg&name=photo*" \ curl -X GET "/v1/file/default?status=completed&content_type=image/jpeg&name=photo*" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
# List with custom ordering and field selection # List with custom ordering and field selection
curl -X GET "/v1/files/default?order_by=bytes desc&select=file_id,filename,bytes" \ curl -X GET "/v1/file/default?order_by=bytes desc&select=file_id,filename,bytes" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
``` ```
@ -157,7 +157,7 @@ curl -X GET "/v1/files/default?order_by=bytes desc&select=file_id,filename,bytes
Get detailed metadata for a specific file. Get detailed metadata for a specific file.
``` ```
GET /files/{uploaderID}/{fileID} GET /file/{uploaderID}/{fileID}
``` ```
**Parameters:** **Parameters:**
@ -168,7 +168,7 @@ GET /files/{uploaderID}/{fileID}
**Example:** **Example:**
```bash ```bash
curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd" \ curl -X GET "/v1/file/default/a1b2c3d4e5f6789012345678901234567890abcd" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
``` ```
@ -197,7 +197,7 @@ curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd" \
Download the actual file content directly from storage. Download the actual file content directly from storage.
``` ```
GET /files/{uploaderID}/{fileID}/content GET /file/{uploaderID}/{fileID}/content
``` ```
**Parameters:** **Parameters:**
@ -208,7 +208,7 @@ GET /files/{uploaderID}/{fileID}/content
**Example:** **Example:**
```bash ```bash
curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd/content" \ curl -X GET "/v1/file/default/a1b2c3d4e5f6789012345678901234567890abcd/content" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
--output downloaded-file.pdf --output downloaded-file.pdf
``` ```
@ -235,7 +235,7 @@ Content-Length: 2048576
Check if a file exists without downloading it. Check if a file exists without downloading it.
``` ```
GET /files/{uploaderID}/{fileID}/exists GET /file/{uploaderID}/{fileID}/exists
``` ```
**Parameters:** **Parameters:**
@ -246,7 +246,7 @@ GET /files/{uploaderID}/{fileID}/exists
**Example:** **Example:**
```bash ```bash
curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd/exists" \ curl -X GET "/v1/file/default/a1b2c3d4e5f6789012345678901234567890abcd/exists" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
``` ```
@ -264,7 +264,7 @@ curl -X GET "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd/exists"
Delete a file and its metadata. Delete a file and its metadata.
``` ```
DELETE /files/{uploaderID}/{fileID} DELETE /file/{uploaderID}/{fileID}
``` ```
**Parameters:** **Parameters:**
@ -275,7 +275,7 @@ DELETE /files/{uploaderID}/{fileID}
**Example:** **Example:**
```bash ```bash
curl -X DELETE "/v1/files/default/a1b2c3d4e5f6789012345678901234567890abcd" \ curl -X DELETE "/v1/file/default/a1b2c3d4e5f6789012345678901234567890abcd" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
``` ```
@ -340,7 +340,7 @@ For large files, use chunked upload for better reliability:
```bash ```bash
# Upload chunk 1 # Upload chunk 1
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-H "Content-Range: bytes 0-1048575/3145728" \ -H "Content-Range: bytes 0-1048575/3145728" \
-H "Content-Sync: chunk-upload" \ -H "Content-Sync: chunk-upload" \
@ -348,7 +348,7 @@ curl -X POST "/v1/files/default" \
-F "file=@chunk1.bin" -F "file=@chunk1.bin"
# Upload chunk 2 # Upload chunk 2
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-H "Content-Range: bytes 1048576-2097151/3145728" \ -H "Content-Range: bytes 1048576-2097151/3145728" \
-H "Content-Sync: chunk-upload" \ -H "Content-Sync: chunk-upload" \
@ -356,7 +356,7 @@ curl -X POST "/v1/files/default" \
-F "file=@chunk2.bin" -F "file=@chunk2.bin"
# Upload final chunk (triggers merge) # Upload final chunk (triggers merge)
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-H "Content-Range: bytes 2097152-3145727/3145728" \ -H "Content-Range: bytes 2097152-3145727/3145728" \
-H "Content-Sync: chunk-upload" \ -H "Content-Sync: chunk-upload" \
@ -397,7 +397,7 @@ All endpoints return standardized error responses:
1. **Upload a file:** 1. **Upload a file:**
```bash ```bash
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-F "file=@document.pdf" \ -F "file=@document.pdf" \
-F "path=documents/important-doc.pdf" \ -F "path=documents/important-doc.pdf" \
@ -407,14 +407,14 @@ curl -X POST "/v1/files/default" \
2. **List files to find the uploaded file:** 2. **List files to find the uploaded file:**
```bash ```bash
curl -X GET "/v1/files/default?name=important-doc*" \ curl -X GET "/v1/file/default?name=important-doc*" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
``` ```
3. **Download the file:** 3. **Download the file:**
```bash ```bash
curl -X GET "/v1/files/default/{file_id}/content" \ curl -X GET "/v1/file/default/{file_id}/content" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
--output downloaded-document.pdf --output downloaded-document.pdf
``` ```
@ -439,7 +439,7 @@ for i in chunk_*; do
START=$((CHUNK_SIZE * (${i#chunk_} - 1))) START=$((CHUNK_SIZE * (${i#chunk_} - 1)))
END=$((START + $(stat -c%s $i) - 1)) END=$((START + $(stat -c%s $i) - 1))
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-H "Content-Range: bytes ${START}-${END}/${TOTAL_SIZE}" \ -H "Content-Range: bytes ${START}-${END}/${TOTAL_SIZE}" \
-H "Content-Sync: chunk-upload" \ -H "Content-Sync: chunk-upload" \
@ -453,7 +453,7 @@ done
1. **Upload with comprehensive metadata:** 1. **Upload with comprehensive metadata:**
```bash ```bash
curl -X POST "/v1/files/default" \ curl -X POST "/v1/file/default" \
-H "Authorization: Bearer {token}" \ -H "Authorization: Bearer {token}" \
-F "file=@report.pdf" \ -F "file=@report.pdf" \
-F "path=reports/2024/quarterly-report.pdf" \ -F "path=reports/2024/quarterly-report.pdf" \
@ -466,21 +466,21 @@ curl -X POST "/v1/files/default" \
2. **List files with filters:** 2. **List files with filters:**
```bash ```bash
curl -X GET "/v1/files/default?status=completed&content_type=application/pdf&order_by=created_at desc" \ curl -X GET "/v1/file/default?status=completed&content_type=application/pdf&order_by=created_at desc" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
``` ```
3. **Get detailed file information:** 3. **Get detailed file information:**
```bash ```bash
curl -X GET "/v1/files/default/{file_id}" \ curl -X GET "/v1/file/default/{file_id}" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
``` ```
4. **Clean up old files:** 4. **Clean up old files:**
```bash ```bash
curl -X DELETE "/v1/files/default/{file_id}" \ curl -X DELETE "/v1/file/default/{file_id}" \
-H "Authorization: Bearer {token}" -H "Authorization: Bearer {token}"
``` ```

View file

@ -20,22 +20,22 @@ func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.Use(oauth.Guard) group.Use(oauth.Guard)
// Upload a file (supports chunked upload) // Upload a file (supports chunked upload)
group.POST("/files/:uploaderID", upload) group.POST("/:uploaderID", upload)
// List files // List files
group.GET("/files/:uploaderID", list) group.GET("/:uploaderID", list)
// Retrieve file // Retrieve file
group.GET("/files/:uploaderID/:fileID", retrieve) group.GET("/:uploaderID/:fileID", retrieve)
// Delete file // Delete file
group.DELETE("/files/:uploaderID/:fileID", delete) group.DELETE("/:uploaderID/:fileID", delete)
// Retrieve file content // Retrieve file content
group.GET("/files/:uploaderID/:fileID/content", content) group.GET("/:uploaderID/:fileID/content", content)
// Check if file exists // Check if file exists
group.GET("/files/:uploaderID/:fileID/exists", exists) group.GET("/:uploaderID/:fileID/exists", exists)
} }
// upload handles file upload // upload handles file upload

View file

@ -13,6 +13,7 @@ import (
"github.com/yaoapp/yao/openapi/kb" "github.com/yaoapp/yao/openapi/kb"
"github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/signin"
) )
// Server is the OpenAPI server // Server is the OpenAPI server
@ -52,6 +53,12 @@ func Load(appConfig config.Config) (*OpenAPI, error) {
return nil, err return nil, err
} }
// Load signin configurations
err = signin.Load(appConfig)
if err != nil {
return nil, err
}
// Create the OpenAPI server // Create the OpenAPI server
Server = &OpenAPI{Config: &config, OAuth: oauthService} Server = &OpenAPI{Config: &config, OAuth: oauthService}
return Server, nil return Server, nil
@ -82,7 +89,7 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
dsl.Attach(group.Group("/dsl"), openapi.OAuth) dsl.Attach(group.Group("/dsl"), openapi.OAuth)
// File handlers // File handlers
file.Attach(group, openapi.OAuth) file.Attach(group.Group("/file"), openapi.OAuth)
// Knowledge Base handlers // Knowledge Base handlers
kb.Attach(group.Group("/kb"), openapi.OAuth) kb.Attach(group.Group("/kb"), openapi.OAuth)
@ -90,5 +97,8 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) {
// Chat handlers // Chat handlers
chat.Attach(group.Group("/chat"), openapi.OAuth) chat.Attach(group.Group("/chat"), openapi.OAuth)
// Signin handlers
signin.Attach(group, openapi.OAuth)
// Custom handlers (Defined by developer) // Custom handlers (Defined by developer)
} }

42
openapi/signin/api.go Normal file
View file

@ -0,0 +1,42 @@
package signin
import (
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
)
// Attach attaches the signin handlers to the router
func Attach(group *gin.RouterGroup, oauth types.OAuth) {
group.GET("/signin", getConfig)
group.POST("/signin", signin)
group.GET("/signin/authback/:id", authback)
}
// getConfig is the handler for get signin configuration
func getConfig(c *gin.Context) {
// Get locale from query parameter (optional)
locale := c.Query("locale")
// Get public configuration for the specified locale
config := GetPublicConfig(locale)
// If no configuration found, return error
if config == nil {
errorResp := &response.ErrorResponse{
Code: response.ErrInvalidRequest.Code,
ErrorDescription: "No signin configuration found for the requested locale",
}
response.RespondWithError(c, response.StatusNotFound, errorResp)
return
}
// Return the public configuration
response.RespondWithSuccess(c, response.StatusOK, config)
}
// signin is the handler for signin (password login)
func signin(c *gin.Context) {}
// authback is the handler for authback
func authback(c *gin.Context) {}

388
openapi/signin/signin.go Normal file
View file

@ -0,0 +1,388 @@
package signin
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/yao/config"
)
// Global variables to store loaded configurations
var (
// Full configurations with sensitive data (for backend use)
fullConfigs = make(map[string]*Config)
// Public configurations without sensitive data (for frontend use)
publicConfigs = make(map[string]*Config)
// Default language code
defaultLang = ""
// Mutex for thread safety
configMutex sync.RWMutex
)
// Config represents the signin page configuration
type Config struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
SuccessURL string `json:"success_url,omitempty"`
FailureURL string `json:"failure_url,omitempty"`
Form *FormConfig `json:"form,omitempty"`
Token *TokenConfig `json:"token,omitempty"`
ThirdParty *ThirdParty `json:"third_party,omitempty"`
}
// FormConfig represents the form configuration
type FormConfig struct {
Username *UsernameConfig `json:"username,omitempty"`
Password *PasswordConfig `json:"password,omitempty"`
Captcha *CaptchaConfig `json:"captcha,omitempty"`
ForgotPasswordLink bool `json:"forgot_password_link,omitempty"`
RememberMe bool `json:"remember_me,omitempty"`
RegisterLink string `json:"register_link,omitempty"`
TermsOfServiceLink string `json:"terms_of_service_link,omitempty"`
PrivacyPolicyLink string `json:"privacy_policy_link,omitempty"`
}
// UsernameConfig represents the username field configuration
type UsernameConfig struct {
Placeholder string `json:"placeholder,omitempty"`
Fields []string `json:"fields,omitempty"`
}
// PasswordConfig represents the password field configuration
type PasswordConfig struct {
Placeholder string `json:"placeholder,omitempty"`
}
// CaptchaConfig represents the captcha configuration
type CaptchaConfig struct {
Type string `json:"type,omitempty"`
Options map[string]interface{} `json:"options,omitempty"`
}
// TokenConfig represents the token configuration
type TokenConfig struct {
ExpiresIn string `json:"expires_in,omitempty"`
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
}
// ThirdParty represents the third party login configuration
type ThirdParty struct {
Register *RegisterConfig `json:"register,omitempty"`
Providers []*Provider `json:"providers,omitempty"`
}
// RegisterConfig represents the auto register configuration
type RegisterConfig struct {
Auto bool `json:"auto,omitempty"`
Role string `json:"role,omitempty"`
}
// Provider represents a third party login provider
type Provider struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Logo string `json:"logo,omitempty"`
Color string `json:"color,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
ClientSecretGenerator *SecretGenerator `json:"client_secret_generator,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Endpoints *Endpoints `json:"endpoints,omitempty"`
Mapping map[string]string `json:"mapping,omitempty"`
}
// SecretGenerator represents the client secret generator configuration
type SecretGenerator struct {
Type string `json:"type,omitempty"`
ExpiresIn string `json:"expires_in,omitempty"`
PrivateKey string `json:"private_key,omitempty"`
Header map[string]interface{} `json:"header,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
}
// Endpoints represents the OAuth endpoints
type Endpoints struct {
Authorization string `json:"authorization,omitempty"`
Token string `json:"token,omitempty"`
UserInfo string `json:"user_info,omitempty"`
}
// Load loads all signin configurations from the openapi directory
func Load(appConfig config.Config) error {
configMutex.Lock()
defer configMutex.Unlock()
// Clear existing configurations
fullConfigs = make(map[string]*Config)
publicConfigs = make(map[string]*Config)
defaultLang = ""
// Find all signin configuration files
files, err := findSigninFiles()
if err != nil {
return fmt.Errorf("failed to find signin files: %v", err)
}
// If no signin files found, that's not necessarily an error
// Some applications might not have signin configurations
if len(files) == 0 {
return nil
}
// Load each configuration file
for _, file := range files {
lang := extractLanguageFromFilename(file)
configPath := filepath.Join("openapi", file)
configRaw, err := application.App.Read(configPath)
if err != nil {
return fmt.Errorf("failed to read signin config %s: %v", file, err)
}
// Parse the configuration
var signinConfig Config
err = application.Parse(configPath, configRaw, &signinConfig)
if err != nil {
return fmt.Errorf("failed to parse signin config %s: %v", file, err)
}
// Process ENV variables in full config
fullConfig := signinConfig
processENVVariables(&fullConfig, appConfig.Root)
// Create public config (without sensitive data)
publicConfig := createPublicConfig(&fullConfig)
// Store configurations
fullConfigs[lang] = &fullConfig
publicConfigs[lang] = &publicConfig
// Set default language
if defaultLang == "" || lang == "en" || file == "signin.yao" {
defaultLang = lang
}
}
return nil
}
// findSigninFiles finds all signin configuration files in the openapi directory
func findSigninFiles() ([]string, error) {
var files []string
signinFilePattern := regexp.MustCompile(`^signin(\.[a-z]{2}(-[a-z]{2})?)?\.yao$`)
// Use Walk to find all signin files in the openapi directory
err := application.App.Walk("openapi", func(root, filename string, isdir bool) error {
if isdir {
return nil
}
baseName := filepath.Base(filename)
if signinFilePattern.MatchString(baseName) {
files = append(files, baseName)
}
return nil
}, "*.yao")
if err != nil {
return nil, err
}
return files, nil
}
// extractLanguageFromFilename extracts language code from filename
func extractLanguageFromFilename(filename string) string {
// signin.yao -> ""
// signin.en.yao -> "en"
// signin.zh-cn.yao -> "zh-cn"
if filename == "signin.yao" {
return ""
}
parts := strings.Split(filename, ".")
if len(parts) >= 3 {
return parts[1]
}
return ""
}
// processENVVariables processes environment variables in the configuration
func processENVVariables(config *Config, rootPath string) {
// Process form captcha options
if config.Form != nil && config.Form.Captcha != nil && config.Form.Captcha.Options != nil {
for key, value := range config.Form.Captcha.Options {
if strValue, ok := value.(string); ok {
config.Form.Captcha.Options[key] = replaceENVVar(strValue)
}
}
}
// Process third party providers
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
for _, provider := range config.ThirdParty.Providers {
provider.ClientID = replaceENVVar(provider.ClientID)
provider.ClientSecret = replaceENVVar(provider.ClientSecret)
// Process client secret generator
if provider.ClientSecretGenerator != nil {
provider.ClientSecretGenerator.PrivateKey = replaceENVVar(provider.ClientSecretGenerator.PrivateKey)
// Convert relative path to absolute path for private key
if provider.ClientSecretGenerator.PrivateKey != "" && !filepath.IsAbs(provider.ClientSecretGenerator.PrivateKey) {
provider.ClientSecretGenerator.PrivateKey = filepath.Join(rootPath, "openapi", "certs", provider.ClientSecretGenerator.PrivateKey)
}
// Process header values
if provider.ClientSecretGenerator.Header != nil {
for key, value := range provider.ClientSecretGenerator.Header {
if strValue, ok := value.(string); ok {
provider.ClientSecretGenerator.Header[key] = replaceENVVar(strValue)
}
}
}
// Process payload values
if provider.ClientSecretGenerator.Payload != nil {
for key, value := range provider.ClientSecretGenerator.Payload {
if strValue, ok := value.(string); ok {
provider.ClientSecretGenerator.Payload[key] = replaceENVVar(strValue)
}
}
}
}
}
}
}
// replaceENVVar replaces environment variables in the format $ENV.VAR_NAME
func replaceENVVar(value string) string {
if strings.HasPrefix(value, "$ENV.") {
envVar := strings.TrimPrefix(value, "$ENV.")
if envValue := os.Getenv(envVar); envValue != "" {
return envValue
}
}
return value
}
// createPublicConfig creates a public version of the configuration without sensitive data
func createPublicConfig(fullConfig *Config) Config {
publicConfig := *fullConfig
// Remove sensitive data from third party providers
if publicConfig.ThirdParty != nil && publicConfig.ThirdParty.Providers != nil {
publicProviders := make([]*Provider, len(publicConfig.ThirdParty.Providers))
for i, provider := range publicConfig.ThirdParty.Providers {
publicProvider := *provider
// Remove sensitive fields
publicProvider.ClientSecret = ""
publicProvider.ClientSecretGenerator = nil
publicProviders[i] = &publicProvider
}
publicConfig.ThirdParty.Providers = publicProviders
}
return publicConfig
}
// GetFullConfig returns the full configuration for a given language
func GetFullConfig(lang string) *Config {
configMutex.RLock()
defer configMutex.RUnlock()
// Normalize language code to lowercase
if lang != "" {
lang = strings.ToLower(lang)
}
// Try to get specific language config
if config, exists := fullConfigs[lang]; exists {
return config
}
// Fallback to default language
if defaultLang != "" {
if config, exists := fullConfigs[defaultLang]; exists {
return config
}
}
// Return any available config as last resort
for _, config := range fullConfigs {
return config
}
return nil
}
// GetPublicConfig returns the public configuration for a given language
func GetPublicConfig(lang string) *Config {
configMutex.RLock()
defer configMutex.RUnlock()
// Normalize language code to lowercase
if lang != "" {
lang = strings.ToLower(lang)
}
// Try to get specific language config
if config, exists := publicConfigs[lang]; exists {
return config
}
// Fallback to default language
if defaultLang != "" {
if config, exists := publicConfigs[defaultLang]; exists {
return config
}
}
// Return any available config as last resort
for _, config := range publicConfigs {
return config
}
return nil
}
// GetAvailableLanguages returns all available language codes
func GetAvailableLanguages() []string {
configMutex.RLock()
defer configMutex.RUnlock()
var languages []string
for lang := range fullConfigs {
if lang != "" {
languages = append(languages, lang)
}
}
// Add default language if it exists and is empty string
if defaultLang == "" && len(fullConfigs) > 0 {
languages = append(languages, "default")
}
return languages
}
// GetDefaultLanguage returns the default language code
func GetDefaultLanguage() string {
configMutex.RLock()
defer configMutex.RUnlock()
if defaultLang == "" {
return "default"
}
return defaultLang
}

View file

@ -127,7 +127,7 @@ func TestFileUpload(t *testing.T) {
t.Run("UploadFileSuccess", func(t *testing.T) { t.Run("UploadFileSuccess", func(t *testing.T) {
// Create multipart request // Create multipart request
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{
"original_filename": testFileName, "original_filename": testFileName,
"path": "documents/reports/quarterly-report.txt", "path": "documents/reports/quarterly-report.txt",
@ -177,7 +177,7 @@ func TestFileUpload(t *testing.T) {
t.Run("UploadFileWithCompression", func(t *testing.T) { t.Run("UploadFileWithCompression", func(t *testing.T) {
// Test with gzip compression // Test with gzip compression
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{
"original_filename": testFileName, "original_filename": testFileName,
"gzip": "true", "gzip": "true",
@ -205,7 +205,7 @@ func TestFileUpload(t *testing.T) {
t.Run("UploadFileInvalidUploader", func(t *testing.T) { t.Run("UploadFileInvalidUploader", func(t *testing.T) {
// Test with invalid uploader ID // Test with invalid uploader ID
requestURL := serverURL + baseURL + "/files/" + invalidUploaderID requestURL := serverURL + baseURL + "/file/" + invalidUploaderID
req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), nil) req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), nil)
assert.NoError(t, err) assert.NoError(t, err)
@ -228,7 +228,7 @@ func TestFileUpload(t *testing.T) {
t.Run("UploadFileNoFile", func(t *testing.T) { t.Run("UploadFileNoFile", func(t *testing.T) {
// Test with no file in request // Test with no file in request
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
req, err := http.NewRequest("POST", requestURL, strings.NewReader("no file data")) req, err := http.NewRequest("POST", requestURL, strings.NewReader("no file data"))
assert.NoError(t, err) assert.NoError(t, err)
@ -252,7 +252,7 @@ func TestFileUpload(t *testing.T) {
t.Run("UploadFileMissingUploaderID", func(t *testing.T) { t.Run("UploadFileMissingUploaderID", func(t *testing.T) {
// Test with missing uploader ID in path // Test with missing uploader ID in path
requestURL := serverURL + baseURL + "/files/" requestURL := serverURL + baseURL + "/file/"
req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), nil) req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), nil)
assert.NoError(t, err) assert.NoError(t, err)
@ -287,7 +287,7 @@ func TestFileChunkedUpload(t *testing.T) {
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
t.Run("ChunkedUploadSuccess", func(t *testing.T) { t.Run("ChunkedUploadSuccess", func(t *testing.T) {
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
uid := fmt.Sprintf("chunked-test-%d", time.Now().UnixNano()) uid := fmt.Sprintf("chunked-test-%d", time.Now().UnixNano())
// Split content into chunks // Split content into chunks
@ -353,7 +353,7 @@ func TestFileList(t *testing.T) {
fileName := fmt.Sprintf("test-file-%d.txt", i) fileName := fmt.Sprintf("test-file-%d.txt", i)
content := fmt.Sprintf("Test content for file %d", i) content := fmt.Sprintf("Test content for file %d", i)
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
req, err := createMultipartRequest(requestURL, "file", fileName, []byte(content), map[string]string{ req, err := createMultipartRequest(requestURL, "file", fileName, []byte(content), map[string]string{
"original_filename": fileName, "original_filename": fileName,
}) })
@ -378,7 +378,7 @@ func TestFileList(t *testing.T) {
t.Run("ListFilesSuccess", func(t *testing.T) { t.Run("ListFilesSuccess", func(t *testing.T) {
// Test basic file listing // Test basic file listing
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID, nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID, nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -405,7 +405,7 @@ func TestFileList(t *testing.T) {
t.Run("ListFilesWithPagination", func(t *testing.T) { t.Run("ListFilesWithPagination", func(t *testing.T) {
// Test with pagination parameters // Test with pagination parameters
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?page=1&page_size=2", nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"?page=1&page_size=2", nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -427,7 +427,7 @@ func TestFileList(t *testing.T) {
t.Run("ListFilesWithFilters", func(t *testing.T) { t.Run("ListFilesWithFilters", func(t *testing.T) {
// Test with filter parameters // Test with filter parameters
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?status=uploaded&content_type=text/plain", nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"?status=uploaded&content_type=text/plain", nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -442,7 +442,7 @@ func TestFileList(t *testing.T) {
t.Run("ListFilesInvalidUploader", func(t *testing.T) { t.Run("ListFilesInvalidUploader", func(t *testing.T) {
// Test with invalid uploader ID // Test with invalid uploader ID
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+invalidUploaderID, nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+invalidUploaderID, nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -476,7 +476,7 @@ func TestFileRetrieve(t *testing.T) {
t.Run("SetupUploadFile", func(t *testing.T) { t.Run("SetupUploadFile", func(t *testing.T) {
// Upload a file first // Upload a file first
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{
"original_filename": testFileName, "original_filename": testFileName,
}) })
@ -499,7 +499,7 @@ func TestFileRetrieve(t *testing.T) {
t.Run("RetrieveFileSuccess", func(t *testing.T) { t.Run("RetrieveFileSuccess", func(t *testing.T) {
// Retrieve file metadata // Retrieve file metadata
encodedFileID := url.QueryEscape(testFileID) encodedFileID := url.QueryEscape(testFileID)
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -528,7 +528,7 @@ func TestFileRetrieve(t *testing.T) {
// Test with non-existent file ID // Test with non-existent file ID
nonExistentID := "non-existent-file-id" nonExistentID := "non-existent-file-id"
encodedFileID := url.QueryEscape(nonExistentID) encodedFileID := url.QueryEscape(nonExistentID)
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -550,7 +550,7 @@ func TestFileRetrieve(t *testing.T) {
t.Run("RetrieveFileMissingIDs", func(t *testing.T) { t.Run("RetrieveFileMissingIDs", func(t *testing.T) {
// Test with missing file ID - this URL actually matches the list endpoint // Test with missing file ID - this URL actually matches the list endpoint
// which is correct RESTful behavior, so we expect 200 OK // which is correct RESTful behavior, so we expect 200 OK
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/", nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/", nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -594,7 +594,7 @@ func TestFileContent(t *testing.T) {
t.Run("SetupUploadFile", func(t *testing.T) { t.Run("SetupUploadFile", func(t *testing.T) {
// Upload a file first // Upload a file first
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{
"original_filename": testFileName, "original_filename": testFileName,
}) })
@ -617,7 +617,7 @@ func TestFileContent(t *testing.T) {
t.Run("GetFileContentSuccess", func(t *testing.T) { t.Run("GetFileContentSuccess", func(t *testing.T) {
// Get file content // Get file content
encodedFileID := url.QueryEscape(testFileID) encodedFileID := url.QueryEscape(testFileID)
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/content", nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -641,7 +641,7 @@ func TestFileContent(t *testing.T) {
// Test with non-existent file ID // Test with non-existent file ID
nonExistentID := "non-existent-file-id" nonExistentID := "non-existent-file-id"
encodedFileID := url.QueryEscape(nonExistentID) encodedFileID := url.QueryEscape(nonExistentID)
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/content", nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -676,7 +676,7 @@ func TestFileExists(t *testing.T) {
t.Run("SetupUploadFile", func(t *testing.T) { t.Run("SetupUploadFile", func(t *testing.T) {
// Upload a file first // Upload a file first
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{
"original_filename": testFileName, "original_filename": testFileName,
}) })
@ -699,7 +699,7 @@ func TestFileExists(t *testing.T) {
t.Run("FileExistsTrue", func(t *testing.T) { t.Run("FileExistsTrue", func(t *testing.T) {
// Check if uploaded file exists // Check if uploaded file exists
encodedFileID := url.QueryEscape(testFileID) encodedFileID := url.QueryEscape(testFileID)
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/exists", nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -726,7 +726,7 @@ func TestFileExists(t *testing.T) {
// Check if non-existent file exists // Check if non-existent file exists
nonExistentID := "non-existent-file-id" nonExistentID := "non-existent-file-id"
encodedFileID := url.QueryEscape(nonExistentID) encodedFileID := url.QueryEscape(nonExistentID)
req, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) req, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/exists", nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -768,7 +768,7 @@ func TestFileDelete(t *testing.T) {
t.Run("DeleteFileSuccess", func(t *testing.T) { t.Run("DeleteFileSuccess", func(t *testing.T) {
// Upload a file first // Upload a file first
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ req, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{
"original_filename": testFileName, "original_filename": testFileName,
}) })
@ -788,7 +788,7 @@ func TestFileDelete(t *testing.T) {
// Now delete the file // Now delete the file
encodedFileID := url.QueryEscape(testFileID) encodedFileID := url.QueryEscape(testFileID)
deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil)
assert.NoError(t, err) assert.NoError(t, err)
deleteReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) deleteReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -814,7 +814,7 @@ func TestFileDelete(t *testing.T) {
// Test deleting non-existent file // Test deleting non-existent file
nonExistentID := "non-existent-file-id" nonExistentID := "non-existent-file-id"
encodedFileID := url.QueryEscape(nonExistentID) encodedFileID := url.QueryEscape(nonExistentID)
req, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) req, err := http.NewRequest("DELETE", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil)
assert.NoError(t, err) assert.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -850,12 +850,12 @@ func TestFileEndpointsUnauthorized(t *testing.T) {
method string method string
path string path string
}{ }{
{"POST", "/files/" + testUploaderID}, {"POST", "/file/" + testUploaderID},
{"GET", "/files/" + testUploaderID}, {"GET", "/file/" + testUploaderID},
{"GET", "/files/" + testUploaderID + "/test-file-id"}, {"GET", "/file/" + testUploaderID + "/test-file-id"},
{"DELETE", "/files/" + testUploaderID + "/test-file-id"}, {"DELETE", "/file/" + testUploaderID + "/test-file-id"},
{"GET", "/files/" + testUploaderID + "/test-file-id/content"}, {"GET", "/file/" + testUploaderID + "/test-file-id/content"},
{"GET", "/files/" + testUploaderID + "/test-file-id/exists"}, {"GET", "/file/" + testUploaderID + "/test-file-id/exists"},
} }
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
@ -908,7 +908,7 @@ func TestFileIntegration(t *testing.T) {
t.Run("FullFileLifecycle", func(t *testing.T) { t.Run("FullFileLifecycle", func(t *testing.T) {
// Step 1: Upload a file // Step 1: Upload a file
requestURL := serverURL + baseURL + "/files/" + testUploaderID requestURL := serverURL + baseURL + "/file/" + testUploaderID
uploadReq, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{ uploadReq, err := createMultipartRequest(requestURL, "file", testFileName, []byte(testFileContent), map[string]string{
"original_filename": testFileName, "original_filename": testFileName,
"path": "integration/test/file.txt", "path": "integration/test/file.txt",
@ -931,7 +931,7 @@ func TestFileIntegration(t *testing.T) {
// Step 2: Verify file exists // Step 2: Verify file exists
encodedFileID := url.QueryEscape(testFileID) encodedFileID := url.QueryEscape(testFileID)
existsReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) existsReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/exists", nil)
assert.NoError(t, err) assert.NoError(t, err)
existsReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) existsReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -942,7 +942,7 @@ func TestFileIntegration(t *testing.T) {
assert.Equal(t, http.StatusOK, existsResp.StatusCode) assert.Equal(t, http.StatusOK, existsResp.StatusCode)
// Step 3: Retrieve file metadata // Step 3: Retrieve file metadata
retrieveReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) retrieveReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil)
assert.NoError(t, err) assert.NoError(t, err)
retrieveReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) retrieveReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -953,7 +953,7 @@ func TestFileIntegration(t *testing.T) {
assert.Equal(t, http.StatusOK, retrieveResp.StatusCode) assert.Equal(t, http.StatusOK, retrieveResp.StatusCode)
// Step 4: Download file content // Step 4: Download file content
contentReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/content", nil) contentReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/content", nil)
assert.NoError(t, err) assert.NoError(t, err)
contentReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) contentReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -968,7 +968,7 @@ func TestFileIntegration(t *testing.T) {
assert.Equal(t, testFileContent, string(content)) assert.Equal(t, testFileContent, string(content))
// Step 5: List files and verify our file is included // Step 5: List files and verify our file is included
listReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"?name="+testFileName, nil) listReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"?name="+testFileName, nil)
assert.NoError(t, err) assert.NoError(t, err)
listReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) listReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -979,7 +979,7 @@ func TestFileIntegration(t *testing.T) {
assert.Equal(t, http.StatusOK, listResp.StatusCode) assert.Equal(t, http.StatusOK, listResp.StatusCode)
// Step 6: Delete the file // Step 6: Delete the file
deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID, nil) deleteReq, err := http.NewRequest("DELETE", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID, nil)
assert.NoError(t, err) assert.NoError(t, err)
deleteReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) deleteReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
@ -990,7 +990,7 @@ func TestFileIntegration(t *testing.T) {
assert.Equal(t, http.StatusOK, deleteResp.StatusCode) assert.Equal(t, http.StatusOK, deleteResp.StatusCode)
// Step 7: Verify file no longer exists // Step 7: Verify file no longer exists
finalExistsReq, err := http.NewRequest("GET", serverURL+baseURL+"/files/"+testUploaderID+"/"+encodedFileID+"/exists", nil) finalExistsReq, err := http.NewRequest("GET", serverURL+baseURL+"/file/"+testUploaderID+"/"+encodedFileID+"/exists", nil)
assert.NoError(t, err) assert.NoError(t, err)
finalExistsReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken) finalExistsReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)

View file

@ -0,0 +1,201 @@
package openapi_test
import (
"encoding/json"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/openapi"
"github.com/yaoapp/yao/openapi/signin"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
func TestSigninLoad(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Test loading signin configurations
err := signin.Load(config.Conf)
assert.NoError(t, err, "signin.Load should succeed")
// Test that we can get available languages
languages := signin.GetAvailableLanguages()
assert.IsType(t, []string{}, languages, "Should return string slice")
t.Logf("Available languages: %v", languages)
// Test default language
defaultLang := signin.GetDefaultLanguage()
assert.IsType(t, "", defaultLang, "Should return string")
t.Logf("Default language: %s", defaultLang)
}
func TestSigninGetConfigs(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Load signin configurations
err := signin.Load(config.Conf)
assert.NoError(t, err, "signin.Load should succeed")
// Test getting configs for different languages
testCases := []string{"", "en", "zh-cn", "fr"}
for _, lang := range testCases {
t.Run("lang_"+lang, func(t *testing.T) {
fullConfig := signin.GetFullConfig(lang)
publicConfig := signin.GetPublicConfig(lang)
if fullConfig != nil {
t.Logf("Full config for '%s': %+v", lang, fullConfig.Title)
assert.NotNil(t, publicConfig, "Public config should exist if full config exists")
// Test that public config removes sensitive data
if fullConfig.ThirdParty != nil && fullConfig.ThirdParty.Providers != nil {
for i := range fullConfig.ThirdParty.Providers {
if publicConfig.ThirdParty != nil && i < len(publicConfig.ThirdParty.Providers) {
publicProvider := publicConfig.ThirdParty.Providers[i]
assert.Empty(t, publicProvider.ClientSecret, "Client secret should be empty in public config")
assert.Nil(t, publicProvider.ClientSecretGenerator, "Client secret generator should be nil in public config")
}
}
}
} else {
t.Logf("No config found for language: %s", lang)
}
})
}
}
func TestSigninLanguageNormalization(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Load signin configurations
err := signin.Load(config.Conf)
assert.NoError(t, err, "signin.Load should succeed")
// Test that language codes are normalized to lowercase
config1 := signin.GetFullConfig("EN")
config2 := signin.GetFullConfig("en")
assert.Equal(t, config1, config2, "Language codes should be normalized to lowercase")
config3 := signin.GetPublicConfig("ZH-CN")
config4 := signin.GetPublicConfig("zh-cn")
assert.Equal(t, config3, config4, "Language codes should be normalized to lowercase")
}
func TestSigninConfigStructure(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL // Server URL not needed for this test
// Load signin configurations
err := signin.Load(config.Conf)
assert.NoError(t, err, "signin.Load should succeed")
// Get a config to test structure
config := signin.GetFullConfig("")
if config != nil {
t.Logf("Config loaded successfully with title: %s", config.Title)
// Verify config structure is valid
assert.IsType(t, &signin.Config{}, config, "Should return correct config type")
// Test form configuration
if config.Form != nil {
t.Logf("Form configuration found")
if config.Form.Username != nil {
assert.IsType(t, []string{}, config.Form.Username.Fields, "Username fields should be string slice")
}
if config.Form.Captcha != nil {
assert.IsType(t, map[string]interface{}{}, config.Form.Captcha.Options, "Captcha options should be map")
}
}
// Test third party configuration
if config.ThirdParty != nil {
t.Logf("Third party configuration found with %d providers", len(config.ThirdParty.Providers))
if config.ThirdParty.Providers != nil {
assert.IsType(t, []*signin.Provider{}, config.ThirdParty.Providers, "Providers should be slice of Provider pointers")
for i, provider := range config.ThirdParty.Providers {
t.Logf("Provider %d: %s", i, provider.ID)
assert.IsType(t, []string{}, provider.Scopes, "Provider scopes should be string slice")
assert.IsType(t, map[string]string{}, provider.Mapping, "Provider mapping should be string map")
}
}
}
} else {
t.Log("No signin configuration found")
}
}
func TestSigninAPI(t *testing.T) {
// Initialize test environment
serverURL := testutils.Prepare(t)
defer testutils.Clean()
// Get base URL from server config
baseURL := ""
if openapi.Server != nil && openapi.Server.Config != nil {
baseURL = openapi.Server.Config.BaseURL
}
// Test API endpoints
testCases := []struct {
name string
endpoint string
expectCode int
}{
{"get config without locale", "/signin", 200},
{"get config with en locale", "/signin?locale=en", 200},
{"get config with zh-cn locale", "/signin?locale=zh-cn", 200},
{"get config with invalid locale", "/signin?locale=invalid", 200}, // should fallback to default
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := serverURL + baseURL + tc.endpoint
resp, err := http.Get(url)
assert.NoError(t, err, "HTTP request should succeed")
if resp != nil {
defer resp.Body.Close()
assert.Equal(t, tc.expectCode, resp.StatusCode, "Expected status code %d", tc.expectCode)
if resp.StatusCode == 200 {
// Parse response body
body, err := io.ReadAll(resp.Body)
assert.NoError(t, err, "Should read response body")
var config signin.Config
err = json.Unmarshal(body, &config)
assert.NoError(t, err, "Should parse JSON response")
t.Logf("API response for %s: %s", tc.endpoint, config.Title)
// Verify it's public config (no sensitive data)
if config.ThirdParty != nil && config.ThirdParty.Providers != nil {
for _, provider := range config.ThirdParty.Providers {
assert.Empty(t, provider.ClientSecret, "Client secret should be empty in API response")
assert.Nil(t, provider.ClientSecretGenerator, "Client secret generator should be nil in API response")
}
}
}
}
})
}
}