feat: add REST API v1 with admin toggle and OpenAPI spec (#702)
Go CI / Lint (push) Has been cancelled
Go CI / Check (push) Has been cancelled
Go CI / Test (mysql, 1.25, mysql:8, ubuntu-latest, 3306:3306) (push) Has been cancelled
Go CI / Test (postgres, 1.25, postgres:16, ubuntu-latest, 5432:5432) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, macOS-latest) (push) Has been cancelled
Go CI / Test (sqlite, 1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, macOS-latest) (push) Has been cancelled
Go CI / Build (1.25, ubuntu-latest) (push) Has been cancelled
Go CI / Build (1.25, windows-latest) (push) Has been cancelled

Adds an HTTP REST API under /api/v1 with bearer-token auth, served
alongside the existing web UI. The API exposes seven endpoints:

  GET    /api/v1/user
  GET    /api/v1/gists
  POST   /api/v1/gists
  GET    /api/v1/gists/:uuid
  PATCH  /api/v1/gists/:uuid
  DELETE /api/v1/gists/:uuid
  GET    /api/v1/gists/:uuid/files/:filename/raw

Auth reuses the existing AccessToken model (extended with a new
ScopeUser permission for /user) and accepts both 'Authorization: Bearer
og_...' and the legacy 'Token og_...' header.

The API is gated by a new 'api-enabled' admin setting (default off) so
deployments must explicitly opt in from Admin Panel → Configuration.
When disabled, requests get a 503 with an actionable hint, and the
access-token settings page shows a banner pointing admins straight to
the toggle.

An embedded OpenAPI 3.1 spec is served at GET /api/v1/openapi.yaml;
import it into Postman / Insomnia / Bruno / openapi-generator for
interactive testing or client generation.

Other UX polish bundled in:
- Settings access-token form now exposes the new User scope and uses
  the existing locale.Tr lookups (translations backfilled for zh-CN).
- Language switcher distinguishes 简体中文 vs 繁體中文 instead of both
  appearing as '中文'.
- Settings page header tabs translated for zh-CN.
- POST /gists cleans up the on-disk repo when commit/create fails
  (previously orphaned).
- visibility=public list excludes private/unlisted gists (regression
  guard added).
- Content-Disposition filename in raw responses is RFC-escaped.

