From b816ee4eec2cafbf689546b04802d65f92efc649 Mon Sep 17 00:00:00 2001 From: logikonline Date: Fri, 9 Jan 2026 15:14:27 -0500 Subject: [PATCH] feat: add Phases 3-5 enhancements (org profiles, pages, wiki v2 API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitcleaner/health-report.md | 26 + docs/phase5-ai-wiki-spec.md | 354 +++++++++ docs/wiki-v2-api-developer-guide.md | 1050 +++++++++++++++++++++++++++ enhancements.md | 156 ++++ models/migrations/migrations.go | 3 + models/migrations/v1_26/v326.go | 35 + models/migrations/v1_26/v327.go | 39 + models/migrations/v1_26/v328.go | 30 + models/organization/org_pinned.go | 273 +++++++ models/organization/org_profile.go | 170 +++++ models/repo/pages.go | 309 ++++++++ models/repo/wiki_ai.go | 134 ++++ modules/errors/codes.go | 14 + modules/pages/config.go | 262 +++++++ modules/structs/org.go | 96 +++ modules/structs/repo_pages.go | 47 ++ modules/structs/repo_wiki_v2.go | 177 +++++ options/locale/locale_en-US.json | 38 + routers/api/v1/api.go | 27 + routers/api/v1/org/pinned.go | 440 +++++++++++ routers/api/v1/org/profile.go | 291 ++++++++ routers/api/v1/repo/pages.go | 387 ++++++++++ routers/api/v2/api.go | 19 + routers/api/v2/wiki.go | 908 +++++++++++++++++++++++ routers/web/org/home.go | 52 ++ routers/web/pages/pages.go | 423 +++++++++++ routers/web/repo/setting/pages.go | 134 ++++ routers/web/web.go | 10 + services/org/pinned.go | 77 ++ services/pages/pages.go | 253 +++++++ services/wiki/wiki_index.go | 369 ++++++++++ templates/org/home.tmpl | 111 +++ templates/pages/documentation.tmpl | 43 ++ templates/pages/footer.tmpl | 32 + templates/pages/header.tmpl | 25 + templates/pages/portfolio.tmpl | 69 ++ templates/pages/product.tmpl | 92 +++ templates/pages/simple.tmpl | 69 ++ templates/repo/settings/navbar.tmpl | 3 + templates/repo/settings/pages.tmpl | 147 ++++ 40 files changed, 7194 insertions(+) create mode 100644 .gitcleaner/health-report.md create mode 100644 docs/phase5-ai-wiki-spec.md create mode 100644 docs/wiki-v2-api-developer-guide.md create mode 100644 models/migrations/v1_26/v326.go create mode 100644 models/migrations/v1_26/v327.go create mode 100644 models/migrations/v1_26/v328.go create mode 100644 models/organization/org_pinned.go create mode 100644 models/organization/org_profile.go create mode 100644 models/repo/pages.go create mode 100644 models/repo/wiki_ai.go create mode 100644 modules/pages/config.go create mode 100644 modules/structs/repo_pages.go create mode 100644 modules/structs/repo_wiki_v2.go create mode 100644 routers/api/v1/org/pinned.go create mode 100644 routers/api/v1/org/profile.go create mode 100644 routers/api/v1/repo/pages.go create mode 100644 routers/api/v2/wiki.go create mode 100644 routers/web/pages/pages.go create mode 100644 routers/web/repo/setting/pages.go create mode 100644 services/org/pinned.go create mode 100644 services/pages/pages.go create mode 100644 services/wiki/wiki_index.go create mode 100644 templates/pages/documentation.tmpl create mode 100644 templates/pages/footer.tmpl create mode 100644 templates/pages/header.tmpl create mode 100644 templates/pages/portfolio.tmpl create mode 100644 templates/pages/product.tmpl create mode 100644 templates/pages/simple.tmpl create mode 100644 templates/repo/settings/pages.tmpl diff --git a/.gitcleaner/health-report.md b/.gitcleaner/health-report.md new file mode 100644 index 0000000000..3a029e447d --- /dev/null +++ b/.gitcleaner/health-report.md @@ -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 diff --git a/docs/phase5-ai-wiki-spec.md b/docs/phase5-ai-wiki-spec.md new file mode 100644 index 0000000000..8cacfd35cb --- /dev/null +++ b/docs/phase5-ai-wiki-spec.md @@ -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": "

Home

..." // 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": "

Installation

...", + "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 installation in Docker, 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* diff --git a/docs/wiki-v2-api-developer-guide.md b/docs/wiki-v2-api-developer-guide.md new file mode 100644 index 0000000000..3cfc292ec9 --- /dev/null +++ b/docs/wiki-v2-api-developer-guide.md @@ -0,0 +1,1050 @@ +# Gitea Wiki V2 API Developer Guide + +**For .NET Plugin Developers** + +This guide explains how to integrate with the Gitea Wiki V2 API from your .NET application. The V2 API provides structured, AI-friendly endpoints designed for external tool integration. + +--- + +## Table of Contents + +1. [Authentication](#authentication) +2. [Base URL](#base-url) +3. [API Endpoints](#api-endpoints) + - [List Wiki Pages](#list-wiki-pages) + - [Get Wiki Page](#get-wiki-page) + - [Create Wiki Page](#create-wiki-page) + - [Update Wiki Page](#update-wiki-page) + - [Delete Wiki Page](#delete-wiki-page) + - [Search Wiki](#search-wiki) + - [Get Link Graph](#get-link-graph) + - [Get Wiki Statistics](#get-wiki-statistics) + - [Get Page Revisions](#get-page-revisions) +4. [Data Models](#data-models) +5. [C# Example Code](#c-example-code) +6. [Error Handling](#error-handling) +7. [Best Practices](#best-practices) + +--- + +## Authentication + +All write operations (create, update, delete) require authentication. Read operations on public repositories work without authentication. + +### Token Authentication + +Use a personal access token in the `Authorization` header: + +```http +Authorization: token YOUR_ACCESS_TOKEN +``` + +### Creating a Token + +1. Go to Gitea → Settings → Applications → Generate New Token +2. Select scopes: `write:repository` (for wiki access) +3. Copy the token and store securely + +### C# HttpClient Setup + +```csharp +var client = new HttpClient(); +client.BaseAddress = new Uri("https://your-gitea-instance.com/api/v2/"); +client.DefaultRequestHeaders.Add("Authorization", $"token {accessToken}"); +client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); +``` + +--- + +## Base URL + +``` +https://{gitea-instance}/api/v2/repos/{owner}/{repo}/wiki +``` + +Replace: +- `{gitea-instance}` - Your Gitea server domain +- `{owner}` - Repository owner (user or organization) +- `{repo}` - Repository name + +--- + +## API Endpoints + +### List Wiki Pages + +Returns all wiki pages with metadata. + +```http +GET /api/v2/repos/{owner}/{repo}/wiki/pages +``` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | int | 1 | Page number (1-based) | +| `limit` | int | 30 | Items per page (max 100) | + +**Response:** `WikiPageListV2` + +```json +{ + "pages": [ + { + "name": "Home", + "title": "Home", + "path": "Home.md", + "url": "https://gitea.example.com/api/v2/repos/owner/repo/wiki/pages/Home", + "html_url": "https://gitea.example.com/owner/repo/wiki/Home", + "word_count": 250, + "last_commit": { + "sha": "abc123...", + "message": "Update Home page", + "date": "2026-01-09T10:30:00Z", + "author": { + "name": "John Doe", + "email": "john@example.com" + } + } + } + ], + "total_count": 15, + "has_more": false +} +``` + +--- + +### Get Wiki Page + +Returns a single page with full content, HTML rendering, and link information. + +```http +GET /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName} +``` + +**Response:** `WikiPageV2` + +```json +{ + "name": "Getting-Started", + "title": "Getting Started", + "path": "Getting-Started.md", + "url": "https://gitea.example.com/api/v2/repos/owner/repo/wiki/pages/Getting-Started", + "html_url": "https://gitea.example.com/owner/repo/wiki/Getting-Started", + "content": "# Getting Started\n\nWelcome to the project...", + "content_html": "

Getting Started

\n

Welcome to the project...

", + "word_count": 450, + "links_out": ["Installation", "Configuration", "FAQ"], + "links_in": ["Home", "README"], + "sidebar": "## Navigation\n- [[Home]]\n- [[Getting-Started]]", + "footer": "Copyright 2026", + "history_url": "https://gitea.example.com/api/v2/repos/owner/repo/wiki/pages/Getting-Started/revisions", + "last_commit": { + "sha": "def456...", + "message": "Add quick start section", + "date": "2026-01-08T14:22:00Z", + "author": { + "name": "Jane Smith", + "email": "jane@example.com" + }, + "committer": { + "name": "Jane Smith", + "email": "jane@example.com" + } + } +} +``` + +**Key Fields for AI Integration:** + +| Field | Description | +|-------|-------------| +| `content` | Raw markdown content (use for AI processing) | +| `content_html` | Pre-rendered HTML (use for display) | +| `links_out` | Pages this page links to | +| `links_in` | Pages that link to this page | +| `word_count` | Word count for content analysis | + +--- + +### Create Wiki Page + +Creates a new wiki page. + +```http +POST /api/v2/repos/{owner}/{repo}/wiki/pages +``` + +**Request Body:** `CreateWikiPageV2Option` + +```json +{ + "name": "New-Feature", + "title": "New Feature Documentation", + "content": "# New Feature\n\nThis page documents the new feature...", + "message": "Add documentation for new feature" +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Page name (used in URL, spaces become dashes) | +| `title` | No | Display title (defaults to name) | +| `content` | Yes | Markdown content | +| `message` | No | Commit message (auto-generated if empty) | + +**Response:** Redirects to the created page (HTTP 302) + +--- + +### Update Wiki Page + +Updates an existing wiki page. Can also rename the page. + +```http +PUT /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName} +``` + +**Request Body:** `UpdateWikiPageV2Option` + +```json +{ + "title": "Updated Title", + "content": "# Updated Content\n\nNew content here...", + "message": "Update page content", + "rename_to": "New-Page-Name" +} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `title` | No | New display title | +| `content` | No | New markdown content | +| `message` | No | Commit message | +| `rename_to` | No | New page name (renames the page) | + +**Response:** Redirects to the updated page (HTTP 302) + +--- + +### Delete Wiki Page + +Deletes a wiki page. + +```http +DELETE /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName} +``` + +**Response:** `WikiDeleteResponseV2` + +```json +{ + "success": true +} +``` + +--- + +### Search Wiki + +Full-text search across all wiki pages. + +```http +GET /api/v2/repos/{owner}/{repo}/wiki/search?q={query} +``` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `q` | string | required | Search query | +| `limit` | int | 20 | Max results (max 100) | +| `offset` | int | 0 | Skip N results | + +**Response:** `WikiSearchResponseV2` + +```json +{ + "query": "installation", + "results": [ + { + "name": "Getting-Started", + "title": "Getting Started", + "snippet": "...Follow these steps for **installation**. First, download the package...", + "score": 8.5, + "word_count": 450, + "last_updated": "2026-01-08T14:22:00Z" + }, + { + "name": "Installation", + "title": "Installation Guide", + "snippet": "# **Installation** Guide\n\nThis page covers all **installation** methods...", + "score": 12.3, + "word_count": 890, + "last_updated": "2026-01-05T09:15:00Z" + } + ], + "total_count": 2 +} +``` + +**Score Calculation:** +- Title match: +10 points +- Page name match: +8 points +- Content matches: +0.5 per occurrence +- Normalized by word count + +--- + +### Get Link Graph + +Returns the wiki's link relationship graph (for visualization or analysis). + +```http +GET /api/v2/repos/{owner}/{repo}/wiki/graph +``` + +**Response:** `WikiGraphV2` + +```json +{ + "nodes": [ + { "name": "Home", "title": "Home", "word_count": 250 }, + { "name": "Getting-Started", "title": "Getting Started", "word_count": 450 }, + { "name": "Installation", "title": "Installation Guide", "word_count": 890 }, + { "name": "Configuration", "title": "Configuration", "word_count": 320 } + ], + "edges": [ + { "source": "Home", "target": "Getting-Started" }, + { "source": "Home", "target": "Installation" }, + { "source": "Getting-Started", "target": "Installation" }, + { "source": "Getting-Started", "target": "Configuration" }, + { "source": "Installation", "target": "Configuration" } + ] +} +``` + +**Use Cases:** +- Build a visual wiki map +- Find navigation paths between pages +- Identify central/hub pages +- Detect isolated page clusters + +--- + +### Get Wiki Statistics + +Returns comprehensive wiki statistics and health metrics. + +```http +GET /api/v2/repos/{owner}/{repo}/wiki/stats +``` + +**Response:** `WikiStatsV2` + +```json +{ + "total_pages": 15, + "total_words": 12500, + "total_commits": 87, + "last_updated": "2026-01-09T10:30:00Z", + "contributors": 5, + "health": { + "orphaned_pages": [ + { "name": "Old-Feature", "word_count": 120 } + ], + "dead_links": [ + { "page": "Getting-Started", "broken_link": "Deprecated-Page" } + ], + "outdated_pages": [ + { "name": "Legacy-Setup", "last_edit": "2025-03-15T00:00:00Z", "days_old": 300 } + ], + "short_pages": [ + { "name": "TODO", "word_count": 25 } + ] + }, + "top_linked": [ + { "name": "Home", "incoming_links": 12 }, + { "name": "Installation", "incoming_links": 8 }, + { "name": "Configuration", "incoming_links": 6 } + ] +} +``` + +**Health Metrics:** + +| Metric | Description | +|--------|-------------| +| `orphaned_pages` | Pages with no incoming links (except Home) | +| `dead_links` | Links pointing to non-existent pages | +| `outdated_pages` | Pages not edited in 180+ days | +| `short_pages` | Pages with < 100 words | + +--- + +### Get Page Revisions + +Returns the revision history for a specific page. + +```http +GET /api/v2/repos/{owner}/{repo}/wiki/pages/{pageName}/revisions +``` + +**Query Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | int | 1 | Page number | + +**Response:** `WikiRevisionsV2` + +```json +{ + "page_name": "Getting-Started", + "revisions": [ + { + "sha": "abc123...", + "message": "Add troubleshooting section", + "date": "2026-01-09T10:30:00Z", + "author": { + "name": "John Doe", + "email": "john@example.com" + } + }, + { + "sha": "def456...", + "message": "Fix typo in installation steps", + "date": "2026-01-08T14:22:00Z", + "author": { + "name": "Jane Smith", + "email": "jane@example.com" + } + } + ], + "total_count": 15 +} +``` + +--- + +## Data Models + +### C# Model Definitions + +```csharp +// Wiki Page (full details) +public class WikiPageV2 +{ + public string Name { get; set; } + public string Title { get; set; } + public string Path { get; set; } + public string Url { get; set; } + public string HtmlUrl { get; set; } + public string Content { get; set; } + public string ContentHtml { get; set; } + public int WordCount { get; set; } + public List LinksOut { get; set; } + public List LinksIn { get; set; } + public string Sidebar { get; set; } + public string Footer { get; set; } + public string HistoryUrl { get; set; } + public WikiCommitV2 LastCommit { get; set; } +} + +// Wiki Commit +public class WikiCommitV2 +{ + public string Sha { get; set; } + public string Message { get; set; } + public DateTime Date { get; set; } + public WikiAuthorV2 Author { get; set; } + public WikiAuthorV2 Committer { get; set; } +} + +// Wiki Author +public class WikiAuthorV2 +{ + public string Username { get; set; } + public string Name { get; set; } + public string Email { get; set; } + public string AvatarUrl { get; set; } +} + +// Page List Response +public class WikiPageListV2 +{ + public List Pages { get; set; } + public long TotalCount { get; set; } + public bool HasMore { get; set; } +} + +// Search Result +public class WikiSearchResultV2 +{ + public string Name { get; set; } + public string Title { get; set; } + public string Snippet { get; set; } + public float Score { get; set; } + public int WordCount { get; set; } + public DateTime LastUpdated { get; set; } +} + +// Search Response +public class WikiSearchResponseV2 +{ + public string Query { get; set; } + public List Results { get; set; } + public long TotalCount { get; set; } +} + +// Graph Node +public class WikiGraphNodeV2 +{ + public string Name { get; set; } + public string Title { get; set; } + public int WordCount { get; set; } +} + +// Graph Edge +public class WikiGraphEdgeV2 +{ + public string Source { get; set; } + public string Target { get; set; } +} + +// Graph Response +public class WikiGraphV2 +{ + public List Nodes { get; set; } + public List Edges { get; set; } +} + +// Health Metrics +public class WikiHealthV2 +{ + public List OrphanedPages { get; set; } + public List DeadLinks { get; set; } + public List OutdatedPages { get; set; } + public List ShortPages { get; set; } +} + +public class WikiOrphanedPageV2 +{ + public string Name { get; set; } + public int WordCount { get; set; } +} + +public class WikiDeadLinkV2 +{ + public string Page { get; set; } + public string BrokenLink { get; set; } +} + +public class WikiOutdatedPageV2 +{ + public string Name { get; set; } + public DateTime LastEdit { get; set; } + public int DaysOld { get; set; } +} + +public class WikiShortPageV2 +{ + public string Name { get; set; } + public int WordCount { get; set; } +} + +// Statistics Response +public class WikiStatsV2 +{ + public long TotalPages { get; set; } + public long TotalWords { get; set; } + public long TotalCommits { get; set; } + public DateTime LastUpdated { get; set; } + public long Contributors { get; set; } + public WikiHealthV2 Health { get; set; } + public List TopLinked { get; set; } +} + +public class WikiTopLinkedPageV2 +{ + public string Name { get; set; } + public int IncomingLinks { get; set; } +} + +// Create/Update Options +public class CreateWikiPageV2Option +{ + public string Name { get; set; } + public string Title { get; set; } + public string Content { get; set; } + public string Message { get; set; } +} + +public class UpdateWikiPageV2Option +{ + public string Title { get; set; } + public string Content { get; set; } + public string Message { get; set; } + public string RenameTo { get; set; } +} +``` + +--- + +## C# Example Code + +### Complete Wiki Client + +```csharp +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; + +public class GiteaWikiClient : IDisposable +{ + private readonly HttpClient _client; + private readonly string _owner; + private readonly string _repo; + private readonly JsonSerializerOptions _jsonOptions; + + public GiteaWikiClient(string baseUrl, string owner, string repo, string accessToken) + { + _owner = owner; + _repo = repo; + + _client = new HttpClient + { + BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") + }; + _client.DefaultRequestHeaders.Add("Authorization", $"token {accessToken}"); + + _jsonOptions = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + } + + private string WikiPath => $"api/v2/repos/{_owner}/{_repo}/wiki"; + + // List all pages + public async Task ListPagesAsync(int page = 1, int limit = 30) + { + var response = await _client.GetAsync($"{WikiPath}/pages?page={page}&limit={limit}"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(_jsonOptions); + } + + // Get a single page + public async Task GetPageAsync(string pageName) + { + var response = await _client.GetAsync($"{WikiPath}/pages/{Uri.EscapeDataString(pageName)}"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(_jsonOptions); + } + + // Create a new page + public async Task CreatePageAsync(string name, string content, string title = null, string message = null) + { + var options = new CreateWikiPageV2Option + { + Name = name, + Title = title ?? name, + Content = content, + Message = message + }; + + var response = await _client.PostAsJsonAsync($"{WikiPath}/pages", options, _jsonOptions); + response.EnsureSuccessStatusCode(); + + // Follow redirect to get the created page + if (response.Headers.Location != null) + { + return await GetPageAsync(name); + } + return null; + } + + // Update a page + public async Task UpdatePageAsync(string pageName, string content = null, string title = null, string renameTo = null, string message = null) + { + var options = new UpdateWikiPageV2Option + { + Title = title, + Content = content, + Message = message, + RenameTo = renameTo + }; + + var response = await _client.PutAsJsonAsync($"{WikiPath}/pages/{Uri.EscapeDataString(pageName)}", options, _jsonOptions); + response.EnsureSuccessStatusCode(); + + var finalName = renameTo ?? pageName; + return await GetPageAsync(finalName); + } + + // Delete a page + public async Task DeletePageAsync(string pageName) + { + var response = await _client.DeleteAsync($"{WikiPath}/pages/{Uri.EscapeDataString(pageName)}"); + response.EnsureSuccessStatusCode(); + return true; + } + + // Search wiki + public async Task SearchAsync(string query, int limit = 20, int offset = 0) + { + var url = $"{WikiPath}/search?q={Uri.EscapeDataString(query)}&limit={limit}&offset={offset}"; + var response = await _client.GetAsync(url); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(_jsonOptions); + } + + // Get link graph + public async Task GetGraphAsync() + { + var response = await _client.GetAsync($"{WikiPath}/graph"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(_jsonOptions); + } + + // Get statistics + public async Task GetStatsAsync() + { + var response = await _client.GetAsync($"{WikiPath}/stats"); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(_jsonOptions); + } + + // Get page revisions + public async Task GetRevisionsAsync(string pageName, int page = 1) + { + var url = $"{WikiPath}/pages/{Uri.EscapeDataString(pageName)}/revisions?page={page}"; + var response = await _client.GetAsync(url); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadFromJsonAsync(_jsonOptions); + } + + public void Dispose() + { + _client?.Dispose(); + } +} +``` + +### Usage Examples + +```csharp +// Initialize client +using var wiki = new GiteaWikiClient( + baseUrl: "https://gitea.example.com", + owner: "myorg", + repo: "myproject", + accessToken: "your_token_here" +); + +// List all pages +var pageList = await wiki.ListPagesAsync(); +Console.WriteLine($"Found {pageList.TotalCount} wiki pages"); + +foreach (var page in pageList.Pages) +{ + Console.WriteLine($"- {page.Title} ({page.WordCount} words)"); +} + +// Get a specific page +var page = await wiki.GetPageAsync("Getting-Started"); +Console.WriteLine($"Content: {page.Content}"); +Console.WriteLine($"Links to: {string.Join(", ", page.LinksOut)}"); +Console.WriteLine($"Linked from: {string.Join(", ", page.LinksIn)}"); + +// Create a new page +var newPage = await wiki.CreatePageAsync( + name: "API-Reference", + content: "# API Reference\n\nThis page documents the API...", + title: "API Reference", + message: "Add API documentation" +); + +// Update a page +var updated = await wiki.UpdatePageAsync( + pageName: "API-Reference", + content: "# API Reference\n\nUpdated content...", + message: "Update API docs" +); + +// Rename a page +var renamed = await wiki.UpdatePageAsync( + pageName: "API-Reference", + renameTo: "API-Documentation", + message: "Rename API page" +); + +// Search +var results = await wiki.SearchAsync("installation"); +foreach (var result in results.Results) +{ + Console.WriteLine($"Found: {result.Title} (score: {result.Score})"); + Console.WriteLine($" {result.Snippet}"); +} + +// Get wiki health +var stats = await wiki.GetStatsAsync(); +Console.WriteLine($"Wiki has {stats.TotalPages} pages with {stats.TotalWords} total words"); + +if (stats.Health.DeadLinks.Any()) +{ + Console.WriteLine("Broken links found:"); + foreach (var deadLink in stats.Health.DeadLinks) + { + Console.WriteLine($" {deadLink.Page} -> {deadLink.BrokenLink}"); + } +} + +// Delete a page +await wiki.DeletePageAsync("Old-Page"); +``` + +### AI Function Calling Integration + +If you're using the wiki with AI function calling (e.g., OpenAI, Azure OpenAI): + +```csharp +// Define functions for AI +var functions = new[] +{ + new + { + name = "search_wiki", + description = "Search the project wiki for information", + parameters = new + { + type = "object", + properties = new + { + query = new { type = "string", description = "Search query" } + }, + required = new[] { "query" } + } + }, + new + { + name = "get_wiki_page", + description = "Get the full content of a wiki page", + parameters = new + { + type = "object", + properties = new + { + page_name = new { type = "string", description = "Name of the wiki page" } + }, + required = new[] { "page_name" } + } + }, + new + { + name = "create_wiki_page", + description = "Create a new wiki page", + parameters = new + { + type = "object", + properties = new + { + name = new { type = "string", description = "Page name" }, + content = new { type = "string", description = "Markdown content" }, + message = new { type = "string", description = "Commit message" } + }, + required = new[] { "name", "content" } + } + }, + new + { + name = "get_wiki_stats", + description = "Get wiki statistics including health metrics", + parameters = new { type = "object", properties = new { } } + } +}; + +// Handle function calls +async Task HandleFunctionCall(string functionName, JsonElement arguments) +{ + switch (functionName) + { + case "search_wiki": + var query = arguments.GetProperty("query").GetString(); + var results = await wiki.SearchAsync(query); + return JsonSerializer.Serialize(results); + + case "get_wiki_page": + var pageName = arguments.GetProperty("page_name").GetString(); + var page = await wiki.GetPageAsync(pageName); + return JsonSerializer.Serialize(page); + + case "create_wiki_page": + var name = arguments.GetProperty("name").GetString(); + var content = arguments.GetProperty("content").GetString(); + var message = arguments.TryGetProperty("message", out var msg) ? msg.GetString() : null; + var newPage = await wiki.CreatePageAsync(name, content, message: message); + return JsonSerializer.Serialize(newPage); + + case "get_wiki_stats": + var stats = await wiki.GetStatsAsync(); + return JsonSerializer.Serialize(stats); + + default: + throw new ArgumentException($"Unknown function: {functionName}"); + } +} +``` + +--- + +## Error Handling + +### Error Response Format + +All errors return a consistent JSON structure: + +```json +{ + "error": { + "code": "WIKI_PAGE_NOT_FOUND", + "message": "Wiki page not found", + "details": {} + } +} +``` + +### Error Codes + +| Code | HTTP Status | Description | +|------|-------------|-------------| +| `WIKI_PAGE_NOT_FOUND` | 404 | The requested page doesn't exist | +| `WIKI_PAGE_ALREADY_EXISTS` | 409 | A page with that name already exists | +| `WIKI_RESERVED_NAME` | 400 | The page name is reserved (e.g., `_Sidebar`) | +| `WIKI_DISABLED` | 403 | Wiki is disabled for this repository | +| `AUTH_TOKEN_MISSING` | 401 | No authentication token provided | +| `REPO_NOT_FOUND` | 404 | Repository doesn't exist | +| `REPO_ARCHIVED` | 403 | Repository is archived (read-only) | +| `ACCESS_DENIED` | 403 | Insufficient permissions | + +### C# Error Handling + +```csharp +public class GiteaApiException : Exception +{ + public string ErrorCode { get; } + public int StatusCode { get; } + + public GiteaApiException(string code, string message, int statusCode) + : base(message) + { + ErrorCode = code; + StatusCode = statusCode; + } +} + +// In your client methods: +public async Task GetPageAsync(string pageName) +{ + var response = await _client.GetAsync($"{WikiPath}/pages/{Uri.EscapeDataString(pageName)}"); + + if (!response.IsSuccessStatusCode) + { + var error = await response.Content.ReadFromJsonAsync(_jsonOptions); + throw new GiteaApiException( + error?.Error?.Code ?? "UNKNOWN", + error?.Error?.Message ?? "Unknown error", + (int)response.StatusCode + ); + } + + return await response.Content.ReadFromJsonAsync(_jsonOptions); +} + +// Usage: +try +{ + var page = await wiki.GetPageAsync("NonExistent"); +} +catch (GiteaApiException ex) when (ex.ErrorCode == "WIKI_PAGE_NOT_FOUND") +{ + Console.WriteLine("Page doesn't exist, creating it..."); + await wiki.CreatePageAsync("NonExistent", "# New Page"); +} +``` + +--- + +## Best Practices + +### 1. Page Naming + +- Use URL-friendly names: `Getting-Started`, not `Getting Started` +- Avoid special characters: `/`, `\`, `?`, `#`, `%` +- Keep names concise but descriptive +- The API automatically converts spaces to dashes + +### 2. Content Format + +- Use standard Markdown +- Wiki links: `[[Page-Name]]` or `[[Page-Name|Display Text]]` +- Relative links: `[text](./Other-Page)` or `[text](Other-Page)` +- The API extracts both formats for the `links_out` field + +### 3. Commit Messages + +- Always provide meaningful commit messages +- If omitted, auto-generated: "Add {page}" or "Update {page}" +- Good for audit trail and history + +### 4. Rate Limiting + +- Be mindful of API rate limits +- Cache responses when appropriate +- Use pagination for large wikis + +### 5. Indexing + +- The wiki is automatically indexed for search +- First access may trigger background indexing +- Stats and graph endpoints trigger re-indexing if needed + +### 6. Error Recovery + +```csharp +// Retry logic for transient failures +public async Task WithRetryAsync(Func> action, int maxRetries = 3) +{ + for (int i = 0; i < maxRetries; i++) + { + try + { + return await action(); + } + catch (HttpRequestException) when (i < maxRetries - 1) + { + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i))); + } + } + throw new Exception("Max retries exceeded"); +} + +// Usage: +var page = await WithRetryAsync(() => wiki.GetPageAsync("Home")); +``` + +--- + +## Support + +For issues with the Wiki V2 API: +- Check the [Gitea documentation](https://docs.gitea.com) +- Report bugs at the Gitea repository +- For .NET client issues, check your authentication and base URL + +--- + +*Document Version: 1.0 | API Version: v2 | Last Updated: January 2026* diff --git a/enhancements.md b/enhancements.md index 23233c40b9..54196c3ca1 100644 --- a/enhancements.md +++ b/enhancements.md @@ -4,6 +4,162 @@ **Date:** January 2026 **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 diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 28917db728..2a7efcf2ec 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -400,6 +400,9 @@ func prepareMigrationTasks() []*migration { 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(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 } diff --git a/models/migrations/v1_26/v326.go b/models/migrations/v1_26/v326.go new file mode 100644 index 0000000000..eb45a88f24 --- /dev/null +++ b/models/migrations/v1_26/v326.go @@ -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)) +} diff --git a/models/migrations/v1_26/v327.go b/models/migrations/v1_26/v327.go new file mode 100644 index 0000000000..7d3541f391 --- /dev/null +++ b/models/migrations/v1_26/v327.go @@ -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)) +} diff --git a/models/migrations/v1_26/v328.go b/models/migrations/v1_26/v328.go new file mode 100644 index 0000000000..488f8b8052 --- /dev/null +++ b/models/migrations/v1_26/v328.go @@ -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)) +} diff --git a/models/organization/org_pinned.go b/models/organization/org_pinned.go new file mode 100644 index 0000000000..31d451cac3 --- /dev/null +++ b/models/organization/org_pinned.go @@ -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" +} diff --git a/models/organization/org_profile.go b/models/organization/org_profile.go new file mode 100644 index 0000000000..4667a4af8c --- /dev/null +++ b/models/organization/org_profile.go @@ -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 +} diff --git a/models/repo/pages.go b/models/repo/pages.go new file mode 100644 index 0000000000..6f0f70a58c --- /dev/null +++ b/models/repo/pages.go @@ -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) +} diff --git a/models/repo/wiki_ai.go b/models/repo/wiki_ai.go new file mode 100644 index 0000000000..facb6820ed --- /dev/null +++ b/models/repo/wiki_ai.go @@ -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 +} diff --git a/modules/errors/codes.go b/modules/errors/codes.go index cfa3bf7686..9ea219dee8 100644 --- a/modules/errors/codes.go +++ b/modules/errors/codes.go @@ -149,6 +149,14 @@ const ( 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 type errorInfo struct { Message string @@ -263,6 +271,12 @@ var errorCatalog = map[ErrorCode]errorInfo{ // Webhook errors WebhookNotFound: {"Webhook not found", http.StatusNotFound}, 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 diff --git a/modules/pages/config.go b/modules/pages/config.go new file mode 100644 index 0000000000..4769c82bde --- /dev/null +++ b/modules/pages/config.go @@ -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 +} diff --git a/modules/structs/org.go b/modules/structs/org.go index c3d70ebf00..9acdcd39d5 100644 --- a/modules/structs/org.go +++ b/modules/structs/org.go @@ -95,3 +95,99 @@ type RenameOrgOption struct { // unique: true 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"` +} diff --git a/modules/structs/repo_pages.go b/modules/structs/repo_pages.go new file mode 100644 index 0000000000..8e897a0225 --- /dev/null +++ b/modules/structs/repo_pages.go @@ -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"` +} diff --git a/modules/structs/repo_wiki_v2.go b/modules/structs/repo_wiki_v2.go new file mode 100644 index 0000000000..1f09f9bac1 --- /dev/null +++ b/modules/structs/repo_wiki_v2.go @@ -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"` +} diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 480aafe879..fdb54aeeb9 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2512,6 +2512,41 @@ "repo.settings.rename_branch_from": "old branch name", "repo.settings.rename_branch_to": "new branch name", "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.parent": "parent", "repo.diff.commit": "commit", @@ -2693,6 +2728,9 @@ "org.repo_updated": "Updated", "org.members": "Members", "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.lower_members": "members", "org.lower_repositories": "repositories", diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 7958b0d50b..cb553df03d 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -1467,6 +1467,18 @@ func Routes() *web.Router { m.Delete("", repo.DeleteAvatar) }, 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) }, repoAssignment(), checkTokenPublicOnly()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) @@ -1651,10 +1663,25 @@ func Routes() *web.Router { ) m.Group("/public_members", func() { m.Get("", org.ListPublicMembers) + m.Get("/roles", org.ListPublicMembersWithRoles) m.Combo("/{username}").Get(org.IsPublicMember). Put(reqToken(), reqOrgMembership(), org.PublicizeMember). 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.Get("", org.ListTeams) m.Post("", reqOrgOwnership(), bind(api.CreateTeamOption{}), org.CreateTeam) diff --git a/routers/api/v1/org/pinned.go b/routers/api/v1/org/pinned.go new file mode 100644 index 0000000000..0eff2c436f --- /dev/null +++ b/routers/api/v1/org/pinned.go @@ -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, + } +} diff --git a/routers/api/v1/org/profile.go b/routers/api/v1/org/profile.go new file mode 100644 index 0000000000..542c9d81d9 --- /dev/null +++ b/routers/api/v1/org/profile.go @@ -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) +} diff --git a/routers/api/v1/repo/pages.go b/routers/api/v1/repo/pages.go new file mode 100644 index 0000000000..c37a119bea --- /dev/null +++ b/routers/api/v1/repo/pages.go @@ -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 +} diff --git a/routers/api/v2/api.go b/routers/api/v2/api.go index b960dba584..24f04ed04a 100644 --- a/routers/api/v2/api.go +++ b/routers/api/v2/api.go @@ -28,6 +28,7 @@ import ( "code.gitea.io/gitea/modules/graceful" "code.gitea.io/gitea/modules/idempotency" "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/util" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/modules/web/middleware" @@ -113,6 +114,24 @@ func Routes() *web.Router { m.Post("/issue/context", GetAIIssueContext) }) }, 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 diff --git a/routers/api/v2/wiki.go b/routers/api/v2/wiki.go new file mode 100644 index 0000000000..3843efbc5d --- /dev/null +++ b/routers/api/v2/wiki.go @@ -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 +} diff --git a/routers/web/org/home.go b/routers/web/org/home.go index 63ae6c683b..4b78b1dfbe 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -20,6 +20,7 @@ import ( "code.gitea.io/gitea/modules/util" shared_user "code.gitea.io/gitea/routers/web/shared/user" "code.gitea.io/gitea/services/context" + org_service "code.gitea.io/gitea/services/org" ) const tplOrgHome templates.TplName = "org/home" @@ -109,8 +110,59 @@ func home(ctx *context.Context, viewRepositories bool) { 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" 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["PageIsViewOverview"] = isViewOverview ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil diff --git a/routers/web/pages/pages.go b/routers/web/pages/pages.go new file mode 100644 index 0000000000..7e88f44969 --- /dev/null +++ b/routers/web/pages/pages.go @@ -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" +} diff --git a/routers/web/repo/setting/pages.go b/routers/web/repo/setting/pages.go new file mode 100644 index 0000000000..5609cf4140 --- /dev/null +++ b/routers/web/repo/setting/pages.go @@ -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") +} diff --git a/routers/web/web.go b/routers/web/web.go index 4da8cdb581..58c9072c1b 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -34,6 +34,7 @@ import ( "code.gitea.io/gitea/routers/web/misc" "code.gitea.io/gitea/routers/web/org" 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/actions" 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.Combo("/pages").Get(repo_setting.Pages).Post(repo_setting.PagesPost) m.Group("/actions/general", func() { m.Get("", repo_setting.ActionsGeneralSettings) m.Post("/actions_unit", repo_setting.ActionsUnitPost) @@ -1514,6 +1516,14 @@ func registerWebRoutes(m *web.Router) { }) // 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() { // activity has its own permission checks m.Get("", repo.Activity) diff --git a/services/org/pinned.go b/services/org/pinned.go new file mode 100644 index 0000000000..a288c79c23 --- /dev/null +++ b/services/org/pinned.go @@ -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 +} diff --git a/services/pages/pages.go b/services/pages/pages.go new file mode 100644 index 0000000000..568bd521d9 --- /dev/null +++ b/services/pages/pages.go @@ -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) +} diff --git a/services/wiki/wiki_index.go b/services/wiki/wiki_index.go new file mode 100644 index 0000000000..ff68962ffc --- /dev/null +++ b/services/wiki/wiki_index.go @@ -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) +} diff --git a/templates/org/home.tmpl b/templates/org/home.tmpl index 3cde3554c9..5b56931081 100644 --- a/templates/org/home.tmpl +++ b/templates/org/home.tmpl @@ -8,9 +8,120 @@ {{if .ProfileReadmeContent}}
{{.ProfileReadmeContent}}
{{end}} + + {{/* Pinned Repositories Section */}} + {{if and .PageIsViewOverview .HasPinnedRepos}} +
+

+ {{svg "octicon-pin" 16}} {{ctx.Locale.Tr "org.pinned_repos"}} +

+ + {{/* Ungrouped pinned repos */}} + {{if .UngroupedPinned}} + + {{end}} + + {{/* Grouped pinned repos */}} + {{range .PinnedGroups}} + {{$groupRepos := index $.PinnedByGroup .ID}} + {{if $groupRepos}} + + {{end}} + {{end}} +
+ {{end}} + + {{/* Public Members Section (on overview) */}} + {{if and .PageIsViewOverview .PublicMembers}} +
+

+ {{svg "octicon-people" 16}} {{ctx.Locale.Tr "org.public_members"}} + {{if .HasMorePublicMembers}} + {{ctx.Locale.Tr "org.view_all_members" .TotalPublicMembers}} + {{end}} +

+ +
+ {{end}} + + {{if .PageIsViewRepositories}} {{template "shared/repo/search" .}} {{template "shared/repo/list" .}} {{template "base/paginate" .}} + {{end}} {{if .ShowMemberAndTeamTab}} diff --git a/templates/pages/documentation.tmpl b/templates/pages/documentation.tmpl new file mode 100644 index 0000000000..c60fbf76cf --- /dev/null +++ b/templates/pages/documentation.tmpl @@ -0,0 +1,43 @@ +{{template "base/head" .}} +
+ {{template "pages/header" .}} + +
+ + +
+
+ {{if .ReadmeContent}} +
+ {{.ReadmeContent}} +
+ {{else}} +

{{ctx.Locale.Tr "repo.no_desc"}}

+ {{end}} +
+
+
+ + {{template "pages/footer" .}} +
+{{template "base/footer" .}} diff --git a/templates/pages/footer.tmpl b/templates/pages/footer.tmpl new file mode 100644 index 0000000000..39ea6a2c9f --- /dev/null +++ b/templates/pages/footer.tmpl @@ -0,0 +1,32 @@ +
+
+ {{if .Config.Footer.Links}} + + {{end}} + + +
+
diff --git a/templates/pages/header.tmpl b/templates/pages/header.tmpl new file mode 100644 index 0000000000..49c52df4ab --- /dev/null +++ b/templates/pages/header.tmpl @@ -0,0 +1,25 @@ +
+
+ +
+
diff --git a/templates/pages/portfolio.tmpl b/templates/pages/portfolio.tmpl new file mode 100644 index 0000000000..87d1fb580d --- /dev/null +++ b/templates/pages/portfolio.tmpl @@ -0,0 +1,69 @@ +{{template "base/head" .}} +
+ {{template "pages/header" .}} + +
+ +
+
+ {{if .Config.Branding.Logo}} + + {{end}} +

{{if .Config.Hero.Title}}{{.Config.Hero.Title}}{{else}}{{.Repository.Name}}{{end}}

+ {{if .Config.Hero.Tagline}} +

{{.Config.Hero.Tagline}}

+ {{end}} +
+ {{if .Config.Hero.CTAPrimary.Text}} + + {{.Config.Hero.CTAPrimary.Text}} + + {{end}} + {{if .Config.Hero.CTASecondary.Text}} + + {{.Config.Hero.CTASecondary.Text}} + + {{end}} +
+
+
+ + + {{if .Config.Gallery.Items}} + + {{end}} + + + {{if .ReadmeContent}} +
+
+
+ {{.ReadmeContent}} +
+
+
+ {{end}} +
+ + {{template "pages/footer" .}} +
+{{template "base/footer" .}} diff --git a/templates/pages/product.tmpl b/templates/pages/product.tmpl new file mode 100644 index 0000000000..e5c6fddc85 --- /dev/null +++ b/templates/pages/product.tmpl @@ -0,0 +1,92 @@ +{{template "base/head" .}} +
+ {{template "pages/header" .}} + +
+ +
+
+
+ {{if .Config.Branding.Logo}} + + {{end}} +

{{if .Config.Hero.Title}}{{.Config.Hero.Title}}{{else}}{{.Repository.Name}}{{end}}

+ {{if .Config.Hero.Tagline}} +

{{.Config.Hero.Tagline}}

+ {{end}} +
+ {{if .Config.Hero.CTAPrimary.Text}} + + {{.Config.Hero.CTAPrimary.Text}} + + {{end}} + {{if .Config.Hero.CTASecondary.Text}} + + {{.Config.Hero.CTASecondary.Text}} + + {{end}} +
+
+
+ + + {{if .Config.Features}} +
+
+
+ {{range .Config.Features}} +
+ {{if .Icon}} +
+ {{if hasPrefix .Icon "./"}} + {{.Title}} + {{else}} + {{svg (printf "octicon-%s" .Icon) 32}} + {{end}} +
+ {{end}} +

{{.Title}}

+

{{.Description}}

+
+ {{end}} +
+
+
+ {{end}} + + + {{if .ReadmeContent}} +
+
+
+ {{.ReadmeContent}} +
+
+
+ {{end}} + + +
+
+
+
+ {{.NumStars}} + {{ctx.Locale.Tr "repo.stars"}} +
+
+ {{.NumForks}} + {{ctx.Locale.Tr "repo.forks"}} +
+
+ +
+
+
+ + {{template "pages/footer" .}} +
+{{template "base/footer" .}} diff --git a/templates/pages/simple.tmpl b/templates/pages/simple.tmpl new file mode 100644 index 0000000000..c73c42d070 --- /dev/null +++ b/templates/pages/simple.tmpl @@ -0,0 +1,69 @@ +{{template "base/head" .}} +
+ {{template "pages/header" .}} + +
+ {{if .Config.Hero.Title}} +
+
+ {{if .Config.Branding.Logo}} + + {{end}} +

{{.Config.Hero.Title}}

+ {{if .Config.Hero.Tagline}} +

{{.Config.Hero.Tagline}}

+ {{end}} +
+ {{if .Config.Hero.CTAPrimary.Text}} + + {{.Config.Hero.CTAPrimary.Text}} + + {{end}} + {{if .Config.Hero.CTASecondary.Text}} + + {{.Config.Hero.CTASecondary.Text}} + + {{end}} +
+
+
+ {{end}} + + {{if .ReadmeContent}} +
+
+
+ {{.ReadmeContent}} +
+
+
+ {{end}} + +
+
+
+
+ {{svg "octicon-star"}} + {{.NumStars}} + {{ctx.Locale.Tr "repo.stars"}} +
+
+ {{svg "octicon-repo-forked"}} + {{.NumForks}} + {{ctx.Locale.Tr "repo.forks"}} +
+ {{if .Repository.PrimaryLanguage}} +
+ + {{.Repository.PrimaryLanguage.Language}} + {{ctx.Locale.Tr "repo.language"}} +
+ {{end}} +
+
+
+
+ + {{template "pages/footer" .}} +
+{{template "base/footer" .}} diff --git a/templates/repo/settings/navbar.tmpl b/templates/repo/settings/navbar.tmpl index ba25e34ba4..9b30770ccc 100644 --- a/templates/repo/settings/navbar.tmpl +++ b/templates/repo/settings/navbar.tmpl @@ -38,6 +38,9 @@ {{end}} {{end}} + + {{ctx.Locale.Tr "repo.settings.pages"}} +
{{ctx.Locale.Tr "actions.actions"}}