mirror of
https://github.com/thomiceli/opengist.git
synced 2026-06-23 04:10:18 +00:00
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
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:
@@ -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>
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -170,6 +170,7 @@ func Setup(dbUri string) error {
|
||||
SettingAllowGistsWithoutLogin: "0",
|
||||
SettingDisableLoginForm: "0",
|
||||
SettingDisableGravatar: "0",
|
||||
SettingApiEnabled: "0",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?
|
||||
|
||||
|
||||
@@ -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: 您想要删除此用户吗?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Vendored
+11
@@ -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>
|
||||
|
||||
Vendored
+24
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user