Docs: docs/usage/api.md walks through enabling the API, creating a
token, the seven endpoints with curl examples, error codes and v1
limitations.
This commit is contained in:
awkj
2026-05-22 04:12:33 +08:00
committed by GitHub
parent 8da72b9545
commit 690f151592
30 changed files with 1793 additions and 1 deletions
+196
View File
@@ -0,0 +1,196 @@
# REST API
Opengist exposes a REST API authenticated with Personal Access Tokens, intended for programmatic access to gist resources.
> **Authoritative OpenAPI 3.1 spec**: [`internal/web/handlers/api/openapi.yaml`](../../internal/web/handlers/api/openapi.yaml)
> **Live spec endpoint**: `GET /api/v1/openapi.yaml` on a running instance
>
> Import that URL into Postman, Insomnia, Bruno, Hoppscotch, or `openapi-generator` for an interactive UI or a generated client.
## Enabling the API
The API is **disabled by default**. An administrator must enable it explicitly:
1. Sign in as an administrator
2. Open **Admin Panel → Configuration**
3. Toggle **Enable REST API at /api/v1**
Disabling the API later does not revoke issued tokens; the routing layer simply returns `503` until it is enabled again.
## Creating a Personal Access Token
1. Sign in and open **Settings → Access Tokens**
2. Enter a name (e.g. `cli`) and choose scopes:
- **Gist scope**: `Read` for read-only access; `Read+Write` to create, update or delete gists
- **User scope**: `Read` is required to call `/api/v1/user`
3. Optionally set an expiry date
4. After submitting, the token is shown **once** — copy it immediately. Tokens look like `og_<64 hex>`.
## Authentication
Send the token in the `Authorization` header (Bearer recommended):
```
Authorization: Bearer og_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
The legacy `Authorization: Token og_xxx` form (used by the existing `.json` endpoints) is also accepted.
## Error responses
All errors share the same shape:
```json
{ "error": "human readable message", "code": "machine_readable_code" }
```
Common codes:
| HTTP | code | Meaning |
|------|------|---------|
| 401 | `unauthorized` | Missing / invalid / expired token |
| 403 | `forbidden` | Token lacks the required scope |
| 404 | `not_found` | Resource does not exist or is not visible to this token |
| 400 | `validation_failed` | Request body is invalid |
| 503 | `api_disabled` | Administrator has disabled the API |
| 500 | `internal_error` | Server-side failure |
When the API is disabled, the `503` response also includes a `hint` field pointing administrators to the toggle.
## Endpoints
All endpoints are prefixed with `/api/v1`.
### `GET /user` — current user
Requires the `user:read` scope.
```bash
curl -H "Authorization: Bearer og_xxx" https://opengist.example/api/v1/user
```
Response `200`:
```json
{
"id": 1,
"username": "alice",
"email": "alice@example.com",
"is_admin": false,
"created_at": "2026-05-16T00:00:00Z"
}
```
### `GET /gists` — list gists
Requires the `gist:read` scope.
Query parameters:
- `page` (default `1`)
- `per_page` (default `10`, max `100`)
- `visibility``mine` (default; only gists owned by the current token's user) or `public` (site-wide public gists)
Response `200`:
```json
{
"data": [
{
"uuid": "abc123",
"title": "Hello",
"description": "",
"visibility": "public",
"html_url": "/alice/abc123",
"created_at": "2026-05-16T00:00:00Z",
"updated_at": "2026-05-16T00:00:00Z",
"owner": {"id": 1, "username": "alice"},
"files": [{"filename": "a.txt", "size": 11}]
}
],
"page": 1,
"per_page": 10,
"total": 1
}
```
### `POST /gists` — create a gist
Requires the `gist:write` scope.
```bash
curl -X POST -H "Authorization: Bearer og_xxx" -H "Content-Type: application/json" \
https://opengist.example/api/v1/gists \
-d '{"title":"Hello","visibility":"public","files":[{"filename":"a.txt","content":"hello"}]}'
```
Response `201`: the full gist object including file contents.
### `GET /gists/{uuid}` — fetch a gist
Requires the `gist:read` scope. Private gists are only visible to their owner.
### `PATCH /gists/{uuid}` — update a gist
Requires the `gist:write` scope. The caller must be the owner. All body fields are optional:
```json
{
"title": "New title",
"description": "...",
"visibility": "unlisted",
"files": [{"filename": "a.txt", "content": "new content"}]
}
```
**`files` semantics**: providing the field **replaces all files**; omitting it leaves the existing files untouched.
### `DELETE /gists/{uuid}` — delete a gist
Requires the `gist:write` scope. Owner only. Returns `204 No Content`.
### `GET /gists/{uuid}/files/{filename}/raw` — raw file contents
Requires the `gist:read` scope. Returns `text/plain` with the raw bytes.
## Known limitations (v1)
- No rate limiting (do it at the reverse proxy if you need it)
- No binary file upload (string `content` only for now)
- No `user=`, `sort=`, or `order=` filters on the list endpoint
- No Like / Fork / Search / Revisions / Webhook endpoints
- No SSH key, invitation code, or admin operation endpoints
- For `visibility=public`, the `total` field is approximated by the current page size (the underlying query caps at 11 rows)
- File lists return at most 11 entries; `per_page>10` will not fetch more
These will be addressed in subsequent versions.
## End-to-end example
```bash
TOK=og_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
BASE=http://localhost:6157
# Current user
curl -s -H "Authorization: Bearer $TOK" $BASE/api/v1/user | jq
# Create a gist
curl -s -X POST -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
$BASE/api/v1/gists \
-d '{"title":"E2E","visibility":"public","files":[{"filename":"hello.txt","content":"hi"}]}' \
| jq
# List
curl -s -H "Authorization: Bearer $TOK" "$BASE/api/v1/gists?per_page=5" | jq
# Fetch one (replace UUID)
curl -s -H "Authorization: Bearer $TOK" $BASE/api/v1/gists/<UUID> | jq
# Raw file
curl -s -H "Authorization: Bearer $TOK" $BASE/api/v1/gists/<UUID>/files/hello.txt/raw
# Patch
curl -s -X PATCH -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
$BASE/api/v1/gists/<UUID> -d '{"title":"E2E renamed"}' | jq
# Delete
curl -s -X DELETE -H "Authorization: Bearer $TOK" -o /dev/null -w "%{http_code}\n" \
$BASE/api/v1/gists/<UUID>
```
+12
View File
@@ -24,6 +24,7 @@ type AccessToken struct {
User User `validate:"-"`
ScopeGist uint // 0 = none, 1 = read, 2 = read+write
ScopeUser uint // 0 = none, 1 = read
}
// GenerateToken creates a new random token and returns the plain text token.
@@ -100,11 +101,16 @@ func (t *AccessToken) HasGistWritePermission() bool {
return t.ScopeGist >= ReadWritePermission
}
func (t *AccessToken) HasUserReadPermission() bool {
return t.ScopeUser >= ReadPermission
}
// -- DTO -- //
type AccessTokenDTO struct {
Name string `form:"name" validate:"required,max=255"`
ScopeGist uint `form:"scope_gist" validate:"min=0,max=2"`
ScopeUser uint `form:"scope_user" validate:"min=0,max=1"`
ExpiresAt string `form:"expires_at"` // empty means no expiration, otherwise date format (YYYY-MM-DD)
}
@@ -120,6 +126,12 @@ func (dto *AccessTokenDTO) ToAccessToken() *AccessToken {
return &AccessToken{
Name: dto.Name,
ScopeGist: dto.ScopeGist,
ScopeUser: dto.ScopeUser,
ExpiresAt: expiresAt,
}
}
// SaveAccessTokenForTest is exported for tests only; saves the entire AccessToken row.
func SaveAccessTokenForTest(t *AccessToken) error {
return db.Save(t).Error
}
+23
View File
@@ -0,0 +1,23 @@
package db_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
)
func TestAccessTokenScopeUser(t *testing.T) {
tok := &db.AccessToken{ScopeUser: db.ReadPermission}
require.True(t, tok.HasUserReadPermission(), "ScopeUser=1 should grant user read")
tokNone := &db.AccessToken{ScopeUser: 0}
require.False(t, tokNone.HasUserReadPermission(), "ScopeUser=0 should not grant user read")
}
func TestAccessTokenDTOScopeUser(t *testing.T) {
dto := &db.AccessTokenDTO{Name: "t", ScopeGist: 1, ScopeUser: 1}
tok := dto.ToAccessToken()
require.Equal(t, uint(1), tok.ScopeUser)
require.Equal(t, uint(1), tok.ScopeGist)
}
+1
View File
@@ -15,6 +15,7 @@ const (
SettingAllowGistsWithoutLogin = "allow-gists-without-login"
SettingDisableLoginForm = "disable-login-form"
SettingDisableGravatar = "disable-gravatar"
SettingApiEnabled = "api-enabled"
)
func GetSetting(key string) (string, error) {
+18
View File
@@ -0,0 +1,18 @@
package db_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestSettingApiEnabledDefault(t *testing.T) {
_ = webtest.Setup(t)
defer webtest.Teardown(t)
v, err := db.GetSetting(db.SettingApiEnabled)
require.NoError(t, err)
require.Equal(t, "0", v, "api-enabled should default to '0' on fresh install")
}
+1
View File
@@ -170,6 +170,7 @@ func Setup(dbUri string) error {
SettingAllowGistsWithoutLogin: "0",
SettingDisableLoginForm: "0",
SettingDisableGravatar: "0",
SettingApiEnabled: "0",
})
}
+26
View File
@@ -124,6 +124,12 @@ func GetGist(user string, gistUuid string) (*Gist, error) {
return gist, err
}
func GetGistByUUID(uuid string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Where("uuid = ?", uuid).First(gist).Error
return gist, err
}
func GetGistByID(gistId string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Preload("Forked.User").Preload("Topics").
@@ -158,6 +164,26 @@ func GetAllGists(offset int) ([]*Gist, error) {
return gists, err
}
// GetAllPublicGists returns publicly-visible gists only (Visibility = public).
// Used by the API; admin pages should keep using GetAllGists which is unfiltered.
func GetAllPublicGists(offset int) ([]*Gist, int64, error) {
var gists []*Gist
var count int64
baseQuery := db.Model(&Gist{}).Where("gists.private = 0")
if err := baseQuery.Count(&count).Error; err != nil {
return nil, 0, err
}
err := baseQuery.
Preload("User").
Limit(11).
Offset(offset * 10).
Order("id asc").
Find(&gists).Error
return gists, count, err
}
func GetAllGistsFromSearch(currentUserId uint, query string, offset int, sort string, order string, topic string) ([]*Gist, error) {
var gists []*Gist
tx := db.Preload("User").Preload("Forked.User").Preload("Topics").
+6
View File
@@ -56,6 +56,12 @@ func (store *LocaleStore) loadLocaleFromYAML(localeCode, path string) error {
case language.EuropeanSpanish:
name = "Español"
}
switch localeCode {
case "zh-CN":
name = "简体中文"
case "zh-TW":
name = "繁體中文"
}
locale := &Locale{
Code: localeCode,
+6
View File
@@ -178,6 +178,8 @@ settings.create-token-help: Access tokens can be used to access the API
settings.token-name: Name
settings.token-permissions: Permissions
settings.token-gist-permission: Gists
settings.token-user-permission: User
settings.token-user-permission-help: Read user info (required for /api/v1/user)
settings.token-permission-none: No access
settings.token-permission-read: Read
settings.token-permission-read-write: Read & Write
@@ -193,6 +195,8 @@ settings.token-no-expiration: No expiration
settings.token-expired: expired
settings.token-created: Token created, make sure to copy it now, you won't be able to see it again!
settings.token-deleted: Access token deleted
settings.api-disabled-warning: The REST API is currently disabled. Tokens you create here cannot be used until an administrator enables it.
settings.api-disabled-go-admin: Open admin configuration
auth.signup-disabled: Administrator has disabled signing up
auth.login: Login
@@ -314,6 +318,8 @@ admin.disable-login: Disable login form
admin.disable-login_help: Forbid logging in via the login form to force using OAuth providers instead.
admin.disable-gravatar: Disable Gravatar
admin.disable-gravatar_help: Disable the usage of Gravatar as an avatar provider.
admin.api-enabled: Enable REST API at /api/v1
admin.api-enabled_help: Allow programmatic access to gists and user info via Personal Access Tokens.
admin.users.delete_confirm: Do you want to delete this user ?
+32
View File
@@ -87,6 +87,11 @@ gist.revision.no-changes: 没有任何变更
gist.revision.no-revisions: 无可供显示的修订
settings: 设置
settings.header.account: 账号
settings.header.mfa: 多因素认证
settings.header.ssh: SSH
settings.header.tokens: 访问令牌
settings.header.style: 样式
settings.email: 电子邮箱
settings.email-help: 用于提交和 Gravatar 头像
settings.email-set: 设置邮箱地址
@@ -107,6 +112,31 @@ settings.ssh-key-added-at: 添加于
settings.ssh-key-never-used: 从未使用过
settings.ssh-key-last-used: 最后使用于
settings.create-token: 创建访问令牌
settings.create-token-help: 访问令牌用于程序化调用 API
settings.token-name: 名称
settings.token-permissions: 权限
settings.token-gist-permission: Gists
settings.token-user-permission: 用户
settings.token-user-permission-help: 读取用户信息(/api/v1/user 端点需要)
settings.token-permission-none: 无访问权限
settings.token-permission-read: 只读
settings.token-permission-read-write: 读写
settings.delete-token: 删除
settings.delete-token-confirm: 确认删除访问令牌
settings.token-created-at: 创建于
settings.token-never-used: 从未使用
settings.token-last-used: 最后使用
settings.token-expiration: 过期时间
settings.token-expiration-help: 留空表示永不过期
settings.token-expires-at: 过期于
settings.token-no-expiration: 永不过期
settings.token-expired: 已过期
settings.token-created: 令牌已创建,请立即复制保存,离开本页后将无法再次查看!
settings.token-deleted: 访问令牌已删除
settings.api-disabled-warning: REST API 当前已禁用,在此处创建的令牌需要管理员启用 API 后才能使用。
settings.api-disabled-go-admin: 前往管理后台启用
auth.signup-disabled: 管理员已禁用注册
auth.login: 登录
auth.signup: 注册
@@ -166,6 +196,8 @@ admin.disable-login: 禁用登录表单
admin.disable-login_help: 禁止使用登录表单进行登录以强制通过 OAuth 提供方登录。
admin.disable-gravatar: 禁用 Gravatar
admin.disable-gravatar_help: 停止使用 Gravatar 作为头像提供方。
admin.api-enabled: 启用 REST API/api/v1
admin.api-enabled_help: 允许通过 Personal Access Token 程序化访问 Gist 和用户信息。
admin.allow-gists-without-login: 允许未登录状态下访问单个 Gists
admin.allow-gists-without-login_help: 允许在不登录的情况下查看和下载 Gist,同时需要登录才能使用 Gists 的发现功能。
admin.users.delete_confirm: 您想要删除此用户吗?
+20
View File
@@ -98,6 +98,26 @@ func TestAdminSetConfig(t *testing.T) {
})
}
func TestAdminToggleApiEnabled(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "admin") // first user → admin
s.Login(t, "admin")
// Default off
v, _ := db.GetSetting(db.SettingApiEnabled)
require.Equal(t, "0", v)
// Toggle on via admin endpoint
s.Request(t, "PUT", "/admin-panel/set-config", url.Values{
"key": {db.SettingApiEnabled},
"value": {"1"},
}, 200)
v, _ = db.GetSetting(db.SettingApiEnabled)
require.Equal(t, "1", v)
}
func TestAdminPagination(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
+18
View File
@@ -0,0 +1,18 @@
package api
import (
_ "embed"
"github.com/thomiceli/opengist/internal/web/context"
)
//go:embed openapi.yaml
var openapiYAML []byte
// OpenAPISpec serves the embedded OpenAPI YAML spec.
// Import this URL into Postman / Insomnia / Bruno / openapi-generator etc.
func OpenAPISpec(ctx *context.Context) error {
ctx.Response().Header().Set("Content-Type", "application/yaml; charset=utf-8")
_, err := ctx.Response().Write(openapiYAML)
return err
}
+275
View File
@@ -0,0 +1,275 @@
openapi: 3.1.0
info:
title: Opengist API
version: "1.0.0"
description: |
REST API for Opengist. Authentication is via Personal Access Tokens
(header `Authorization: Bearer og_<token>`).
servers:
- url: /
description: Current instance
security:
- bearerAuth: []
paths:
/api/v1/user:
get:
summary: Get the authenticated user
tags: [User]
responses:
"200":
description: User info
content:
application/json:
schema: { $ref: "#/components/schemas/User" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"503": { $ref: "#/components/responses/ApiDisabled" }
/api/v1/gists:
get:
summary: List gists
tags: [Gists]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: per_page, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 10 } }
- { name: visibility, in: query, schema: { type: string, enum: [mine, public], default: mine } }
responses:
"200":
description: Paginated gist list
content:
application/json:
schema: { $ref: "#/components/schemas/PaginatedGists" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"503": { $ref: "#/components/responses/ApiDisabled" }
post:
summary: Create a gist
tags: [Gists]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateGistRequest" }
responses:
"201":
description: Created
content:
application/json:
schema: { $ref: "#/components/schemas/GistDetail" }
"400": { $ref: "#/components/responses/ValidationFailed" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"503": { $ref: "#/components/responses/ApiDisabled" }
/api/v1/gists/{uuid}:
parameters:
- { name: uuid, in: path, required: true, schema: { type: string } }
get:
summary: Get a gist
tags: [Gists]
responses:
"200":
description: Gist detail
content:
application/json:
schema: { $ref: "#/components/schemas/GistDetail" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"503": { $ref: "#/components/responses/ApiDisabled" }
patch:
summary: Update a gist
tags: [Gists]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/UpdateGistRequest" }
responses:
"200":
description: Updated
content:
application/json:
schema: { $ref: "#/components/schemas/GistDetail" }
"400": { $ref: "#/components/responses/ValidationFailed" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"503": { $ref: "#/components/responses/ApiDisabled" }
delete:
summary: Delete a gist
tags: [Gists]
responses:
"204": { description: Deleted }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"503": { $ref: "#/components/responses/ApiDisabled" }
/api/v1/gists/{uuid}/files/{filename}/raw:
parameters:
- { name: uuid, in: path, required: true, schema: { type: string } }
- { name: filename, in: path, required: true, schema: { type: string } }
get:
summary: Get raw file content
tags: [Gists]
responses:
"200":
description: Raw bytes
content:
text/plain:
schema: { type: string }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"503": { $ref: "#/components/responses/ApiDisabled" }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
schemas:
Error:
type: object
required: [error, code]
properties:
error: { type: string }
code: { type: string }
User:
type: object
required: [id, username, email, is_admin, created_at]
properties:
id: { type: integer }
username: { type: string }
email: { type: string }
is_admin: { type: boolean }
created_at: { type: string, format: date-time }
GistOwner:
type: object
required: [id, username]
properties:
id: { type: integer }
username: { type: string }
FileSummary:
type: object
required: [filename, size]
properties:
filename: { type: string }
size: { type: integer }
language: { type: string }
FileDetail:
allOf:
- $ref: "#/components/schemas/FileSummary"
- type: object
properties:
content: { type: string }
binary: { type: boolean }
truncated: { type: boolean }
GistSummary:
type: object
required: [uuid, title, visibility, html_url, created_at, updated_at, owner, files]
properties:
uuid: { type: string }
title: { type: string }
description: { type: string }
visibility: { type: string, enum: [public, unlisted, private] }
html_url: { type: string, description: "Relative path to view this gist in a browser" }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
owner: { $ref: "#/components/schemas/GistOwner" }
files:
type: array
items: { $ref: "#/components/schemas/FileSummary" }
GistDetail:
type: object
required: [uuid, title, visibility, html_url, created_at, updated_at, owner, files]
properties:
uuid: { type: string }
title: { type: string }
description: { type: string }
visibility: { type: string, enum: [public, unlisted, private] }
html_url: { type: string, description: "Relative path to view this gist in a browser" }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
owner: { $ref: "#/components/schemas/GistOwner" }
files:
type: array
items: { $ref: "#/components/schemas/FileDetail" }
PaginatedGists:
type: object
required: [data, page, per_page, total]
properties:
data:
type: array
items: { $ref: "#/components/schemas/GistSummary" }
page: { type: integer }
per_page: { type: integer }
total: { type: integer }
FileInput:
type: object
required: [filename, content]
properties:
filename: { type: string }
content: { type: string }
CreateGistRequest:
type: object
required: [files]
properties:
title: { type: string }
description: { type: string }
visibility: { type: string, enum: [public, unlisted, private], default: public }
files:
type: array
minItems: 1
items: { $ref: "#/components/schemas/FileInput" }
UpdateGistRequest:
type: object
properties:
title: { type: string }
description: { type: string }
visibility: { type: string, enum: [public, unlisted, private] }
files:
type: array
items: { $ref: "#/components/schemas/FileInput" }
responses:
Unauthorized:
description: Missing/invalid/expired token
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Forbidden:
description: Insufficient scope
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
NotFound:
description: Resource not found
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
ValidationFailed:
description: Request body invalid
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
ApiDisabled:
description: API disabled by administrator
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
+16
View File
@@ -0,0 +1,16 @@
package api_test
import (
"testing"
"github.com/stretchr/testify/require"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestOpenAPISpec(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
resp := s.Request(t, "GET", "/api/v1/openapi.yaml", nil, 200)
require.Contains(t, resp.Header.Get("Content-Type"), "yaml")
}
+93
View File
@@ -0,0 +1,93 @@
package v1
import "time"
// --- Common ---
type Pagination struct {
Page int `json:"page"`
PerPage int `json:"per_page"`
Total int64 `json:"total"`
}
// --- User ---
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
CreatedAt time.Time `json:"created_at"`
}
// --- Gist ---
type FileSummary struct {
Filename string `json:"filename"`
Size int `json:"size"`
Language string `json:"language,omitempty"`
}
type FileDetail struct {
Filename string `json:"filename"`
Size int `json:"size"`
Language string `json:"language,omitempty"`
Content string `json:"content"`
Binary bool `json:"binary"`
Truncated bool `json:"truncated"`
}
type GistOwner struct {
ID uint `json:"id"`
Username string `json:"username"`
}
type GistSummary struct {
UUID string `json:"uuid"`
Title string `json:"title"`
Description string `json:"description"`
Visibility string `json:"visibility"`
HTMLURL string `json:"html_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Owner GistOwner `json:"owner"`
Files []FileSummary `json:"files"`
}
type GistDetail struct {
UUID string `json:"uuid"`
Title string `json:"title"`
Description string `json:"description"`
Visibility string `json:"visibility"`
HTMLURL string `json:"html_url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Owner GistOwner `json:"owner"`
Files []FileDetail `json:"files"`
}
type PaginatedGists struct {
Data []GistSummary `json:"data"`
Pagination
}
// --- Create / Update ---
type FileInput struct {
Filename string `json:"filename"`
Content string `json:"content"`
}
type CreateGistRequest struct {
Title string `json:"title"`
Description string `json:"description"`
Visibility string `json:"visibility"` // "public" | "unlisted" | "private"
Files []FileInput `json:"files"`
}
type UpdateGistRequest struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Visibility *string `json:"visibility,omitempty"`
Files *[]FileInput `json:"files,omitempty"` // nil = no change; non-nil = full replace
}
+18
View File
@@ -0,0 +1,18 @@
package v1
import (
"github.com/thomiceli/opengist/internal/web/context"
)
// ErrorBody is the unified envelope returned by every API error response.
type ErrorBody struct {
Message string `json:"error"`
Code string `json:"code"`
}
// WriteJSONError writes an application/json error response.
// status is the HTTP status code, code is a machine-readable identifier (snake_case),
// msg is the human-readable message.
func WriteJSONError(ctx *context.Context, status int, code, msg string) error {
return ctx.JSON(status, ErrorBody{Message: msg, Code: code})
}
@@ -0,0 +1,31 @@
package v1_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/web/context"
v1 "github.com/thomiceli/opengist/internal/web/handlers/api/v1"
)
func TestWriteJSONError(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
ctx := &context.Context{Context: e.NewContext(req, rec)}
err := v1.WriteJSONError(ctx, http.StatusNotFound, "not_found", "gist not found")
require.NoError(t, err)
require.Equal(t, http.StatusNotFound, rec.Code)
require.True(t, strings.Contains(rec.Header().Get("Content-Type"), "application/json"))
var body map[string]string
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, "not_found", body["code"])
require.Equal(t, "gist not found", body["error"])
}
+331
View File
@@ -0,0 +1,331 @@
package v1
import (
"mime"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/web/context"
)
const (
defaultPerPage = 10
maxPerPage = 100
)
func parsePagination(ctx *context.Context) (page, perPage int) {
page, _ = strconv.Atoi(ctx.QueryParam("page"))
if page < 1 {
page = 1
}
perPage, _ = strconv.Atoi(ctx.QueryParam("per_page"))
if perPage < 1 {
perPage = defaultPerPage
}
if perPage > maxPerPage {
perPage = maxPerPage
}
return
}
func summarizeGist(g *db.Gist) GistSummary {
files, _, _ := g.Files("HEAD", true)
fs := make([]FileSummary, 0, len(files))
for _, f := range files {
fs = append(fs, FileSummary{
Filename: f.Filename,
Size: int(f.Size),
// Language is omitted in v1 (no per-file language detection in db layer)
})
}
return GistSummary{
UUID: g.Uuid,
Title: g.Title,
Description: g.Description,
Visibility: g.Private.String(),
HTMLURL: "/" + g.User.Username + "/" + g.Identifier(),
CreatedAt: time.Unix(g.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(g.UpdatedAt, 0).UTC(),
Owner: GistOwner{ID: g.User.ID, Username: g.User.Username},
Files: fs,
}
}
func detailGist(g *db.Gist) (GistDetail, error) {
files, _, err := g.Files("HEAD", true)
if err != nil {
return GistDetail{}, err
}
fs := make([]FileDetail, 0, len(files))
for _, f := range files {
fd := FileDetail{
Filename: f.Filename,
Size: int(f.Size),
Truncated: f.Truncated,
}
if f.MimeType.CanBeEdited() {
fd.Content = f.Content
} else {
fd.Binary = true
}
fs = append(fs, fd)
}
return GistDetail{
UUID: g.Uuid,
Title: g.Title,
Description: g.Description,
Visibility: g.Private.String(),
HTMLURL: "/" + g.User.Username + "/" + g.Identifier(),
CreatedAt: time.Unix(g.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(g.UpdatedAt, 0).UTC(),
Owner: GistOwner{ID: g.User.ID, Username: g.User.Username},
Files: fs,
}, nil
}
// lookupGistByUUID fetches a gist by UUID and enforces visibility.
// Returns 404 (via ErrorBody.Code) if the gist doesn't exist OR is private and the
// caller is not its owner.
func lookupGistByUUID(ctx *context.Context, uuid string) (*db.Gist, *ErrorBody) {
g, err := db.GetGistByUUID(uuid)
if err != nil {
return nil, &ErrorBody{Code: "not_found", Message: "gist not found"}
}
if g.Private == db.PrivateVisibility {
if ctx.User == nil || ctx.User.ID != g.UserID {
return nil, &ErrorBody{Code: "not_found", Message: "gist not found"}
}
}
return g, nil
}
// GetGist handles GET /api/v1/gists/:uuid
func GetGist(ctx *context.Context) error {
g, errBody := lookupGistByUUID(ctx, ctx.Param("uuid"))
if errBody != nil {
return WriteJSONError(ctx, 404, errBody.Code, errBody.Message)
}
resp, err := detailGist(g)
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to serialize gist")
}
return ctx.JSON(200, resp)
}
// ListGists handles GET /api/v1/gists?page=&per_page=&visibility=mine|public
func ListGists(ctx *context.Context) error {
page, perPage := parsePagination(ctx)
visibility := ctx.QueryParam("visibility")
if visibility == "" {
visibility = "mine"
}
// db.GetAllGistsFromUser uses offset as page index (0-based) and internally
// applies Offset(offset*10) with a fixed Limit(11).
pageIdx := page - 1
user := ctx.User
var gists []*db.Gist
var total int64
var err error
switch visibility {
case "mine":
gists, total, err = db.GetAllGistsFromUser(user.ID, user.ID, "", "", "", nil, pageIdx, "created", "desc")
case "public":
gists, total, err = db.GetAllPublicGists(pageIdx)
default:
return WriteJSONError(ctx, 400, "validation_failed", "unknown visibility (allowed: mine, public)")
}
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to list gists")
}
// db.GetAllGistsFromUser doesn't accept a limit; trim manually.
if len(gists) > perPage {
gists = gists[:perPage]
}
data := make([]GistSummary, 0, len(gists))
for _, g := range gists {
data = append(data, summarizeGist(g))
}
return ctx.JSON(200, PaginatedGists{
Data: data,
Pagination: Pagination{
Page: page,
PerPage: perPage,
Total: total,
},
})
}
// UpdateGist handles PATCH /api/v1/gists/:uuid
func UpdateGist(ctx *context.Context) error {
g, errBody := lookupGistByUUID(ctx, ctx.Param("uuid"))
if errBody != nil {
return WriteJSONError(ctx, 404, errBody.Code, errBody.Message)
}
// non-owners get 404 (don't reveal existence)
if g.UserID != ctx.User.ID {
return WriteJSONError(ctx, 404, "not_found", "gist not found")
}
var req UpdateGistRequest
if err := ctx.Bind(&req); err != nil {
return WriteJSONError(ctx, 400, "validation_failed", "invalid JSON body")
}
if req.Title != nil {
g.Title = *req.Title
}
if req.Description != nil {
g.Description = *req.Description
}
if req.Visibility != nil {
g.Private = db.ParseVisibility[string](*req.Visibility)
}
if req.Files != nil {
if len(*req.Files) == 0 {
return WriteJSONError(ctx, 400, "validation_failed", "files: at least one file required when provided")
}
fileDTOs := make([]db.FileDTO, 0, len(*req.Files))
for _, f := range *req.Files {
name := git.CleanTreePathName(f.Filename)
if name == "" {
return WriteJSONError(ctx, 400, "validation_failed", "files: filename cannot be empty")
}
fileDTOs = append(fileDTOs, db.FileDTO{Filename: name, Content: f.Content})
}
g.NbFiles = len(fileDTOs)
if err := g.AddAndCommitFiles(&fileDTOs); err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to commit files")
}
}
if err := g.Update(); err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to update gist")
}
g.UpdateLanguages()
_ = g.UpdatePreviewAndCount(true)
resp, err := detailGist(g)
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to serialize gist")
}
return ctx.JSON(200, resp)
}
// DeleteGist handles DELETE /api/v1/gists/:uuid
func DeleteGist(ctx *context.Context) error {
g, errBody := lookupGistByUUID(ctx, ctx.Param("uuid"))
if errBody != nil {
return WriteJSONError(ctx, 404, errBody.Code, errBody.Message)
}
if g.UserID != ctx.User.ID {
return WriteJSONError(ctx, 404, "not_found", "gist not found")
}
if err := g.DeleteRepository(); err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to delete repository")
}
if err := g.Delete(); err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to delete gist")
}
return ctx.NoContent(204)
}
// RawFile handles GET /api/v1/gists/:uuid/files/:filename/raw
func RawFile(ctx *context.Context) error {
g, errBody := lookupGistByUUID(ctx, ctx.Param("uuid"))
if errBody != nil {
return WriteJSONError(ctx, 404, errBody.Code, errBody.Message)
}
filename := ctx.Param("filename")
content, _, err := git.GetFileContent(g.User.Username, g.Uuid, "HEAD", filename, false)
if err != nil || content == "" {
return WriteJSONError(ctx, 404, "not_found", "file not found")
}
ctx.Response().Header().Set("Content-Type", "text/plain; charset=utf-8")
dispo := mime.FormatMediaType("inline", map[string]string{"filename": filename})
if dispo == "" {
dispo = `inline`
}
ctx.Response().Header().Set("Content-Disposition", dispo)
return ctx.String(http.StatusOK, content)
}
// CreateGist handles POST /api/v1/gists
func CreateGist(ctx *context.Context) error {
var req CreateGistRequest
if err := ctx.Bind(&req); err != nil {
return WriteJSONError(ctx, 400, "validation_failed", "invalid JSON body")
}
if len(req.Files) == 0 {
return WriteJSONError(ctx, 400, "validation_failed", "files: at least one file required")
}
visibility := db.ParseVisibility[string](req.Visibility)
fileDTOs := make([]db.FileDTO, 0, len(req.Files))
for _, f := range req.Files {
name := git.CleanTreePathName(f.Filename)
if name == "" {
return WriteJSONError(ctx, 400, "validation_failed", "files: filename cannot be empty")
}
fileDTOs = append(fileDTOs, db.FileDTO{Filename: name, Content: f.Content})
}
user := ctx.User
gist := &db.Gist{
Title: req.Title,
Description: req.Description,
Private: visibility,
UserID: user.ID,
User: *user,
NbFiles: len(fileDTOs),
}
id, err := uuid.NewRandom()
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "uuid generation failed")
}
gist.Uuid = strings.ReplaceAll(id.String(), "-", "")
if gist.Title == "" {
gist.Title = fileDTOs[0].Filename
}
if err := gist.InitRepository(); err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to init repo")
}
if err := gist.AddAndCommitFiles(&fileDTOs); err != nil {
_ = gist.DeleteRepository()
return WriteJSONError(ctx, 500, "internal_error", "failed to commit files")
}
if err := gist.Create(); err != nil {
_ = gist.DeleteRepository()
return WriteJSONError(ctx, 500, "internal_error", "failed to create gist")
}
gist.AddInIndex()
gist.UpdateLanguages()
_ = gist.UpdatePreviewAndCount(true)
// reload to fetch timestamps
saved, err := db.GetGistByID(strconv.FormatUint(uint64(gist.ID), 10))
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to reload gist")
}
resp, err := detailGist(saved)
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to serialize gist")
}
return ctx.JSON(201, resp)
}
+244
View File
@@ -0,0 +1,244 @@
package v1_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
v1 "github.com/thomiceli/opengist/internal/web/handlers/api/v1"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// setupAPIUser registers "admin" (first user, auto-admin) + "thomas" (regular),
// logs in as thomas, creates a token with full gist + user scope, enables API.
func setupAPIUser(t *testing.T) (*webtest.Server, string) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
s.Login(t, "thomas")
tok := s.CreateAccessToken(t, "t", db.ReadWritePermission, db.ReadPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
return s, tok
}
func TestListGists_Empty(t *testing.T) {
s, tok := setupAPIUser(t)
var resp v1.PaginatedGists
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists", tok, nil, &resp, 200)
require.Equal(t, 1, resp.Page)
require.Equal(t, 10, resp.PerPage)
require.Equal(t, int64(0), resp.Total)
require.Empty(t, resp.Data)
}
func TestListGists_Mine(t *testing.T) {
s, tok := setupAPIUser(t)
_, _, _, _ = s.CreateGistAs(t, "thomas", "0")
_, _, _, _ = s.CreateGistAs(t, "thomas", "0")
var resp v1.PaginatedGists
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists?per_page=5", tok, nil, &resp, 200)
require.Equal(t, int64(2), resp.Total)
require.Len(t, resp.Data, 2)
require.Equal(t, "thomas", resp.Data[0].Owner.Username)
}
func TestCreateGist(t *testing.T) {
s, tok := setupAPIUser(t)
req := v1.CreateGistRequest{
Title: "Hello",
Description: "from API",
Visibility: "public",
Files: []v1.FileInput{
{Filename: "a.txt", Content: "hello world"},
},
}
var resp v1.GistDetail
s.APIRequestUnmarshal(t, "POST", "/api/v1/gists", tok, req, &resp, 201)
require.Equal(t, "Hello", resp.Title)
require.Equal(t, "public", resp.Visibility)
require.Len(t, resp.Files, 1)
require.Equal(t, "a.txt", resp.Files[0].Filename)
require.Equal(t, "hello world", resp.Files[0].Content)
}
func TestCreateGist_EmptyFiles(t *testing.T) {
s, tok := setupAPIUser(t)
req := v1.CreateGistRequest{Title: "x", Visibility: "public", Files: []v1.FileInput{}}
var body map[string]string
s.APIRequestUnmarshal(t, "POST", "/api/v1/gists", tok, req, &body, 400)
require.Equal(t, "validation_failed", body["code"])
}
func TestCreateGist_NoWriteScope(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
s.Login(t, "thomas")
tok := s.CreateAccessToken(t, "ro", db.ReadPermission, db.NoPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
req := v1.CreateGistRequest{Title: "x", Visibility: "public", Files: []v1.FileInput{{Filename: "a", Content: "b"}}}
var body map[string]string
s.APIRequestUnmarshal(t, "POST", "/api/v1/gists", tok, req, &body, 403)
require.Equal(t, "forbidden", body["code"])
}
func TestGetGist_Public(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
var resp v1.GistDetail
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists/"+gist.Uuid, tok, nil, &resp, 200)
require.Equal(t, gist.Uuid, resp.UUID)
require.NotEmpty(t, resp.Files)
}
func TestGetGist_PrivateOwner(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "2")
s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid, tok, nil, 200)
}
func TestGetGist_PrivateOther_404(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
s.Register(t, "alice")
s.Login(t, "thomas")
_, gist, _, _ := s.CreateGistAs(t, "thomas", "2")
s.Login(t, "alice")
tok := s.CreateAccessToken(t, "t", db.ReadPermission, db.ReadPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists/"+gist.Uuid, tok, nil, &body, 404)
require.Equal(t, "not_found", body["code"])
}
func TestGetGist_NotFound(t *testing.T) {
s, tok := setupAPIUser(t)
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists/doesnotexist", tok, nil, &body, 404)
require.Equal(t, "not_found", body["code"])
}
func TestUpdateGist_Title(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
newTitle := "Renamed"
req := v1.UpdateGistRequest{Title: &newTitle}
var resp v1.GistDetail
s.APIRequestUnmarshal(t, "PATCH", "/api/v1/gists/"+gist.Uuid, tok, req, &resp, 200)
require.Equal(t, "Renamed", resp.Title)
}
func TestUpdateGist_ReplaceFiles(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
files := []v1.FileInput{{Filename: "new.txt", Content: "fresh"}}
req := v1.UpdateGistRequest{Files: &files}
var resp v1.GistDetail
s.APIRequestUnmarshal(t, "PATCH", "/api/v1/gists/"+gist.Uuid, tok, req, &resp, 200)
require.Len(t, resp.Files, 1)
require.Equal(t, "new.txt", resp.Files[0].Filename)
require.Equal(t, "fresh", resp.Files[0].Content)
}
func TestUpdateGist_NotOwner_404(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
s.Register(t, "alice")
s.Login(t, "thomas")
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
s.Login(t, "alice")
tok := s.CreateAccessToken(t, "t", db.ReadWritePermission, db.NoPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
newTitle := "hax"
req := v1.UpdateGistRequest{Title: &newTitle}
var body map[string]string
s.APIRequestUnmarshal(t, "PATCH", "/api/v1/gists/"+gist.Uuid, tok, req, &body, 404)
require.Equal(t, "not_found", body["code"])
}
func TestDeleteGist(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
s.APIRequest(t, "DELETE", "/api/v1/gists/"+gist.Uuid, tok, nil, 204)
s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid, tok, nil, 404)
}
func TestDeleteGist_NotOwner_404(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
s.Register(t, "alice")
s.Login(t, "thomas")
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
s.Login(t, "alice")
tok := s.CreateAccessToken(t, "t", db.ReadWritePermission, db.NoPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
s.APIRequest(t, "DELETE", "/api/v1/gists/"+gist.Uuid, tok, nil, 404)
}
func TestListGists_PublicExcludesPrivate(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
s.Register(t, "alice")
s.Login(t, "thomas")
_, _, _, _ = s.CreateGistAs(t, "thomas", "0") // public
_, _, _, _ = s.CreateGistAs(t, "thomas", "2") // private
s.Login(t, "alice")
tok := s.CreateAccessToken(t, "t", db.ReadPermission, db.NoPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
var resp v1.PaginatedGists
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists?visibility=public", tok, nil, &resp, 200)
for _, g := range resp.Data {
require.Equal(t, "public", g.Visibility, "private/unlisted must not appear in visibility=public list")
}
}
func TestRawFile(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
// CreateGistAs uses {name: file.txt, content: hello}
body := s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid+"/files/file.txt/raw", tok, nil, 200)
require.Equal(t, "hello", string(body))
}
func TestRawFile_NotFound(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists/"+gist.Uuid+"/files/doesnotexist.txt/raw", tok, nil, &body, 404)
require.Equal(t, "not_found", body["code"])
}
@@ -0,0 +1,108 @@
package v1_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestApiAuth_MissingToken(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Login(t, "thomas")
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", "", nil, &body, 401)
require.Equal(t, "unauthorized", body["code"])
}
func TestApiAuth_ApiDisabled(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Login(t, "thomas")
tok := s.CreateAccessToken(t, "t", db.ReadPermission, db.ReadPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "0"))
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", tok, nil, &body, 503)
require.Equal(t, "api_disabled", body["code"])
require.Contains(t, body["hint"], "/admin-panel/configuration")
}
func TestApiAuth_BearerAndTokenPrefix(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Login(t, "thomas")
tok := s.CreateAccessToken(t, "t", db.ReadPermission, db.ReadPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
// Bearer
s.APIRequest(t, "GET", "/api/v1/user", tok, nil, 200)
// Token prefix (legacy)
req := newJSONReqWithAuth("GET", "/api/v1/user", "Token "+tok)
resp := s.RawRequest(t, req, 200)
_ = json.NewDecoder(resp.Body).Decode(&map[string]interface{}{})
}
func TestApiAuth_ExpiredToken(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Login(t, "thomas")
tok := s.CreateAccessToken(t, "t", db.ReadPermission, db.ReadPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
// Force the token to be expired.
all, _ := db.GetAccessTokensByUserID(s.User().ID)
require.Len(t, all, 1)
all[0].ExpiresAt = 1
require.NoError(t, db.SaveAccessTokenForTest(all[0]))
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", tok, nil, &body, 401)
require.Equal(t, "unauthorized", body["code"])
}
func newJSONReqWithAuth(method, uri, authHeader string) *http.Request {
req := httptest.NewRequest(method, uri, strings.NewReader(""))
req.Header.Set("Authorization", authHeader)
req.Header.Set("Accept", "application/json")
return req
}
func TestApiScope_GistReadInsufficient(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Login(t, "thomas")
tok := s.CreateAccessToken(t, "no-gist", db.NoPermission, db.ReadPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists", tok, nil, &body, 403)
require.Equal(t, "forbidden", body["code"])
}
func TestApiScope_UserReadInsufficient(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Login(t, "thomas")
tok := s.CreateAccessToken(t, "no-user", db.ReadPermission, db.NoPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", tok, nil, &body, 403)
require.Equal(t, "forbidden", body["code"])
}
+20
View File
@@ -0,0 +1,20 @@
package v1
import (
"time"
"github.com/thomiceli/opengist/internal/web/context"
)
// GetUser handles GET /api/v1/user
func GetUser(ctx *context.Context) error {
u := ctx.User
resp := UserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
IsAdmin: u.IsAdmin,
CreatedAt: time.Unix(u.CreatedAt, 0).UTC(),
}
return ctx.JSON(200, resp)
}
+28
View File
@@ -0,0 +1,28 @@
package v1_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
v1 "github.com/thomiceli/opengist/internal/web/handlers/api/v1"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
func TestGetUser(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
// admin is the first user (ID=1), thomas is a regular user
s.Register(t, "admin")
s.Register(t, "thomas")
s.Login(t, "thomas")
tok := s.CreateAccessToken(t, "t", db.ReadPermission, db.ReadPermission)
require.NoError(t, db.UpdateSetting(db.SettingApiEnabled, "1"))
var resp v1.UserResponse
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", tok, nil, &resp, 200)
require.Equal(t, "thomas", resp.Username)
require.NotZero(t, resp.ID)
require.False(t, resp.IsAdmin)
require.False(t, resp.CreatedAt.IsZero())
}
@@ -17,7 +17,11 @@ func AccessTokens(ctx *context.Context) error {
return ctx.ErrorRes(500, "Cannot get access tokens", err)
}
apiEnabled, _ := db.GetSetting(db.SettingApiEnabled)
ctx.SetData("accessTokens", tokens)
ctx.SetData("apiEnabled", apiEnabled == "1")
ctx.SetData("userIsAdmin", user.IsAdmin)
ctx.SetData("settingsHeaderPage", "tokens")
ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_tokens.html")
@@ -273,6 +273,24 @@ func TestAccessTokenLastUsedUpdate(t *testing.T) {
require.NotEqual(t, int64(0), tokenFromDB.LastUsedAt)
}
func TestCreateTokenWithUserScope(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "thomas")
s.Login(t, "thomas")
s.Request(t, "POST", "/settings/access-tokens", db.AccessTokenDTO{
Name: "with-user",
ScopeGist: db.ReadPermission,
ScopeUser: db.ReadPermission,
}, 302)
tokens, err := db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, uint(1), tokens[0].ScopeUser)
}
func TestAccessTokenWithRequireLogin(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
+103 -1
View File
@@ -64,9 +64,12 @@ func (s *Server) registerMiddlewares() {
CookieHTTPOnly: true,
CookieSameSite: http.SameSiteStrictMode,
Skipper: func(ctx echo.Context) bool {
// skip CSRF for /api/v1 (uses bearer tokens, not session cookies)
if strings.HasPrefix(ctx.Request().URL.Path, "/api/v1/") {
return true
}
/* skip CSRF for embeds */
gistName := ctx.Param("gistname")
/* skip CSRF for git clients */
matchUploadPack, _ := regexp.MatchString("(.*?)/git-upload-pack$", ctx.Request().URL.Path)
matchReceivePack, _ := regexp.MatchString("(.*?)/git-receive-pack$", ctx.Request().URL.Path)
@@ -337,6 +340,45 @@ func loadSettings(ctx *context.Context) error {
return nil
}
func apiScopeGistRead(next Handler) Handler {
return func(ctx *context.Context) error {
tok, ok := ctx.GetData("accessToken").(*db.AccessToken)
if !ok || !tok.HasGistReadPermission() {
return ctx.JSON(403, map[string]string{
"error": "token lacks gist:read scope",
"code": "forbidden",
})
}
return next(ctx)
}
}
func apiScopeGistWrite(next Handler) Handler {
return func(ctx *context.Context) error {
tok, ok := ctx.GetData("accessToken").(*db.AccessToken)
if !ok || !tok.HasGistWritePermission() {
return ctx.JSON(403, map[string]string{
"error": "token lacks gist:write scope",
"code": "forbidden",
})
}
return next(ctx)
}
}
func apiScopeUserRead(next Handler) Handler {
return func(ctx *context.Context) error {
tok, ok := ctx.GetData("accessToken").(*db.AccessToken)
if !ok || !tok.HasUserReadPermission() {
return ctx.JSON(403, map[string]string{
"error": "token lacks user:read scope",
"code": "forbidden",
})
}
return next(ctx)
}
}
// getUserByToken checks the Authorization header for token-based auth.
// Expects format: Authorization: Token <token>
// Returns the user if the token is valid and has gist read permission, nil otherwise.
@@ -486,3 +528,63 @@ func setAllGistsMode(mode string) Middleware {
}
}
}
// apiAuth validates /api/v1 requests: admin toggle, Authorization header, token validity.
// Injects ctx.User and ctx.SetData("accessToken", token).
func apiAuth(next Handler) Handler {
return func(ctx *context.Context) error {
enabled, err := db.GetSetting(db.SettingApiEnabled)
if err != nil {
return ctx.JSON(500, map[string]string{
"error": "failed to read API setting",
"code": "internal_error",
})
}
if enabled != "1" {
return ctx.JSON(503, map[string]string{
"error": "API is disabled by administrator",
"code": "api_disabled",
"hint": "Ask an administrator to enable the API at /admin-panel/configuration",
})
}
h := ctx.Request().Header.Get("Authorization")
var plain string
switch {
case strings.HasPrefix(h, "Bearer "):
plain = strings.TrimPrefix(h, "Bearer ")
case strings.HasPrefix(h, "Token "):
plain = strings.TrimPrefix(h, "Token ")
default:
return ctx.JSON(401, map[string]string{
"error": "missing or invalid Authorization header",
"code": "unauthorized",
})
}
tok, err := db.GetAccessTokenByToken(plain)
if err != nil {
return ctx.JSON(401, map[string]string{
"error": "invalid token",
"code": "unauthorized",
})
}
if tok.IsExpired() {
return ctx.JSON(401, map[string]string{
"error": "token expired",
"code": "unauthorized",
})
}
ctx.User = &tok.User
ctx.SetData("accessToken", tok)
// Synchronous update so that test teardown (TRUNCATE on postgres)
// can't race with an in-flight UPDATE on a separate goroutine.
// Cost is one indexed UPDATE per request; negligible vs the network
// round-trip the caller already paid.
_ = tok.UpdateLastUsed()
return next(ctx)
}
}
+16
View File
@@ -13,6 +13,8 @@ import (
"github.com/thomiceli/opengist/internal/index"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers/admin"
api "github.com/thomiceli/opengist/internal/web/handlers/api"
apiv1 "github.com/thomiceli/opengist/internal/web/handlers/api/v1"
"github.com/thomiceli/opengist/internal/web/handlers/auth"
"github.com/thomiceli/opengist/internal/web/handlers/gist"
"github.com/thomiceli/opengist/internal/web/handlers/git"
@@ -162,6 +164,20 @@ func (s *Server) registerRoutes() {
r.Any("/:user/:gistname/*", git.GitHttp, gistSoftInit)
}
apiV1 := r.SubGroup("/api/v1")
{
apiV1.Use(apiAuth)
apiV1.GET("/user", apiv1.GetUser, apiScopeUserRead)
apiV1.GET("/gists", apiv1.ListGists, apiScopeGistRead)
apiV1.GET("/gists/:uuid", apiv1.GetGist, apiScopeGistRead)
apiV1.GET("/gists/:uuid/files/:filename/raw", apiv1.RawFile, apiScopeGistRead)
apiV1.POST("/gists", apiv1.CreateGist, apiScopeGistWrite)
apiV1.PATCH("/gists/:uuid", apiv1.UpdateGist, apiScopeGistWrite)
apiV1.DELETE("/gists/:uuid", apiv1.DeleteGist, apiScopeGistWrite)
}
r.GET("/api/v1/openapi.yaml", api.OpenAPISpec)
r.Any("/*", noRouteFound)
}
+74
View File
@@ -0,0 +1,74 @@
package test
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
)
// APIRequest sends a JSON request to /api/v1/* and asserts the status code.
// body: nil, raw JSON string, or any serializable struct/map.
// token: empty disables Authorization; otherwise sent as "Bearer <token>".
// Returns the response body bytes (already drained).
func (s *Server) APIRequest(t *testing.T, method, uri, token string, body interface{}, expectedCode int) []byte {
var bodyReader *bytes.Reader
switch v := body.(type) {
case nil:
bodyReader = bytes.NewReader(nil)
case string:
bodyReader = bytes.NewReader([]byte(v))
default:
buf, err := json.Marshal(v)
require.NoError(t, err, "failed to marshal body")
bodyReader = bytes.NewReader(buf)
}
req := httptest.NewRequest(method, uri, bodyReader)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
s.server.ServeHTTP(w, req)
if expectedCode != 0 {
require.Equalf(t, expectedCode, w.Code,
"unexpected status for %s %s: got %d, body=%s",
method, uri, w.Code, strings.TrimSpace(w.Body.String()))
}
return w.Body.Bytes()
}
// APIRequestUnmarshal calls APIRequest and unmarshals the body into out (if non-nil).
func (s *Server) APIRequestUnmarshal(t *testing.T, method, uri, token string, body, out interface{}, expectedCode int) {
raw := s.APIRequest(t, method, uri, token, body, expectedCode)
if out != nil && len(raw) > 0 {
require.NoErrorf(t, json.Unmarshal(raw, out),
"failed to unmarshal response: %s", string(raw))
}
}
// CreateAccessToken creates an access token for the currently logged-in user
// and returns the plain token. The caller must be logged in via s.Login(...).
func (s *Server) CreateAccessToken(t *testing.T, name string, scopeGist, scopeUser uint) string {
u := s.User()
require.NotNil(t, u, "must be logged in to create a token")
tok := &db.AccessToken{
Name: name,
UserID: u.ID,
ScopeGist: scopeGist,
ScopeUser: scopeUser,
}
plain, err := tok.GenerateToken()
require.NoError(t, err)
require.NoError(t, tok.Create())
require.NotEmpty(t, plain)
require.NotZero(t, tok.ID)
return plain
}
+20
View File
@@ -181,6 +181,26 @@ func (s *Server) CreateGist(t *testing.T, visibility string) (gistPath string, g
return gistPath, gist, username, identifier
}
// CreateGistAs creates a gist as the specified user. visibility "0"/"1"/"2".
// The caller does not need to be logged in beforehand; this method logs in as user.
func (s *Server) CreateGistAs(t *testing.T, user, visibility string) (string, *db.Gist, string, string) {
s.Logout()
s.Login(t, user)
resp := s.Request(t, "POST", "/", url.Values{
"title": {"Test"},
"name": {"file.txt"},
"content": {"hello"},
"private": {visibility},
}, 302)
loc := resp.Header.Get("Location")
parts := strings.Split(strings.TrimPrefix(loc, "/"), "/")
require.Len(t, parts, 2)
gist, err := db.GetGist(parts[0], parts[1])
require.NoError(t, err)
require.NotNil(t, gist)
return "", gist, gist.User.Username, gist.Identifier()
}
func Setup(t *testing.T) *Server {
tmpDir := t.TempDir()
t.Setenv("OPENGIST_SKIP_GIT_HOOKS", "1")
+11
View File
@@ -143,6 +143,17 @@
</button>
</div>
</li>
<li class="list-none gap-x-4 py-5">
<div class="flex items-center justify-between">
<span class="flex grow flex-col">
<span class="text-sm font-medium leading-6 text-slate-700 dark:text-slate-300">{{ .locale.Tr "admin.api-enabled" }}</span>
<span class="text-sm text-gray-400 dark:text-gray-400">{{ .locale.Tr "admin.api-enabled_help" }} <a href="{{ $.c.ExternalUrl }}/api/docs" class="text-primary-500 underline">/api/docs</a></span>
</span>
<button type="button" id="api-enabled" data-bool="{{ .ApiEnabled }}" class="toggle-button {{ if .ApiEnabled }}bg-primary-600{{else}}bg-gray-300 dark:bg-gray-400{{end}} relative inline-flex h-6 w-11 ml-4 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-600 focus:ring-offset-2" role="switch" aria-checked="false" aria-labelledby="availability-label" aria-describedby="availability-description">
<span aria-hidden="true" class="{{ if .ApiEnabled }}translate-x-5{{else}}translate-x-0{{end}} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span>
</button>
</div>
</li>
</ul>
{{ .csrfHtml }}
</div>
+24
View File
@@ -1,6 +1,19 @@
{{ template "header" .}}
{{ template "settings_header" .}}
<div class="relative mx-auto max-w-160 space-y-8">
{{ if not .apiEnabled }}
<div class="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-950 dark:border-amber-700 px-4 py-3 flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 flex-shrink-0 text-amber-600 dark:text-amber-400 mt-0.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
</svg>
<div class="flex-1 text-sm text-amber-800 dark:text-amber-200">
{{ .locale.Tr "settings.api-disabled-warning" }}
{{ if .userIsAdmin }}
<a href="{{ .c.ExternalUrl }}/admin-panel/configuration" class="ml-2 underline font-medium hover:text-amber-900 dark:hover:text-amber-100">{{ .locale.Tr "settings.api-disabled-go-admin" }} →</a>
{{ end }}
</div>
</div>
{{ end }}
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
<div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
@@ -30,6 +43,17 @@
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">{{ .locale.Tr "settings.token-user-permission" }}</label>
<div class="space-y-2">
<label class="block text-sm text-slate-600 dark:text-slate-400">{{ .locale.Tr "settings.token-user-permission-help" }}</label>
<select name="scope_user" class="dark:bg-gray-800 block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
<option value="0">{{ .locale.Tr "settings.token-permission-none" }}</option>
<option value="1">{{ .locale.Tr "settings.token-permission-read" }}</option>
</select>
</div>
</div>
<div class="mt-4">
<label for="expires_at" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.token-expiration" }} </label>
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-2">