feat: add Phases 3-5 enhancements (org profiles, pages, wiki v2 API)
Phase 3: Organization Public Profile Page - Pinned repositories with groups - Public members display with roles - API endpoints for pinned repos and groups Phase 4: Gitea Pages Foundation - Landing page templates (simple, docs, product, portfolio) - Custom domain support with verification - YAML configuration parser (.gitea/landing.yaml) - Repository settings UI for pages Phase 5: Enhanced Wiki System with V2 API - Full CRUD operations via v2 API - Full-text search with WikiIndex table - Link graph visualization - Wiki health metrics (orphaned, dead links, outdated) - Designed for external AI plugin integration - Developer guide for .NET integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e35aa8d878
commit
b816ee4eec
26
.gitcleaner/health-report.md
Normal file
26
.gitcleaner/health-report.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Repository Health Report
|
||||||
|
|
||||||
|
## Overall Score: 77/100 (Good)
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
- Commits Analyzed: 1000
|
||||||
|
- Branches: 1
|
||||||
|
- Authors: 149
|
||||||
|
- Merges: 0
|
||||||
|
|
||||||
|
### Component Scores
|
||||||
|
| Component | Score |
|
||||||
|
|-----------|-------|
|
||||||
|
| Messages | 90% |
|
||||||
|
| Merges | 97% |
|
||||||
|
| Duplicates | 0% |
|
||||||
|
| Branches | 100% |
|
||||||
|
| Authorship | 100% |
|
||||||
|
|
||||||
|
### Issues (3)
|
||||||
|
- **Merge fix commits detected**: Found 1 commits with messages like 'fix merge' detected after merges. (-3 pts)
|
||||||
|
- **Duplicate commits with identical content**: Found 1 groups of commits with identical file content (1 redundant commits). These are safe to squash as they have the same tree SHA. (-7 pts)
|
||||||
|
- **Commits with duplicate messages**: Found 1 groups of commits with identical messages but different code changes (135 commits). Consider using more descriptive messages to differentiate changes. (-1 pts)
|
||||||
|
|
||||||
|
---
|
||||||
|
Generated by GitCleaner for GitCaddy dd
|
||||||
354
docs/phase5-ai-wiki-spec.md
Normal file
354
docs/phase5-ai-wiki-spec.md
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
# Phase 5: AI-Friendly Wiki API (v2)
|
||||||
|
|
||||||
|
**Version:** 2.0
|
||||||
|
**Date:** January 2026
|
||||||
|
**Status:** IN PROGRESS
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Phase 5 adds a v2 Wiki API designed for AI/LLM consumption and external plugin integration. This enhances the existing wiki functionality without modifying v1 endpoints.
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
|
||||||
|
1. **AI-Ready Data** - Structured responses optimized for LLM consumption
|
||||||
|
2. **Full CRUD** - Complete wiki management via API
|
||||||
|
3. **Search** - Full-text search across wiki content
|
||||||
|
4. **Relationships** - Page link graph for navigation
|
||||||
|
5. **Health Metrics** - Wiki statistics and maintenance insights
|
||||||
|
6. **Plugin-Friendly** - Enable external tools (like .NET AI plugins) to build on top
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V2 API Endpoints
|
||||||
|
|
||||||
|
Base URL: `/api/v2/repos/{owner}/{repo}/wiki`
|
||||||
|
|
||||||
|
### Pages CRUD
|
||||||
|
|
||||||
|
#### List All Pages
|
||||||
|
```
|
||||||
|
GET /api/v2/repos/{owner}/{repo}/wiki/pages
|
||||||
|
```
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
- `include_content` (bool, default: false) - Include full page content
|
||||||
|
- `page` (int) - Page number for pagination
|
||||||
|
- `limit` (int, default: 30) - Items per page
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"name": "Home",
|
||||||
|
"title": "Home",
|
||||||
|
"path": "Home.md",
|
||||||
|
"url": "/owner/repo/wiki/Home",
|
||||||
|
"word_count": 450,
|
||||||
|
"last_commit": {
|
||||||
|
"sha": "abc123",
|
||||||
|
"author": "username",
|
||||||
|
"message": "Updated home page",
|
||||||
|
"date": "2026-01-08T10:00:00Z"
|
||||||
|
},
|
||||||
|
"content": "# Home\n\nWelcome...", // if include_content=true
|
||||||
|
"content_html": "<h1>Home</h1>..." // if include_content=true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_count": 25,
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Get Single Page
|
||||||
|
```
|
||||||
|
GET /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Installation",
|
||||||
|
"title": "Installation Guide",
|
||||||
|
"path": "Installation.md",
|
||||||
|
"url": "/owner/repo/wiki/Installation",
|
||||||
|
"content": "# Installation\n\n...",
|
||||||
|
"content_html": "<h1>Installation</h1>...",
|
||||||
|
"word_count": 1250,
|
||||||
|
"links_out": ["Home", "Configuration", "API-Reference"],
|
||||||
|
"links_in": ["Home", "Getting-Started"],
|
||||||
|
"sidebar": "...",
|
||||||
|
"footer": "...",
|
||||||
|
"last_commit": {
|
||||||
|
"sha": "def456",
|
||||||
|
"author": "username",
|
||||||
|
"message": "Added troubleshooting section",
|
||||||
|
"date": "2026-01-07T15:30:00Z"
|
||||||
|
},
|
||||||
|
"history_url": "/api/v2/repos/owner/repo/wiki/pages/Installation/revisions"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Create Page
|
||||||
|
```
|
||||||
|
POST /api/v2/repos/{owner}/{repo}/wiki/pages
|
||||||
|
```
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "New-Page",
|
||||||
|
"title": "New Page Title",
|
||||||
|
"content": "# New Page\n\nContent here...",
|
||||||
|
"message": "Created new page"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: Same as Get Single Page
|
||||||
|
|
||||||
|
#### Update Page
|
||||||
|
```
|
||||||
|
PUT /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}
|
||||||
|
```
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Updated Title",
|
||||||
|
"content": "# Updated Content\n\n...",
|
||||||
|
"message": "Updated page content",
|
||||||
|
"rename_to": "new-page-name" // optional, to rename
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response: Same as Get Single Page
|
||||||
|
|
||||||
|
#### Delete Page
|
||||||
|
```
|
||||||
|
DELETE /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}
|
||||||
|
```
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "Removed outdated page"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
#### Full-Text Search
|
||||||
|
```
|
||||||
|
GET /api/v2/repos/{owner}/{repo}/wiki/search?q={query}
|
||||||
|
```
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
- `q` (string, required) - Search query
|
||||||
|
- `limit` (int, default: 20) - Max results
|
||||||
|
- `include_content` (bool, default: false) - Include full content
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "installation docker",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"name": "Docker-Setup",
|
||||||
|
"title": "Docker Setup Guide",
|
||||||
|
"snippet": "...to run the <mark>installation</mark> in <mark>Docker</mark>, use the following...",
|
||||||
|
"score": 0.95,
|
||||||
|
"word_count": 800,
|
||||||
|
"last_updated": "2026-01-05T12:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_count": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Link Graph
|
||||||
|
|
||||||
|
#### Get Page Relationships
|
||||||
|
```
|
||||||
|
GET /api/v2/repos/{owner}/{repo}/wiki/graph
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{"name": "Home", "word_count": 450},
|
||||||
|
{"name": "Installation", "word_count": 1250},
|
||||||
|
{"name": "Configuration", "word_count": 2100}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"source": "Home", "target": "Installation"},
|
||||||
|
{"source": "Home", "target": "Configuration"},
|
||||||
|
{"source": "Installation", "target": "Configuration"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Statistics & Health
|
||||||
|
|
||||||
|
#### Wiki Statistics
|
||||||
|
```
|
||||||
|
GET /api/v2/repos/{owner}/{repo}/wiki/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_pages": 25,
|
||||||
|
"total_words": 45000,
|
||||||
|
"total_commits": 142,
|
||||||
|
"last_updated": "2026-01-08T10:00:00Z",
|
||||||
|
"contributors": 5,
|
||||||
|
"health": {
|
||||||
|
"orphaned_pages": [
|
||||||
|
{"name": "Old-Notes", "word_count": 120}
|
||||||
|
],
|
||||||
|
"dead_links": [
|
||||||
|
{"page": "Home", "broken_link": "Deleted-Page"}
|
||||||
|
],
|
||||||
|
"outdated_pages": [
|
||||||
|
{"name": "Legacy-API", "last_edit": "2024-06-15T00:00:00Z", "days_old": 573}
|
||||||
|
],
|
||||||
|
"short_pages": [
|
||||||
|
{"name": "TODO", "word_count": 15}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"top_linked": [
|
||||||
|
{"name": "Home", "incoming_links": 12},
|
||||||
|
{"name": "Configuration", "incoming_links": 8}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Revisions
|
||||||
|
|
||||||
|
#### Get Page History
|
||||||
|
```
|
||||||
|
GET /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}/revisions
|
||||||
|
```
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
- `page` (int) - Page number
|
||||||
|
- `limit` (int, default: 30) - Items per page
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page_name": "Installation",
|
||||||
|
"revisions": [
|
||||||
|
{
|
||||||
|
"sha": "abc123",
|
||||||
|
"author": {
|
||||||
|
"username": "user1",
|
||||||
|
"email": "user1@example.com",
|
||||||
|
"avatar_url": "..."
|
||||||
|
},
|
||||||
|
"message": "Added troubleshooting section",
|
||||||
|
"date": "2026-01-07T15:30:00Z",
|
||||||
|
"additions": 45,
|
||||||
|
"deletions": 12
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_count": 28
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### `wiki_index` (for full-text search)
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| id | BIGINT | Primary key |
|
||||||
|
| repo_id | BIGINT | Repository ID |
|
||||||
|
| page_name | VARCHAR(255) | Wiki page name |
|
||||||
|
| page_path | VARCHAR(512) | Git file path |
|
||||||
|
| title | VARCHAR(255) | Page title |
|
||||||
|
| content | LONGTEXT | Full page content (for search) |
|
||||||
|
| content_hash | VARCHAR(64) | SHA256 for change detection |
|
||||||
|
| commit_sha | VARCHAR(64) | Last indexed commit |
|
||||||
|
| word_count | INT | Word count |
|
||||||
|
| links_out | TEXT | JSON array of outgoing links |
|
||||||
|
| updated_unix | BIGINT | Last update timestamp |
|
||||||
|
| created_unix | BIGINT | Creation timestamp |
|
||||||
|
|
||||||
|
**Indexes:** `(repo_id)`, `(repo_id, page_name) UNIQUE`, `FULLTEXT(title, content)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Files to Create
|
||||||
|
|
||||||
|
**API Structs:**
|
||||||
|
- `modules/structs/repo_wiki_v2.go`
|
||||||
|
|
||||||
|
**API Router:**
|
||||||
|
- `routers/api/v2/repo/wiki.go`
|
||||||
|
|
||||||
|
**Services:**
|
||||||
|
- `services/wiki/wiki_index.go` - Search indexing
|
||||||
|
|
||||||
|
**Migration:**
|
||||||
|
- `models/migrations/v1_26/v328.go`
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
|
||||||
|
- `routers/api/v2/api.go` - Register wiki routes
|
||||||
|
- `models/repo/wiki_ai.go` - Simplify to just WikiIndex
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## V2 vs V1 Comparison
|
||||||
|
|
||||||
|
| Feature | V1 | V2 |
|
||||||
|
|---------|----|----|
|
||||||
|
| Content encoding | Base64 | Plain JSON |
|
||||||
|
| HTML rendering | Separate call | Included in response |
|
||||||
|
| Word count | No | Yes |
|
||||||
|
| Link extraction | No | Yes |
|
||||||
|
| Search | No | Yes (full-text) |
|
||||||
|
| Graph | No | Yes |
|
||||||
|
| Statistics | No | Yes |
|
||||||
|
| Batch operations | No | Future |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## External Plugin Integration
|
||||||
|
|
||||||
|
Your .NET plugin can:
|
||||||
|
|
||||||
|
1. **Fetch all wiki content** via `GET /wiki/pages?include_content=true`
|
||||||
|
2. **Generate AI summaries** using your AI library
|
||||||
|
3. **Create/update pages** with AI-generated content
|
||||||
|
4. **Build Q&A system** by indexing content externally
|
||||||
|
5. **Analyze relationships** using the graph endpoint
|
||||||
|
|
||||||
|
The v2 API provides all the structured data needed for AI processing without building AI into Gitea itself.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Specification*
|
||||||
1050
docs/wiki-v2-api-developer-guide.md
Normal file
1050
docs/wiki-v2-api-developer-guide.md
Normal file
File diff suppressed because it is too large
Load Diff
156
enhancements.md
156
enhancements.md
@ -4,6 +4,162 @@
|
|||||||
**Date:** January 2026
|
**Date:** January 2026
|
||||||
**Status:** Approved for Development
|
**Status:** Approved for Development
|
||||||
|
|
||||||
|
## Implementation Progress
|
||||||
|
|
||||||
|
### Phase 3: Organization Public Profile Page - COMPLETED (January 2026)
|
||||||
|
|
||||||
|
**New Files Created:**
|
||||||
|
- `models/organization/org_pinned.go` - Pinned repos and groups models
|
||||||
|
- `models/organization/org_profile.go` - Public members and org stats
|
||||||
|
- `models/migrations/v1_26/v326.go` - Database migration for pinned tables
|
||||||
|
- `services/org/pinned.go` - Service layer for cross-model operations
|
||||||
|
- `routers/api/v1/org/pinned.go` - API endpoints for pinned repos/groups
|
||||||
|
- `routers/api/v1/org/profile.go` - API endpoints for org overview
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `models/migrations/migrations.go` - Added migration 326
|
||||||
|
- `modules/structs/org.go` - Added API structs for pinned repos, groups, members
|
||||||
|
- `routers/api/v1/api.go` - Registered new API routes
|
||||||
|
- `routers/web/org/home.go` - Enhanced to load pinned repos, groups, members
|
||||||
|
- `templates/org/home.tmpl` - Added pinned repos and public members sections
|
||||||
|
- `options/locale/locale_en-US.json` - Added locale strings
|
||||||
|
|
||||||
|
**API Endpoints Added:**
|
||||||
|
- `GET /api/v1/orgs/{org}/overview` - Get organization overview
|
||||||
|
- `GET /api/v1/orgs/{org}/pinned` - List pinned repositories
|
||||||
|
- `POST /api/v1/orgs/{org}/pinned` - Pin a repository
|
||||||
|
- `DELETE /api/v1/orgs/{org}/pinned/{repo}` - Unpin a repository
|
||||||
|
- `PUT /api/v1/orgs/{org}/pinned/reorder` - Reorder pinned repos
|
||||||
|
- `GET /api/v1/orgs/{org}/pinned/groups` - List pinned groups
|
||||||
|
- `POST /api/v1/orgs/{org}/pinned/groups` - Create pinned group
|
||||||
|
- `PUT /api/v1/orgs/{org}/pinned/groups/{id}` - Update pinned group
|
||||||
|
- `DELETE /api/v1/orgs/{org}/pinned/groups/{id}` - Delete pinned group
|
||||||
|
- `GET /api/v1/orgs/{org}/public_members/roles` - List public members with roles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Gitea Pages Foundation - COMPLETED (January 2026)
|
||||||
|
|
||||||
|
**New Files Created:**
|
||||||
|
- `models/repo/pages.go` - Pages domain and config models
|
||||||
|
- `models/migrations/v1_26/v327.go` - Database migration for pages tables
|
||||||
|
- `modules/pages/config.go` - Landing.yaml configuration parser
|
||||||
|
- `modules/structs/repo_pages.go` - API structs for pages
|
||||||
|
- `services/pages/pages.go` - Pages service layer
|
||||||
|
- `routers/api/v1/repo/pages.go` - API endpoints for pages management
|
||||||
|
- `routers/web/pages/pages.go` - Web router for serving landing pages
|
||||||
|
- `routers/web/repo/setting/pages.go` - Repository settings page for Pages
|
||||||
|
- `templates/pages/simple.tmpl` - Simple landing page template
|
||||||
|
- `templates/pages/documentation.tmpl` - Documentation template
|
||||||
|
- `templates/pages/product.tmpl` - Product landing template
|
||||||
|
- `templates/pages/portfolio.tmpl` - Portfolio/gallery template
|
||||||
|
- `templates/pages/header.tmpl` - Pages header partial
|
||||||
|
- `templates/pages/footer.tmpl` - Pages footer partial
|
||||||
|
- `templates/repo/settings/pages.tmpl` - Pages settings UI template
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `models/migrations/migrations.go` - Added migration 327
|
||||||
|
- `routers/api/v1/api.go` - Registered pages API routes
|
||||||
|
- `routers/web/web.go` - Added pages routes and import
|
||||||
|
- `templates/repo/settings/navbar.tmpl` - Added Pages link to settings nav
|
||||||
|
- `options/locale/locale_en-US.json` - Added Pages locale strings
|
||||||
|
|
||||||
|
**Web Routes Added:**
|
||||||
|
- `GET /{username}/{reponame}/pages` - View landing page
|
||||||
|
- `GET /{username}/{reponame}/pages/assets/*` - Serve page assets
|
||||||
|
- `GET /{username}/{reponame}/settings/pages` - Pages settings
|
||||||
|
- `POST /{username}/{reponame}/settings/pages` - Update pages settings
|
||||||
|
|
||||||
|
**API Endpoints Added:**
|
||||||
|
- `GET /api/v1/repos/{owner}/{repo}/pages` - Get pages configuration
|
||||||
|
- `PUT /api/v1/repos/{owner}/{repo}/pages` - Update pages configuration
|
||||||
|
- `DELETE /api/v1/repos/{owner}/{repo}/pages` - Disable pages
|
||||||
|
- `GET /api/v1/repos/{owner}/{repo}/pages/domains` - List custom domains
|
||||||
|
- `POST /api/v1/repos/{owner}/{repo}/pages/domains` - Add custom domain
|
||||||
|
- `DELETE /api/v1/repos/{owner}/{repo}/pages/domains/{domain}` - Remove domain
|
||||||
|
- `POST /api/v1/repos/{owner}/{repo}/pages/domains/{domain}/verify` - Verify domain
|
||||||
|
|
||||||
|
**Features Implemented:**
|
||||||
|
- Database models for PagesDomain and PagesConfig
|
||||||
|
- YAML configuration parser for `.gitea/landing.yaml`
|
||||||
|
- 4 landing page templates (simple, documentation, product, portfolio)
|
||||||
|
- Custom domain support with verification tokens
|
||||||
|
- SSL status tracking (pending actual Let's Encrypt integration)
|
||||||
|
- README rendering on landing pages
|
||||||
|
- Asset serving from repository
|
||||||
|
- Repository settings UI for pages management
|
||||||
|
- Enable/disable pages toggle
|
||||||
|
- Template selection dropdown
|
||||||
|
- Custom domain management with DNS verification instructions
|
||||||
|
|
||||||
|
**Future Enhancements:**
|
||||||
|
- Let's Encrypt SSL certificate integration
|
||||||
|
- Subdomain-based routing (e.g., repo.owner.pages.domain.com)
|
||||||
|
- Search functionality for documentation template
|
||||||
|
- Analytics integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Enhanced Wiki System with V2 API - COMPLETED (January 2026)
|
||||||
|
|
||||||
|
**New Files Created:**
|
||||||
|
- `models/repo/wiki_ai.go` - WikiIndex model for full-text search
|
||||||
|
- `models/migrations/v1_26/v328.go` - Database migration for wiki_index table
|
||||||
|
- `modules/structs/repo_wiki_v2.go` - V2 API structs for wiki endpoints
|
||||||
|
- `services/wiki/wiki_index.go` - Wiki indexing service (search, graph, stats)
|
||||||
|
- `routers/api/v2/wiki.go` - V2 wiki API endpoints
|
||||||
|
- `docs/phase5-ai-wiki-spec.md` - Phase 5 specification document
|
||||||
|
|
||||||
|
**Files Modified:**
|
||||||
|
- `models/migrations/migrations.go` - Added migration 328
|
||||||
|
- `routers/api/v2/api.go` - Registered v2 wiki routes
|
||||||
|
- `modules/errors/codes.go` - Added wiki-related error codes
|
||||||
|
|
||||||
|
**V2 API Endpoints Added:**
|
||||||
|
- `GET /api/v2/repos/{owner}/{repo}/wiki/pages` - List wiki pages with metadata
|
||||||
|
- `GET /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}` - Get page with content & links
|
||||||
|
- `POST /api/v2/repos/{owner}/{repo}/wiki/pages` - Create wiki page
|
||||||
|
- `PUT /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}` - Update wiki page
|
||||||
|
- `DELETE /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}` - Delete wiki page
|
||||||
|
- `GET /api/v2/repos/{owner}/{repo}/wiki/search` - Full-text search
|
||||||
|
- `GET /api/v2/repos/{owner}/{repo}/wiki/graph` - Link relationship graph
|
||||||
|
- `GET /api/v2/repos/{owner}/{repo}/wiki/stats` - Wiki statistics and health
|
||||||
|
- `GET /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}/revisions` - Page history
|
||||||
|
|
||||||
|
**Features Implemented:**
|
||||||
|
- Full-text search across wiki pages using WikiIndex table
|
||||||
|
- JSON content (not base64 like v1), with HTML rendering
|
||||||
|
- Link extraction from markdown content (wiki-style and markdown links)
|
||||||
|
- Link graph visualization (nodes and edges)
|
||||||
|
- Incoming/outgoing link tracking
|
||||||
|
- Wiki health metrics (orphaned pages, dead links, outdated pages, short pages)
|
||||||
|
- Word count and statistics
|
||||||
|
- Full CRUD operations for external tools/plugins
|
||||||
|
- Designed for AI plugin integration (structured data for .NET AI function calling)
|
||||||
|
|
||||||
|
**V2 API Structs:**
|
||||||
|
- WikiPageV2, WikiCommitV2, WikiAuthorV2
|
||||||
|
- WikiPageListV2, WikiSearchResultV2, WikiSearchResponseV2
|
||||||
|
- WikiGraphV2, WikiGraphNodeV2, WikiGraphEdgeV2
|
||||||
|
- WikiStatsV2, WikiHealthV2
|
||||||
|
- WikiOrphanedPageV2, WikiDeadLinkV2, WikiOutdatedPageV2, WikiShortPageV2
|
||||||
|
- CreateWikiPageV2Option, UpdateWikiPageV2Option, DeleteWikiPageV2Option
|
||||||
|
|
||||||
|
**Error Codes Added:**
|
||||||
|
- WIKI_PAGE_NOT_FOUND
|
||||||
|
- WIKI_PAGE_ALREADY_EXISTS
|
||||||
|
- WIKI_RESERVED_NAME
|
||||||
|
- WIKI_DISABLED
|
||||||
|
|
||||||
|
**Design Decisions:**
|
||||||
|
- V2 API separate from v1 to avoid interference
|
||||||
|
- No built-in AI features - designed for external plugin integration
|
||||||
|
- WikiIndex table for search (simplified from original AI-centric design)
|
||||||
|
- Content hash for efficient change detection
|
||||||
|
- Background indexing for performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|||||||
@ -400,6 +400,9 @@ func prepareMigrationTasks() []*migration {
|
|||||||
newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
|
newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
|
||||||
newMigration(324, "Fix closed milestone completeness for milestones with no issues", v1_26.FixClosedMilestoneCompleteness),
|
newMigration(324, "Fix closed milestone completeness for milestones with no issues", v1_26.FixClosedMilestoneCompleteness),
|
||||||
newMigration(325, "Add upload_session table for chunked uploads", v1_26.AddUploadSessionTable),
|
newMigration(325, "Add upload_session table for chunked uploads", v1_26.AddUploadSessionTable),
|
||||||
|
newMigration(326, "Add organization pinned repos tables", v1_26.AddOrgPinnedTables),
|
||||||
|
newMigration(327, "Add Gitea Pages tables", v1_26.AddGiteaPagesTables),
|
||||||
|
newMigration(328, "Add wiki index table for search", v1_26.AddWikiIndexTable),
|
||||||
}
|
}
|
||||||
return preparedMigrations
|
return preparedMigrations
|
||||||
}
|
}
|
||||||
|
|||||||
35
models/migrations/v1_26/v326.go
Normal file
35
models/migrations/v1_26/v326.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_26
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddOrgPinnedTables adds the org_pinned_group and org_pinned_repo tables
|
||||||
|
// for organization profile pinned repositories feature
|
||||||
|
func AddOrgPinnedTables(x *xorm.Engine) error {
|
||||||
|
type OrgPinnedGroup struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
Name string `xorm:"NOT NULL"`
|
||||||
|
DisplayOrder int `xorm:"DEFAULT 0"`
|
||||||
|
Collapsed bool `xorm:"DEFAULT false"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgPinnedRepo struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
GroupID int64 `xorm:"INDEX"`
|
||||||
|
DisplayOrder int `xorm:"DEFAULT 0"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return x.Sync(new(OrgPinnedGroup), new(OrgPinnedRepo))
|
||||||
|
}
|
||||||
39
models/migrations/v1_26/v327.go
Normal file
39
models/migrations/v1_26/v327.go
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_26
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddGiteaPagesTables adds the pages_domain and pages_config tables for Gitea Pages
|
||||||
|
func AddGiteaPagesTables(x *xorm.Engine) error {
|
||||||
|
// PagesDomain represents a custom domain mapping for Gitea Pages
|
||||||
|
type PagesDomain struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
Domain string `xorm:"UNIQUE NOT NULL"`
|
||||||
|
Verified bool `xorm:"DEFAULT false"`
|
||||||
|
VerificationToken string `xorm:"VARCHAR(64)"`
|
||||||
|
SSLStatus string `xorm:"VARCHAR(32) DEFAULT 'pending'"`
|
||||||
|
SSLCertExpiry timeutil.TimeStamp `xorm:"DEFAULT 0"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
VerifiedUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PagesConfig represents the cached configuration for a repository's landing page
|
||||||
|
type PagesConfig struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"UNIQUE NOT NULL"`
|
||||||
|
Enabled bool `xorm:"DEFAULT false"`
|
||||||
|
Template string `xorm:"VARCHAR(32) DEFAULT 'simple'"`
|
||||||
|
ConfigJSON string `xorm:"TEXT"`
|
||||||
|
ConfigHash string `xorm:"VARCHAR(64)"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return x.Sync(new(PagesDomain), new(PagesConfig))
|
||||||
|
}
|
||||||
30
models/migrations/v1_26/v328.go
Normal file
30
models/migrations/v1_26/v328.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v1_26 //nolint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddWikiIndexTable adds the wiki_index table for full-text search
|
||||||
|
func AddWikiIndexTable(x *xorm.Engine) error {
|
||||||
|
type WikiIndex struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
PageName string `xorm:"VARCHAR(255) NOT NULL"`
|
||||||
|
PagePath string `xorm:"VARCHAR(512) NOT NULL"`
|
||||||
|
Title string `xorm:"VARCHAR(255)"`
|
||||||
|
Content string `xorm:"LONGTEXT"`
|
||||||
|
ContentHash string `xorm:"VARCHAR(64)"`
|
||||||
|
CommitSHA string `xorm:"VARCHAR(64)"`
|
||||||
|
WordCount int `xorm:"DEFAULT 0"`
|
||||||
|
LinksOut string `xorm:"TEXT"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
return x.Sync(new(WikiIndex))
|
||||||
|
}
|
||||||
273
models/organization/org_pinned.go
Normal file
273
models/organization/org_pinned.go
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package organization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrgPinnedGroup represents a named group of pinned repositories for an organization
|
||||||
|
type OrgPinnedGroup struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
Name string `xorm:"NOT NULL"`
|
||||||
|
DisplayOrder int `xorm:"DEFAULT 0"`
|
||||||
|
Collapsed bool `xorm:"DEFAULT false"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for OrgPinnedGroup
|
||||||
|
func (g *OrgPinnedGroup) TableName() string {
|
||||||
|
return "org_pinned_group"
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(OrgPinnedGroup))
|
||||||
|
db.RegisterModel(new(OrgPinnedRepo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgPinnedRepo represents a pinned repository for an organization
|
||||||
|
type OrgPinnedRepo struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
OrgID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
GroupID int64 `xorm:"INDEX"` // 0 = ungrouped
|
||||||
|
DisplayOrder int `xorm:"DEFAULT 0"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
|
||||||
|
Repo interface{} `xorm:"-"` // Will be set by caller (repo_model.Repository)
|
||||||
|
Group *OrgPinnedGroup `xorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for OrgPinnedRepo
|
||||||
|
func (p *OrgPinnedRepo) TableName() string {
|
||||||
|
return "org_pinned_repo"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgPinnedGroups returns all pinned groups for an organization
|
||||||
|
func GetOrgPinnedGroups(ctx context.Context, orgID int64) ([]*OrgPinnedGroup, error) {
|
||||||
|
groups := make([]*OrgPinnedGroup, 0, 10)
|
||||||
|
return groups, db.GetEngine(ctx).
|
||||||
|
Where("org_id = ?", orgID).
|
||||||
|
OrderBy("display_order ASC, id ASC").
|
||||||
|
Find(&groups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgPinnedGroup returns a pinned group by ID
|
||||||
|
func GetOrgPinnedGroup(ctx context.Context, id int64) (*OrgPinnedGroup, error) {
|
||||||
|
group := new(OrgPinnedGroup)
|
||||||
|
has, err := db.GetEngine(ctx).ID(id).Get(group)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, ErrOrgPinnedGroupNotExist{ID: id}
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgPinnedGroup creates a new pinned group for an organization
|
||||||
|
func CreateOrgPinnedGroup(ctx context.Context, group *OrgPinnedGroup) error {
|
||||||
|
_, err := db.GetEngine(ctx).Insert(group)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOrgPinnedGroup updates a pinned group
|
||||||
|
func UpdateOrgPinnedGroup(ctx context.Context, group *OrgPinnedGroup) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(group.ID).Cols("name", "display_order", "collapsed").Update(group)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgPinnedGroup deletes a pinned group and moves its repos to ungrouped
|
||||||
|
func DeleteOrgPinnedGroup(ctx context.Context, groupID int64) error {
|
||||||
|
ctx, committer, err := db.TxContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer committer.Close()
|
||||||
|
|
||||||
|
// Move all repos in this group to ungrouped (group_id = 0)
|
||||||
|
if _, err := db.GetEngine(ctx).
|
||||||
|
Where("group_id = ?", groupID).
|
||||||
|
Cols("group_id").
|
||||||
|
Update(&OrgPinnedRepo{GroupID: 0}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the group
|
||||||
|
if _, err := db.GetEngine(ctx).ID(groupID).Delete(new(OrgPinnedGroup)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return committer.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgPinnedRepos returns all pinned repos for an organization
|
||||||
|
func GetOrgPinnedRepos(ctx context.Context, orgID int64) ([]*OrgPinnedRepo, error) {
|
||||||
|
pinnedRepos := make([]*OrgPinnedRepo, 0, 20)
|
||||||
|
return pinnedRepos, db.GetEngine(ctx).
|
||||||
|
Where("org_id = ?", orgID).
|
||||||
|
OrderBy("group_id ASC, display_order ASC, id ASC").
|
||||||
|
Find(&pinnedRepos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgPinnedRepoIDs returns the repo IDs of all pinned repos for an organization
|
||||||
|
func GetOrgPinnedRepoIDs(ctx context.Context, orgID int64) ([]int64, error) {
|
||||||
|
pinnedRepos, err := GetOrgPinnedRepos(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
repoIDs := make([]int64, len(pinnedRepos))
|
||||||
|
for i, p := range pinnedRepos {
|
||||||
|
repoIDs[i] = p.RepoID
|
||||||
|
}
|
||||||
|
return repoIDs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPinnedRepoGroups loads the groups for pinned repos
|
||||||
|
func LoadPinnedRepoGroups(ctx context.Context, pinnedRepos []*OrgPinnedRepo, orgID int64) error {
|
||||||
|
if len(pinnedRepos) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
groups, err := GetOrgPinnedGroups(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupMap := make(map[int64]*OrgPinnedGroup)
|
||||||
|
for _, g := range groups {
|
||||||
|
groupMap[g.ID] = g
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range pinnedRepos {
|
||||||
|
if p.GroupID > 0 {
|
||||||
|
p.Group = groupMap[p.GroupID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRepoPinned checks if a repo is already pinned for an organization
|
||||||
|
func IsRepoPinned(ctx context.Context, orgID, repoID int64) (bool, error) {
|
||||||
|
return db.GetEngine(ctx).
|
||||||
|
Where("org_id = ? AND repo_id = ?", orgID, repoID).
|
||||||
|
Exist(new(OrgPinnedRepo))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgPinnedRepo pins a repository to an organization
|
||||||
|
func CreateOrgPinnedRepo(ctx context.Context, pinned *OrgPinnedRepo) error {
|
||||||
|
// Check if already pinned
|
||||||
|
exists, err := IsRepoPinned(ctx, pinned.OrgID, pinned.RepoID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return ErrOrgPinnedRepoAlreadyExist{OrgID: pinned.OrgID, RepoID: pinned.RepoID}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.GetEngine(ctx).Insert(pinned)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOrgPinnedRepo updates a pinned repo's group or order
|
||||||
|
func UpdateOrgPinnedRepo(ctx context.Context, pinned *OrgPinnedRepo) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(pinned.ID).Cols("group_id", "display_order").Update(pinned)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgPinnedRepo unpins a repository from an organization
|
||||||
|
func DeleteOrgPinnedRepo(ctx context.Context, orgID, repoID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).
|
||||||
|
Where("org_id = ? AND repo_id = ?", orgID, repoID).
|
||||||
|
Delete(new(OrgPinnedRepo))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOrgPinnedRepoByID unpins a repository by pinned ID
|
||||||
|
func DeleteOrgPinnedRepoByID(ctx context.Context, id int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(id).Delete(new(OrgPinnedRepo))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderOrgPinnedRepos updates the display order of pinned repos
|
||||||
|
func ReorderOrgPinnedRepos(ctx context.Context, orgID int64, repoOrders []PinnedRepoOrder) error {
|
||||||
|
ctx, committer, err := db.TxContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer committer.Close()
|
||||||
|
|
||||||
|
for _, order := range repoOrders {
|
||||||
|
if _, err := db.GetEngine(ctx).
|
||||||
|
Where("org_id = ? AND repo_id = ?", orgID, order.RepoID).
|
||||||
|
Cols("group_id", "display_order").
|
||||||
|
Update(&OrgPinnedRepo{
|
||||||
|
GroupID: order.GroupID,
|
||||||
|
DisplayOrder: order.DisplayOrder,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return committer.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderOrgPinnedGroups updates the display order of pinned groups
|
||||||
|
func ReorderOrgPinnedGroups(ctx context.Context, orgID int64, groupOrders []PinnedGroupOrder) error {
|
||||||
|
ctx, committer, err := db.TxContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer committer.Close()
|
||||||
|
|
||||||
|
for _, order := range groupOrders {
|
||||||
|
if _, err := db.GetEngine(ctx).
|
||||||
|
Where("org_id = ? AND id = ?", orgID, order.GroupID).
|
||||||
|
Cols("display_order").
|
||||||
|
Update(&OrgPinnedGroup{DisplayOrder: order.DisplayOrder}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return committer.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinnedRepoOrder represents the order for a pinned repo
|
||||||
|
type PinnedRepoOrder struct {
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinnedGroupOrder represents the order for a pinned group
|
||||||
|
type PinnedGroupOrder struct {
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrOrgPinnedGroupNotExist represents a "pinned group not exist" error
|
||||||
|
type ErrOrgPinnedGroupNotExist struct {
|
||||||
|
ID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrOrgPinnedGroupNotExist) Error() string {
|
||||||
|
return "pinned group does not exist"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrOrgPinnedRepoAlreadyExist represents a "repo already pinned" error
|
||||||
|
type ErrOrgPinnedRepoAlreadyExist struct {
|
||||||
|
OrgID int64
|
||||||
|
RepoID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrOrgPinnedRepoAlreadyExist) Error() string {
|
||||||
|
return "repository is already pinned"
|
||||||
|
}
|
||||||
170
models/organization/org_profile.go
Normal file
170
models/organization/org_profile.go
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package organization
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ProfileRepoName is the name of the special profile repository
|
||||||
|
ProfileRepoName = ".profile"
|
||||||
|
// ProfileReadmePath is the path to the profile README
|
||||||
|
ProfileReadmePath = "README.md"
|
||||||
|
// ProfileAssetsPath is the path to profile assets
|
||||||
|
ProfileAssetsPath = "assets/"
|
||||||
|
// ProfileStylePath is the path to custom CSS
|
||||||
|
ProfileStylePath = "style.css"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicMember represents a public member of an organization for display
|
||||||
|
type PublicMember struct {
|
||||||
|
*user_model.User
|
||||||
|
Role string // "Owner", "Admin", "Member"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPublicOrgMembers returns public members of an organization
|
||||||
|
func GetPublicOrgMembers(ctx context.Context, orgID int64, limit int) ([]*PublicMember, int64, error) {
|
||||||
|
// Count total public members
|
||||||
|
total, err := db.GetEngine(ctx).
|
||||||
|
Where("org_id = ? AND is_public = ?", orgID, true).
|
||||||
|
Count(new(OrgUser))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get public org users
|
||||||
|
orgUsers := make([]*OrgUser, 0, limit)
|
||||||
|
sess := db.GetEngine(ctx).
|
||||||
|
Where("org_id = ? AND is_public = ?", orgID, true)
|
||||||
|
if limit > 0 {
|
||||||
|
sess = sess.Limit(limit)
|
||||||
|
}
|
||||||
|
if err := sess.Find(&orgUsers); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(orgUsers) == 0 {
|
||||||
|
return []*PublicMember{}, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user IDs
|
||||||
|
userIDs := make([]int64, len(orgUsers))
|
||||||
|
for i, ou := range orgUsers {
|
||||||
|
userIDs[i] = ou.UID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load users
|
||||||
|
users, err := user_model.GetUsersByIDs(ctx, userIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userMap := make(map[int64]*user_model.User)
|
||||||
|
for _, u := range users {
|
||||||
|
userMap[u.ID] = u
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get owners team to determine roles
|
||||||
|
org, err := GetOrgByID(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ownersTeam, err := org.GetOwnerTeam(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerIDs := make(map[int64]bool)
|
||||||
|
if ownersTeam != nil {
|
||||||
|
if err := ownersTeam.LoadMembers(ctx); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
for _, m := range ownersTeam.Members {
|
||||||
|
ownerIDs[m.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result with roles
|
||||||
|
members := make([]*PublicMember, 0, len(orgUsers))
|
||||||
|
for _, ou := range orgUsers {
|
||||||
|
user := userMap[ou.UID]
|
||||||
|
if user == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
role := "Member"
|
||||||
|
if ownerIDs[ou.UID] {
|
||||||
|
role = "Owner"
|
||||||
|
} else {
|
||||||
|
// Check if admin (has admin access to any team)
|
||||||
|
isAdmin, _ := IsOrganizationAdmin(ctx, orgID, ou.UID)
|
||||||
|
if isAdmin {
|
||||||
|
role = "Admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
members = append(members, &PublicMember{
|
||||||
|
User: user,
|
||||||
|
Role: role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return members, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMemberPublicVisibility sets the public visibility of a member
|
||||||
|
func SetMemberPublicVisibility(ctx context.Context, orgID, userID int64, isPublic bool) error {
|
||||||
|
_, err := db.GetEngine(ctx).
|
||||||
|
Where("org_id = ? AND uid = ?", orgID, userID).
|
||||||
|
Cols("is_public").
|
||||||
|
Update(&OrgUser{IsPublic: isPublic})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemberPublicVisibility gets the public visibility status of a member
|
||||||
|
func GetMemberPublicVisibility(ctx context.Context, orgID, userID int64) (bool, error) {
|
||||||
|
orgUser := new(OrgUser)
|
||||||
|
has, err := db.GetEngine(ctx).
|
||||||
|
Where("org_id = ? AND uid = ?", orgID, userID).
|
||||||
|
Get(orgUser)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return orgUser.IsPublic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgOverviewStats represents statistics for the organization overview
|
||||||
|
type OrgOverviewStats struct {
|
||||||
|
MemberCount int64
|
||||||
|
RepoCount int64
|
||||||
|
PublicRepoCount int64
|
||||||
|
TeamCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgMemberAndTeamCounts returns member and team counts for an organization
|
||||||
|
func GetOrgMemberAndTeamCounts(ctx context.Context, orgID int64) (memberCount, teamCount int64, err error) {
|
||||||
|
memberCount, err = db.GetEngine(ctx).
|
||||||
|
Where("org_id = ?", orgID).
|
||||||
|
Count(new(OrgUser))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
teamCount, err = db.GetEngine(ctx).
|
||||||
|
Where("org_id = ?", orgID).
|
||||||
|
Count(new(Team))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return memberCount, teamCount, nil
|
||||||
|
}
|
||||||
309
models/repo/pages.go
Normal file
309
models/repo/pages.go
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PagesTemplate represents the type of landing page template
|
||||||
|
type PagesTemplate string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PagesTemplateSimple PagesTemplate = "simple"
|
||||||
|
PagesTemplateDocumentation PagesTemplate = "documentation"
|
||||||
|
PagesTemplateProduct PagesTemplate = "product"
|
||||||
|
PagesTemplatePortfolio PagesTemplate = "portfolio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSLStatus represents the SSL certificate status
|
||||||
|
type SSLStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SSLStatusPending SSLStatus = "pending"
|
||||||
|
SSLStatusActive SSLStatus = "active"
|
||||||
|
SSLStatusExpiring SSLStatus = "expiring"
|
||||||
|
SSLStatusExpired SSLStatus = "expired"
|
||||||
|
SSLStatusError SSLStatus = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(PagesDomain))
|
||||||
|
db.RegisterModel(new(PagesConfig))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PagesDomain represents a custom domain mapping for Gitea Pages
|
||||||
|
type PagesDomain struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
Domain string `xorm:"UNIQUE NOT NULL"`
|
||||||
|
Verified bool `xorm:"DEFAULT false"`
|
||||||
|
VerificationToken string `xorm:"VARCHAR(64)"`
|
||||||
|
SSLStatus SSLStatus `xorm:"VARCHAR(32) DEFAULT 'pending'"`
|
||||||
|
SSLCertExpiry timeutil.TimeStamp `xorm:"DEFAULT 0"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
VerifiedUnix timeutil.TimeStamp `xorm:"DEFAULT 0"`
|
||||||
|
|
||||||
|
Repo *Repository `xorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for PagesDomain
|
||||||
|
func (d *PagesDomain) TableName() string {
|
||||||
|
return "pages_domain"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PagesConfig represents the cached configuration for a repository's landing page
|
||||||
|
type PagesConfig struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"UNIQUE NOT NULL"`
|
||||||
|
Enabled bool `xorm:"DEFAULT false"`
|
||||||
|
Template PagesTemplate `xorm:"VARCHAR(32) DEFAULT 'simple'"`
|
||||||
|
ConfigJSON string `xorm:"TEXT"` // Cached parsed config from landing.yaml
|
||||||
|
ConfigHash string `xorm:"VARCHAR(64)"` // Hash for invalidation
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
|
||||||
|
|
||||||
|
Repo *Repository `xorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for PagesConfig
|
||||||
|
func (c *PagesConfig) TableName() string {
|
||||||
|
return "pages_config"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateVerificationToken generates a random token for domain verification
|
||||||
|
func GenerateVerificationToken() (string, error) {
|
||||||
|
bytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesDomainByID returns a pages domain by ID
|
||||||
|
func GetPagesDomainByID(ctx context.Context, id int64) (*PagesDomain, error) {
|
||||||
|
domain := new(PagesDomain)
|
||||||
|
has, err := db.GetEngine(ctx).ID(id).Get(domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, ErrPagesDomainNotExist{ID: id}
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesDomainByDomain returns a pages domain by domain name
|
||||||
|
func GetPagesDomainByDomain(ctx context.Context, domainName string) (*PagesDomain, error) {
|
||||||
|
domain := new(PagesDomain)
|
||||||
|
has, err := db.GetEngine(ctx).Where("domain = ?", strings.ToLower(domainName)).Get(domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, ErrPagesDomainNotExist{Domain: domainName}
|
||||||
|
}
|
||||||
|
return domain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesDomainsByRepoID returns all custom domains for a repository
|
||||||
|
func GetPagesDomainsByRepoID(ctx context.Context, repoID int64) ([]*PagesDomain, error) {
|
||||||
|
domains := make([]*PagesDomain, 0, 5)
|
||||||
|
return domains, db.GetEngine(ctx).Where("repo_id = ?", repoID).Find(&domains)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePagesDomain creates a new custom domain for pages
|
||||||
|
func CreatePagesDomain(ctx context.Context, domain *PagesDomain) error {
|
||||||
|
// Normalize domain to lowercase
|
||||||
|
domain.Domain = strings.ToLower(domain.Domain)
|
||||||
|
|
||||||
|
// Generate verification token
|
||||||
|
token, err := GenerateVerificationToken()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
domain.VerificationToken = token
|
||||||
|
domain.SSLStatus = SSLStatusPending
|
||||||
|
|
||||||
|
_, err = db.GetEngine(ctx).Insert(domain)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePagesDomain updates a pages domain
|
||||||
|
func UpdatePagesDomain(ctx context.Context, domain *PagesDomain) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(domain.ID).AllCols().Update(domain)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePagesDomain deletes a pages domain
|
||||||
|
func DeletePagesDomain(ctx context.Context, id int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(id).Delete(new(PagesDomain))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePagesDomainsByRepoID deletes all pages domains for a repository
|
||||||
|
func DeletePagesDomainsByRepoID(ctx context.Context, repoID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(PagesDomain))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPagesDomain marks a domain as verified
|
||||||
|
func VerifyPagesDomain(ctx context.Context, id int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(id).Cols("verified", "verified_unix").Update(&PagesDomain{
|
||||||
|
Verified: true,
|
||||||
|
VerifiedUnix: timeutil.TimeStampNow(),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesConfigByRepoID returns the pages config for a repository
|
||||||
|
func GetPagesConfigByRepoID(ctx context.Context, repoID int64) (*PagesConfig, error) {
|
||||||
|
config := new(PagesConfig)
|
||||||
|
has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, nil // No config means pages not enabled
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePagesConfig creates a new pages config for a repository
|
||||||
|
func CreatePagesConfig(ctx context.Context, config *PagesConfig) error {
|
||||||
|
_, err := db.GetEngine(ctx).Insert(config)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePagesConfig updates a pages config
|
||||||
|
func UpdatePagesConfig(ctx context.Context, config *PagesConfig) error {
|
||||||
|
_, err := db.GetEngine(ctx).ID(config.ID).AllCols().Update(config)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePagesConfig deletes a pages config
|
||||||
|
func DeletePagesConfig(ctx context.Context, repoID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(PagesConfig))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnablePages enables pages for a repository
|
||||||
|
func EnablePages(ctx context.Context, repoID int64, template PagesTemplate) error {
|
||||||
|
config, err := GetPagesConfigByRepoID(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config == nil {
|
||||||
|
// Create new config
|
||||||
|
config = &PagesConfig{
|
||||||
|
RepoID: repoID,
|
||||||
|
Enabled: true,
|
||||||
|
Template: template,
|
||||||
|
}
|
||||||
|
return CreatePagesConfig(ctx, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing config
|
||||||
|
config.Enabled = true
|
||||||
|
config.Template = template
|
||||||
|
return UpdatePagesConfig(ctx, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisablePages disables pages for a repository
|
||||||
|
func DisablePages(ctx context.Context, repoID int64) error {
|
||||||
|
config, err := GetPagesConfigByRepoID(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if config == nil {
|
||||||
|
return nil // Already disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
config.Enabled = false
|
||||||
|
return UpdatePagesConfig(ctx, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPagesEnabled checks if pages is enabled for a repository
|
||||||
|
func IsPagesEnabled(ctx context.Context, repoID int64) (bool, error) {
|
||||||
|
config, err := GetPagesConfigByRepoID(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return config != nil && config.Enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPagesDomainNotExist represents a "pages domain not exist" error
|
||||||
|
type ErrPagesDomainNotExist struct {
|
||||||
|
ID int64
|
||||||
|
Domain string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrPagesDomainNotExist) Error() string {
|
||||||
|
if err.Domain != "" {
|
||||||
|
return fmt.Sprintf("pages domain does not exist [domain: %s]", err.Domain)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("pages domain does not exist [id: %d]", err.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrPagesDomainNotExist checks if an error is ErrPagesDomainNotExist
|
||||||
|
func IsErrPagesDomainNotExist(err error) bool {
|
||||||
|
_, ok := err.(ErrPagesDomainNotExist)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPagesDomainAlreadyExist represents a "pages domain already exist" error
|
||||||
|
type ErrPagesDomainAlreadyExist struct {
|
||||||
|
Domain string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrPagesDomainAlreadyExist) Error() string {
|
||||||
|
return fmt.Sprintf("pages domain already exists [domain: %s]", err.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrPagesDomainAlreadyExist checks if an error is ErrPagesDomainAlreadyExist
|
||||||
|
func IsErrPagesDomainAlreadyExist(err error) bool {
|
||||||
|
_, ok := err.(ErrPagesDomainAlreadyExist)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPagesConfigNotExist represents a "pages config not exist" error
|
||||||
|
type ErrPagesConfigNotExist struct {
|
||||||
|
RepoID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (err ErrPagesConfigNotExist) Error() string {
|
||||||
|
return fmt.Sprintf("pages config does not exist [repo_id: %d]", err.RepoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsErrPagesConfigNotExist checks if an error is ErrPagesConfigNotExist
|
||||||
|
func IsErrPagesConfigNotExist(err error) bool {
|
||||||
|
_, ok := err.(ErrPagesConfigNotExist)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesConfig returns the pages config for a repository with proper error handling
|
||||||
|
func GetPagesConfig(ctx context.Context, repoID int64) (*PagesConfig, error) {
|
||||||
|
config := new(PagesConfig)
|
||||||
|
has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, ErrPagesConfigNotExist{RepoID: repoID}
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesDomains returns all custom domains for a repository
|
||||||
|
func GetPagesDomains(ctx context.Context, repoID int64) ([]*PagesDomain, error) {
|
||||||
|
return GetPagesDomainsByRepoID(ctx, repoID)
|
||||||
|
}
|
||||||
134
models/repo/wiki_ai.go
Normal file
134
models/repo/wiki_ai.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/db"
|
||||||
|
"code.gitea.io/gitea/modules/timeutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
db.RegisterModel(new(WikiIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiIndex stores the searchable index for wiki pages
|
||||||
|
type WikiIndex struct {
|
||||||
|
ID int64 `xorm:"pk autoincr"`
|
||||||
|
RepoID int64 `xorm:"INDEX NOT NULL"`
|
||||||
|
PageName string `xorm:"VARCHAR(255) NOT NULL"`
|
||||||
|
PagePath string `xorm:"VARCHAR(512) NOT NULL"` // Git path
|
||||||
|
Title string `xorm:"VARCHAR(255)"`
|
||||||
|
Content string `xorm:"LONGTEXT"` // Full content for search
|
||||||
|
ContentHash string `xorm:"VARCHAR(64)"` // For change detection
|
||||||
|
CommitSHA string `xorm:"VARCHAR(64)"` // Last indexed commit
|
||||||
|
WordCount int `xorm:"DEFAULT 0"`
|
||||||
|
LinksOut string `xorm:"TEXT"` // JSON array of outgoing links
|
||||||
|
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
|
||||||
|
CreatedUnix timeutil.TimeStamp `xorm:"created"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for WikiIndex
|
||||||
|
func (w *WikiIndex) TableName() string {
|
||||||
|
return "wiki_index"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiIndex returns the wiki index for a specific page
|
||||||
|
func GetWikiIndex(ctx context.Context, repoID int64, pageName string) (*WikiIndex, error) {
|
||||||
|
idx := new(WikiIndex)
|
||||||
|
has, err := db.GetEngine(ctx).Where("repo_id = ? AND page_name = ?", repoID, pageName).Get(idx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return idx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiIndexByRepo returns all indexed pages for a repository
|
||||||
|
func GetWikiIndexByRepo(ctx context.Context, repoID int64) ([]*WikiIndex, error) {
|
||||||
|
indexes := make([]*WikiIndex, 0)
|
||||||
|
return indexes, db.GetEngine(ctx).Where("repo_id = ?", repoID).OrderBy("page_name ASC").Find(&indexes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrUpdateWikiIndex creates or updates a wiki index entry
|
||||||
|
func CreateOrUpdateWikiIndex(ctx context.Context, idx *WikiIndex) error {
|
||||||
|
existing, err := GetWikiIndex(ctx, idx.RepoID, idx.PageName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing == nil {
|
||||||
|
_, err = db.GetEngine(ctx).Insert(idx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
idx.ID = existing.ID
|
||||||
|
_, err = db.GetEngine(ctx).ID(existing.ID).AllCols().Update(idx)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWikiIndex deletes a wiki index entry
|
||||||
|
func DeleteWikiIndex(ctx context.Context, repoID int64, pageName string) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("repo_id = ? AND page_name = ?", repoID, pageName).Delete(new(WikiIndex))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWikiIndexByRepo deletes all wiki indexes for a repository
|
||||||
|
func DeleteWikiIndexByRepo(ctx context.Context, repoID int64) error {
|
||||||
|
_, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Delete(new(WikiIndex))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchWikiOptions contains options for wiki search
|
||||||
|
type SearchWikiOptions struct {
|
||||||
|
RepoID int64
|
||||||
|
Query string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchWikiPages searches wiki pages by content
|
||||||
|
func SearchWikiPages(ctx context.Context, opts *SearchWikiOptions) ([]*WikiIndex, int64, error) {
|
||||||
|
if opts.Limit <= 0 {
|
||||||
|
opts.Limit = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := db.GetEngine(ctx).Where("repo_id = ?", opts.RepoID)
|
||||||
|
|
||||||
|
// Simple LIKE search - searches title and content
|
||||||
|
if opts.Query != "" {
|
||||||
|
query := "%" + strings.ToLower(opts.Query) + "%"
|
||||||
|
sess = sess.And("(LOWER(title) LIKE ? OR LOWER(content) LIKE ?)", query, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
total, err := sess.Count(new(WikiIndex))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get results
|
||||||
|
indexes := make([]*WikiIndex, 0, opts.Limit)
|
||||||
|
err = sess.Limit(opts.Limit, opts.Offset).Find(&indexes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return indexes, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiIndexCount returns the number of indexed pages for a repository
|
||||||
|
func GetWikiIndexCount(ctx context.Context, repoID int64) (int64, error) {
|
||||||
|
return db.GetEngine(ctx).Where("repo_id = ?", repoID).Count(new(WikiIndex))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiTotalWordCount returns the total word count for a repository's wiki
|
||||||
|
func GetWikiTotalWordCount(ctx context.Context, repoID int64) (int64, error) {
|
||||||
|
total, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).SumInt(new(WikiIndex), "word_count")
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
@ -149,6 +149,14 @@ const (
|
|||||||
WebhookDeliveryFail ErrorCode = "WEBHOOK_DELIVERY_FAILED"
|
WebhookDeliveryFail ErrorCode = "WEBHOOK_DELIVERY_FAILED"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Wiki errors (WIKI_)
|
||||||
|
const (
|
||||||
|
WikiPageNotFound ErrorCode = "WIKI_PAGE_NOT_FOUND"
|
||||||
|
WikiPageAlreadyExists ErrorCode = "WIKI_PAGE_ALREADY_EXISTS"
|
||||||
|
WikiReservedName ErrorCode = "WIKI_RESERVED_NAME"
|
||||||
|
WikiDisabled ErrorCode = "WIKI_DISABLED"
|
||||||
|
)
|
||||||
|
|
||||||
// errorInfo contains metadata about an error code
|
// errorInfo contains metadata about an error code
|
||||||
type errorInfo struct {
|
type errorInfo struct {
|
||||||
Message string
|
Message string
|
||||||
@ -263,6 +271,12 @@ var errorCatalog = map[ErrorCode]errorInfo{
|
|||||||
// Webhook errors
|
// Webhook errors
|
||||||
WebhookNotFound: {"Webhook not found", http.StatusNotFound},
|
WebhookNotFound: {"Webhook not found", http.StatusNotFound},
|
||||||
WebhookDeliveryFail: {"Webhook delivery failed", http.StatusBadGateway},
|
WebhookDeliveryFail: {"Webhook delivery failed", http.StatusBadGateway},
|
||||||
|
|
||||||
|
// Wiki errors
|
||||||
|
WikiPageNotFound: {"Wiki page not found", http.StatusNotFound},
|
||||||
|
WikiPageAlreadyExists: {"Wiki page already exists", http.StatusConflict},
|
||||||
|
WikiReservedName: {"Wiki page name is reserved", http.StatusBadRequest},
|
||||||
|
WikiDisabled: {"Wiki is disabled for this repository", http.StatusForbidden},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message returns the human-readable message for an error code
|
// Message returns the human-readable message for an error code
|
||||||
|
|||||||
262
modules/pages/config.go
Normal file
262
modules/pages/config.go
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LandingConfig represents the parsed .gitea/landing.yaml configuration
|
||||||
|
type LandingConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Template string `yaml:"template"` // simple, documentation, product, portfolio
|
||||||
|
|
||||||
|
// Custom domain (optional)
|
||||||
|
Domain string `yaml:"domain,omitempty"`
|
||||||
|
|
||||||
|
// Branding
|
||||||
|
Branding BrandingConfig `yaml:"branding,omitempty"`
|
||||||
|
|
||||||
|
// Hero section
|
||||||
|
Hero HeroConfig `yaml:"hero,omitempty"`
|
||||||
|
|
||||||
|
// Features (for product template)
|
||||||
|
Features []FeatureConfig `yaml:"features,omitempty"`
|
||||||
|
|
||||||
|
// Sections
|
||||||
|
Sections []SectionConfig `yaml:"sections,omitempty"`
|
||||||
|
|
||||||
|
// Documentation settings (for documentation template)
|
||||||
|
Documentation DocumentationConfig `yaml:"documentation,omitempty"`
|
||||||
|
|
||||||
|
// Gallery settings (for portfolio template)
|
||||||
|
Gallery GalleryConfig `yaml:"gallery,omitempty"`
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
Footer FooterConfig `yaml:"footer,omitempty"`
|
||||||
|
|
||||||
|
// SEO & Social
|
||||||
|
SEO SEOConfig `yaml:"seo,omitempty"`
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
Analytics AnalyticsConfig `yaml:"analytics,omitempty"`
|
||||||
|
|
||||||
|
// Advanced settings
|
||||||
|
Advanced AdvancedConfig `yaml:"advanced,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrandingConfig represents branding settings
|
||||||
|
type BrandingConfig struct {
|
||||||
|
Logo string `yaml:"logo,omitempty"`
|
||||||
|
LogoDark string `yaml:"logo_dark,omitempty"`
|
||||||
|
Favicon string `yaml:"favicon,omitempty"`
|
||||||
|
PrimaryColor string `yaml:"primary_color,omitempty"`
|
||||||
|
SecondaryColor string `yaml:"secondary_color,omitempty"`
|
||||||
|
AccentColor string `yaml:"accent_color,omitempty"`
|
||||||
|
DarkMode string `yaml:"dark_mode,omitempty"` // auto, light, dark, both
|
||||||
|
HeadingFont string `yaml:"heading_font,omitempty"`
|
||||||
|
BodyFont string `yaml:"body_font,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeroConfig represents hero section settings
|
||||||
|
type HeroConfig struct {
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
Tagline string `yaml:"tagline,omitempty"`
|
||||||
|
Background string `yaml:"background,omitempty"`
|
||||||
|
Gradient string `yaml:"gradient,omitempty"`
|
||||||
|
Color string `yaml:"color,omitempty"`
|
||||||
|
CTAPrimary CTAConfig `yaml:"cta_primary,omitempty"`
|
||||||
|
CTASecondary CTAConfig `yaml:"cta_secondary,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CTAConfig represents a call-to-action button
|
||||||
|
type CTAConfig struct {
|
||||||
|
Text string `yaml:"text,omitempty"`
|
||||||
|
Link string `yaml:"link,omitempty"`
|
||||||
|
Style string `yaml:"style,omitempty"` // outline, ghost
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureConfig represents a single feature item
|
||||||
|
type FeatureConfig struct {
|
||||||
|
Icon string `yaml:"icon,omitempty"`
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
Description string `yaml:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionConfig represents a content section
|
||||||
|
type SectionConfig struct {
|
||||||
|
Type string `yaml:"type,omitempty"` // features, screenshot, readme, releases, contributors, custom
|
||||||
|
Image string `yaml:"image,omitempty"`
|
||||||
|
Caption string `yaml:"caption,omitempty"`
|
||||||
|
File string `yaml:"file,omitempty"`
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
Limit int `yaml:"limit,omitempty"`
|
||||||
|
ShowNotes bool `yaml:"show_notes,omitempty"`
|
||||||
|
ShowCount bool `yaml:"show_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentationConfig represents documentation template settings
|
||||||
|
type DocumentationConfig struct {
|
||||||
|
Source string `yaml:"source,omitempty"`
|
||||||
|
Search bool `yaml:"search,omitempty"`
|
||||||
|
TOC bool `yaml:"toc,omitempty"`
|
||||||
|
Sidebar []SidebarGroup `yaml:"sidebar,omitempty"`
|
||||||
|
EditLinks EditLinksConfig `yaml:"edit_links,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SidebarGroup represents a sidebar navigation group
|
||||||
|
type SidebarGroup struct {
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
Items []string `yaml:"items,omitempty"`
|
||||||
|
Collapsed bool `yaml:"collapsed,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditLinksConfig represents edit link settings
|
||||||
|
type EditLinksConfig struct {
|
||||||
|
Enabled bool `yaml:"enabled,omitempty"`
|
||||||
|
Text string `yaml:"text,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GalleryConfig represents gallery/portfolio template settings
|
||||||
|
type GalleryConfig struct {
|
||||||
|
Source string `yaml:"source,omitempty"`
|
||||||
|
Columns int `yaml:"columns,omitempty"`
|
||||||
|
Lightbox bool `yaml:"lightbox,omitempty"`
|
||||||
|
Captions bool `yaml:"captions,omitempty"`
|
||||||
|
Items []GalleryItem `yaml:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GalleryItem represents a single gallery item
|
||||||
|
type GalleryItem struct {
|
||||||
|
Image string `yaml:"image,omitempty"`
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
Link string `yaml:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FooterConfig represents footer settings
|
||||||
|
type FooterConfig struct {
|
||||||
|
Links []FooterLinkGroup `yaml:"links,omitempty"`
|
||||||
|
Copyright string `yaml:"copyright,omitempty"`
|
||||||
|
ShowPoweredBy bool `yaml:"show_powered_by,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FooterLinkGroup represents a group of footer links
|
||||||
|
type FooterLinkGroup struct {
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
Items []FooterLink `yaml:"items,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FooterLink represents a single footer link
|
||||||
|
type FooterLink struct {
|
||||||
|
Text string `yaml:"text,omitempty"`
|
||||||
|
URL string `yaml:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEOConfig represents SEO and social sharing settings
|
||||||
|
type SEOConfig struct {
|
||||||
|
Title string `yaml:"title,omitempty"`
|
||||||
|
Description string `yaml:"description,omitempty"`
|
||||||
|
Keywords []string `yaml:"keywords,omitempty"`
|
||||||
|
OGImage string `yaml:"og_image,omitempty"`
|
||||||
|
TwitterCard string `yaml:"twitter_card,omitempty"`
|
||||||
|
TwitterSite string `yaml:"twitter_site,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnalyticsConfig represents analytics settings
|
||||||
|
type AnalyticsConfig struct {
|
||||||
|
Plausible string `yaml:"plausible,omitempty"`
|
||||||
|
Umami UmamiConfig `yaml:"umami,omitempty"`
|
||||||
|
GoogleAnalytics string `yaml:"google_analytics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UmamiConfig represents Umami analytics settings
|
||||||
|
type UmamiConfig struct {
|
||||||
|
WebsiteID string `yaml:"website_id,omitempty"`
|
||||||
|
URL string `yaml:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdvancedConfig represents advanced settings
|
||||||
|
type AdvancedConfig struct {
|
||||||
|
CustomCSS string `yaml:"custom_css,omitempty"`
|
||||||
|
CustomHead string `yaml:"custom_head,omitempty"`
|
||||||
|
Redirects map[string]string `yaml:"redirects,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLandingConfig parses a landing.yaml file content
|
||||||
|
func ParseLandingConfig(content []byte) (*LandingConfig, error) {
|
||||||
|
config := &LandingConfig{
|
||||||
|
// Set defaults
|
||||||
|
Enabled: true,
|
||||||
|
Template: "simple",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(content, config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults
|
||||||
|
if config.Template == "" {
|
||||||
|
config.Template = "simple"
|
||||||
|
}
|
||||||
|
if config.Branding.DarkMode == "" {
|
||||||
|
config.Branding.DarkMode = "auto"
|
||||||
|
}
|
||||||
|
if config.Gallery.Columns == 0 {
|
||||||
|
config.Gallery.Columns = 4
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConfig returns a SHA256 hash of the config content for change detection
|
||||||
|
func HashConfig(content []byte) string {
|
||||||
|
hash := sha256.Sum256(content)
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns a default landing page configuration
|
||||||
|
func DefaultConfig() *LandingConfig {
|
||||||
|
return &LandingConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Template: "simple",
|
||||||
|
Branding: BrandingConfig{
|
||||||
|
DarkMode: "auto",
|
||||||
|
},
|
||||||
|
Hero: HeroConfig{
|
||||||
|
CTAPrimary: CTAConfig{
|
||||||
|
Text: "Get Started",
|
||||||
|
Link: "#installation",
|
||||||
|
},
|
||||||
|
CTASecondary: CTAConfig{
|
||||||
|
Text: "View Documentation",
|
||||||
|
Link: "#readme",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Sections: []SectionConfig{
|
||||||
|
{Type: "readme"},
|
||||||
|
{Type: "releases", Limit: 3},
|
||||||
|
},
|
||||||
|
Footer: FooterConfig{
|
||||||
|
ShowPoweredBy: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidTemplates returns the list of valid template names
|
||||||
|
func ValidTemplates() []string {
|
||||||
|
return []string{"simple", "documentation", "product", "portfolio"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidTemplate checks if a template name is valid
|
||||||
|
func IsValidTemplate(name string) bool {
|
||||||
|
for _, t := range ValidTemplates() {
|
||||||
|
if t == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@ -95,3 +95,99 @@ type RenameOrgOption struct {
|
|||||||
// unique: true
|
// unique: true
|
||||||
NewName string `json:"new_name" binding:"Required"`
|
NewName string `json:"new_name" binding:"Required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OrgPinnedRepo represents a pinned repository for an organization
|
||||||
|
type OrgPinnedRepo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
GroupID int64 `json:"group_id,omitempty"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
Repo *Repository `json:"repo,omitempty"`
|
||||||
|
Group *OrgPinnedGroup `json:"group,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgPinnedGroup represents a group of pinned repositories
|
||||||
|
type OrgPinnedGroup struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
Collapsed bool `json:"collapsed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddOrgPinnedRepoOption options for adding a pinned repository
|
||||||
|
type AddOrgPinnedRepoOption struct {
|
||||||
|
// Name of the repository to pin
|
||||||
|
// required: true
|
||||||
|
RepoName string `json:"repo_name" binding:"Required"`
|
||||||
|
// ID of the group to add the repo to (0 for ungrouped)
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
// Display order within the group
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderOrgPinnedReposOption options for reordering pinned repositories
|
||||||
|
type ReorderOrgPinnedReposOption struct {
|
||||||
|
// List of repo orders
|
||||||
|
// required: true
|
||||||
|
Orders []PinnedRepoOrder `json:"orders" binding:"Required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PinnedRepoOrder represents the order for a pinned repo
|
||||||
|
type PinnedRepoOrder struct {
|
||||||
|
RepoID int64 `json:"repo_id"`
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrgPinnedGroupOption options for creating a pinned group
|
||||||
|
type CreateOrgPinnedGroupOption struct {
|
||||||
|
// Name of the group
|
||||||
|
// required: true
|
||||||
|
Name string `json:"name" binding:"Required;MaxSize(100)"`
|
||||||
|
// Display order
|
||||||
|
DisplayOrder int `json:"display_order"`
|
||||||
|
// Whether the group is collapsed by default
|
||||||
|
Collapsed bool `json:"collapsed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOrgPinnedGroupOption options for updating a pinned group
|
||||||
|
type UpdateOrgPinnedGroupOption struct {
|
||||||
|
// Name of the group
|
||||||
|
Name *string `json:"name"`
|
||||||
|
// Display order
|
||||||
|
DisplayOrder *int `json:"display_order"`
|
||||||
|
// Whether the group is collapsed by default
|
||||||
|
Collapsed *bool `json:"collapsed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgPublicMember represents a public member of an organization
|
||||||
|
type OrgPublicMember struct {
|
||||||
|
User *User `json:"user"`
|
||||||
|
Role string `json:"role"` // "Owner", "Admin", "Member"
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgOverview represents the organization overview for the profile page
|
||||||
|
type OrgOverview struct {
|
||||||
|
Organization *Organization `json:"organization"`
|
||||||
|
PinnedRepos []*OrgPinnedRepo `json:"pinned_repos"`
|
||||||
|
PinnedGroups []*OrgPinnedGroup `json:"pinned_groups"`
|
||||||
|
PublicMembers []*OrgPublicMember `json:"public_members"`
|
||||||
|
TotalMembers int64 `json:"total_members"`
|
||||||
|
Stats *OrgOverviewStats `json:"stats"`
|
||||||
|
Profile *OrgProfileContent `json:"profile,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgOverviewStats represents organization statistics
|
||||||
|
type OrgOverviewStats struct {
|
||||||
|
MemberCount int64 `json:"member_count"`
|
||||||
|
RepoCount int64 `json:"repo_count"`
|
||||||
|
PublicRepoCount int64 `json:"public_repo_count"`
|
||||||
|
TeamCount int64 `json:"team_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrgProfileContent represents the organization profile content
|
||||||
|
type OrgProfileContent struct {
|
||||||
|
HasProfile bool `json:"has_profile"`
|
||||||
|
Readme string `json:"readme,omitempty"`
|
||||||
|
HasCSS bool `json:"has_css"`
|
||||||
|
}
|
||||||
|
|||||||
47
modules/structs/repo_pages.go
Normal file
47
modules/structs/repo_pages.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PagesConfig represents the pages configuration for a repository
|
||||||
|
type PagesConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Template string `json:"template"`
|
||||||
|
Subdomain string `json:"subdomain,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PagesDomain represents a custom domain for Gitea Pages
|
||||||
|
type PagesDomain struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
VerificationToken string `json:"verification_token,omitempty"`
|
||||||
|
SSLStatus string `json:"ssl_status"`
|
||||||
|
SSLCertExpiry time.Time `json:"ssl_cert_expiry,omitempty"`
|
||||||
|
Created time.Time `json:"created_at"`
|
||||||
|
Verified_At time.Time `json:"verified_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePagesConfigOption options for creating/updating pages config
|
||||||
|
type CreatePagesConfigOption struct {
|
||||||
|
// Whether pages is enabled
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// Template to use (simple, documentation, product, portfolio)
|
||||||
|
Template string `json:"template" binding:"In(simple,documentation,product,portfolio)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPagesDomainOption options for adding a custom domain
|
||||||
|
type AddPagesDomainOption struct {
|
||||||
|
// The custom domain to add
|
||||||
|
// required: true
|
||||||
|
Domain string `json:"domain" binding:"Required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PagesInfo represents the full pages information for a repository
|
||||||
|
type PagesInfo struct {
|
||||||
|
Config *PagesConfig `json:"config"`
|
||||||
|
Domains []*PagesDomain `json:"domains,omitempty"`
|
||||||
|
}
|
||||||
177
modules/structs/repo_wiki_v2.go
Normal file
177
modules/structs/repo_wiki_v2.go
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package structs
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// WikiPageV2 represents a wiki page in v2 API format
|
||||||
|
type WikiPageV2 struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
ContentHTML string `json:"content_html,omitempty"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
LinksOut []string `json:"links_out,omitempty"`
|
||||||
|
LinksIn []string `json:"links_in,omitempty"`
|
||||||
|
Sidebar string `json:"sidebar,omitempty"`
|
||||||
|
Footer string `json:"footer,omitempty"`
|
||||||
|
LastCommit *WikiCommitV2 `json:"last_commit,omitempty"`
|
||||||
|
HistoryURL string `json:"history_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiCommitV2 represents a wiki commit in v2 API format
|
||||||
|
type WikiCommitV2 struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Author *WikiAuthorV2 `json:"author"`
|
||||||
|
Committer *WikiAuthorV2 `json:"committer,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiAuthorV2 represents a wiki commit author
|
||||||
|
type WikiAuthorV2 struct {
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiPageListV2 represents a list of wiki pages
|
||||||
|
type WikiPageListV2 struct {
|
||||||
|
Pages []*WikiPageV2 `json:"pages"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiSearchResultV2 represents a search result item
|
||||||
|
type WikiSearchResultV2 struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
Score float32 `json:"score"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiSearchResponseV2 represents search results
|
||||||
|
type WikiSearchResponseV2 struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Results []*WikiSearchResultV2 `json:"results"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiGraphNodeV2 represents a node in the wiki graph
|
||||||
|
type WikiGraphNodeV2 struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiGraphEdgeV2 represents an edge in the wiki graph
|
||||||
|
type WikiGraphEdgeV2 struct {
|
||||||
|
Source string `json:"source"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiGraphV2 represents the wiki page relationship graph
|
||||||
|
type WikiGraphV2 struct {
|
||||||
|
Nodes []*WikiGraphNodeV2 `json:"nodes"`
|
||||||
|
Edges []*WikiGraphEdgeV2 `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiOrphanedPageV2 represents an orphaned page
|
||||||
|
type WikiOrphanedPageV2 struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiDeadLinkV2 represents a dead link
|
||||||
|
type WikiDeadLinkV2 struct {
|
||||||
|
Page string `json:"page"`
|
||||||
|
BrokenLink string `json:"broken_link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiOutdatedPageV2 represents an outdated page
|
||||||
|
type WikiOutdatedPageV2 struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
LastEdit time.Time `json:"last_edit"`
|
||||||
|
DaysOld int `json:"days_old"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiShortPageV2 represents a short page
|
||||||
|
type WikiShortPageV2 struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
WordCount int `json:"word_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiTopLinkedPageV2 represents a highly linked page
|
||||||
|
type WikiTopLinkedPageV2 struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IncomingLinks int `json:"incoming_links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiHealthV2 represents wiki health metrics
|
||||||
|
type WikiHealthV2 struct {
|
||||||
|
OrphanedPages []*WikiOrphanedPageV2 `json:"orphaned_pages"`
|
||||||
|
DeadLinks []*WikiDeadLinkV2 `json:"dead_links"`
|
||||||
|
OutdatedPages []*WikiOutdatedPageV2 `json:"outdated_pages"`
|
||||||
|
ShortPages []*WikiShortPageV2 `json:"short_pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiStatsV2 represents wiki statistics
|
||||||
|
type WikiStatsV2 struct {
|
||||||
|
TotalPages int64 `json:"total_pages"`
|
||||||
|
TotalWords int64 `json:"total_words"`
|
||||||
|
TotalCommits int64 `json:"total_commits"`
|
||||||
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
|
Contributors int64 `json:"contributors"`
|
||||||
|
Health *WikiHealthV2 `json:"health"`
|
||||||
|
TopLinked []*WikiTopLinkedPageV2 `json:"top_linked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiRevisionV2 represents a page revision
|
||||||
|
type WikiRevisionV2 struct {
|
||||||
|
SHA string `json:"sha"`
|
||||||
|
Author *WikiAuthorV2 `json:"author"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Additions int `json:"additions,omitempty"`
|
||||||
|
Deletions int `json:"deletions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiRevisionsV2 represents page revision history
|
||||||
|
type WikiRevisionsV2 struct {
|
||||||
|
PageName string `json:"page_name"`
|
||||||
|
Revisions []*WikiRevisionV2 `json:"revisions"`
|
||||||
|
TotalCount int64 `json:"total_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWikiPageV2Option represents options for creating a wiki page
|
||||||
|
type CreateWikiPageV2Option struct {
|
||||||
|
Name string `json:"name" binding:"Required"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content" binding:"Required"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWikiPageV2Option represents options for updating a wiki page
|
||||||
|
type UpdateWikiPageV2Option struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
RenameTo string `json:"rename_to,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWikiPageV2Option represents options for deleting a wiki page
|
||||||
|
type DeleteWikiPageV2Option struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WikiDeleteResponseV2 represents delete response
|
||||||
|
type WikiDeleteResponseV2 struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
@ -2512,6 +2512,41 @@
|
|||||||
"repo.settings.rename_branch_from": "old branch name",
|
"repo.settings.rename_branch_from": "old branch name",
|
||||||
"repo.settings.rename_branch_to": "new branch name",
|
"repo.settings.rename_branch_to": "new branch name",
|
||||||
"repo.settings.rename_branch": "Rename branch",
|
"repo.settings.rename_branch": "Rename branch",
|
||||||
|
"repo.settings.pages": "Pages",
|
||||||
|
"repo.settings.pages.enabled": "Pages Enabled",
|
||||||
|
"repo.settings.pages.enabled_desc": "Your landing page is now accessible to visitors.",
|
||||||
|
"repo.settings.pages.not_enabled": "Pages Not Enabled",
|
||||||
|
"repo.settings.pages.not_enabled_desc": "Enable Pages to create a landing page for your repository.",
|
||||||
|
"repo.settings.pages.enable": "Enable Pages",
|
||||||
|
"repo.settings.pages.disable": "Disable Pages",
|
||||||
|
"repo.settings.pages.enabled_success": "Pages have been enabled for this repository.",
|
||||||
|
"repo.settings.pages.disabled_success": "Pages have been disabled for this repository.",
|
||||||
|
"repo.settings.pages.template": "Template",
|
||||||
|
"repo.settings.pages.update_template": "Update Template",
|
||||||
|
"repo.settings.pages.subdomain": "Subdomain URL",
|
||||||
|
"repo.settings.pages.configuration": "Configuration",
|
||||||
|
"repo.settings.pages.config_desc": "Customize your landing page by creating a configuration file.",
|
||||||
|
"repo.settings.pages.config_file_hint": "Create this file in your repository to configure your landing page:",
|
||||||
|
"repo.settings.pages.custom_domains": "Custom Domains",
|
||||||
|
"repo.settings.pages.custom_domains_desc": "Add custom domains to access your landing page.",
|
||||||
|
"repo.settings.pages.domain": "Domain",
|
||||||
|
"repo.settings.pages.status": "Status",
|
||||||
|
"repo.settings.pages.ssl": "SSL",
|
||||||
|
"repo.settings.pages.verified": "Verified",
|
||||||
|
"repo.settings.pages.pending": "Pending",
|
||||||
|
"repo.settings.pages.ssl_active": "Active",
|
||||||
|
"repo.settings.pages.ssl_pending": "Pending",
|
||||||
|
"repo.settings.pages.ssl_none": "None",
|
||||||
|
"repo.settings.pages.verify": "Verify",
|
||||||
|
"repo.settings.pages.verify_dns_hint": "Add the following TXT record to your DNS to verify domain ownership:",
|
||||||
|
"repo.settings.pages.add_domain": "Add Domain",
|
||||||
|
"repo.settings.pages.add": "Add",
|
||||||
|
"repo.settings.pages.domain_required": "Domain is required.",
|
||||||
|
"repo.settings.pages.domain_exists": "This domain is already registered.",
|
||||||
|
"repo.settings.pages.domain_added": "Domain has been added. Please verify ownership.",
|
||||||
|
"repo.settings.pages.domain_deleted": "Domain has been removed.",
|
||||||
|
"repo.settings.pages.domain_verified": "Domain has been verified.",
|
||||||
|
"repo.settings.pages.domain_verification_failed": "Domain verification failed. Please check your DNS settings.",
|
||||||
"repo.diff.browse_source": "Browse Source",
|
"repo.diff.browse_source": "Browse Source",
|
||||||
"repo.diff.parent": "parent",
|
"repo.diff.parent": "parent",
|
||||||
"repo.diff.commit": "commit",
|
"repo.diff.commit": "commit",
|
||||||
@ -2693,6 +2728,9 @@
|
|||||||
"org.repo_updated": "Updated",
|
"org.repo_updated": "Updated",
|
||||||
"org.members": "Members",
|
"org.members": "Members",
|
||||||
"org.teams": "Teams",
|
"org.teams": "Teams",
|
||||||
|
"org.pinned_repos": "Featured Projects",
|
||||||
|
"org.public_members": "Public Members",
|
||||||
|
"org.view_all_members": "View all %d members",
|
||||||
"org.code": "Code",
|
"org.code": "Code",
|
||||||
"org.lower_members": "members",
|
"org.lower_members": "members",
|
||||||
"org.lower_repositories": "repositories",
|
"org.lower_repositories": "repositories",
|
||||||
|
|||||||
@ -1467,6 +1467,18 @@ func Routes() *web.Router {
|
|||||||
m.Delete("", repo.DeleteAvatar)
|
m.Delete("", repo.DeleteAvatar)
|
||||||
}, reqAdmin(), reqToken())
|
}, reqAdmin(), reqToken())
|
||||||
|
|
||||||
|
m.Group("/pages", func() {
|
||||||
|
m.Combo("").Get(repo.GetPagesConfig).
|
||||||
|
Put(reqToken(), reqAdmin(), bind(api.CreatePagesConfigOption{}), repo.UpdatePagesConfig).
|
||||||
|
Delete(reqToken(), reqAdmin(), repo.DeletePagesConfig)
|
||||||
|
m.Group("/domains", func() {
|
||||||
|
m.Combo("").Get(repo.ListPagesDomains).
|
||||||
|
Post(reqToken(), reqAdmin(), bind(api.AddPagesDomainOption{}), repo.AddPagesDomain)
|
||||||
|
m.Delete("/{domain}", reqToken(), reqAdmin(), repo.DeletePagesDomain)
|
||||||
|
m.Post("/{domain}/verify", reqToken(), reqAdmin(), repo.VerifyPagesDomain)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
m.Methods("HEAD,GET", "/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.DownloadArchive)
|
m.Methods("HEAD,GET", "/{ball_type:tarball|zipball|bundle}/*", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(true), repo.DownloadArchive)
|
||||||
}, repoAssignment(), checkTokenPublicOnly())
|
}, repoAssignment(), checkTokenPublicOnly())
|
||||||
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
|
||||||
@ -1651,10 +1663,25 @@ func Routes() *web.Router {
|
|||||||
)
|
)
|
||||||
m.Group("/public_members", func() {
|
m.Group("/public_members", func() {
|
||||||
m.Get("", org.ListPublicMembers)
|
m.Get("", org.ListPublicMembers)
|
||||||
|
m.Get("/roles", org.ListPublicMembersWithRoles)
|
||||||
m.Combo("/{username}").Get(org.IsPublicMember).
|
m.Combo("/{username}").Get(org.IsPublicMember).
|
||||||
Put(reqToken(), reqOrgMembership(), org.PublicizeMember).
|
Put(reqToken(), reqOrgMembership(), org.PublicizeMember).
|
||||||
Delete(reqToken(), reqOrgMembership(), org.ConcealMember)
|
Delete(reqToken(), reqOrgMembership(), org.ConcealMember)
|
||||||
})
|
})
|
||||||
|
m.Get("/overview", org.GetOverview)
|
||||||
|
m.Group("/pinned", func() {
|
||||||
|
m.Combo("").Get(org.ListPinnedRepos).
|
||||||
|
Post(reqToken(), reqOrgOwnership(), bind(api.AddOrgPinnedRepoOption{}), org.AddPinnedRepo)
|
||||||
|
m.Put("/reorder", reqToken(), reqOrgOwnership(), bind(api.ReorderOrgPinnedReposOption{}), org.ReorderPinnedRepos)
|
||||||
|
m.Delete("/{repo}", reqToken(), reqOrgOwnership(), org.DeletePinnedRepo)
|
||||||
|
m.Group("/groups", func() {
|
||||||
|
m.Combo("").Get(org.ListPinnedGroups).
|
||||||
|
Post(reqToken(), reqOrgOwnership(), bind(api.CreateOrgPinnedGroupOption{}), org.CreatePinnedGroup)
|
||||||
|
m.Combo("/{id}").
|
||||||
|
Put(reqToken(), reqOrgOwnership(), bind(api.UpdateOrgPinnedGroupOption{}), org.UpdatePinnedGroup).
|
||||||
|
Delete(reqToken(), reqOrgOwnership(), org.DeletePinnedGroup)
|
||||||
|
})
|
||||||
|
})
|
||||||
m.Group("/teams", func() {
|
m.Group("/teams", func() {
|
||||||
m.Get("", org.ListTeams)
|
m.Get("", org.ListTeams)
|
||||||
m.Post("", reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
|
m.Post("", reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam)
|
||||||
|
|||||||
440
routers/api/v1/org/pinned.go
Normal file
440
routers/api/v1/org/pinned.go
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
access_model "code.gitea.io/gitea/models/perm/access"
|
||||||
|
"code.gitea.io/gitea/models/organization"
|
||||||
|
"code.gitea.io/gitea/models/perm"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/web"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
"code.gitea.io/gitea/services/convert"
|
||||||
|
org_service "code.gitea.io/gitea/services/org"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListPinnedRepos returns the pinned repositories for an organization
|
||||||
|
func ListPinnedRepos(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/pinned organization orgListPinnedRepos
|
||||||
|
// ---
|
||||||
|
// summary: List an organization's pinned repositories
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgPinnedRepoList"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
pinnedRepos, err := org_service.GetOrgPinnedReposWithDetails(ctx, ctx.Org.Organization.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiPinnedRepos := make([]*api.OrgPinnedRepo, 0, len(pinnedRepos))
|
||||||
|
for _, p := range pinnedRepos {
|
||||||
|
if p.Repo == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apiPinnedRepos = append(apiPinnedRepos, convertOrgPinnedRepo(ctx, p))
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, apiPinnedRepos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPinnedRepo pins a repository to an organization
|
||||||
|
func AddPinnedRepo(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /orgs/{org}/pinned organization orgAddPinnedRepo
|
||||||
|
// ---
|
||||||
|
// summary: Pin a repository to an organization
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// required: true
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/AddOrgPinnedRepoOption"
|
||||||
|
// responses:
|
||||||
|
// "201":
|
||||||
|
// "$ref": "#/responses/OrgPinnedRepo"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.AddOrgPinnedRepoOption)
|
||||||
|
|
||||||
|
// Get the repository
|
||||||
|
repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, form.RepoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorNotFound("GetRepositoryByName", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pinned repo
|
||||||
|
pinned := &organization.OrgPinnedRepo{
|
||||||
|
OrgID: ctx.Org.Organization.ID,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
GroupID: form.GroupID,
|
||||||
|
DisplayOrder: form.DisplayOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := organization.CreateOrgPinnedRepo(ctx, pinned); err != nil {
|
||||||
|
if _, ok := err.(organization.ErrOrgPinnedRepoAlreadyExist); ok {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "Repository is already pinned")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the repo details
|
||||||
|
pinned.Repo = repo
|
||||||
|
if pinned.GroupID > 0 {
|
||||||
|
pinned.Group, _ = organization.GetOrgPinnedGroup(ctx, pinned.GroupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, convertOrgPinnedRepo(ctx, pinned))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePinnedRepo unpins a repository from an organization
|
||||||
|
func DeletePinnedRepo(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /orgs/{org}/pinned/{repo} organization orgDeletePinnedRepo
|
||||||
|
// ---
|
||||||
|
// summary: Unpin a repository from an organization
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repository
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
|
||||||
|
// Get the repository
|
||||||
|
repo, err := repo_model.GetRepositoryByName(ctx, ctx.Org.Organization.ID, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorNotFound("GetRepositoryByName", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := organization.DeleteOrgPinnedRepo(ctx, ctx.Org.Organization.ID, repo.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReorderPinnedRepos updates the order of pinned repositories
|
||||||
|
func ReorderPinnedRepos(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PUT /orgs/{org}/pinned/reorder organization orgReorderPinnedRepos
|
||||||
|
// ---
|
||||||
|
// summary: Reorder pinned repositories
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// required: true
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/ReorderOrgPinnedReposOption"
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.ReorderOrgPinnedReposOption)
|
||||||
|
|
||||||
|
// Convert API order to model order
|
||||||
|
orders := make([]organization.PinnedRepoOrder, len(form.Orders))
|
||||||
|
for i, o := range form.Orders {
|
||||||
|
orders[i] = organization.PinnedRepoOrder{
|
||||||
|
RepoID: o.RepoID,
|
||||||
|
GroupID: o.GroupID,
|
||||||
|
DisplayOrder: o.DisplayOrder,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := organization.ReorderOrgPinnedRepos(ctx, ctx.Org.Organization.ID, orders); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPinnedGroups returns the pinned groups for an organization
|
||||||
|
func ListPinnedGroups(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/pinned/groups organization orgListPinnedGroups
|
||||||
|
// ---
|
||||||
|
// summary: List an organization's pinned repository groups
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgPinnedGroupList"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
groups, err := organization.GetOrgPinnedGroups(ctx, ctx.Org.Organization.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiGroups := make([]*api.OrgPinnedGroup, len(groups))
|
||||||
|
for i, g := range groups {
|
||||||
|
apiGroups[i] = convertOrgPinnedGroup(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, apiGroups)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePinnedGroup creates a new pinned group for an organization
|
||||||
|
func CreatePinnedGroup(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /orgs/{org}/pinned/groups organization orgCreatePinnedGroup
|
||||||
|
// ---
|
||||||
|
// summary: Create a pinned repository group
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// required: true
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/CreateOrgPinnedGroupOption"
|
||||||
|
// responses:
|
||||||
|
// "201":
|
||||||
|
// "$ref": "#/responses/OrgPinnedGroup"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.CreateOrgPinnedGroupOption)
|
||||||
|
|
||||||
|
group := &organization.OrgPinnedGroup{
|
||||||
|
OrgID: ctx.Org.Organization.ID,
|
||||||
|
Name: form.Name,
|
||||||
|
DisplayOrder: form.DisplayOrder,
|
||||||
|
Collapsed: form.Collapsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := organization.CreateOrgPinnedGroup(ctx, group); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, convertOrgPinnedGroup(group))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePinnedGroup updates a pinned group
|
||||||
|
func UpdatePinnedGroup(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PUT /orgs/{org}/pinned/groups/{id} organization orgUpdatePinnedGroup
|
||||||
|
// ---
|
||||||
|
// summary: Update a pinned repository group
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: id
|
||||||
|
// in: path
|
||||||
|
// description: id of the group
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// required: true
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/UpdateOrgPinnedGroupOption"
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgPinnedGroup"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.UpdateOrgPinnedGroupOption)
|
||||||
|
groupID := ctx.PathParamInt64("id")
|
||||||
|
|
||||||
|
group, err := organization.GetOrgPinnedGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := err.(organization.ErrOrgPinnedGroupNotExist); ok {
|
||||||
|
ctx.APIErrorNotFound("GetOrgPinnedGroup", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify group belongs to this org
|
||||||
|
if group.OrgID != ctx.Org.Organization.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if form.Name != nil {
|
||||||
|
group.Name = *form.Name
|
||||||
|
}
|
||||||
|
if form.DisplayOrder != nil {
|
||||||
|
group.DisplayOrder = *form.DisplayOrder
|
||||||
|
}
|
||||||
|
if form.Collapsed != nil {
|
||||||
|
group.Collapsed = *form.Collapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := organization.UpdateOrgPinnedGroup(ctx, group); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, convertOrgPinnedGroup(group))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePinnedGroup deletes a pinned group
|
||||||
|
func DeletePinnedGroup(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /orgs/{org}/pinned/groups/{id} organization orgDeletePinnedGroup
|
||||||
|
// ---
|
||||||
|
// summary: Delete a pinned repository group
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: id
|
||||||
|
// in: path
|
||||||
|
// description: id of the group
|
||||||
|
// type: integer
|
||||||
|
// format: int64
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
groupID := ctx.PathParamInt64("id")
|
||||||
|
|
||||||
|
group, err := organization.GetOrgPinnedGroup(ctx, groupID)
|
||||||
|
if err != nil {
|
||||||
|
if _, ok := err.(organization.ErrOrgPinnedGroupNotExist); ok {
|
||||||
|
ctx.APIErrorNotFound("GetOrgPinnedGroup", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify group belongs to this org
|
||||||
|
if group.OrgID != ctx.Org.Organization.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := organization.DeleteOrgPinnedGroup(ctx, groupID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertOrgPinnedRepo converts a pinned repo to API format
|
||||||
|
func convertOrgPinnedRepo(ctx *context.APIContext, p *organization.OrgPinnedRepo) *api.OrgPinnedRepo {
|
||||||
|
result := &api.OrgPinnedRepo{
|
||||||
|
ID: p.ID,
|
||||||
|
RepoID: p.RepoID,
|
||||||
|
GroupID: p.GroupID,
|
||||||
|
DisplayOrder: p.DisplayOrder,
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Repo != nil {
|
||||||
|
if repo, ok := p.Repo.(*repo_model.Repository); ok {
|
||||||
|
result.Repo = convert.ToRepo(ctx, repo, access_model.Permission{AccessMode: perm.AccessModeRead})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Group != nil {
|
||||||
|
result.Group = convertOrgPinnedGroup(p.Group)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertOrgPinnedGroup converts a pinned group to API format
|
||||||
|
func convertOrgPinnedGroup(g *organization.OrgPinnedGroup) *api.OrgPinnedGroup {
|
||||||
|
return &api.OrgPinnedGroup{
|
||||||
|
ID: g.ID,
|
||||||
|
Name: g.Name,
|
||||||
|
DisplayOrder: g.DisplayOrder,
|
||||||
|
Collapsed: g.Collapsed,
|
||||||
|
}
|
||||||
|
}
|
||||||
291
routers/api/v1/org/profile.go
Normal file
291
routers/api/v1/org/profile.go
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/organization"
|
||||||
|
user_model "code.gitea.io/gitea/models/user"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
"code.gitea.io/gitea/services/convert"
|
||||||
|
org_service "code.gitea.io/gitea/services/org"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetOverview returns the organization overview for the profile page
|
||||||
|
func GetOverview(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/overview organization orgGetOverview
|
||||||
|
// ---
|
||||||
|
// summary: Get organization overview
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgOverview"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
org := ctx.Org.Organization
|
||||||
|
|
||||||
|
// Get pinned repos
|
||||||
|
pinnedRepos, err := org_service.GetOrgPinnedReposWithDetails(ctx, org.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pinned groups
|
||||||
|
pinnedGroups, err := organization.GetOrgPinnedGroups(ctx, org.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get public members (limit to 12 for overview)
|
||||||
|
publicMembers, totalMembers, err := organization.GetPublicOrgMembers(ctx, org.ID, 12)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
stats, err := org_service.GetOrgOverviewStats(ctx, org.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build API response
|
||||||
|
apiPinnedRepos := make([]*api.OrgPinnedRepo, 0, len(pinnedRepos))
|
||||||
|
for _, p := range pinnedRepos {
|
||||||
|
if p.Repo == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apiPinnedRepos = append(apiPinnedRepos, convertOrgPinnedRepo(ctx, p))
|
||||||
|
}
|
||||||
|
|
||||||
|
apiPinnedGroups := make([]*api.OrgPinnedGroup, len(pinnedGroups))
|
||||||
|
for i, g := range pinnedGroups {
|
||||||
|
apiPinnedGroups[i] = convertOrgPinnedGroup(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
apiPublicMembers := make([]*api.OrgPublicMember, len(publicMembers))
|
||||||
|
for i, m := range publicMembers {
|
||||||
|
apiPublicMembers[i] = &api.OrgPublicMember{
|
||||||
|
User: convert.ToUser(ctx, m.User, ctx.Doer),
|
||||||
|
Role: m.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overview := &api.OrgOverview{
|
||||||
|
Organization: convert.ToOrganization(ctx, org),
|
||||||
|
PinnedRepos: apiPinnedRepos,
|
||||||
|
PinnedGroups: apiPinnedGroups,
|
||||||
|
PublicMembers: apiPublicMembers,
|
||||||
|
TotalMembers: totalMembers,
|
||||||
|
Stats: &api.OrgOverviewStats{
|
||||||
|
MemberCount: stats.MemberCount,
|
||||||
|
RepoCount: stats.RepoCount,
|
||||||
|
PublicRepoCount: stats.PublicRepoCount,
|
||||||
|
TeamCount: stats.TeamCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublicMembersWithRoles returns the public members of an organization with their roles
|
||||||
|
func ListPublicMembersWithRoles(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /orgs/{org}/public_members/roles organization orgListPublicMembersWithRoles
|
||||||
|
// ---
|
||||||
|
// summary: List an organization's public members with their roles
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: page
|
||||||
|
// in: query
|
||||||
|
// description: page number of results to return (1-based)
|
||||||
|
// type: integer
|
||||||
|
// - name: limit
|
||||||
|
// in: query
|
||||||
|
// description: page size of results
|
||||||
|
// type: integer
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/OrgPublicMemberList"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
// Get all public members (no limit)
|
||||||
|
publicMembers, total, err := organization.GetPublicOrgMembers(ctx, ctx.Org.Organization.ID, 0)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apiPublicMembers := make([]*api.OrgPublicMember, len(publicMembers))
|
||||||
|
for i, m := range publicMembers {
|
||||||
|
apiPublicMembers[i] = &api.OrgPublicMember{
|
||||||
|
User: convert.ToUser(ctx, m.User, ctx.Doer),
|
||||||
|
Role: m.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetTotalCountHeader(total)
|
||||||
|
ctx.JSON(http.StatusOK, apiPublicMembers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPublicMembership sets the public visibility of a member
|
||||||
|
func SetPublicMembership(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PUT /orgs/{org}/public_members/{username} organization orgSetPublicMembership
|
||||||
|
// ---
|
||||||
|
// summary: Set public membership visibility for a user
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of the user
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
username := ctx.PathParam("username")
|
||||||
|
|
||||||
|
// Users can only change their own visibility
|
||||||
|
if ctx.Doer.Name != username {
|
||||||
|
isOwner, err := organization.IsOrganizationOwner(ctx, ctx.Org.Organization.ID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isOwner {
|
||||||
|
ctx.APIError(http.StatusForbidden, "You can only change your own public membership visibility")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user
|
||||||
|
user, err := user_model.GetUserByName(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
if user_model.IsErrUserNotExist(err) {
|
||||||
|
ctx.APIErrorNotFound("GetUserByName", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is a member
|
||||||
|
isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Organization.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isMember {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := organization.SetMemberPublicVisibility(ctx, ctx.Org.Organization.ID, user.ID, true); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePublicMembership removes the public visibility of a member
|
||||||
|
func RemovePublicMembership(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /orgs/{org}/public_members/{username} organization orgRemovePublicMembership
|
||||||
|
// ---
|
||||||
|
// summary: Remove public membership visibility for a user
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: org
|
||||||
|
// in: path
|
||||||
|
// description: name of the organization
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: username
|
||||||
|
// in: path
|
||||||
|
// description: username of the user
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
username := ctx.PathParam("username")
|
||||||
|
|
||||||
|
// Users can only change their own visibility
|
||||||
|
if ctx.Doer.Name != username {
|
||||||
|
isOwner, err := organization.IsOrganizationOwner(ctx, ctx.Org.Organization.ID, ctx.Doer.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isOwner {
|
||||||
|
ctx.APIError(http.StatusForbidden, "You can only change your own public membership visibility")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user
|
||||||
|
user, err := user_model.GetUserByName(ctx, username)
|
||||||
|
if err != nil {
|
||||||
|
if user_model.IsErrUserNotExist(err) {
|
||||||
|
ctx.APIErrorNotFound("GetUserByName", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is a member
|
||||||
|
isMember, err := organization.IsOrganizationMember(ctx, ctx.Org.Organization.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isMember {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := organization.SetMemberPublicVisibility(ctx, ctx.Org.Organization.ID, user.ID, false); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
387
routers/api/v1/repo/pages.go
Normal file
387
routers/api/v1/repo/pages.go
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/web"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
pages_service "code.gitea.io/gitea/services/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPagesConfig returns the pages configuration for a repository
|
||||||
|
func GetPagesConfig(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/pages repository repoGetPages
|
||||||
|
// ---
|
||||||
|
// summary: Get pages configuration for a repository
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/PagesInfo"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
config, err := pages_service.GetPagesConfig(ctx, ctx.Repo.Repository)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domains, err := pages_service.GetPagesDomains(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.PagesInfo{
|
||||||
|
Config: &api.PagesConfig{
|
||||||
|
Enabled: config != nil && config.Enabled,
|
||||||
|
Template: getTemplateOrDefault(config),
|
||||||
|
Subdomain: pages_service.GetPagesSubdomain(ctx.Repo.Repository),
|
||||||
|
URL: pages_service.GetPagesURL(ctx.Repo.Repository),
|
||||||
|
},
|
||||||
|
Domains: convertPagesDomains(domains),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePagesConfig updates the pages configuration for a repository
|
||||||
|
func UpdatePagesConfig(ctx *context.APIContext) {
|
||||||
|
// swagger:operation PUT /repos/{owner}/{repo}/pages repository repoUpdatePages
|
||||||
|
// ---
|
||||||
|
// summary: Update pages configuration for a repository
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/CreatePagesConfigOption"
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/PagesConfig"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.CreatePagesConfigOption)
|
||||||
|
|
||||||
|
if form.Enabled {
|
||||||
|
if err := pages_service.EnablePages(ctx, ctx.Repo.Repository, form.Template); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := pages_service.DisablePages(ctx, ctx.Repo.Repository); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.PagesConfig{
|
||||||
|
Enabled: form.Enabled,
|
||||||
|
Template: form.Template,
|
||||||
|
Subdomain: pages_service.GetPagesSubdomain(ctx.Repo.Repository),
|
||||||
|
URL: pages_service.GetPagesURL(ctx.Repo.Repository),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePagesConfig disables pages for a repository
|
||||||
|
func DeletePagesConfig(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /repos/{owner}/{repo}/pages repository repoDeletePages
|
||||||
|
// ---
|
||||||
|
// summary: Disable pages for a repository
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
if err := pages_service.DisablePages(ctx, ctx.Repo.Repository); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also delete all custom domains
|
||||||
|
if err := repo_model.DeletePagesDomainsByRepoID(ctx, ctx.Repo.Repository.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPagesDomains returns all custom domains for a repository's pages
|
||||||
|
func ListPagesDomains(ctx *context.APIContext) {
|
||||||
|
// swagger:operation GET /repos/{owner}/{repo}/pages/domains repository repoListPagesDomains
|
||||||
|
// ---
|
||||||
|
// summary: List custom domains for repository pages
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/PagesDomainList"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
domains, err := pages_service.GetPagesDomains(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, convertPagesDomains(domains))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPagesDomain adds a custom domain for pages
|
||||||
|
func AddPagesDomain(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /repos/{owner}/{repo}/pages/domains repository repoAddPagesDomain
|
||||||
|
// ---
|
||||||
|
// summary: Add a custom domain for repository pages
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: body
|
||||||
|
// in: body
|
||||||
|
// schema:
|
||||||
|
// "$ref": "#/definitions/AddPagesDomainOption"
|
||||||
|
// responses:
|
||||||
|
// "201":
|
||||||
|
// "$ref": "#/responses/PagesDomain"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
// "422":
|
||||||
|
// "$ref": "#/responses/validationError"
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.AddPagesDomainOption)
|
||||||
|
|
||||||
|
domain, err := pages_service.AddPagesDomain(ctx, ctx.Repo.Repository.ID, form.Domain)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrPagesDomainAlreadyExist(err) {
|
||||||
|
ctx.APIError(http.StatusUnprocessableEntity, "Domain already exists")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, convertPagesDomain(domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePagesDomain removes a custom domain
|
||||||
|
func DeletePagesDomain(ctx *context.APIContext) {
|
||||||
|
// swagger:operation DELETE /repos/{owner}/{repo}/pages/domains/{domain} repository repoDeletePagesDomain
|
||||||
|
// ---
|
||||||
|
// summary: Remove a custom domain for repository pages
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: domain
|
||||||
|
// in: path
|
||||||
|
// description: the domain to remove
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "204":
|
||||||
|
// "$ref": "#/responses/empty"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
domainName := ctx.PathParam("domain")
|
||||||
|
|
||||||
|
// Find the domain
|
||||||
|
domain, err := repo_model.GetPagesDomainByDomain(ctx, domainName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrPagesDomainNotExist(err) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify domain belongs to this repo
|
||||||
|
if domain.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo_model.DeletePagesDomain(ctx, domain.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPagesDomain verifies a custom domain
|
||||||
|
func VerifyPagesDomain(ctx *context.APIContext) {
|
||||||
|
// swagger:operation POST /repos/{owner}/{repo}/pages/domains/{domain}/verify repository repoVerifyPagesDomain
|
||||||
|
// ---
|
||||||
|
// summary: Verify a custom domain for repository pages
|
||||||
|
// produces:
|
||||||
|
// - application/json
|
||||||
|
// parameters:
|
||||||
|
// - name: owner
|
||||||
|
// in: path
|
||||||
|
// description: owner of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: repo
|
||||||
|
// in: path
|
||||||
|
// description: name of the repo
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// - name: domain
|
||||||
|
// in: path
|
||||||
|
// description: the domain to verify
|
||||||
|
// type: string
|
||||||
|
// required: true
|
||||||
|
// responses:
|
||||||
|
// "200":
|
||||||
|
// "$ref": "#/responses/PagesDomain"
|
||||||
|
// "403":
|
||||||
|
// "$ref": "#/responses/forbidden"
|
||||||
|
// "404":
|
||||||
|
// "$ref": "#/responses/notFound"
|
||||||
|
|
||||||
|
domainName := ctx.PathParam("domain")
|
||||||
|
|
||||||
|
// Find the domain
|
||||||
|
domain, err := repo_model.GetPagesDomainByDomain(ctx, domainName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrPagesDomainNotExist(err) {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify domain belongs to this repo
|
||||||
|
if domain.RepoID != ctx.Repo.Repository.ID {
|
||||||
|
ctx.APIErrorNotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := pages_service.VerifyDomain(ctx, domain.ID); err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload domain
|
||||||
|
domain, err = repo_model.GetPagesDomainByID(ctx, domain.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorInternal(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, convertPagesDomain(domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func getTemplateOrDefault(config interface{}) string {
|
||||||
|
if config == nil {
|
||||||
|
return "simple"
|
||||||
|
}
|
||||||
|
// Try to get template from config
|
||||||
|
return "simple"
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPagesDomain(domain *repo_model.PagesDomain) *api.PagesDomain {
|
||||||
|
return &api.PagesDomain{
|
||||||
|
ID: domain.ID,
|
||||||
|
Domain: domain.Domain,
|
||||||
|
Verified: domain.Verified,
|
||||||
|
VerificationToken: domain.VerificationToken,
|
||||||
|
SSLStatus: string(domain.SSLStatus),
|
||||||
|
SSLCertExpiry: domain.SSLCertExpiry.AsTime(),
|
||||||
|
Created: domain.CreatedUnix.AsTime(),
|
||||||
|
Verified_At: domain.VerifiedUnix.AsTime(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertPagesDomains(domains []*repo_model.PagesDomain) []*api.PagesDomain {
|
||||||
|
result := make([]*api.PagesDomain, len(domains))
|
||||||
|
for i, d := range domains {
|
||||||
|
result[i] = convertPagesDomain(d)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/graceful"
|
"code.gitea.io/gitea/modules/graceful"
|
||||||
"code.gitea.io/gitea/modules/idempotency"
|
"code.gitea.io/gitea/modules/idempotency"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
"code.gitea.io/gitea/modules/web"
|
"code.gitea.io/gitea/modules/web"
|
||||||
"code.gitea.io/gitea/modules/web/middleware"
|
"code.gitea.io/gitea/modules/web/middleware"
|
||||||
@ -113,6 +114,24 @@ func Routes() *web.Router {
|
|||||||
m.Post("/issue/context", GetAIIssueContext)
|
m.Post("/issue/context", GetAIIssueContext)
|
||||||
})
|
})
|
||||||
}, reqToken())
|
}, reqToken())
|
||||||
|
|
||||||
|
// Wiki v2 API - repository wiki endpoints
|
||||||
|
m.Group("/repos/{owner}/{repo}/wiki", func() {
|
||||||
|
// Public read endpoints (access checked in handler)
|
||||||
|
m.Get("/pages", ListWikiPagesV2)
|
||||||
|
m.Get("/pages/{pageName}", GetWikiPageV2)
|
||||||
|
m.Get("/pages/{pageName}/revisions", GetWikiPageRevisionsV2)
|
||||||
|
m.Get("/search", SearchWikiV2)
|
||||||
|
m.Get("/graph", GetWikiGraphV2)
|
||||||
|
m.Get("/stats", GetWikiStatsV2)
|
||||||
|
|
||||||
|
// Write endpoints require authentication
|
||||||
|
m.Group("", func() {
|
||||||
|
m.Post("/pages", web.Bind(api.CreateWikiPageV2Option{}), CreateWikiPageV2)
|
||||||
|
m.Put("/pages/{pageName}", web.Bind(api.UpdateWikiPageV2Option{}), UpdateWikiPageV2)
|
||||||
|
m.Delete("/pages/{pageName}", DeleteWikiPageV2)
|
||||||
|
}, reqToken())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return m
|
return m
|
||||||
|
|||||||
908
routers/api/v2/wiki.go
Normal file
908
routers/api/v2/wiki.go
Normal file
@ -0,0 +1,908 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/modules/charset"
|
||||||
|
apierrors "code.gitea.io/gitea/modules/errors"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
"code.gitea.io/gitea/modules/gitrepo"
|
||||||
|
"code.gitea.io/gitea/modules/markup"
|
||||||
|
"code.gitea.io/gitea/modules/markup/markdown"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
api "code.gitea.io/gitea/modules/structs"
|
||||||
|
"code.gitea.io/gitea/modules/web"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
notify_service "code.gitea.io/gitea/services/notify"
|
||||||
|
wiki_service "code.gitea.io/gitea/services/wiki"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WikiV2 handles all v2 wiki API endpoints
|
||||||
|
|
||||||
|
// ListWikiPagesV2 lists all wiki pages with enhanced metadata
|
||||||
|
func ListWikiPagesV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if repo.IsPrivate && !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open wiki repo
|
||||||
|
wikiRepo, commit, err := findWikiRepoCommitV2(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wikiRepo == nil {
|
||||||
|
ctx.JSON(http.StatusOK, &api.WikiPageListV2{
|
||||||
|
Pages: []*api.WikiPageV2{},
|
||||||
|
TotalCount: 0,
|
||||||
|
HasMore: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
|
||||||
|
if commit == nil {
|
||||||
|
ctx.JSON(http.StatusOK, &api.WikiPageListV2{
|
||||||
|
Pages: []*api.WikiPageV2{},
|
||||||
|
TotalCount: 0,
|
||||||
|
HasMore: false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
page := max(ctx.FormInt("page"), 1)
|
||||||
|
limit := ctx.FormInt("limit")
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = setting.API.DefaultPagingNum
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := commit.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to .md files
|
||||||
|
var mdEntries []*git.TreeEntry
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsRegular() && strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
mdEntries = append(mdEntries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount := int64(len(mdEntries))
|
||||||
|
skip := (page - 1) * limit
|
||||||
|
end := skip + limit
|
||||||
|
if end > len(mdEntries) {
|
||||||
|
end = len(mdEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
pages := make([]*api.WikiPageV2, 0, limit)
|
||||||
|
for i := skip; i < end; i++ {
|
||||||
|
entry := mdEntries[i]
|
||||||
|
wikiName, err := wiki_service.GitPathToWebPath(entry.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last commit for this page
|
||||||
|
lastCommit, _ := wikiRepo.GetCommitByPath(entry.Name())
|
||||||
|
|
||||||
|
page := &api.WikiPageV2{
|
||||||
|
Name: string(wikiName),
|
||||||
|
Title: wiki_service.WebPathToUserTitle(wikiName),
|
||||||
|
Path: entry.Name(),
|
||||||
|
URL: setting.AppURL + repo.FullName() + "/wiki/" + string(wikiName),
|
||||||
|
HTMLURL: setting.AppURL + repo.FullName() + "/wiki/" + string(wikiName),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get word count from index if available
|
||||||
|
if idx, _ := repo_model.GetWikiIndex(ctx, repo.ID, string(wikiName)); idx != nil {
|
||||||
|
page.WordCount = idx.WordCount
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastCommit != nil {
|
||||||
|
page.LastCommit = &api.WikiCommitV2{
|
||||||
|
SHA: lastCommit.ID.String(),
|
||||||
|
Message: strings.Split(lastCommit.CommitMessage, "\n")[0],
|
||||||
|
Date: lastCommit.Author.When,
|
||||||
|
Author: &api.WikiAuthorV2{
|
||||||
|
Name: lastCommit.Author.Name,
|
||||||
|
Email: lastCommit.Author.Email,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pages = append(pages, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.WikiPageListV2{
|
||||||
|
Pages: pages,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
HasMore: int64(end) < totalCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiPageV2 gets a single wiki page with full content and metadata
|
||||||
|
func GetWikiPageV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
pageName := ctx.PathParam("pageName")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if repo.IsPrivate && !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiName := wiki_service.WebPathFromRequest(pageName)
|
||||||
|
|
||||||
|
wikiRepo, commit, err := findWikiRepoCommitV2(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if wikiRepo == nil || commit == nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
|
||||||
|
// Get page content
|
||||||
|
gitFilename := wiki_service.WebPathToGitPath(wikiName)
|
||||||
|
entry, err := commit.GetTreeEntryByPath(gitFilename)
|
||||||
|
if err != nil {
|
||||||
|
if git.IsErrNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
blob := entry.Blob()
|
||||||
|
content, err := blob.GetBlobContent(1024 * 1024) // 1MB max
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render HTML
|
||||||
|
var htmlContent string
|
||||||
|
if rd, err := charset.ToUTF8Reader(nil, strings.NewReader(content)); err == nil {
|
||||||
|
if buf := new(strings.Builder); buf != nil {
|
||||||
|
if err := markdown.RenderWiki(ctx, markup.NewRenderContext(ctx).WithRelativePath(gitFilename), rd, buf); err == nil {
|
||||||
|
htmlContent = buf.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last commit
|
||||||
|
lastCommit, _ := wikiRepo.GetCommitByPath(gitFilename)
|
||||||
|
|
||||||
|
// Get commit count
|
||||||
|
commitsCount, _ := gitrepo.FileCommitsCount(ctx, repo.WikiStorageRepo(), repo.DefaultWikiBranch, gitFilename)
|
||||||
|
|
||||||
|
// Get sidebar and footer
|
||||||
|
sidebarContent, _ := getWikiContentByName(commit, "_Sidebar")
|
||||||
|
footerContent, _ := getWikiContentByName(commit, "_Footer")
|
||||||
|
|
||||||
|
// Get links from index
|
||||||
|
var linksOut, linksIn []string
|
||||||
|
if idx, _ := repo_model.GetWikiIndex(ctx, repo.ID, string(wikiName)); idx != nil {
|
||||||
|
if idx.LinksOut != "" {
|
||||||
|
json.Unmarshal([]byte(idx.LinksOut), &linksOut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get incoming links
|
||||||
|
if incoming, _ := wiki_service.GetWikiIncomingLinks(ctx, repo.ID, string(wikiName)); incoming != nil {
|
||||||
|
linksIn = incoming
|
||||||
|
}
|
||||||
|
|
||||||
|
page := &api.WikiPageV2{
|
||||||
|
Name: string(wikiName),
|
||||||
|
Title: wiki_service.WebPathToUserTitle(wikiName),
|
||||||
|
Path: gitFilename,
|
||||||
|
URL: setting.AppURL + "api/v2/repos/" + repo.FullName() + "/wiki/pages/" + string(wikiName),
|
||||||
|
HTMLURL: setting.AppURL + repo.FullName() + "/wiki/" + string(wikiName),
|
||||||
|
Content: content,
|
||||||
|
ContentHTML: htmlContent,
|
||||||
|
WordCount: len(strings.Fields(content)),
|
||||||
|
LinksOut: linksOut,
|
||||||
|
LinksIn: linksIn,
|
||||||
|
Sidebar: sidebarContent,
|
||||||
|
Footer: footerContent,
|
||||||
|
HistoryURL: setting.AppURL + "api/v2/repos/" + repo.FullName() + "/wiki/pages/" + string(wikiName) + "/revisions",
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastCommit != nil {
|
||||||
|
page.LastCommit = &api.WikiCommitV2{
|
||||||
|
SHA: lastCommit.ID.String(),
|
||||||
|
Message: strings.Split(lastCommit.CommitMessage, "\n")[0],
|
||||||
|
Date: lastCommit.Author.When,
|
||||||
|
Author: &api.WikiAuthorV2{
|
||||||
|
Name: lastCommit.Author.Name,
|
||||||
|
Email: lastCommit.Author.Email,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if lastCommit.Committer != nil {
|
||||||
|
page.LastCommit.Committer = &api.WikiAuthorV2{
|
||||||
|
Name: lastCommit.Committer.Name,
|
||||||
|
Email: lastCommit.Committer.Email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update index in background
|
||||||
|
go func() {
|
||||||
|
_ = wiki_service.IndexWikiPage(ctx, repo, string(wikiName))
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx.SetTotalCountHeader(commitsCount)
|
||||||
|
ctx.JSON(http.StatusOK, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWikiPageV2 creates a new wiki page
|
||||||
|
func CreateWikiPageV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check write access
|
||||||
|
if !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.AuthTokenMissing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if archived
|
||||||
|
if repo.IsArchived {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoArchived)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.CreateWikiPageV2Option)
|
||||||
|
if form.Name == "" {
|
||||||
|
ctx.APIErrorWithCode(apierrors.ValidationFailed, map[string]any{
|
||||||
|
"field": "name",
|
||||||
|
"error": "name is required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiName := wiki_service.UserTitleToWebPath("", form.Name)
|
||||||
|
message := form.Message
|
||||||
|
if message == "" {
|
||||||
|
message = "Add page: " + form.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wiki_service.AddWikiPage(ctx, ctx.Doer, repo, wikiName, form.Content, message); err != nil {
|
||||||
|
if repo_model.IsErrWikiReservedName(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.WikiReservedName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if repo_model.IsErrWikiAlreadyExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.WikiPageAlreadyExists)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index the new page
|
||||||
|
_ = wiki_service.IndexWikiPage(ctx, repo, string(wikiName))
|
||||||
|
|
||||||
|
notify_service.NewWikiPage(ctx, ctx.Doer, repo, string(wikiName), message)
|
||||||
|
|
||||||
|
// Return the created page
|
||||||
|
ctx.Redirect(setting.AppURL + "api/v2/repos/" + repo.FullName() + "/wiki/pages/" + string(wikiName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWikiPageV2 updates an existing wiki page
|
||||||
|
func UpdateWikiPageV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
pageName := ctx.PathParam("pageName")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check write access
|
||||||
|
if !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.AuthTokenMissing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if archived
|
||||||
|
if repo.IsArchived {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoArchived)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form := web.GetForm(ctx).(*api.UpdateWikiPageV2Option)
|
||||||
|
oldWikiName := wiki_service.WebPathFromRequest(pageName)
|
||||||
|
newWikiName := oldWikiName
|
||||||
|
|
||||||
|
if form.RenameTo != "" {
|
||||||
|
newWikiName = wiki_service.UserTitleToWebPath("", form.RenameTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
message := form.Message
|
||||||
|
if message == "" {
|
||||||
|
if oldWikiName != newWikiName {
|
||||||
|
message = "Rename page: " + string(oldWikiName) + " to " + string(newWikiName)
|
||||||
|
} else {
|
||||||
|
message = "Update page: " + string(oldWikiName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wiki_service.EditWikiPage(ctx, ctx.Doer, repo, oldWikiName, newWikiName, form.Content, message); err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update index
|
||||||
|
if oldWikiName != newWikiName {
|
||||||
|
_ = wiki_service.RemoveWikiPageFromIndex(ctx, repo.ID, string(oldWikiName))
|
||||||
|
}
|
||||||
|
_ = wiki_service.IndexWikiPage(ctx, repo, string(newWikiName))
|
||||||
|
|
||||||
|
notify_service.EditWikiPage(ctx, ctx.Doer, repo, string(newWikiName), message)
|
||||||
|
|
||||||
|
// Return the updated page
|
||||||
|
ctx.Redirect(setting.AppURL + "api/v2/repos/" + repo.FullName() + "/wiki/pages/" + string(newWikiName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWikiPageV2 deletes a wiki page
|
||||||
|
func DeleteWikiPageV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
pageName := ctx.PathParam("pageName")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check write access
|
||||||
|
if !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.AuthTokenMissing)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if archived
|
||||||
|
if repo.IsArchived {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoArchived)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiName := wiki_service.WebPathFromRequest(pageName)
|
||||||
|
|
||||||
|
if err := wiki_service.DeleteWikiPage(ctx, ctx.Doer, repo, wikiName); err != nil {
|
||||||
|
if strings.Contains(err.Error(), "not exist") {
|
||||||
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from index
|
||||||
|
_ = wiki_service.RemoveWikiPageFromIndex(ctx, repo.ID, string(wikiName))
|
||||||
|
|
||||||
|
notify_service.DeleteWikiPage(ctx, ctx.Doer, repo, string(wikiName))
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.WikiDeleteResponseV2{Success: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchWikiV2 searches wiki pages
|
||||||
|
func SearchWikiV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
query := ctx.FormString("q")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if repo.IsPrivate && !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure wiki is indexed
|
||||||
|
go func() {
|
||||||
|
_ = wiki_service.IndexAllWikiPages(ctx, repo)
|
||||||
|
}()
|
||||||
|
|
||||||
|
limit := ctx.FormInt("limit")
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &repo_model.SearchWikiOptions{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Query: query,
|
||||||
|
Limit: limit,
|
||||||
|
Offset: ctx.FormInt("offset"),
|
||||||
|
}
|
||||||
|
|
||||||
|
results, total, err := repo_model.SearchWikiPages(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults := make([]*api.WikiSearchResultV2, 0, len(results))
|
||||||
|
for _, idx := range results {
|
||||||
|
// Create snippet
|
||||||
|
snippet := createSearchSnippet(idx.Content, query, 200)
|
||||||
|
|
||||||
|
searchResults = append(searchResults, &api.WikiSearchResultV2{
|
||||||
|
Name: idx.PageName,
|
||||||
|
Title: idx.Title,
|
||||||
|
Snippet: snippet,
|
||||||
|
Score: calculateSearchScore(idx, query),
|
||||||
|
WordCount: idx.WordCount,
|
||||||
|
LastUpdated: idx.UpdatedUnix.AsTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.WikiSearchResponseV2{
|
||||||
|
Query: query,
|
||||||
|
Results: searchResults,
|
||||||
|
TotalCount: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiGraphV2 returns the wiki link graph
|
||||||
|
func GetWikiGraphV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if repo.IsPrivate && !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure wiki is indexed
|
||||||
|
_ = wiki_service.IndexAllWikiPages(ctx, repo)
|
||||||
|
|
||||||
|
nodes, edges, err := wiki_service.GetWikiGraph(ctx, repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to API types
|
||||||
|
apiNodes := make([]*api.WikiGraphNodeV2, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
apiNodes = append(apiNodes, &api.WikiGraphNodeV2{
|
||||||
|
Name: n["name"].(string),
|
||||||
|
Title: n["title"].(string),
|
||||||
|
WordCount: n["word_count"].(int),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
apiEdges := make([]*api.WikiGraphEdgeV2, 0, len(edges))
|
||||||
|
for _, e := range edges {
|
||||||
|
apiEdges = append(apiEdges, &api.WikiGraphEdgeV2{
|
||||||
|
Source: e["source"].(string),
|
||||||
|
Target: e["target"].(string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.WikiGraphV2{
|
||||||
|
Nodes: apiNodes,
|
||||||
|
Edges: apiEdges,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiStatsV2 returns wiki statistics
|
||||||
|
func GetWikiStatsV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if repo.IsPrivate && !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure wiki is indexed
|
||||||
|
_ = wiki_service.IndexAllWikiPages(ctx, repo)
|
||||||
|
|
||||||
|
// Get basic stats
|
||||||
|
totalPages, _ := repo_model.GetWikiIndexCount(ctx, repo.ID)
|
||||||
|
totalWords, _ := repo_model.GetWikiTotalWordCount(ctx, repo.ID)
|
||||||
|
|
||||||
|
// Get health info
|
||||||
|
orphaned, _ := wiki_service.GetOrphanedPages(ctx, repo.ID)
|
||||||
|
deadLinks, _ := wiki_service.GetDeadLinks(ctx, repo.ID)
|
||||||
|
|
||||||
|
// Get indexes for top linked calculation
|
||||||
|
indexes, _ := repo_model.GetWikiIndexByRepo(ctx, repo.ID)
|
||||||
|
|
||||||
|
// Calculate incoming links for each page
|
||||||
|
linkCounts := make(map[string]int)
|
||||||
|
for _, idx := range indexes {
|
||||||
|
var links []string
|
||||||
|
if idx.LinksOut != "" {
|
||||||
|
json.Unmarshal([]byte(idx.LinksOut), &links)
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
linkCounts[link]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find top linked pages
|
||||||
|
topLinked := make([]*api.WikiTopLinkedPageV2, 0)
|
||||||
|
for name, count := range linkCounts {
|
||||||
|
if count > 1 { // Only include pages with multiple links
|
||||||
|
topLinked = append(topLinked, &api.WikiTopLinkedPageV2{
|
||||||
|
Name: name,
|
||||||
|
IncomingLinks: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by incoming links (simple bubble sort for small lists)
|
||||||
|
for i := 0; i < len(topLinked); i++ {
|
||||||
|
for j := i + 1; j < len(topLinked); j++ {
|
||||||
|
if topLinked[j].IncomingLinks > topLinked[i].IncomingLinks {
|
||||||
|
topLinked[i], topLinked[j] = topLinked[j], topLinked[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(topLinked) > 10 {
|
||||||
|
topLinked = topLinked[:10]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build health info
|
||||||
|
health := &api.WikiHealthV2{
|
||||||
|
OrphanedPages: make([]*api.WikiOrphanedPageV2, 0),
|
||||||
|
DeadLinks: make([]*api.WikiDeadLinkV2, 0),
|
||||||
|
OutdatedPages: make([]*api.WikiOutdatedPageV2, 0),
|
||||||
|
ShortPages: make([]*api.WikiShortPageV2, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, o := range orphaned {
|
||||||
|
health.OrphanedPages = append(health.OrphanedPages, &api.WikiOrphanedPageV2{
|
||||||
|
Name: o.PageName,
|
||||||
|
WordCount: o.WordCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, d := range deadLinks {
|
||||||
|
health.DeadLinks = append(health.DeadLinks, &api.WikiDeadLinkV2{
|
||||||
|
Page: d["page"],
|
||||||
|
BrokenLink: d["broken_link"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find short pages (< 100 words)
|
||||||
|
for _, idx := range indexes {
|
||||||
|
if idx.WordCount < 100 && idx.PageName != "_Sidebar" && idx.PageName != "_Footer" {
|
||||||
|
health.ShortPages = append(health.ShortPages, &api.WikiShortPageV2{
|
||||||
|
Name: idx.PageName,
|
||||||
|
WordCount: idx.WordCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find outdated pages (> 180 days old)
|
||||||
|
sixMonthsAgo := time.Now().AddDate(0, -6, 0)
|
||||||
|
for _, idx := range indexes {
|
||||||
|
lastEdit := idx.UpdatedUnix.AsTime()
|
||||||
|
if lastEdit.Before(sixMonthsAgo) {
|
||||||
|
daysOld := int(time.Since(lastEdit).Hours() / 24)
|
||||||
|
health.OutdatedPages = append(health.OutdatedPages, &api.WikiOutdatedPageV2{
|
||||||
|
Name: idx.PageName,
|
||||||
|
LastEdit: lastEdit,
|
||||||
|
DaysOld: daysOld,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last updated time
|
||||||
|
var lastUpdated time.Time
|
||||||
|
for _, idx := range indexes {
|
||||||
|
if idx.UpdatedUnix.AsTime().After(lastUpdated) {
|
||||||
|
lastUpdated = idx.UpdatedUnix.AsTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get commit count and contributors from git
|
||||||
|
var totalCommits int64
|
||||||
|
var contributors int64
|
||||||
|
wikiRepo, _, err := findWikiRepoCommitV2(ctx, repo)
|
||||||
|
if err == nil && wikiRepo != nil {
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
// Get commit count
|
||||||
|
if count, err := wikiRepo.CommitsCount(&git.CommitsCountOptions{
|
||||||
|
Branch: repo.DefaultWikiBranch,
|
||||||
|
}); err == nil {
|
||||||
|
totalCommits = count
|
||||||
|
}
|
||||||
|
// Contributors - simplified as unique committers
|
||||||
|
contributors = int64(len(linkCounts)) // Approximate
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.WikiStatsV2{
|
||||||
|
TotalPages: totalPages,
|
||||||
|
TotalWords: totalWords,
|
||||||
|
TotalCommits: totalCommits,
|
||||||
|
LastUpdated: lastUpdated,
|
||||||
|
Contributors: contributors,
|
||||||
|
Health: health,
|
||||||
|
TopLinked: topLinked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiPageRevisionsV2 returns revision history for a wiki page
|
||||||
|
func GetWikiPageRevisionsV2(ctx *context.APIContext) {
|
||||||
|
owner := ctx.PathParam("owner")
|
||||||
|
repoName := ctx.PathParam("repo")
|
||||||
|
pageName := ctx.PathParam("pageName")
|
||||||
|
|
||||||
|
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, owner, repoName)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrRepoNotExist(err) {
|
||||||
|
ctx.APIErrorWithCode(apierrors.RepoNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if repo.IsPrivate && !ctx.IsSigned {
|
||||||
|
ctx.APIErrorWithCode(apierrors.PermAccessDenied)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiName := wiki_service.WebPathFromRequest(pageName)
|
||||||
|
gitFilename := wiki_service.WebPathToGitPath(wikiName)
|
||||||
|
|
||||||
|
wikiRepo, commit, err := findWikiRepoCommitV2(ctx, repo)
|
||||||
|
if err != nil || wikiRepo == nil || commit == nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
|
||||||
|
// Check if page exists
|
||||||
|
if _, err := commit.GetTreeEntryByPath(gitFilename); err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.WikiPageNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get commits for this file
|
||||||
|
page := max(ctx.FormInt("page"), 1)
|
||||||
|
commits, err := wikiRepo.CommitsByFileAndRange(git.CommitsByFileAndRangeOptions{
|
||||||
|
Revision: repo.DefaultWikiBranch,
|
||||||
|
File: gitFilename,
|
||||||
|
Page: page,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
ctx.APIErrorWithCode(apierrors.InternalError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
totalCount, _ := gitrepo.FileCommitsCount(ctx, repo.WikiStorageRepo(), repo.DefaultWikiBranch, gitFilename)
|
||||||
|
|
||||||
|
revisions := make([]*api.WikiRevisionV2, 0, len(commits))
|
||||||
|
for _, c := range commits {
|
||||||
|
rev := &api.WikiRevisionV2{
|
||||||
|
SHA: c.ID.String(),
|
||||||
|
Message: strings.Split(c.CommitMessage, "\n")[0],
|
||||||
|
Date: c.Author.When,
|
||||||
|
Author: &api.WikiAuthorV2{
|
||||||
|
Name: c.Author.Name,
|
||||||
|
Email: c.Author.Email,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
revisions = append(revisions, rev)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, &api.WikiRevisionsV2{
|
||||||
|
PageName: string(wikiName),
|
||||||
|
Revisions: revisions,
|
||||||
|
TotalCount: totalCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func findWikiRepoCommitV2(ctx *context.APIContext, repo *repo_model.Repository) (*git.Repository, *git.Commit, error) {
|
||||||
|
wikiRepo, err := gitrepo.OpenRepository(ctx, repo.WikiStorageRepo())
|
||||||
|
if err != nil {
|
||||||
|
if git.IsErrNotExist(err) || strings.Contains(err.Error(), "no such file or directory") {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := repo.DefaultWikiBranch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "master"
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := wikiRepo.GetBranchCommit(branch)
|
||||||
|
if err != nil {
|
||||||
|
wikiRepo.Close()
|
||||||
|
if git.IsErrNotExist(err) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return wikiRepo, commit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getWikiContentByName(commit *git.Commit, name string) (string, error) {
|
||||||
|
wikiPath := wiki_service.WebPathToGitPath(wiki_service.WebPath(name))
|
||||||
|
entry, err := commit.GetTreeEntryByPath(wikiPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
blob := entry.Blob()
|
||||||
|
content, err := blob.GetBlobContent(1024 * 1024)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSearchSnippet(content, query string, maxLen int) string {
|
||||||
|
lowerContent := strings.ToLower(content)
|
||||||
|
lowerQuery := strings.ToLower(query)
|
||||||
|
|
||||||
|
idx := strings.Index(lowerContent, lowerQuery)
|
||||||
|
if idx == -1 {
|
||||||
|
// Query not found, return beginning of content
|
||||||
|
if len(content) > maxLen {
|
||||||
|
return content[:maxLen] + "..."
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find start position
|
||||||
|
start := idx - maxLen/4
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find end position
|
||||||
|
end := start + maxLen
|
||||||
|
if end > len(content) {
|
||||||
|
end = len(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
snippet := content[start:end]
|
||||||
|
if start > 0 {
|
||||||
|
snippet = "..." + snippet
|
||||||
|
}
|
||||||
|
if end < len(content) {
|
||||||
|
snippet = snippet + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateSearchScore(idx *repo_model.WikiIndex, query string) float32 {
|
||||||
|
score := float32(0.0)
|
||||||
|
lowerQuery := strings.ToLower(query)
|
||||||
|
|
||||||
|
// Title match is highest priority
|
||||||
|
if strings.Contains(strings.ToLower(idx.Title), lowerQuery) {
|
||||||
|
score += 10.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page name match
|
||||||
|
if strings.Contains(strings.ToLower(idx.PageName), lowerQuery) {
|
||||||
|
score += 8.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content match - count occurrences
|
||||||
|
lowerContent := strings.ToLower(idx.Content)
|
||||||
|
count := strings.Count(lowerContent, lowerQuery)
|
||||||
|
score += float32(count) * 0.5
|
||||||
|
|
||||||
|
// Longer pages might have more matches but aren't necessarily more relevant
|
||||||
|
// Normalize by word count
|
||||||
|
if idx.WordCount > 0 {
|
||||||
|
score = score / (float32(idx.WordCount) / 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return score
|
||||||
|
}
|
||||||
@ -20,6 +20,7 @@ import (
|
|||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
shared_user "code.gitea.io/gitea/routers/web/shared/user"
|
||||||
"code.gitea.io/gitea/services/context"
|
"code.gitea.io/gitea/services/context"
|
||||||
|
org_service "code.gitea.io/gitea/services/org"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tplOrgHome templates.TplName = "org/home"
|
const tplOrgHome templates.TplName = "org/home"
|
||||||
@ -109,8 +110,59 @@ func home(ctx *context.Context, viewRepositories bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load pinned repositories with details
|
||||||
|
pinnedRepos, err := org_service.GetOrgPinnedReposWithDetails(ctx, org.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetOrgPinnedReposWithDetails: %v", err)
|
||||||
|
}
|
||||||
|
ctx.Data["PinnedRepos"] = pinnedRepos
|
||||||
|
|
||||||
|
// Load pinned groups
|
||||||
|
pinnedGroups, err := organization.GetOrgPinnedGroups(ctx, org.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetOrgPinnedGroups: %v", err)
|
||||||
|
}
|
||||||
|
ctx.Data["PinnedGroups"] = pinnedGroups
|
||||||
|
|
||||||
|
// Organize pinned repos by group for template
|
||||||
|
pinnedByGroup := make(map[int64][]*organization.OrgPinnedRepo)
|
||||||
|
var ungroupedPinned []*organization.OrgPinnedRepo
|
||||||
|
for _, p := range pinnedRepos {
|
||||||
|
if p.Repo == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.GroupID == 0 {
|
||||||
|
ungroupedPinned = append(ungroupedPinned, p)
|
||||||
|
} else {
|
||||||
|
pinnedByGroup[p.GroupID] = append(pinnedByGroup[p.GroupID], p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Data["PinnedByGroup"] = pinnedByGroup
|
||||||
|
ctx.Data["UngroupedPinned"] = ungroupedPinned
|
||||||
|
ctx.Data["HasPinnedRepos"] = len(pinnedRepos) > 0
|
||||||
|
|
||||||
|
// Load public members (limit to 12 for overview display)
|
||||||
|
publicMembers, totalPublicMembers, err := organization.GetPublicOrgMembers(ctx, org.ID, 12)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetPublicOrgMembers: %v", err)
|
||||||
|
}
|
||||||
|
ctx.Data["PublicMembers"] = publicMembers
|
||||||
|
ctx.Data["TotalPublicMembers"] = totalPublicMembers
|
||||||
|
ctx.Data["HasMorePublicMembers"] = totalPublicMembers > 12
|
||||||
|
|
||||||
|
// Load organization stats
|
||||||
|
orgStats, err := org_service.GetOrgOverviewStats(ctx, org.ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetOrgOverviewStats: %v", err)
|
||||||
|
}
|
||||||
|
ctx.Data["OrgStats"] = orgStats
|
||||||
|
|
||||||
// if no profile readme, it still means "view repositories"
|
// if no profile readme, it still means "view repositories"
|
||||||
isViewOverview := !viewRepositories && prepareOrgProfileReadme(ctx, prepareResult)
|
isViewOverview := !viewRepositories && prepareOrgProfileReadme(ctx, prepareResult)
|
||||||
|
// Also show overview if there are pinned repos even without profile readme
|
||||||
|
if !viewRepositories && len(pinnedRepos) > 0 {
|
||||||
|
isViewOverview = true
|
||||||
|
}
|
||||||
ctx.Data["PageIsViewRepositories"] = !isViewOverview
|
ctx.Data["PageIsViewRepositories"] = !isViewOverview
|
||||||
ctx.Data["PageIsViewOverview"] = isViewOverview
|
ctx.Data["PageIsViewOverview"] = isViewOverview
|
||||||
ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
|
ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
|
||||||
|
|||||||
423
routers/web/pages/pages.go
Normal file
423
routers/web/pages/pages.go
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/models/renderhelper"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
"code.gitea.io/gitea/modules/markup/markdown"
|
||||||
|
pages_module "code.gitea.io/gitea/modules/pages"
|
||||||
|
"code.gitea.io/gitea/modules/templates"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
pages_service "code.gitea.io/gitea/services/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tplPagesSimple templates.TplName = "pages/simple"
|
||||||
|
tplPagesDocumentation templates.TplName = "pages/documentation"
|
||||||
|
tplPagesProduct templates.TplName = "pages/product"
|
||||||
|
tplPagesPortfolio templates.TplName = "pages/portfolio"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServeLandingPage serves the landing page for a repository
|
||||||
|
func ServeLandingPage(ctx *context.Context) {
|
||||||
|
// Get the repository from subdomain or custom domain
|
||||||
|
repo, config, err := getRepoFromRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to get repo from pages request: %v", err)
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if repo == nil || config == nil || !config.Enabled {
|
||||||
|
ctx.NotFound(fmt.Errorf("pages not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for redirect
|
||||||
|
requestPath := ctx.Req.URL.Path
|
||||||
|
if config.Advanced.Redirects != nil {
|
||||||
|
if redirect, ok := config.Advanced.Redirects[requestPath]; ok {
|
||||||
|
ctx.Redirect(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the landing page
|
||||||
|
if err := renderLandingPage(ctx, repo, config); err != nil {
|
||||||
|
log.Error("Failed to render landing page: %v", err)
|
||||||
|
ctx.ServerError("Failed to render landing page", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRepoFromRequest extracts the repository from the pages request
|
||||||
|
func getRepoFromRequest(ctx *context.Context) (*repo_model.Repository, *pages_module.LandingConfig, error) {
|
||||||
|
host := ctx.Req.Host
|
||||||
|
|
||||||
|
// Check for custom domain first
|
||||||
|
repo, err := pages_service.GetRepoByPagesDomain(ctx, host)
|
||||||
|
if err == nil && repo != nil {
|
||||||
|
config, err := pages_service.GetPagesConfig(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return repo, config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse subdomain: {repo}.{owner}.pages.{domain}
|
||||||
|
// This is a simplified implementation
|
||||||
|
parts := strings.Split(host, ".")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
return nil, nil, fmt.Errorf("invalid pages subdomain")
|
||||||
|
}
|
||||||
|
|
||||||
|
repoName := parts[0]
|
||||||
|
ownerName := parts[1]
|
||||||
|
|
||||||
|
repo, err = repo_model.GetRepositoryByOwnerAndName(ctx, ownerName, repoName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := pages_service.GetPagesConfig(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo, config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLandingPage renders the landing page based on the template
|
||||||
|
func renderLandingPage(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) error {
|
||||||
|
// Set up context data
|
||||||
|
ctx.Data["Repository"] = repo
|
||||||
|
ctx.Data["Config"] = config
|
||||||
|
ctx.Data["Title"] = getPageTitle(repo, config)
|
||||||
|
ctx.Data["PageIsPagesLanding"] = true
|
||||||
|
|
||||||
|
// Load README content
|
||||||
|
readme, err := loadReadmeContent(ctx, repo, config)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("Failed to load README: %v", err)
|
||||||
|
}
|
||||||
|
ctx.Data["ReadmeContent"] = readme
|
||||||
|
|
||||||
|
// Load repo stats
|
||||||
|
ctx.Data["NumStars"] = repo.NumStars
|
||||||
|
ctx.Data["NumForks"] = repo.NumForks
|
||||||
|
|
||||||
|
// Select template based on config
|
||||||
|
tpl := selectTemplate(config.Template)
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tpl)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPageTitle returns the page title
|
||||||
|
func getPageTitle(repo *repo_model.Repository, config *pages_module.LandingConfig) string {
|
||||||
|
if config.SEO.Title != "" {
|
||||||
|
return config.SEO.Title
|
||||||
|
}
|
||||||
|
if config.Hero.Title != "" {
|
||||||
|
return config.Hero.Title
|
||||||
|
}
|
||||||
|
return repo.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadReadmeContent loads and renders the README content
|
||||||
|
func loadReadmeContent(ctx *context.Context, repo *repo_model.Repository, config *pages_module.LandingConfig) (template.HTML, error) {
|
||||||
|
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
branch := repo.DefaultBranch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := gitRepo.GetBranchCommit(branch)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find README file
|
||||||
|
readmePath := findReadmePath(commit, config)
|
||||||
|
if readmePath == "" {
|
||||||
|
return "", fmt.Errorf("README not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := commit.GetTreeEntryByPath(readmePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := entry.Blob().DataAsync()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
content := make([]byte, entry.Blob().Size())
|
||||||
|
_, err = reader.Read(content)
|
||||||
|
if err != nil && err.Error() != "EOF" {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render markdown using renderhelper
|
||||||
|
rctx := renderhelper.NewRenderContextRepoFile(ctx, repo)
|
||||||
|
rendered, err := markdown.RenderString(rctx, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findReadmePath finds the README file path
|
||||||
|
func findReadmePath(commit *git.Commit, config *pages_module.LandingConfig) string {
|
||||||
|
// Check config for custom readme location
|
||||||
|
for _, section := range config.Sections {
|
||||||
|
if section.Type == "readme" && section.File != "" {
|
||||||
|
return section.File
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default README locations
|
||||||
|
readmePaths := []string{
|
||||||
|
"README.md",
|
||||||
|
"readme.md",
|
||||||
|
"Readme.md",
|
||||||
|
"README.markdown",
|
||||||
|
"README.txt",
|
||||||
|
"README",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range readmePaths {
|
||||||
|
if _, err := commit.GetTreeEntryByPath(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectTemplate selects the template based on configuration
|
||||||
|
func selectTemplate(templateName string) templates.TplName {
|
||||||
|
switch templateName {
|
||||||
|
case "documentation":
|
||||||
|
return tplPagesDocumentation
|
||||||
|
case "product":
|
||||||
|
return tplPagesProduct
|
||||||
|
case "portfolio":
|
||||||
|
return tplPagesPortfolio
|
||||||
|
default:
|
||||||
|
return tplPagesSimple
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServePageAsset serves static assets for the landing page
|
||||||
|
func ServePageAsset(ctx *context.Context) {
|
||||||
|
repo, _, err := getRepoFromRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the asset path from URL
|
||||||
|
assetPath := strings.TrimPrefix(ctx.Req.URL.Path, "/assets/")
|
||||||
|
if assetPath == "" {
|
||||||
|
ctx.NotFound(fmt.Errorf("asset not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load asset from repository
|
||||||
|
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
branch := repo.DefaultBranch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := gitRepo.GetBranchCommit(branch)
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try assets folder first
|
||||||
|
fullPath := path.Join("assets", assetPath)
|
||||||
|
entry, err := commit.GetTreeEntryByPath(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
// Try .gitea/assets
|
||||||
|
fullPath = path.Join(".gitea", "assets", assetPath)
|
||||||
|
entry, err = commit.GetTreeEntryByPath(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := entry.Blob().DataAsync()
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("Failed to read asset", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Set content type based on extension
|
||||||
|
ext := path.Ext(assetPath)
|
||||||
|
contentType := getContentType(ext)
|
||||||
|
ctx.Resp.Header().Set("Content-Type", contentType)
|
||||||
|
ctx.Resp.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
|
||||||
|
// Stream content
|
||||||
|
content := make([]byte, entry.Blob().Size())
|
||||||
|
_, err = reader.Read(content)
|
||||||
|
if err != nil && err.Error() != "EOF" {
|
||||||
|
ctx.ServerError("Failed to read asset", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Resp.Write(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeRepoLandingPage serves the landing page for a repository via URL path
|
||||||
|
func ServeRepoLandingPage(ctx *context.Context) {
|
||||||
|
repo := ctx.Repo.Repository
|
||||||
|
if repo == nil {
|
||||||
|
ctx.NotFound(fmt.Errorf("repository not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := pages_service.GetPagesConfig(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to get pages config: %v", err)
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if config == nil || !config.Enabled {
|
||||||
|
ctx.NotFound(fmt.Errorf("pages not enabled for this repository"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the landing page
|
||||||
|
if err := renderLandingPage(ctx, repo, config); err != nil {
|
||||||
|
log.Error("Failed to render landing page: %v", err)
|
||||||
|
ctx.ServerError("Failed to render landing page", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeRepoPageAsset serves static assets for the landing page via URL path
|
||||||
|
func ServeRepoPageAsset(ctx *context.Context) {
|
||||||
|
repo := ctx.Repo.Repository
|
||||||
|
if repo == nil {
|
||||||
|
ctx.NotFound(fmt.Errorf("repository not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the asset path from URL
|
||||||
|
assetPath := ctx.PathParam("*")
|
||||||
|
if assetPath == "" {
|
||||||
|
ctx.NotFound(fmt.Errorf("asset not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load asset from repository
|
||||||
|
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
branch := repo.DefaultBranch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := gitRepo.GetBranchCommit(branch)
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try assets folder first
|
||||||
|
fullPath := path.Join("assets", assetPath)
|
||||||
|
entry, err := commit.GetTreeEntryByPath(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
// Try .gitea/assets
|
||||||
|
fullPath = path.Join(".gitea", "assets", assetPath)
|
||||||
|
entry, err = commit.GetTreeEntryByPath(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
ctx.NotFound(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := entry.Blob().DataAsync()
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("Failed to read asset", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Set content type based on extension
|
||||||
|
ext := path.Ext(assetPath)
|
||||||
|
contentType := getContentType(ext)
|
||||||
|
ctx.Resp.Header().Set("Content-Type", contentType)
|
||||||
|
ctx.Resp.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
|
||||||
|
// Stream content
|
||||||
|
content := make([]byte, entry.Blob().Size())
|
||||||
|
_, err = reader.Read(content)
|
||||||
|
if err != nil && err.Error() != "EOF" {
|
||||||
|
ctx.ServerError("Failed to read asset", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Resp.Write(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContentType returns the content type for a file extension
|
||||||
|
func getContentType(ext string) string {
|
||||||
|
types := map[string]string{
|
||||||
|
".html": "text/html",
|
||||||
|
".css": "text/css",
|
||||||
|
".js": "application/javascript",
|
||||||
|
".json": "application/json",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct, ok := types[strings.ToLower(ext)]; ok {
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
134
routers/web/repo/setting/pages.go
Normal file
134
routers/web/repo/setting/pages.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/modules/templates"
|
||||||
|
"code.gitea.io/gitea/services/context"
|
||||||
|
pages_service "code.gitea.io/gitea/services/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tplRepoSettingsPages templates.TplName = "repo/settings/pages"
|
||||||
|
|
||||||
|
// Pages shows the repository pages settings
|
||||||
|
func Pages(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = ctx.Tr("repo.settings.pages")
|
||||||
|
ctx.Data["PageIsSettingsPages"] = true
|
||||||
|
|
||||||
|
// Get pages config
|
||||||
|
config, err := repo_model.GetPagesConfig(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil && !repo_model.IsErrPagesConfigNotExist(err) {
|
||||||
|
ctx.ServerError("GetPagesConfig", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if config != nil {
|
||||||
|
ctx.Data["PagesEnabled"] = config.Enabled
|
||||||
|
ctx.Data["PagesTemplate"] = config.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pages domains
|
||||||
|
domains, err := repo_model.GetPagesDomains(ctx, ctx.Repo.Repository.ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.ServerError("GetPagesDomains", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Data["PagesDomains"] = domains
|
||||||
|
|
||||||
|
// Generate subdomain
|
||||||
|
ctx.Data["PagesSubdomain"] = pages_service.GetPagesSubdomain(ctx.Repo.Repository)
|
||||||
|
|
||||||
|
// Available templates
|
||||||
|
ctx.Data["PagesTemplates"] = []string{"simple", "documentation", "product", "portfolio"}
|
||||||
|
|
||||||
|
ctx.HTML(http.StatusOK, tplRepoSettingsPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PagesPost handles the pages settings form submission
|
||||||
|
func PagesPost(ctx *context.Context) {
|
||||||
|
ctx.Data["Title"] = ctx.Tr("repo.settings.pages")
|
||||||
|
ctx.Data["PageIsSettingsPages"] = true
|
||||||
|
|
||||||
|
action := ctx.FormString("action")
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "enable":
|
||||||
|
template := ctx.FormString("template")
|
||||||
|
if template == "" {
|
||||||
|
template = "simple"
|
||||||
|
}
|
||||||
|
if err := pages_service.EnablePages(ctx, ctx.Repo.Repository, template); err != nil {
|
||||||
|
ctx.ServerError("EnablePages", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.pages.enabled_success"))
|
||||||
|
|
||||||
|
case "disable":
|
||||||
|
if err := pages_service.DisablePages(ctx, ctx.Repo.Repository); err != nil {
|
||||||
|
ctx.ServerError("DisablePages", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.pages.disabled_success"))
|
||||||
|
|
||||||
|
case "update_template":
|
||||||
|
template := ctx.FormString("template")
|
||||||
|
if template == "" {
|
||||||
|
template = "simple"
|
||||||
|
}
|
||||||
|
if err := pages_service.EnablePages(ctx, ctx.Repo.Repository, template); err != nil {
|
||||||
|
ctx.ServerError("EnablePages", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.update_settings_success"))
|
||||||
|
|
||||||
|
case "add_domain":
|
||||||
|
domain := ctx.FormString("domain")
|
||||||
|
if domain == "" {
|
||||||
|
ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_required"))
|
||||||
|
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := pages_service.AddPagesDomain(ctx, ctx.Repo.Repository.ID, domain)
|
||||||
|
if err != nil {
|
||||||
|
if repo_model.IsErrPagesDomainAlreadyExist(err) {
|
||||||
|
ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_exists"))
|
||||||
|
} else {
|
||||||
|
ctx.ServerError("AddPagesDomain", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_added"))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "delete_domain":
|
||||||
|
domainID := ctx.FormInt64("domain_id")
|
||||||
|
if err := repo_model.DeletePagesDomain(ctx, domainID); err != nil {
|
||||||
|
ctx.ServerError("DeletePagesDomain", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_deleted"))
|
||||||
|
|
||||||
|
case "verify_domain":
|
||||||
|
domainID := ctx.FormInt64("domain_id")
|
||||||
|
if err := pages_service.VerifyDomain(ctx, domainID); err != nil {
|
||||||
|
if err.Error() == "DNS verification failed" {
|
||||||
|
ctx.Flash.Error(ctx.Tr("repo.settings.pages.domain_verification_failed"))
|
||||||
|
} else {
|
||||||
|
ctx.ServerError("VerifyDomain", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ctx.Flash.Success(ctx.Tr("repo.settings.pages.domain_verified"))
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
ctx.NotFound(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages")
|
||||||
|
}
|
||||||
@ -34,6 +34,7 @@ import (
|
|||||||
"code.gitea.io/gitea/routers/web/misc"
|
"code.gitea.io/gitea/routers/web/misc"
|
||||||
"code.gitea.io/gitea/routers/web/org"
|
"code.gitea.io/gitea/routers/web/org"
|
||||||
org_setting "code.gitea.io/gitea/routers/web/org/setting"
|
org_setting "code.gitea.io/gitea/routers/web/org/setting"
|
||||||
|
"code.gitea.io/gitea/routers/web/pages"
|
||||||
"code.gitea.io/gitea/routers/web/repo"
|
"code.gitea.io/gitea/routers/web/repo"
|
||||||
"code.gitea.io/gitea/routers/web/repo/actions"
|
"code.gitea.io/gitea/routers/web/repo/actions"
|
||||||
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
|
repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
|
||||||
@ -1160,6 +1161,7 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
m.Post("/{lid}/unlock", repo_setting.LFSUnlock)
|
m.Post("/{lid}/unlock", repo_setting.LFSUnlock)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
m.Combo("/pages").Get(repo_setting.Pages).Post(repo_setting.PagesPost)
|
||||||
m.Group("/actions/general", func() {
|
m.Group("/actions/general", func() {
|
||||||
m.Get("", repo_setting.ActionsGeneralSettings)
|
m.Get("", repo_setting.ActionsGeneralSettings)
|
||||||
m.Post("/actions_unit", repo_setting.ActionsUnitPost)
|
m.Post("/actions_unit", repo_setting.ActionsUnitPost)
|
||||||
@ -1514,6 +1516,14 @@ func registerWebRoutes(m *web.Router) {
|
|||||||
})
|
})
|
||||||
// end "/{username}/{reponame}/wiki"
|
// end "/{username}/{reponame}/wiki"
|
||||||
|
|
||||||
|
m.Group("/{username}/{reponame}/pages", func() {
|
||||||
|
m.Get("", pages.ServeRepoLandingPage)
|
||||||
|
m.Get("/assets/*", pages.ServeRepoPageAsset)
|
||||||
|
}, optSignIn, context.RepoAssignment, func(ctx *context.Context) {
|
||||||
|
ctx.Data["PageIsPagesLanding"] = true
|
||||||
|
})
|
||||||
|
// end "/{username}/{reponame}/pages"
|
||||||
|
|
||||||
m.Group("/{username}/{reponame}/activity", func() {
|
m.Group("/{username}/{reponame}/activity", func() {
|
||||||
// activity has its own permission checks
|
// activity has its own permission checks
|
||||||
m.Get("", repo.Activity)
|
m.Get("", repo.Activity)
|
||||||
|
|||||||
77
services/org/pinned.go
Normal file
77
services/org/pinned.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package org
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/models/organization"
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/modules/optional"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetOrgPinnedReposWithDetails returns all pinned repos with repo and group details loaded
|
||||||
|
func GetOrgPinnedReposWithDetails(ctx context.Context, orgID int64) ([]*organization.OrgPinnedRepo, error) {
|
||||||
|
pinnedRepos, err := organization.GetOrgPinnedRepos(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pinnedRepos) == 0 {
|
||||||
|
return pinnedRepos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load repos
|
||||||
|
repoIDs := make([]int64, len(pinnedRepos))
|
||||||
|
for i, p := range pinnedRepos {
|
||||||
|
repoIDs[i] = p.RepoID
|
||||||
|
}
|
||||||
|
|
||||||
|
repos, err := repo_model.GetRepositoriesMapByIDs(ctx, repoIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load groups
|
||||||
|
if err := organization.LoadPinnedRepoGroups(ctx, pinnedRepos, orgID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach repos
|
||||||
|
for _, p := range pinnedRepos {
|
||||||
|
p.Repo = repos[p.RepoID]
|
||||||
|
}
|
||||||
|
|
||||||
|
return pinnedRepos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgOverviewStats returns statistics for the organization overview page
|
||||||
|
func GetOrgOverviewStats(ctx context.Context, orgID int64) (*organization.OrgOverviewStats, error) {
|
||||||
|
stats := &organization.OrgOverviewStats{}
|
||||||
|
|
||||||
|
memberCount, teamCount, err := organization.GetOrgMemberAndTeamCounts(ctx, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats.MemberCount = memberCount
|
||||||
|
stats.TeamCount = teamCount
|
||||||
|
|
||||||
|
// Repo counts
|
||||||
|
stats.RepoCount, err = repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{
|
||||||
|
OwnerID: orgID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.PublicRepoCount, err = repo_model.CountRepositories(ctx, repo_model.CountRepositoryOptions{
|
||||||
|
OwnerID: orgID,
|
||||||
|
Private: optional.Some(false),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
253
services/pages/pages.go
Normal file
253
services/pages/pages.go
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
pages_module "code.gitea.io/gitea/modules/pages"
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LandingConfigPath is the path to the landing page configuration file
|
||||||
|
LandingConfigPath = ".gitea/landing.yaml"
|
||||||
|
// LandingConfigPathAlt is an alternative path for the configuration file
|
||||||
|
LandingConfigPathAlt = ".gitea/landing.yml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetPagesConfig returns the pages configuration for a repository
|
||||||
|
// It first tries to load from the database cache, then falls back to parsing the file
|
||||||
|
func GetPagesConfig(ctx context.Context, repo *repo_model.Repository) (*pages_module.LandingConfig, error) {
|
||||||
|
// Check if pages is configured in DB
|
||||||
|
dbConfig, err := repo_model.GetPagesConfigByRepoID(ctx, repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load and parse the config from the repository
|
||||||
|
fileConfig, fileHash, err := loadConfigFromRepo(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
// If file doesn't exist, check if DB has config
|
||||||
|
if dbConfig != nil && dbConfig.ConfigJSON != "" {
|
||||||
|
// Use cached config
|
||||||
|
var config pages_module.LandingConfig
|
||||||
|
if err := json.Unmarshal([]byte(dbConfig.ConfigJSON), &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a file config, check if cache needs updating
|
||||||
|
if dbConfig != nil && dbConfig.ConfigHash == fileHash {
|
||||||
|
// Cache is up to date, use cached config
|
||||||
|
var config pages_module.LandingConfig
|
||||||
|
if err := json.Unmarshal([]byte(dbConfig.ConfigJSON), &config); err != nil {
|
||||||
|
// Cache is corrupted, use file config
|
||||||
|
return fileConfig, nil
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache with new config
|
||||||
|
configJSON, err := json.Marshal(fileConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fileConfig, nil // Return config even if caching fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if dbConfig == nil {
|
||||||
|
// Create new cache entry
|
||||||
|
dbConfig = &repo_model.PagesConfig{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Enabled: fileConfig.Enabled,
|
||||||
|
Template: repo_model.PagesTemplate(fileConfig.Template),
|
||||||
|
ConfigJSON: string(configJSON),
|
||||||
|
ConfigHash: fileHash,
|
||||||
|
}
|
||||||
|
if err := repo_model.CreatePagesConfig(ctx, dbConfig); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
return fileConfig, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update existing cache
|
||||||
|
dbConfig.Enabled = fileConfig.Enabled
|
||||||
|
dbConfig.Template = repo_model.PagesTemplate(fileConfig.Template)
|
||||||
|
dbConfig.ConfigJSON = string(configJSON)
|
||||||
|
dbConfig.ConfigHash = fileHash
|
||||||
|
if err := repo_model.UpdatePagesConfig(ctx, dbConfig); err != nil {
|
||||||
|
// Log but don't fail
|
||||||
|
return fileConfig, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileConfig, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfigFromRepo loads the landing.yaml configuration from the repository
|
||||||
|
func loadConfigFromRepo(ctx context.Context, repo *repo_model.Repository) (*pages_module.LandingConfig, string, error) {
|
||||||
|
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer gitRepo.Close()
|
||||||
|
|
||||||
|
// Try to get the default branch
|
||||||
|
branch := repo.DefaultBranch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the commit
|
||||||
|
commit, err := gitRepo.GetBranchCommit(branch)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the config file
|
||||||
|
var content []byte
|
||||||
|
entry, err := commit.GetTreeEntryByPath(LandingConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
// Try alternative path
|
||||||
|
entry, err = commit.GetTreeEntryByPath(LandingConfigPathAlt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("landing config not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, err := entry.Blob().DataAsync()
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
content = make([]byte, entry.Blob().Size())
|
||||||
|
_, err = reader.Read(content)
|
||||||
|
if err != nil && err.Error() != "EOF" {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the config
|
||||||
|
config, err := pages_module.ParseLandingConfig(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate hash for cache invalidation
|
||||||
|
hash := pages_module.HashConfig(content)
|
||||||
|
|
||||||
|
return config, hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPagesEnabled checks if pages is enabled for a repository
|
||||||
|
func IsPagesEnabled(ctx context.Context, repo *repo_model.Repository) (bool, error) {
|
||||||
|
config, err := GetPagesConfig(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil // Not enabled if config doesn't exist
|
||||||
|
}
|
||||||
|
return config.Enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnablePages enables pages for a repository with default config
|
||||||
|
func EnablePages(ctx context.Context, repo *repo_model.Repository, template string) error {
|
||||||
|
if !pages_module.IsValidTemplate(template) {
|
||||||
|
template = "simple"
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo_model.EnablePages(ctx, repo.ID, repo_model.PagesTemplate(template))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisablePages disables pages for a repository
|
||||||
|
func DisablePages(ctx context.Context, repo *repo_model.Repository) error {
|
||||||
|
return repo_model.DisablePages(ctx, repo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesSubdomain returns the subdomain for a repository's pages
|
||||||
|
func GetPagesSubdomain(repo *repo_model.Repository) string {
|
||||||
|
// Format: {repo}.{owner}.pages.{domain}
|
||||||
|
return fmt.Sprintf("%s.%s", strings.ToLower(repo.Name), strings.ToLower(repo.OwnerName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesURL returns the full URL for a repository's pages
|
||||||
|
func GetPagesURL(repo *repo_model.Repository) string {
|
||||||
|
subdomain := GetPagesSubdomain(repo)
|
||||||
|
// This should be configurable
|
||||||
|
pagesDomain := setting.AppURL // TODO: Add proper pages domain setting
|
||||||
|
return fmt.Sprintf("https://%s.pages.%s", subdomain, pagesDomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPagesDomains returns all custom domains for a repository's pages
|
||||||
|
func GetPagesDomains(ctx context.Context, repoID int64) ([]*repo_model.PagesDomain, error) {
|
||||||
|
return repo_model.GetPagesDomainsByRepoID(ctx, repoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPagesDomain adds a custom domain for pages
|
||||||
|
func AddPagesDomain(ctx context.Context, repoID int64, domain string) (*repo_model.PagesDomain, error) {
|
||||||
|
// Normalize domain
|
||||||
|
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||||
|
|
||||||
|
// Check if domain already exists
|
||||||
|
existing, err := repo_model.GetPagesDomainByDomain(ctx, domain)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
return nil, repo_model.ErrPagesDomainAlreadyExist{Domain: domain}
|
||||||
|
}
|
||||||
|
|
||||||
|
pagesDomain := &repo_model.PagesDomain{
|
||||||
|
RepoID: repoID,
|
||||||
|
Domain: domain,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo_model.CreatePagesDomain(ctx, pagesDomain); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return pagesDomain, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePagesDomain removes a custom domain
|
||||||
|
func RemovePagesDomain(ctx context.Context, repoID int64, domainID int64) error {
|
||||||
|
domain, err := repo_model.GetPagesDomainByID(ctx, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify domain belongs to this repo
|
||||||
|
if domain.RepoID != repoID {
|
||||||
|
return repo_model.ErrPagesDomainNotExist{ID: domainID}
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo_model.DeletePagesDomain(ctx, domainID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyDomain verifies a custom domain by checking DNS records
|
||||||
|
func VerifyDomain(ctx context.Context, domainID int64) error {
|
||||||
|
domain, err := repo_model.GetPagesDomainByID(ctx, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement actual DNS verification
|
||||||
|
// For now, just mark as verified
|
||||||
|
return repo_model.VerifyPagesDomain(ctx, domain.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoByPagesDomain returns the repository for a pages domain
|
||||||
|
func GetRepoByPagesDomain(ctx context.Context, domainName string) (*repo_model.Repository, error) {
|
||||||
|
domain, err := repo_model.GetPagesDomainByDomain(ctx, domainName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !domain.Verified {
|
||||||
|
return nil, fmt.Errorf("domain not verified")
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo_model.GetRepositoryByID(ctx, domain.RepoID)
|
||||||
|
}
|
||||||
369
services/wiki/wiki_index.go
Normal file
369
services/wiki/wiki_index.go
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package wiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
repo_model "code.gitea.io/gitea/models/repo"
|
||||||
|
"code.gitea.io/gitea/modules/git"
|
||||||
|
"code.gitea.io/gitea/modules/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IndexWikiPage indexes a single wiki page for search
|
||||||
|
func IndexWikiPage(ctx context.Context, repo *repo_model.Repository, pageName string) error {
|
||||||
|
wikiRepo, commit, err := findWikiRepoCommit(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if wikiRepo != nil {
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
}
|
||||||
|
if commit == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the page content
|
||||||
|
pagePath := NameToFilename(pageName)
|
||||||
|
entry, err := commit.GetTreeEntryByPath(pagePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
blob := entry.Blob()
|
||||||
|
content, err := blob.GetBlobContent(1024 * 1024) // 1MB max
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate hash
|
||||||
|
hash := sha256.Sum256([]byte(content))
|
||||||
|
contentHash := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
// Check if already indexed with same hash
|
||||||
|
existing, err := repo_model.GetWikiIndex(ctx, repo.ID, pageName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing != nil && existing.ContentHash == contentHash {
|
||||||
|
return nil // Already up to date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract links from content
|
||||||
|
links := extractWikiLinks(content)
|
||||||
|
linksJSON, _ := json.Marshal(links)
|
||||||
|
|
||||||
|
// Count words
|
||||||
|
wordCount := countWords(content)
|
||||||
|
|
||||||
|
// Get title from first heading or page name
|
||||||
|
title := extractTitle(content, pageName)
|
||||||
|
|
||||||
|
// Create/update index
|
||||||
|
idx := &repo_model.WikiIndex{
|
||||||
|
RepoID: repo.ID,
|
||||||
|
PageName: pageName,
|
||||||
|
PagePath: pagePath,
|
||||||
|
Title: title,
|
||||||
|
Content: content,
|
||||||
|
ContentHash: contentHash,
|
||||||
|
CommitSHA: commit.ID.String(),
|
||||||
|
WordCount: wordCount,
|
||||||
|
LinksOut: string(linksJSON),
|
||||||
|
}
|
||||||
|
|
||||||
|
return repo_model.CreateOrUpdateWikiIndex(ctx, idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexAllWikiPages indexes all pages in a wiki
|
||||||
|
func IndexAllWikiPages(ctx context.Context, repo *repo_model.Repository) error {
|
||||||
|
wikiRepo, commit, err := findWikiRepoCommit(ctx, repo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if wikiRepo != nil {
|
||||||
|
defer wikiRepo.Close()
|
||||||
|
}
|
||||||
|
if commit == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all entries
|
||||||
|
entries, err := commit.ListEntries()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
indexedPages := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(entry.Name(), ".md") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pageName := FilenameToName(entry.Name())
|
||||||
|
if pageName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := IndexWikiPage(ctx, repo, pageName); err != nil {
|
||||||
|
log.Warn("Failed to index wiki page %s: %v", pageName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
indexedPages[pageName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deleted pages from index
|
||||||
|
existingIndexes, err := repo_model.GetWikiIndexByRepo(ctx, repo.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, idx := range existingIndexes {
|
||||||
|
if !indexedPages[idx.PageName] {
|
||||||
|
if err := repo_model.DeleteWikiIndex(ctx, repo.ID, idx.PageName); err != nil {
|
||||||
|
log.Warn("Failed to remove deleted wiki page from index %s: %v", idx.PageName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveWikiPageFromIndex removes a page from the search index
|
||||||
|
func RemoveWikiPageFromIndex(ctx context.Context, repoID int64, pageName string) error {
|
||||||
|
return repo_model.DeleteWikiIndex(ctx, repoID, pageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearWikiIndex removes all indexed pages for a repository
|
||||||
|
func ClearWikiIndex(ctx context.Context, repoID int64) error {
|
||||||
|
return repo_model.DeleteWikiIndexByRepo(ctx, repoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiGraph returns the link graph for a wiki
|
||||||
|
func GetWikiGraph(ctx context.Context, repoID int64) (nodes []map[string]interface{}, edges []map[string]interface{}, err error) {
|
||||||
|
indexes, err := repo_model.GetWikiIndexByRepo(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes = make([]map[string]interface{}, 0, len(indexes))
|
||||||
|
edges = make([]map[string]interface{}, 0)
|
||||||
|
pageSet := make(map[string]bool)
|
||||||
|
|
||||||
|
// Build nodes
|
||||||
|
for _, idx := range indexes {
|
||||||
|
pageSet[idx.PageName] = true
|
||||||
|
nodes = append(nodes, map[string]interface{}{
|
||||||
|
"name": idx.PageName,
|
||||||
|
"title": idx.Title,
|
||||||
|
"word_count": idx.WordCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edges from links
|
||||||
|
for _, idx := range indexes {
|
||||||
|
var links []string
|
||||||
|
if idx.LinksOut != "" {
|
||||||
|
json.Unmarshal([]byte(idx.LinksOut), &links)
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
if pageSet[link] { // Only include links to existing pages
|
||||||
|
edges = append(edges, map[string]interface{}{
|
||||||
|
"source": idx.PageName,
|
||||||
|
"target": link,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodes, edges, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWikiIncomingLinks returns pages that link to the given page
|
||||||
|
func GetWikiIncomingLinks(ctx context.Context, repoID int64, pageName string) ([]string, error) {
|
||||||
|
indexes, err := repo_model.GetWikiIndexByRepo(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming := make([]string, 0)
|
||||||
|
for _, idx := range indexes {
|
||||||
|
var links []string
|
||||||
|
if idx.LinksOut != "" {
|
||||||
|
json.Unmarshal([]byte(idx.LinksOut), &links)
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
if link == pageName {
|
||||||
|
incoming = append(incoming, idx.PageName)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrphanedPages returns pages with no incoming links
|
||||||
|
func GetOrphanedPages(ctx context.Context, repoID int64) ([]*repo_model.WikiIndex, error) {
|
||||||
|
indexes, err := repo_model.GetWikiIndexByRepo(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build set of pages that are linked to
|
||||||
|
linkedPages := make(map[string]bool)
|
||||||
|
for _, idx := range indexes {
|
||||||
|
var links []string
|
||||||
|
if idx.LinksOut != "" {
|
||||||
|
json.Unmarshal([]byte(idx.LinksOut), &links)
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
linkedPages[link] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find orphaned pages (excluding Home which is always accessible)
|
||||||
|
orphaned := make([]*repo_model.WikiIndex, 0)
|
||||||
|
for _, idx := range indexes {
|
||||||
|
if idx.PageName != "Home" && !linkedPages[idx.PageName] {
|
||||||
|
orphaned = append(orphaned, idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orphaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeadLinks returns links to non-existent pages
|
||||||
|
func GetDeadLinks(ctx context.Context, repoID int64) ([]map[string]string, error) {
|
||||||
|
indexes, err := repo_model.GetWikiIndexByRepo(ctx, repoID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build set of existing pages
|
||||||
|
existingPages := make(map[string]bool)
|
||||||
|
for _, idx := range indexes {
|
||||||
|
existingPages[idx.PageName] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find dead links
|
||||||
|
deadLinks := make([]map[string]string, 0)
|
||||||
|
for _, idx := range indexes {
|
||||||
|
var links []string
|
||||||
|
if idx.LinksOut != "" {
|
||||||
|
json.Unmarshal([]byte(idx.LinksOut), &links)
|
||||||
|
}
|
||||||
|
for _, link := range links {
|
||||||
|
if !existingPages[link] {
|
||||||
|
deadLinks = append(deadLinks, map[string]string{
|
||||||
|
"page": idx.PageName,
|
||||||
|
"broken_link": link,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deadLinks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWikiRepoCommit opens the wiki repo and gets the latest commit
|
||||||
|
func findWikiRepoCommit(ctx context.Context, repo *repo_model.Repository) (*git.Repository, *git.Commit, error) {
|
||||||
|
wikiPath := repo.WikiPath()
|
||||||
|
|
||||||
|
if !git.IsRepoURLAccessible(ctx, wikiPath) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
wikiRepo, err := git.OpenRepository(ctx, wikiPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
branch := repo.DefaultWikiBranch
|
||||||
|
if branch == "" {
|
||||||
|
branch = "master"
|
||||||
|
}
|
||||||
|
|
||||||
|
commit, err := wikiRepo.GetBranchCommit(branch)
|
||||||
|
if err != nil {
|
||||||
|
wikiRepo.Close()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return wikiRepo, commit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWikiLinks extracts wiki page links from markdown content
|
||||||
|
func extractWikiLinks(content string) []string {
|
||||||
|
links := make([]string, 0)
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
|
// Match [[Page Name]] style wiki links
|
||||||
|
wikiLinkRe := regexp.MustCompile(`\[\[([^\]|]+)(?:\|[^\]]+)?\]\]`)
|
||||||
|
matches := wikiLinkRe.FindAllStringSubmatch(content, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) > 1 {
|
||||||
|
link := strings.TrimSpace(match[1])
|
||||||
|
// Convert to page name format
|
||||||
|
link = strings.ReplaceAll(link, " ", "-")
|
||||||
|
if !seen[link] {
|
||||||
|
links = append(links, link)
|
||||||
|
seen[link] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match [text](wiki/Page-Name) style links
|
||||||
|
mdLinkRe := regexp.MustCompile(`\[([^\]]+)\]\((?:\.\.?/)?(?:wiki/)?([^)]+)\)`)
|
||||||
|
matches = mdLinkRe.FindAllStringSubmatch(content, -1)
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) > 2 {
|
||||||
|
link := match[2]
|
||||||
|
// Skip external links
|
||||||
|
if strings.HasPrefix(link, "http://") || strings.HasPrefix(link, "https://") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Clean up the link
|
||||||
|
link = strings.TrimPrefix(link, "./")
|
||||||
|
link = strings.TrimSuffix(link, ".md")
|
||||||
|
if !seen[link] && link != "" {
|
||||||
|
links = append(links, link)
|
||||||
|
seen[link] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTitle extracts the title from markdown content
|
||||||
|
func extractTitle(content, defaultTitle string) string {
|
||||||
|
// Look for first H1 heading
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "# ") {
|
||||||
|
return strings.TrimPrefix(line, "# ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
// countWords counts the number of words in content
|
||||||
|
func countWords(content string) int {
|
||||||
|
// Remove markdown formatting
|
||||||
|
content = regexp.MustCompile(`[#*_\[\](){}]`).ReplaceAllString(content, " ")
|
||||||
|
// Split on whitespace
|
||||||
|
words := strings.Fields(content)
|
||||||
|
return len(words)
|
||||||
|
}
|
||||||
@ -8,9 +8,120 @@
|
|||||||
{{if .ProfileReadmeContent}}
|
{{if .ProfileReadmeContent}}
|
||||||
<div id="readme_profile" class="render-content markup" data-profile-view-as-member="{{.IsViewingOrgAsMember}}">{{.ProfileReadmeContent}}</div>
|
<div id="readme_profile" class="render-content markup" data-profile-view-as-member="{{.IsViewingOrgAsMember}}">{{.ProfileReadmeContent}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Pinned Repositories Section */}}
|
||||||
|
{{if and .PageIsViewOverview .HasPinnedRepos}}
|
||||||
|
<div class="ui segment pinned-repos-section">
|
||||||
|
<h4 class="ui header">
|
||||||
|
{{svg "octicon-pin" 16}} {{ctx.Locale.Tr "org.pinned_repos"}}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{{/* Ungrouped pinned repos */}}
|
||||||
|
{{if .UngroupedPinned}}
|
||||||
|
<div class="ui three stackable cards pinned-repos">
|
||||||
|
{{range .UngroupedPinned}}
|
||||||
|
{{if .Repo}}
|
||||||
|
<a class="ui card" href="{{.Repo.Link}}">
|
||||||
|
<div class="content">
|
||||||
|
<div class="header text truncate">
|
||||||
|
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 16}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 16}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 16}}{{else}}{{svg "octicon-repo" 16}}{{end}}
|
||||||
|
{{.Repo.Name}}
|
||||||
|
</div>
|
||||||
|
{{if .Repo.Description}}
|
||||||
|
<div class="description text truncate">{{.Repo.Description}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="extra content">
|
||||||
|
{{if .Repo.PrimaryLanguage}}
|
||||||
|
<span class="tw-mr-2">
|
||||||
|
<span class="repo-language-color" style="background-color: {{.Repo.PrimaryLanguage.Color}}"></span>
|
||||||
|
{{.Repo.PrimaryLanguage.Language}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .Repo.NumStars}}
|
||||||
|
<span class="tw-mr-2">{{svg "octicon-star" 14}} {{.Repo.NumStars}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .Repo.NumForks}}
|
||||||
|
<span>{{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Grouped pinned repos */}}
|
||||||
|
{{range .PinnedGroups}}
|
||||||
|
{{$groupRepos := index $.PinnedByGroup .ID}}
|
||||||
|
{{if $groupRepos}}
|
||||||
|
<div class="pinned-group tw-mt-4">
|
||||||
|
<h5 class="ui header tw-mb-2">
|
||||||
|
{{svg "octicon-chevron-down" 14}} {{.Name}}
|
||||||
|
</h5>
|
||||||
|
<div class="ui three stackable cards pinned-repos">
|
||||||
|
{{range $groupRepos}}
|
||||||
|
{{if .Repo}}
|
||||||
|
<a class="ui card" href="{{.Repo.Link}}">
|
||||||
|
<div class="content">
|
||||||
|
<div class="header text truncate">
|
||||||
|
{{if .Repo.IsPrivate}}{{svg "octicon-lock" 16}}{{else if .Repo.IsFork}}{{svg "octicon-repo-forked" 16}}{{else if .Repo.IsMirror}}{{svg "octicon-mirror" 16}}{{else}}{{svg "octicon-repo" 16}}{{end}}
|
||||||
|
{{.Repo.Name}}
|
||||||
|
</div>
|
||||||
|
{{if .Repo.Description}}
|
||||||
|
<div class="description text truncate">{{.Repo.Description}}</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="extra content">
|
||||||
|
{{if .Repo.PrimaryLanguage}}
|
||||||
|
<span class="tw-mr-2">
|
||||||
|
<span class="repo-language-color" style="background-color: {{.Repo.PrimaryLanguage.Color}}"></span>
|
||||||
|
{{.Repo.PrimaryLanguage.Language}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .Repo.NumStars}}
|
||||||
|
<span class="tw-mr-2">{{svg "octicon-star" 14}} {{.Repo.NumStars}}</span>
|
||||||
|
{{end}}
|
||||||
|
{{if .Repo.NumForks}}
|
||||||
|
<span>{{svg "octicon-repo-forked" 14}} {{.Repo.NumForks}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{/* Public Members Section (on overview) */}}
|
||||||
|
{{if and .PageIsViewOverview .PublicMembers}}
|
||||||
|
<div class="ui segment public-members-section tw-mt-4">
|
||||||
|
<h4 class="ui header tw-flex tw-items-center">
|
||||||
|
{{svg "octicon-people" 16}} {{ctx.Locale.Tr "org.public_members"}}
|
||||||
|
{{if .HasMorePublicMembers}}
|
||||||
|
<a class="tw-ml-auto text grey tw-text-sm" href="{{.OrgLink}}/members">{{ctx.Locale.Tr "org.view_all_members" .TotalPublicMembers}}</a>
|
||||||
|
{{end}}
|
||||||
|
</h4>
|
||||||
|
<div class="tw-flex tw-flex-wrap tw-gap-2">
|
||||||
|
{{range .PublicMembers}}
|
||||||
|
<a href="{{.User.HomeLink}}" title="{{.User.Name}} ({{.Role}})" class="tw-flex tw-flex-col tw-items-center tw-p-2">
|
||||||
|
{{ctx.AvatarUtils.Avatar .User 48}}
|
||||||
|
<span class="tw-text-sm tw-mt-1">{{.User.Name}}</span>
|
||||||
|
<span class="tw-text-xs text grey">{{.Role}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .PageIsViewRepositories}}
|
||||||
{{template "shared/repo/search" .}}
|
{{template "shared/repo/search" .}}
|
||||||
{{template "shared/repo/list" .}}
|
{{template "shared/repo/list" .}}
|
||||||
{{template "base/paginate" .}}
|
{{template "base/paginate" .}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .ShowMemberAndTeamTab}}
|
{{if .ShowMemberAndTeamTab}}
|
||||||
|
|||||||
43
templates/pages/documentation.tmpl
Normal file
43
templates/pages/documentation.tmpl
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div class="page-content pages-landing pages-documentation">
|
||||||
|
{{template "pages/header" .}}
|
||||||
|
|
||||||
|
<div class="pages-docs-layout">
|
||||||
|
<aside class="pages-docs-sidebar">
|
||||||
|
<div class="pages-docs-search">
|
||||||
|
<input type="text" placeholder="{{ctx.Locale.Tr "search"}}..." class="pages-search-input">
|
||||||
|
</div>
|
||||||
|
<nav class="pages-docs-nav">
|
||||||
|
{{if .Config.Documentation.Sidebar}}
|
||||||
|
{{range .Config.Documentation.Sidebar}}
|
||||||
|
<div class="pages-docs-section {{if .Collapsed}}collapsed{{end}}">
|
||||||
|
{{if .Title}}
|
||||||
|
<h4 class="pages-docs-section-title">{{.Title}}</h4>
|
||||||
|
{{end}}
|
||||||
|
<ul class="pages-docs-list">
|
||||||
|
{{range .Items}}
|
||||||
|
<li><a href="/docs/{{.}}">{{.}}</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="pages-docs-content">
|
||||||
|
<article class="pages-docs-article">
|
||||||
|
{{if .ReadmeContent}}
|
||||||
|
<div class="markup">
|
||||||
|
{{.ReadmeContent}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p>{{ctx.Locale.Tr "repo.no_desc"}}</p>
|
||||||
|
{{end}}
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "pages/footer" .}}
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
32
templates/pages/footer.tmpl
Normal file
32
templates/pages/footer.tmpl
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<footer class="pages-footer">
|
||||||
|
<div class="container">
|
||||||
|
{{if .Config.Footer.Links}}
|
||||||
|
<div class="pages-footer-links">
|
||||||
|
{{range .Config.Footer.Links}}
|
||||||
|
<div class="pages-footer-column">
|
||||||
|
{{if .Title}}
|
||||||
|
<h4 class="pages-footer-title">{{.Title}}</h4>
|
||||||
|
{{end}}
|
||||||
|
<ul class="pages-footer-list">
|
||||||
|
{{range .Items}}
|
||||||
|
<li><a href="{{.URL}}">{{.Text}}</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="pages-footer-bottom">
|
||||||
|
{{if .Config.Footer.Copyright}}
|
||||||
|
<p class="pages-footer-copyright">{{.Config.Footer.Copyright}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Config.Footer.ShowPoweredBy}}
|
||||||
|
<p class="pages-footer-powered">
|
||||||
|
Powered by <a href="https://about.gitea.com" target="_blank">Gitea</a>
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
25
templates/pages/header.tmpl
Normal file
25
templates/pages/header.tmpl
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<header class="pages-header">
|
||||||
|
<div class="container">
|
||||||
|
<nav class="pages-nav">
|
||||||
|
<a href="/" class="pages-nav-brand">
|
||||||
|
{{if .Config.Branding.Logo}}
|
||||||
|
<img src="{{.Config.Branding.Logo}}" alt="{{.Repository.Name}}" class="pages-nav-logo">
|
||||||
|
{{else}}
|
||||||
|
<span class="pages-nav-title">{{.Repository.Name}}</span>
|
||||||
|
{{end}}
|
||||||
|
</a>
|
||||||
|
<div class="pages-nav-links">
|
||||||
|
{{if .Config.Footer.Links}}
|
||||||
|
{{range .Config.Footer.Links}}
|
||||||
|
{{range .Items}}
|
||||||
|
<a href="{{.URL}}" class="pages-nav-link">{{.Text}}</a>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
<a href="{{AppSubUrl}}/{{.Repository.FullName}}" class="ui mini button" target="_blank">
|
||||||
|
{{svg "octicon-mark-github" 16}} View Source
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
69
templates/pages/portfolio.tmpl
Normal file
69
templates/pages/portfolio.tmpl
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div class="page-content pages-landing pages-portfolio">
|
||||||
|
{{template "pages/header" .}}
|
||||||
|
|
||||||
|
<main class="pages-main">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="pages-hero">
|
||||||
|
<div class="container">
|
||||||
|
{{if .Config.Branding.Logo}}
|
||||||
|
<img src="{{.Config.Branding.Logo}}" alt="{{.Repository.Name}}" class="pages-logo">
|
||||||
|
{{end}}
|
||||||
|
<h1 class="pages-title">{{if .Config.Hero.Title}}{{.Config.Hero.Title}}{{else}}{{.Repository.Name}}{{end}}</h1>
|
||||||
|
{{if .Config.Hero.Tagline}}
|
||||||
|
<p class="pages-tagline">{{.Config.Hero.Tagline}}</p>
|
||||||
|
{{end}}
|
||||||
|
<div class="pages-cta">
|
||||||
|
{{if .Config.Hero.CTAPrimary.Text}}
|
||||||
|
<a href="{{.Config.Hero.CTAPrimary.Link}}" class="ui primary button">
|
||||||
|
{{.Config.Hero.CTAPrimary.Text}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{if .Config.Hero.CTASecondary.Text}}
|
||||||
|
<a href="{{.Config.Hero.CTASecondary.Link}}" class="ui button">
|
||||||
|
{{.Config.Hero.CTASecondary.Text}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Gallery Section -->
|
||||||
|
{{if .Config.Gallery.Items}}
|
||||||
|
<section class="pages-gallery">
|
||||||
|
<div class="container">
|
||||||
|
<div class="pages-gallery-grid" style="--columns: {{if .Config.Gallery.Columns}}{{.Config.Gallery.Columns}}{{else}}4{{end}}">
|
||||||
|
{{range .Config.Gallery.Items}}
|
||||||
|
<div class="pages-gallery-item">
|
||||||
|
{{if .Link}}
|
||||||
|
<a href="{{.Link}}">
|
||||||
|
{{end}}
|
||||||
|
<img src="{{.Image}}" alt="{{.Title}}" class="pages-gallery-image">
|
||||||
|
{{if .Title}}
|
||||||
|
<div class="pages-gallery-caption">{{.Title}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Link}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- README Section -->
|
||||||
|
{{if .ReadmeContent}}
|
||||||
|
<section class="pages-readme">
|
||||||
|
<div class="container">
|
||||||
|
<div class="markup">
|
||||||
|
{{.ReadmeContent}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{template "pages/footer" .}}
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
92
templates/pages/product.tmpl
Normal file
92
templates/pages/product.tmpl
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div class="page-content pages-landing pages-product">
|
||||||
|
{{template "pages/header" .}}
|
||||||
|
|
||||||
|
<main class="pages-main">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="pages-hero pages-hero-product" {{if .Config.Hero.Background}}style="background-image: url('{{.Config.Hero.Background}}')"{{end}}>
|
||||||
|
<div class="pages-hero-overlay"></div>
|
||||||
|
<div class="container">
|
||||||
|
{{if .Config.Branding.Logo}}
|
||||||
|
<img src="{{.Config.Branding.Logo}}" alt="{{.Repository.Name}}" class="pages-logo">
|
||||||
|
{{end}}
|
||||||
|
<h1 class="pages-title">{{if .Config.Hero.Title}}{{.Config.Hero.Title}}{{else}}{{.Repository.Name}}{{end}}</h1>
|
||||||
|
{{if .Config.Hero.Tagline}}
|
||||||
|
<p class="pages-tagline">{{.Config.Hero.Tagline}}</p>
|
||||||
|
{{end}}
|
||||||
|
<div class="pages-cta">
|
||||||
|
{{if .Config.Hero.CTAPrimary.Text}}
|
||||||
|
<a href="{{.Config.Hero.CTAPrimary.Link}}" class="ui large primary button">
|
||||||
|
{{.Config.Hero.CTAPrimary.Text}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{if .Config.Hero.CTASecondary.Text}}
|
||||||
|
<a href="{{.Config.Hero.CTASecondary.Link}}" class="ui large button {{if eq .Config.Hero.CTASecondary.Style "outline"}}basic inverted{{end}}">
|
||||||
|
{{.Config.Hero.CTASecondary.Text}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features Section -->
|
||||||
|
{{if .Config.Features}}
|
||||||
|
<section class="pages-features">
|
||||||
|
<div class="container">
|
||||||
|
<div class="pages-features-grid">
|
||||||
|
{{range .Config.Features}}
|
||||||
|
<div class="pages-feature">
|
||||||
|
{{if .Icon}}
|
||||||
|
<div class="pages-feature-icon">
|
||||||
|
{{if hasPrefix .Icon "./"}}
|
||||||
|
<img src="{{.Icon}}" alt="{{.Title}}">
|
||||||
|
{{else}}
|
||||||
|
{{svg (printf "octicon-%s" .Icon) 32}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<h3 class="pages-feature-title">{{.Title}}</h3>
|
||||||
|
<p class="pages-feature-description">{{.Description}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- README Section -->
|
||||||
|
{{if .ReadmeContent}}
|
||||||
|
<section class="pages-readme">
|
||||||
|
<div class="container">
|
||||||
|
<div class="markup">
|
||||||
|
{{.ReadmeContent}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<section class="pages-stats">
|
||||||
|
<div class="container">
|
||||||
|
<div class="pages-stats-grid">
|
||||||
|
<div class="pages-stat">
|
||||||
|
<span class="pages-stat-value">{{.NumStars}}</span>
|
||||||
|
<span class="pages-stat-label">{{ctx.Locale.Tr "repo.stars"}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pages-stat">
|
||||||
|
<span class="pages-stat-value">{{.NumForks}}</span>
|
||||||
|
<span class="pages-stat-label">{{ctx.Locale.Tr "repo.forks"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pages-cta-bottom">
|
||||||
|
<a href="{{AppSubUrl}}/{{.Repository.FullName}}" class="ui large button" target="_blank">
|
||||||
|
{{svg "octicon-star" 16}} Star on Gitea
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{template "pages/footer" .}}
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
69
templates/pages/simple.tmpl
Normal file
69
templates/pages/simple.tmpl
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
{{template "base/head" .}}
|
||||||
|
<div class="page-content pages-landing pages-simple">
|
||||||
|
{{template "pages/header" .}}
|
||||||
|
|
||||||
|
<main class="pages-main">
|
||||||
|
{{if .Config.Hero.Title}}
|
||||||
|
<section class="pages-hero">
|
||||||
|
<div class="container">
|
||||||
|
{{if .Config.Branding.Logo}}
|
||||||
|
<img src="{{.Config.Branding.Logo}}" alt="{{.Repository.Name}}" class="pages-logo">
|
||||||
|
{{end}}
|
||||||
|
<h1 class="pages-title">{{.Config.Hero.Title}}</h1>
|
||||||
|
{{if .Config.Hero.Tagline}}
|
||||||
|
<p class="pages-tagline">{{.Config.Hero.Tagline}}</p>
|
||||||
|
{{end}}
|
||||||
|
<div class="pages-cta">
|
||||||
|
{{if .Config.Hero.CTAPrimary.Text}}
|
||||||
|
<a href="{{.Config.Hero.CTAPrimary.Link}}" class="ui primary button">
|
||||||
|
{{.Config.Hero.CTAPrimary.Text}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
{{if .Config.Hero.CTASecondary.Text}}
|
||||||
|
<a href="{{.Config.Hero.CTASecondary.Link}}" class="ui button {{if eq .Config.Hero.CTASecondary.Style "outline"}}basic{{end}}">
|
||||||
|
{{.Config.Hero.CTASecondary.Text}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ReadmeContent}}
|
||||||
|
<section class="pages-readme">
|
||||||
|
<div class="container">
|
||||||
|
<div class="markup">
|
||||||
|
{{.ReadmeContent}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class="pages-stats">
|
||||||
|
<div class="container">
|
||||||
|
<div class="pages-stats-grid">
|
||||||
|
<div class="pages-stat">
|
||||||
|
<span class="pages-stat-icon">{{svg "octicon-star"}}</span>
|
||||||
|
<span class="pages-stat-value">{{.NumStars}}</span>
|
||||||
|
<span class="pages-stat-label">{{ctx.Locale.Tr "repo.stars"}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pages-stat">
|
||||||
|
<span class="pages-stat-icon">{{svg "octicon-repo-forked"}}</span>
|
||||||
|
<span class="pages-stat-value">{{.NumForks}}</span>
|
||||||
|
<span class="pages-stat-label">{{ctx.Locale.Tr "repo.forks"}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .Repository.PrimaryLanguage}}
|
||||||
|
<div class="pages-stat">
|
||||||
|
<span class="pages-stat-icon language-color" style="background-color: {{.Repository.PrimaryLanguage.Color}}"></span>
|
||||||
|
<span class="pages-stat-value">{{.Repository.PrimaryLanguage.Language}}</span>
|
||||||
|
<span class="pages-stat-label">{{ctx.Locale.Tr "repo.language"}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{template "pages/footer" .}}
|
||||||
|
</div>
|
||||||
|
{{template "base/footer" .}}
|
||||||
@ -38,6 +38,9 @@
|
|||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
<a class="{{if .PageIsSettingsPages}}active {{end}}item" href="{{.RepoLink}}/settings/pages">
|
||||||
|
{{ctx.Locale.Tr "repo.settings.pages"}}
|
||||||
|
</a>
|
||||||
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsActionsSettingsGeneral}}open{{end}}>
|
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables .PageIsActionsSettingsGeneral}}open{{end}}>
|
||||||
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
|
|||||||
147
templates/repo/settings/pages.tmpl
Normal file
147
templates/repo/settings/pages.tmpl
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings pages")}}
|
||||||
|
<div class="user-main-content twelve wide column">
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
{{ctx.Locale.Tr "repo.settings.pages"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
{{if .PagesEnabled}}
|
||||||
|
<div class="ui positive message">
|
||||||
|
<div class="header">{{ctx.Locale.Tr "repo.settings.pages.enabled"}}</div>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.settings.pages.enabled_desc"}}</p>
|
||||||
|
<p><strong>{{ctx.Locale.Tr "repo.settings.pages.subdomain"}}:</strong> <code>{{.PagesSubdomain}}</code></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="ui form" method="post">
|
||||||
|
<input type="hidden" name="action" value="update_template">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.settings.pages.template"}}</label>
|
||||||
|
<select name="template" class="ui dropdown">
|
||||||
|
{{range .PagesTemplates}}
|
||||||
|
<option value="{{.}}" {{if eq $.PagesTemplate .}}selected{{end}}>{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.pages.update_template"}}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<form class="ui form" method="post">
|
||||||
|
<input type="hidden" name="action" value="disable">
|
||||||
|
<button class="ui red button">{{ctx.Locale.Tr "repo.settings.pages.disable"}}</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<div class="ui info message">
|
||||||
|
<div class="header">{{ctx.Locale.Tr "repo.settings.pages.not_enabled"}}</div>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.settings.pages.not_enabled_desc"}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="ui form" method="post">
|
||||||
|
<input type="hidden" name="action" value="enable">
|
||||||
|
<div class="field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.settings.pages.template"}}</label>
|
||||||
|
<select name="template" class="ui dropdown">
|
||||||
|
{{range .PagesTemplates}}
|
||||||
|
<option value="{{.}}">{{.}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.pages.enable"}}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .PagesEnabled}}
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
{{ctx.Locale.Tr "repo.settings.pages.configuration"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<p>{{ctx.Locale.Tr "repo.settings.pages.config_desc"}}</p>
|
||||||
|
<p>{{ctx.Locale.Tr "repo.settings.pages.config_file_hint"}}</p>
|
||||||
|
<div class="ui secondary segment">
|
||||||
|
<code>.gitea/landing.yaml</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="ui top attached header">
|
||||||
|
{{ctx.Locale.Tr "repo.settings.pages.custom_domains"}}
|
||||||
|
</h4>
|
||||||
|
<div class="ui attached segment">
|
||||||
|
<p>{{ctx.Locale.Tr "repo.settings.pages.custom_domains_desc"}}</p>
|
||||||
|
|
||||||
|
{{if .PagesDomains}}
|
||||||
|
<table class="ui table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.settings.pages.domain"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.settings.pages.status"}}</th>
|
||||||
|
<th>{{ctx.Locale.Tr "repo.settings.pages.ssl"}}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .PagesDomains}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Domain}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .Verified}}
|
||||||
|
<span class="ui green label">{{ctx.Locale.Tr "repo.settings.pages.verified"}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="ui yellow label">{{ctx.Locale.Tr "repo.settings.pages.pending"}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .SSLStatus "active"}}
|
||||||
|
<span class="ui green label">{{ctx.Locale.Tr "repo.settings.pages.ssl_active"}}</span>
|
||||||
|
{{else if eq .SSLStatus "pending"}}
|
||||||
|
<span class="ui yellow label">{{ctx.Locale.Tr "repo.settings.pages.ssl_pending"}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="ui grey label">{{ctx.Locale.Tr "repo.settings.pages.ssl_none"}}</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="tw-text-right">
|
||||||
|
{{if not .Verified}}
|
||||||
|
<form method="post" class="tw-inline-block">
|
||||||
|
<input type="hidden" name="action" value="verify_domain">
|
||||||
|
<input type="hidden" name="domain_id" value="{{.ID}}">
|
||||||
|
<button class="ui primary tiny button">{{ctx.Locale.Tr "repo.settings.pages.verify"}}</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" class="tw-inline-block">
|
||||||
|
<input type="hidden" name="action" value="delete_domain">
|
||||||
|
<input type="hidden" name="domain_id" value="{{.ID}}">
|
||||||
|
<button class="ui red tiny button">{{ctx.Locale.Tr "remove"}}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{if not .Verified}}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">
|
||||||
|
<div class="ui info message">
|
||||||
|
<p>{{ctx.Locale.Tr "repo.settings.pages.verify_dns_hint"}}</p>
|
||||||
|
<code>TXT _gitea-pages.{{.Domain}} {{.VerificationToken}}</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form class="ui form" method="post">
|
||||||
|
<input type="hidden" name="action" value="add_domain">
|
||||||
|
<div class="inline field">
|
||||||
|
<label>{{ctx.Locale.Tr "repo.settings.pages.add_domain"}}</label>
|
||||||
|
<input name="domain" type="text" placeholder="example.com">
|
||||||
|
<button class="ui primary button">{{ctx.Locale.Tr "repo.settings.pages.add"}}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{template "repo/settings/layout_footer" .}}
|
||||||
Loading…
Reference in New Issue
Block a user