API for gists and users (#707)
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

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
Thomas
2026-06-03 02:26:17 +08:00
committed by GitHub
parent 5c23d7feed
commit 8e462397f4
60 changed files with 5505 additions and 1158 deletions
+3
View File
@@ -56,6 +56,9 @@ http.port: 6157
# Enable or disable git operations (clone, pull, push) via HTTP (either `true` or `false`). Default: true
http.git-enabled: true
# Enable or disable the REST API (either `true` or `false`). Default: true
api.enabled: true
# File permissions for Unix socket (octal format). Default: 0666
unix-socket-permissions: 0666
+1
View File
@@ -21,6 +21,7 @@ aside: false
| http.host | OG_HTTP_HOST | `0.0.0.0` | The host on which the HTTP server should bind. Use an IP address for network binding. Use a path for Unix socket binding (e.g. /run/opengist.sock) |
| http.port | OG_HTTP_PORT | `6157` | The port on which the HTTP server should listen. |
| http.git-enabled | OG_HTTP_GIT_ENABLED | `true` | Enable or disable git operations (clone, pull, push) via HTTP. (`true` or `false`) |
| api.enabled | OG_API_ENABLED | `true` | Enable or disable the REST API. (`true` or `false`) |
| unix-socket-permissions | OG_UNIX_SOCKET_PERMISSIONS | `0666` | File permissions for Unix socket (octal format). |
| metrics.enabled | OG_METRICS_ENABLED | `false` | Enable or disable Prometheus metrics server (`true` or `false`) |
| metrics.host | OG_METRICS_HOST | `0.0.0.0` | The host on which the metrics server should bind. |
+4
View File
@@ -53,6 +53,8 @@ type config struct {
HttpPort string `yaml:"http.port" env:"OG_HTTP_PORT"`
HttpGit bool `yaml:"http.git-enabled" env:"OG_HTTP_GIT_ENABLED"`
ApiEnabled bool `yaml:"api.enabled" env:"OG_API_ENABLED"`
UnixSocketPermissions string `yaml:"unix-socket-permissions" env:"OG_UNIX_SOCKET_PERMISSIONS"`
SshGit bool `yaml:"ssh.git-enabled" env:"OG_SSH_GIT_ENABLED"`
@@ -120,6 +122,8 @@ func configWithDefaults() (*config, error) {
c.HttpPort = "6157"
c.HttpGit = true
c.ApiEnabled = true
c.UnixSocketPermissions = "0666"
c.SshGit = true
+24 -5
View File
@@ -8,9 +8,14 @@ import (
)
const (
NoPermission = 0
ReadPermission = 1
ReadWritePermission = 2
ScopeGist = iota
ScopeUser
)
const (
NoPermission = iota
ReadPermission
ReadWritePermission
)
type AccessToken struct {
@@ -24,7 +29,7 @@ type AccessToken struct {
User User `validate:"-"`
ScopeGist uint // 0 = none, 1 = read, 2 = read+write
ScopeUser uint // 0 = none, 1 = read
ScopeUser uint // 0 = none, 1 = read, 2 = read+write
}
// GenerateToken creates a new random token and returns the plain text token.
@@ -105,12 +110,26 @@ func (t *AccessToken) HasUserReadPermission() bool {
return t.ScopeUser >= ReadPermission
}
func (t *AccessToken) HasUserWritePermission() bool {
return t.ScopeUser >= ReadWritePermission
}
func (t *AccessToken) CheckForPermission(scope, permission uint) bool {
if scope == ScopeGist {
return t.ScopeGist >= permission
}
if scope == ScopeUser {
return t.ScopeUser >= permission
}
return false
}
// -- 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"`
ScopeUser uint `form:"scope_user" validate:"min=0,max=2"`
ExpiresAt string `form:"expires_at"` // empty means no expiration, otherwise date format (YYYY-MM-DD)
}
-1
View File
@@ -15,7 +15,6 @@ 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
@@ -1,18 +0,0 @@
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,7 +170,6 @@ func Setup(dbUri string) error {
SettingAllowGistsWithoutLogin: "0",
SettingDisableLoginForm: "0",
SettingDisableGravatar: "0",
SettingApiEnabled: "0",
})
}
+254 -37
View File
@@ -12,6 +12,7 @@ import (
"github.com/alecthomas/chroma/v2/lexers"
"github.com/dustin/go-humanize"
"github.com/rs/zerolog/log"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/index"
"gorm.io/gorm"
@@ -126,7 +127,7 @@ func GetGist(user string, gistUuid string) (*Gist, error) {
func GetGistByUUID(uuid string) (*Gist, error) {
gist := new(Gist)
err := db.Preload("User").Where("uuid = ?", uuid).First(gist).Error
err := db.Preload("User").Preload("Forked.User").Where("uuid = ?", uuid).First(gist).Error
return gist, err
}
@@ -139,14 +140,88 @@ func GetGistByID(gistId string) (*Gist, error) {
return gist, err
}
func GetAllGistsForCurrentUser(currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
// GetAllGistsForCurrentUser returns gists visible to currentUserId - all public
// gists plus the user's own private/unlisted ones - ordered by sort/order and
// paginated to one extra row (the 11th is the peek-next sentinel).
// `since`, when non-nil, restricts results to gists updated at or after that
// instant (used by the API; the web handler passes nil).
func GetAllGistsForCurrentUser(currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
err := db.Preload("User").
query := db.Preload("User").
Preload("Forked.User").
Preload("Topics").
Where("gists.private = 0 or gists.user_id = ?", currentUserId).
Limit(11).
Offset(offset * 10).
Where("gists.private = 0 or gists.user_id = ?", currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order(sort + "_at " + order).
Find(&gists).Error
return gists, err
}
// GetAllGistsFromUserVisibleTo returns gists owned by fromUserId, filtered
// to what currentUserId is allowed to see (public always; private/unlisted
// only when currentUserId == fromUserId). Same pagination/since shape as
// the other API list helpers. Pass currentUserId=0 to force the
// public-only subset.
func GetAllGistsFromUserVisibleTo(fromUserId uint, currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
query := gistsFromUserStatement(fromUserId, currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
}
// GetAllGistsOfUser returns every gist owned by userID - public, unlisted,
// and private - with the same pagination/since semantics as GetAllGistsForCurrentUser.
// Used by the API list endpoint for callers whose
// token holds gist:read: they see all of their own content but nothing from
// other users (others' public gists live under /gists/public).
func GetAllGistsOfUser(userID uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
query := db.Preload("User").
Preload("Forked.User").
Preload("Topics").
Where("gists.user_id = ?", userID)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order(sort + "_at " + order).
Find(&gists).Error
return gists, err
}
// GetAllPublicGistsOfUser returns only the public gists owned by userID, with
// the same pagination/since semantics as GetAllGistsForCurrentUser. Used by
// the API list endpoint for callers that authenticate but whose token lacks
// gist:read - they get only their own public gists.
func GetAllPublicGistsOfUser(userID uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
query := db.Preload("User").
Preload("Forked.User").
Preload("Topics").
Where("gists.private = 0 AND gists.user_id = ?", userID)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order(sort + "_at " + order).
Find(&gists).Error
@@ -164,26 +239,6 @@ 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").
@@ -261,10 +316,19 @@ func likedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
Joins("join users on likes.user_id = users.id")
}
func GetAllGistsLikedByUser(fromUserId uint, currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
// GetAllGistsLikedByUser returns gists that fromUserId has starred, filtered
// to what currentUserId is allowed to see. `since`, when non-nil, restricts
// results to gists updated at or after that instant (used by the API; the web
// handler passes nil for both since and the explicit pagination args).
func GetAllGistsLikedByUser(fromUserId uint, currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
err := likedStatement(fromUserId, currentUserId).Limit(11).
Offset(offset * 10).
query := likedStatement(fromUserId, currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
@@ -283,10 +347,19 @@ func forkedStatement(fromUserId uint, currentUserId uint) *gorm.DB {
Joins("join users on gists.user_id = users.id")
}
func GetAllGistsForkedByUser(fromUserId uint, currentUserId uint, offset int, sort string, order string) ([]*Gist, error) {
// GetAllGistsForkedByUser returns gists forked by fromUserId, filtered to
// what currentUserId is allowed to see. `since`, when non-nil, restricts
// results to gists updated at or after that instant (used by the API; the
// web handler passes nil for both since and the explicit pagination args).
func GetAllGistsForkedByUser(fromUserId uint, currentUserId uint, since *time.Time, offset int, sort string, order string, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
err := forkedStatement(fromUserId, currentUserId).Limit(11).
Offset(offset * 10).
query := forkedStatement(fromUserId, currentUserId)
if since != nil {
query = query.Where("gists.updated_at >= ?", since.Unix())
}
err := query.
Limit(limit).
Offset(offset * perPage).
Order("gists." + sort + "_at " + order).
Find(&gists).Error
return gists, err
@@ -298,6 +371,59 @@ func CountAllGistsForkedByUser(fromUserId uint, currentUserId uint) (int64, erro
return count, err
}
// applySince narrows a gist query to rows updated at or after `since` when it
// is non-nil, matching the filter the API list queries apply. Kept separate so
// the count helpers stay in sync with their Find counterparts.
func applySince(q *gorm.DB, since *time.Time) *gorm.DB {
if since != nil {
return q.Where("gists.updated_at >= ?", since.Unix())
}
return q
}
// The Count* helpers below mirror the API list queries (including the optional
// `since` filter) so list responses can report a total. They're separate from
// the web UI's CountAll* helpers above, which don't take `since`.
func CountAllGistsForCurrentUser(currentUserId uint, since *time.Time) (int64, error) {
var count int64
q := applySince(db.Model(&Gist{}).Where("gists.private = 0 or gists.user_id = ?", currentUserId), since)
err := q.Count(&count).Error
return count, err
}
func CountAllGistsFromUserVisibleTo(fromUserId uint, currentUserId uint, since *time.Time) (int64, error) {
var count int64
err := applySince(gistsFromUserStatement(fromUserId, currentUserId).Model(&Gist{}), since).Count(&count).Error
return count, err
}
func CountAllGistsOfUser(userID uint, since *time.Time) (int64, error) {
var count int64
q := applySince(db.Model(&Gist{}).Where("gists.user_id = ?", userID), since)
err := q.Count(&count).Error
return count, err
}
func CountAllPublicGistsOfUser(userID uint, since *time.Time) (int64, error) {
var count int64
q := applySince(db.Model(&Gist{}).Where("gists.private = 0 AND gists.user_id = ?", userID), since)
err := q.Count(&count).Error
return count, err
}
func CountAllGistsLikedByUserSince(fromUserId uint, currentUserId uint, since *time.Time) (int64, error) {
var count int64
err := applySince(likedStatement(fromUserId, currentUserId).Model(&Gist{}), since).Count(&count).Error
return count, err
}
func CountAllGistsForkedByUserSince(fromUserId uint, currentUserId uint, since *time.Time) (int64, error) {
var count int64
err := applySince(forkedStatement(fromUserId, currentUserId).Model(&Gist{}), since).Count(&count).Error
return count, err
}
func GetAllGistsRows() ([]*Gist, error) {
var gists []*Gist
err := db.Table("gists").
@@ -415,19 +541,35 @@ func (gist *Gist) GetUsersLikes(offset int) ([]*User, error) {
return users, err
}
func (gist *Gist) GetForks(currentUserId uint, offset int) ([]*Gist, error) {
// GetForks returns gists that fork this gist, filtered to what
// currentUserId is allowed to see. `offset` is the page index (0-based);
// `limit` caps the returned slice (pass perPage+1 for the peek-next
// sentinel). `perPage` is the slice size used for the offset arithmetic
// (offset * perPage rows are skipped).
func (gist *Gist) GetForks(currentUserId uint, offset int, limit int, perPage int) ([]*Gist, error) {
var gists []*Gist
err := db.Model(&gist).Preload("User").
Where("forked_id = ?", gist.ID).
Where("(gists.private = 0) or (gists.private > 0 and gists.user_id = ?)", currentUserId).
Limit(11).
Offset(offset * 10).
Limit(limit).
Offset(offset * perPage).
Order("updated_at desc").
Find(&gists).Error
return gists, err
}
// CountForks returns the number of forks of this gist visible to currentUserId,
// using the same visibility filter as GetForks (pass 0 for the public subset).
func (gist *Gist) CountForks(currentUserId uint) (int64, error) {
var count int64
err := db.Model(&Gist{}).
Where("forked_id = ?", gist.ID).
Where("(gists.private = 0) or (gists.private > 0 and gists.user_id = ?)", currentUserId).
Count(&count).Error
return count, err
}
func (gist *Gist) CanWrite(user *User) bool {
return user != nil && gist.UserID == user.ID
}
@@ -507,8 +649,55 @@ func (gist *Gist) FileNames(revision string) ([]string, error) {
return git.GetFilesOfRepository(gist.User.Username, gist.Uuid, revision)
}
func (gist *Gist) Log(skip int) ([]*git.Commit, error) {
return git.GetLog(gist.User.Username, gist.Uuid, skip)
// GistCommit pairs a raw git commit with the Opengist account whose email
// matches the commit's AuthorEmail (when one exists). The git.Commit pointer
// is embedded so callers/templates can read AuthorName, Hash, Timestamp,
// Files, etc. directly. User is nil when no account matches - callers can
// fall back to the embedded AuthorName/AuthorEmail.
type GistCommit struct {
*git.Commit
User *User
}
// Log returns the gist's commit history starting from `revision` (pass
// "HEAD" for the full history or a SHA to walk from a specific commit
// downward), with each commit's author resolved to an Opengist user via a
// single bulk email lookup. Lookup is case-insensitive on both sides -
// matches the historical web behavior even when the DB stores mixed-case
// emails. `skip` is the number of commits to skip from the top of the walk
// (use offset*per_page for paging); `limit` caps the returned slice (pass
// per_page+1 to enable the peek-next sentinel trick).
func (gist *Gist) Log(revision string, skip int, limit int) ([]*GistCommit, error) {
raw, err := git.GetLog(gist.User.Username, gist.Uuid, revision, skip, limit)
if err != nil {
return nil, err
}
// Collect distinct lowercased author emails.
loweredSet := make(map[string]struct{}, len(raw))
for _, c := range raw {
if c.AuthorEmail == "" {
continue
}
loweredSet[strings.ToLower(c.AuthorEmail)] = struct{}{}
}
// One IN query, then re-key by lowercased email so we can look up
// case-insensitively even if the DB column holds a mixed-case value.
byDBEmail, _ := GetUsersFromEmails(loweredSet)
byLowered := make(map[string]*User, len(byDBEmail))
for e, u := range byDBEmail {
byLowered[strings.ToLower(e)] = u
}
out := make([]*GistCommit, len(raw))
for i, c := range raw {
out[i] = &GistCommit{
Commit: c,
User: byLowered[strings.ToLower(c.AuthorEmail)],
}
}
return out, nil
}
func (gist *Gist) NbCommits() (string, error) {
@@ -638,6 +827,34 @@ func (gist *Gist) Identifier() string {
return gist.Uuid
}
// HTTPCloneURL returns the HTTPS clone URL (`{baseURL}/{user}/{identifier}.git`).
// Returns "" when HTTP git access is disabled (config.HttpGit == false).
func (gist *Gist) HTTPCloneURL(baseURL string) string {
if !config.C.HttpGit {
return ""
}
return baseURL + "/" + gist.User.Username + "/" + gist.Identifier() + ".git"
}
// SSHCloneURL returns the SSH clone URL. `fallbackHost` is the request's Host
// header (or any host:port-shaped string) used when SshExternalDomain isn't
// configured — only its hostname part is kept. Returns "" when SSH git access
// is disabled (config.SshGit == false).
func (gist *Gist) SSHCloneURL(fallbackHost string) string {
if !config.C.SshGit {
return ""
}
sshDomain := config.C.SshExternalDomain
if sshDomain == "" {
sshDomain = strings.Split(fallbackHost, ":")[0]
}
path := gist.User.Username + "/" + gist.Identifier() + ".git"
if config.C.SshPort == "22" {
return sshDomain + ":" + path
}
return "ssh://" + sshDomain + ":" + config.C.SshPort + "/" + path
}
func (gist *Gist) GetLanguagesFromFiles() ([]string, error) {
files, _, err := gist.Files("HEAD", true)
if err != nil {
+123
View File
@@ -0,0 +1,123 @@
package db
import (
"encoding/base64"
"net/url"
"strconv"
"time"
"github.com/thomiceli/opengist/internal/render/lang"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// ToAPISimple returns the v1 API list-shape representation of the gist. baseURL is
// the scheme+host root (no trailing slash) the caller derived from config or
// the request.
func (gist *Gist) ToAPISimple(baseURL string) types.GistSimple {
sshHost := ""
if u, err := url.Parse(baseURL); err == nil {
sshHost = u.Host
}
return types.GistSimple{
ID: gist.Uuid,
Owner: gist.User.ToSimpleAPI(),
Title: gist.Title,
HTMLUrl: baseURL + "/" + gist.User.Username + "/" + gist.Identifier(),
SlugUrl: gist.Identifier(),
Description: gist.Description,
Public: gist.Private == PublicVisibility,
Visibility: gist.Private.String(),
LikeCount: gist.NbLikes,
ForkCount: gist.NbForks,
CloneUrl: gist.HTTPCloneURL(baseURL),
SSHUrl: gist.SSHCloneURL(sshHost),
Topics: gist.TopicsSlice(),
CreatedAt: time.Unix(gist.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(gist.UpdatedAt, 0).UTC(),
}
}
// ToAPI returns the v1 API detail-shape representation, including file
// contents at `revision` and the 10 most recent commits up to that
// revision. Pass "HEAD" for the current state; pass a SHA to render the
// gist (and its history) as it stood at that commit. Returns any error
// encountered while listing the gist's files or commit log (an unknown
// revision surfaces here as the Files error).
func (gist *Gist) ToAPI(baseURL string, revision string) (types.Gist, error) {
files, truncated, err := gist.Files(revision, true)
if err != nil {
return types.Gist{}, err
}
fm := make(map[string]types.GistFile, len(files))
for _, f := range files {
ff := types.GistFile{
Filename: f.Filename,
Type: f.MimeType.ContentType,
Language: lang.Parse(f),
Size: int(f.Size),
Truncated: f.Truncated,
}
if f.MimeType.CanBeEdited() {
ff.Content = f.Content
ff.Encoding = f.MimeType.Charset
} else {
ff.Content = base64.StdEncoding.EncodeToString([]byte(f.Content))
ff.Encoding = "base64"
}
fm[f.Filename] = ff
}
var forked *types.GistSimple
if gist.Forked != nil {
ff := gist.Forked.ToAPISimple(baseURL)
forked = &ff
}
forks, err := gist.GetForks(gist.UserID, 0, 11, 10)
if err != nil {
return types.Gist{}, err
}
forksMap := make([]types.GistSimple, 0)
for _, fork := range forks {
forksMap = append(forksMap, fork.ToAPISimple(baseURL))
}
logCommits, err := gist.Log(revision, 0, 10)
if err != nil {
return types.Gist{}, err
}
commits := make([]types.GistCommit, 0, len(logCommits))
for _, c := range logCommits {
commits = append(commits, c.ToAPI())
}
return types.Gist{
GistSimple: gist.ToAPISimple(baseURL),
ForkOf: forked,
Forks: forksMap,
Files: fm,
Commits: commits,
Truncated: truncated,
}, nil
}
// ToAPI converts a single resolved commit into its API shape. Shared by the
// gist-detail endpoint (10-most-recent embed) and the dedicated
// /gists/:uuid/commits endpoint so the wire shape is identical.
func (c *GistCommit) ToAPI() types.GistCommit {
ts, _ := strconv.ParseInt(c.Timestamp, 10, 64)
entry := types.GistCommit{
Version: c.Hash,
Author: types.CommitAuthor{Name: c.AuthorName, Email: c.AuthorEmail},
ChangeStatus: types.CommitChangeStatus{
Files: c.FilesChanged,
Additions: c.Additions,
Deletions: c.Deletions,
Total: c.Additions + c.Deletions,
},
CommittedAt: time.Unix(ts, 0).UTC(),
}
if c.User != nil {
s := c.User.ToSimpleAPI()
entry.User = &s
}
return entry
}
+32
View File
@@ -0,0 +1,32 @@
package db
import (
"time"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// ToSimpleAPI returns the public simple-user shape used inside gist responses
// and by the public user-lookup endpoints. It carries no private fields (no
// email). Fields whose underlying feature doesn't exist in Opengist
// (followers, repos, ...) are still populated with the spec-shaped URLs so
// clients can parse cleanly.
func (u *User) ToSimpleAPI() types.SimpleUser {
return types.SimpleUser{
ID: u.ID,
Login: u.Username,
Username: u.Username,
AvatarURL: u.AvatarURL,
Type: "User",
CreatedAt: time.Unix(u.CreatedAt, 0).UTC(),
}
}
// ToPrivateAPI returns the self shape for the authenticated-user endpoints
// (GET/PATCH /user): the public fields plus the caller's own email.
func (u *User) ToPrivateAPI() types.PrivateUser {
return types.PrivateUser{
SimpleUser: u.ToSimpleAPI(),
Email: u.Email,
}
}
+7 -3
View File
@@ -327,7 +327,11 @@ func GetFileSize(user string, gist string, revision string, filename string) (ui
return strconv.ParseUint(strings.TrimSuffix(string(stdout), "\n"), 10, 64)
}
func GetLog(user string, gist string, skip int) ([]*Commit, error) {
// GetLog returns commits walked from `revision` (typically "HEAD" or a
// specific SHA), skipping `skip` rows from the top of the walk and limited
// to `limit` rows. Pass "HEAD" for the gist's full history; pass a SHA to
// see the history ending at (and including) that commit.
func GetLog(user string, gist string, revision string, skip int, limit int) ([]*Commit, error) {
repositoryPath := RepositoryPath(user, gist)
cmd := exec.Command(
@@ -335,14 +339,14 @@ func GetLog(user string, gist string, skip int) ([]*Commit, error) {
"--no-pager",
"log",
"-n",
"11",
strconv.Itoa(limit),
"--no-color",
"-p",
"--skip",
strconv.Itoa(skip),
"--format=format:c %H%na %aN%nm %ae%nt %at",
"--shortstat",
"HEAD",
revision,
)
cmd.Dir = repositoryPath
stdout, _ := cmd.StdoutPipe()
+5 -3
View File
@@ -100,14 +100,16 @@ like Opengist actually`,
require.False(t, truncated, "Content should not be truncated")
require.Equal(t, "I really\nlike Opengist actually", content, "Content is not correct")
commits, err := GetLog("thomas", "gist1", 0)
commits, err := GetLog("thomas", "gist1", "HEAD", 0, 11)
require.NoError(t, err, "Could not get log")
require.Equal(t, 2, len(commits), "Commits count are not correct")
require.Regexp(t, "[a-f0-9]{40}", commits[0].Hash, "Commit ID is not correct")
require.Regexp(t, "[0-9]{10}", commits[0].Timestamp, "Commit timestamp is not correct")
require.Equal(t, "thomas", commits[0].AuthorName, "Commit author name is not correct")
require.Equal(t, "thomas@mail.com", commits[0].AuthorEmail, "Commit author email is not correct")
require.Equal(t, "4 files changed, 2 insertions, 2 deletions", commits[0].Changed, "Commit author name is not correct")
require.Equal(t, 4, commits[0].FilesChanged, "FilesChanged is not correct")
require.Equal(t, 2, commits[0].Additions, "Additions is not correct")
require.Equal(t, 2, commits[0].Deletions, "Deletions is not correct")
require.Contains(t, commits[0].Files, File{
Filename: "my_renamed_file.txt",
@@ -157,7 +159,7 @@ like Opengist actually`,
IsDeleted: false,
}, "File new_file.txt is not correct")
commitsSkip1, err := GetLog("thomas", "gist1", 1)
commitsSkip1, err := GetLog("thomas", "gist1", "HEAD", 1, 11)
require.NoError(t, err, "Could not get log")
require.Equal(t, commitsSkip1[0], commits[1], "Commits skips are not correct")
}
+14 -2
View File
@@ -2,6 +2,7 @@ package git
import (
"fmt"
"mime"
"net/http"
"strings"
@@ -10,6 +11,7 @@ import (
type MimeType struct {
ContentType string
Charset string
extension string
golangContentType string // json, m3u, etc. still renderable as text
}
@@ -88,6 +90,16 @@ func (mt MimeType) RenderType() string {
return "Binary"
}
func DetectMimeType(data []byte, extension string) MimeType {
return MimeType{mimetype.Detect(data).String(), extension, http.DetectContentType(data)}
// Header returns the value for a Content-Type HTTP header, re-attaching the
// charset parameter that DetectMimeType split out of ContentType.
func (mt MimeType) Header() string {
if mt.Charset == "" {
return mt.ContentType
}
return mime.FormatMediaType(mt.ContentType, map[string]string{"charset": mt.Charset})
}
func DetectMimeType(data []byte, extension string) MimeType {
mediaType, params, _ := mime.ParseMediaType(mimetype.Detect(data).String())
return MimeType{mediaType, params["charset"], extension, http.DetectContentType(data)}
}
+31 -10
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"regexp"
"strconv"
"strings"
)
@@ -23,12 +24,14 @@ type File struct {
}
type Commit struct {
Hash string
AuthorName string
AuthorEmail string
Timestamp string
Changed string
Files []File
Hash string
AuthorName string
AuthorEmail string
Timestamp string
FilesChanged int
Additions int
Deletions int
Files []File
}
func truncateCommandOutput(out io.Reader, maxBytes int64) (string, bool, error) {
@@ -60,6 +63,18 @@ func truncateCommandOutput(out io.Reader, maxBytes int64) (string, bool, error)
var reLogBinaryNames = regexp.MustCompile(`Binary files (.+) and (.+) differ`)
// shortstat patterns. Git emits a line like:
//
// " 4 files changed, 2 insertions(+), 2 deletions(-)"
//
// with insertions or deletions optionally absent when zero. The capture
// group in each regex is the count.
var (
reShortstatFiles = regexp.MustCompile(`(\d+) files? changed`)
reShortstatInsertions = regexp.MustCompile(`(\d+) insertions?`)
reShortstatDeletions = regexp.MustCompile(`(\d+) deletions?`)
)
// inspired from https://github.com/go-gitea/gitea/blob/main/services/gitdiff/gitdiff.go
func parseLog(out io.Reader, maxFiles int, maxBytes int) ([]*Commit, error) {
var commits []*Commit
@@ -120,10 +135,16 @@ loopLog:
// Commit shortstat
case ' ':
changed := []byte(line)[1:]
changed = bytes.ReplaceAll(changed, []byte("(+)"), []byte(""))
changed = bytes.ReplaceAll(changed, []byte("(-)"), []byte(""))
currentCommit.Changed = string(changed)
shortstat := line[1:]
if m := reShortstatFiles.FindStringSubmatch(shortstat); len(m) == 2 {
currentCommit.FilesChanged, _ = strconv.Atoi(m[1])
}
if m := reShortstatInsertions.FindStringSubmatch(shortstat); len(m) == 2 {
currentCommit.Additions, _ = strconv.Atoi(m[1])
}
if m := reShortstatDeletions.FindStringSubmatch(shortstat); len(m) == 2 {
currentCommit.Deletions, _ = strconv.Atoi(m[1])
}
// shortstat is followed by an empty line
line, err = input.ReadString('\n')
-1
View File
@@ -179,7 +179,6 @@ 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
-1
View File
@@ -118,7 +118,6 @@ 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: 读写
+47
View File
@@ -0,0 +1,47 @@
// Package lang derives a human-readable language label for a gist file.
// Kept dependency-light (no db import) so callers in lower layers (db,
// API types) can use it without creating import cycles.
package lang
import (
"path/filepath"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/thomiceli/opengist/internal/git"
)
// Parse returns the language label for a file (e.g. "Go", "Markdown", "SVG",
// "Text"). Falls back to "Text" for unknown / fallback lexers.
func Parse(file *git.File) string {
mt := file.MimeType
switch {
case mt.IsCSV():
return "CSV"
case mt.IsText() && filepath.Ext(file.Filename) == ".md":
return "Markdown"
case mt.IsSVG():
return "SVG"
case mt.CanBeEmbedded():
return mt.RenderType()
case mt.CanBeRendered():
return parseFileTypeName(*newLexer(file.Filename).Config())
default:
return mt.RenderType()
}
}
func parseFileTypeName(config chroma.Config) string {
fileType := config.Name
if fileType == "fallback" || fileType == "plaintext" {
return "Text"
}
return fileType
}
func newLexer(filename string) chroma.Lexer {
if l := lexers.Get(filename); l != nil {
return l
}
return lexers.Fallback
}
+25 -2
View File
@@ -2,6 +2,7 @@ package context
import (
"context"
"fmt"
"html/template"
"net/http"
"sync"
@@ -18,6 +19,19 @@ type dataKey string
const DataKeyStr dataKey = "data"
type HTTPError struct {
Internal error `json:"-"`
Message interface{} `json:"message"`
Code int `json:"status"`
}
func (he *HTTPError) Error() string {
if he.Internal == nil {
return fmt.Sprintf("code=%d, message=%v", he.Code, he.Message)
}
return fmt.Sprintf("code=%d, message=%v, internal=%v", he.Code, he.Message, he.Internal)
}
type Context struct {
echo.Context
@@ -58,14 +72,18 @@ func (ctx *Context) DataMap() echo.Map {
}
func (ctx *Context) ErrorRes(code int, message string, err error) error {
return ctx.errorRes(code, message, err)
}
func (ctx *Context) errorRes(code int, message string, err error) error {
if code >= 500 && err != nil {
var skipLogger = log.With().CallerWithSkipFrameCount(3).Logger()
var skipLogger = log.With().CallerWithSkipFrameCount(4).Logger()
skipLogger.Error().Err(err).Msg(message)
}
ctx.SetRequest(ctx.Request().WithContext(context.WithValue(ctx.Request().Context(), DataKeyStr, ctx.data)))
return &echo.HTTPError{Code: code, Message: message, Internal: err}
return &HTTPError{Code: code, Message: message, Internal: err}
}
func (ctx *Context) RedirectTo(location string) error {
@@ -89,6 +107,11 @@ func (ctx *Context) JsonWithCode(code int, data any) error {
return ctx.JSON(code, data)
}
func (ctx *Context) ErrorJson(code int, message string, err error) error {
ctx.SetData("err_render", "json")
return ctx.errorRes(code, message, err)
}
func (ctx *Context) PlainText(code int, message string) error {
return ctx.String(code, message)
}
-20
View File
@@ -98,26 +98,6 @@ 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)
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
package v1
import (
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/web/context"
)
const (
defaultPerPage = 30
maxPerPage = 100
)
func apiBaseURL(ctx *context.Context) string {
if config.C.ExternalUrl != "" {
return config.C.ExternalUrl
}
scheme := "http"
if ctx.Request().TLS != nil || ctx.Request().Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + ctx.Request().Host
}
// writePaginationHeaders sets the pagination response headers for a list
// endpoint. X-Page and X-Per-Page are always set, alongside the RFC 5988 Link
// header (next/prev). When total is non-nil it also sets X-Total and the
// derived X-Total-Pages = ceil(total / perPage); endpoints whose total would
// cost an extra round-trip (e.g. commits, which would need a git call) pass nil
// to omit them.
func writePaginationHeaders(ctx *context.Context, baseURL string, page, perPage int, hasMore bool, total *int64) {
h := ctx.Response().Header()
h.Set("X-Page", strconv.Itoa(page))
h.Set("X-Per-Page", strconv.Itoa(perPage))
if total != nil {
h.Set("X-Total", strconv.FormatInt(*total, 10))
totalPages := 0
if perPage > 0 {
totalPages = int((*total + int64(perPage) - 1) / int64(perPage))
}
h.Set("X-Total-Pages", strconv.Itoa(totalPages))
}
writeLinkHeader(ctx, baseURL, page, hasMore)
}
func parsePage(ctx *context.Context) int {
p, _ := strconv.Atoi(ctx.QueryParam("page"))
if p < 1 {
p = 1
}
return p
}
func parsePerPage(ctx *context.Context) int {
pp, _ := strconv.Atoi(ctx.QueryParam("per_page"))
if pp < 1 {
return defaultPerPage
}
if pp > maxPerPage {
return maxPerPage
}
return pp
}
// parseSince reads the optional `since` query param as an RFC 3339 timestamp.
// Returns (nil, nil) when absent and an HTTP-ready error envelope on parse failure.
func parseSince(ctx *context.Context) (*time.Time, error) {
s := ctx.QueryParam("since")
if s == "" {
return nil, nil
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return nil, err
}
return &t, nil
}
// writeLinkHeader writes an RFC 5988 Link header for paginated responses.
// Includes rel=next when there's a next page and rel=prev when page > 1.
// URLs are rebuilt from the base URL + original query (with page rewritten) so
// they survive whatever proxy stripped from the inbound request's path.
func writeLinkHeader(ctx *context.Context, baseURL string, page int, hasMore bool) {
if !hasMore && page <= 1 {
return
}
reqURL := ctx.Request().URL
build := func(p int) string {
q := reqURL.Query()
q.Set("page", strconv.Itoa(p))
u := url.URL{Path: reqURL.Path, RawQuery: q.Encode()}
return strings.TrimRight(baseURL, "/") + u.RequestURI()
}
var links []string
if hasMore {
links = append(links, fmt.Sprintf(`<%s>; rel="next"`, build(page+1)))
}
if page > 1 {
links = append(links, fmt.Sprintf(`<%s>; rel="prev"`, build(page-1)))
}
ctx.Response().Header().Set("Link", strings.Join(links, ", "))
}
+203
View File
@@ -0,0 +1,203 @@
package v1_test
import (
"encoding/json"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// setupPaginationEnv stands up a server with `count` public gists owned by
// "thomas", with the API enabled and no logged-in session. The list endpoint
// can then be hit anonymously - visibility plays no role in these tests, so
// keeping everything public + anon keeps the focus on pagination plumbing.
func setupPaginationEnv(t *testing.T, count int) *webtest.Server {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
// First-registered = auto-admin in some flows; register a stub first so
// the gist owner is a regular user.
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
for i := 0; i < count; i++ {
s.CreateGistAs(t, "thomas", "0") // public
}
s.Logout()
return s
}
// parseLinkHeader parses an RFC 5988 Link header into a {rel: url} map.
// Bare-bones - assumes well-formed input as emitted by writeLinkHeader.
func parseLinkHeader(t *testing.T, h string) map[string]string {
out := map[string]string{}
if h == "" {
return out
}
for _, part := range strings.Split(h, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
end := strings.Index(part, ">")
require.Greater(t, end, 0, "malformed link entry: %s", part)
linkURL := part[1:end]
rest := part[end+1:]
const relPrefix = `rel="`
i := strings.Index(rest, relPrefix)
require.GreaterOrEqual(t, i, 0, "missing rel in link entry: %s", part)
name := rest[i+len(relPrefix):]
name = name[:strings.Index(name, `"`)]
out[name] = linkURL
}
return out
}
// listAnonymous fires an anonymous GET /api/v1/gists?... and returns both the
// decoded array and the Link header (parsed by rel).
func listAnonymous(t *testing.T, s *webtest.Server, query string) ([]types.GistSimple, map[string]string) {
return apiList[types.GistSimple](t, s, "/api/v1/gists?"+query, "", 200)
}
// apiList fires a GET against a list endpoint and returns the decoded array
// (the body is a bare JSON array) plus the Link header (parsed by rel).
func apiList[T any](t *testing.T, s *webtest.Server, uri, token string, status int) ([]T, map[string]string) {
w, body := s.APIRequest(t, "GET", uri, token, nil, status)
var arr []T
require.NoError(t, json.Unmarshal(body, &arr))
return arr, parseLinkHeader(t, w.Header().Get("Link"))
}
// --- per_page ---
func TestPerPage_Default_Is30(t *testing.T) {
// Create 31 gists so the default 30 caps and "more" is signaled.
s := setupPaginationEnv(t, 31)
arr, links := listAnonymous(t, s, "")
require.Len(t, arr, 30, "missing per_page must default to 30")
require.Contains(t, links, "next", "31 gists / 30 per page must have rel=next")
}
func TestPerPage_Custom_LimitsResults(t *testing.T) {
s := setupPaginationEnv(t, 5)
arr, _ := listAnonymous(t, s, "per_page=2")
require.Len(t, arr, 2)
}
func TestPerPage_BelowOne_FallsBackToDefault(t *testing.T) {
// per_page=0 (and negative values) should not break the response - the
// helper clamps to defaultPerPage (30). With 5 gists, all 5 come back.
s := setupPaginationEnv(t, 5)
arr, _ := listAnonymous(t, s, "per_page=0")
require.Len(t, arr, 5)
}
func TestPerPage_OverMaxCapped(t *testing.T) {
// per_page=999 is server-side clamped to 100. With 5 gists, the cap
// doesn't visibly trim anything but we still expect a 200 (no error from
// the oversize value) and all 5 rows returned.
s := setupPaginationEnv(t, 5)
arr, _ := listAnonymous(t, s, "per_page=999")
require.Len(t, arr, 5)
}
// --- page + Link header ---
func TestPage_FirstPage_OnlyNext(t *testing.T) {
s := setupPaginationEnv(t, 5)
arr, links := listAnonymous(t, s, "per_page=2&page=1")
require.Len(t, arr, 2)
require.Contains(t, links, "next", "first page must advertise rel=next when there are more rows")
require.NotContains(t, links, "prev", "first page must NOT advertise rel=prev")
// rel=next URL preserves per_page and bumps page to 2.
u, err := url.Parse(links["next"])
require.NoError(t, err)
require.Equal(t, "2", u.Query().Get("page"))
require.Equal(t, "2", u.Query().Get("per_page"))
}
func TestPage_MiddlePage_PrevAndNext(t *testing.T) {
s := setupPaginationEnv(t, 5)
arr, links := listAnonymous(t, s, "per_page=2&page=2")
require.Len(t, arr, 2)
require.Contains(t, links, "next", "middle page must have rel=next")
require.Contains(t, links, "prev", "middle page must have rel=prev")
}
func TestPage_LastPage_OnlyPrev(t *testing.T) {
s := setupPaginationEnv(t, 5)
arr, links := listAnonymous(t, s, "per_page=2&page=3")
require.Len(t, arr, 1, "page 3 of (5 rows / per_page=2) must hold the trailing row")
require.NotContains(t, links, "next", "last page must NOT advertise rel=next")
require.Contains(t, links, "prev")
}
func TestPage_BelowOne_FallsBackToOne(t *testing.T) {
s := setupPaginationEnv(t, 3)
// page=0 and page=-1 should both behave like page=1 (no error, first page).
arr, _ := listAnonymous(t, s, "per_page=2&page=0")
require.Len(t, arr, 2)
}
// TestPagination_TotalHeaders - the X-Total / X-Total-Pages headers report the
// full match count and derived page count, independent of the page's size.
func TestPagination_TotalHeaders(t *testing.T) {
s := setupPaginationEnv(t, 5)
w, body := s.APIRequest(t, "GET", "/api/v1/gists?per_page=2", "", nil, 200)
var arr []types.GistSimple
require.NoError(t, json.Unmarshal(body, &arr))
require.Len(t, arr, 2)
require.Equal(t, "1", w.Header().Get("X-Page"))
require.Equal(t, "2", w.Header().Get("X-Per-Page"))
require.Equal(t, "5", w.Header().Get("X-Total"), "total must count all matching gists")
require.Equal(t, "3", w.Header().Get("X-Total-Pages"), "ceil(5/2) = 3 pages")
}
func TestLinkHeader_SinglePage_NoHeader(t *testing.T) {
s := setupPaginationEnv(t, 1)
w, _ := s.APIRequest(t, "GET", "/api/v1/gists?per_page=10", "", nil, 200)
require.Empty(t, w.Header().Get("Link"),
"single-page response must omit the Link header (no prev, no next)")
}
// --- since ---
func TestSince_Future_ReturnsEmpty(t *testing.T) {
s := setupPaginationEnv(t, 3)
future := time.Now().Add(1 * time.Hour).UTC().Format(time.RFC3339)
arr, _ := listAnonymous(t, s, "since="+url.QueryEscape(future))
require.Empty(t, arr, "since=future must filter out everything")
}
func TestSince_Past_ReturnsAll(t *testing.T) {
s := setupPaginationEnv(t, 3)
past := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339)
arr, _ := listAnonymous(t, s, "since="+url.QueryEscape(past))
require.Len(t, arr, 3)
}
func TestSince_InvalidFormat_400(t *testing.T) {
s := setupPaginationEnv(t, 1)
s.APIRequest(t, "GET", "/api/v1/gists?since=not-a-date", "", nil, 400)
}
-93
View File
@@ -1,93 +0,0 @@
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
@@ -1,18 +0,0 @@
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})
}
@@ -1,31 +0,0 @@
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"])
}
+52 -298
View File
@@ -1,331 +1,85 @@
package v1
import (
"mime"
"net/http"
"strconv"
"strings"
"time"
"regexp"
"gorm.io/gorm"
"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
)
// reCommitSHA matches a partial or full git SHA-1 - hex only, 440 chars.
// Used at the API boundary to keep user input from reaching `git log` as
// something that could parse as an option (e.g. "--all", "-p"). git itself
// has no clean "end of options" marker on the log command, so the cheapest
// robust defense is to forbid anything non-hex up front.
var reCommitSHA = regexp.MustCompile(`^[0-9a-fA-F]{4,40}$`)
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) {
// lookupGistByUUID fetches a gist by UUID and enforces visibility. The caller
// is treated as "able to see private gists" only when they're the owner AND
// their token holds gist:read (or there's no token at all, in which case the
// gist isn't private to start with). Returns gorm.ErrRecordNotFound for the
// hidden case so the handler can map it to a 404 without leaking existence.
func lookupGistByUUID(ctx *context.Context, uuid string) (*db.Gist, error) {
g, err := db.GetGistByUUID(uuid)
if err != nil {
return nil, &ErrorBody{Code: "not_found", Message: "gist not found"}
return nil, err
}
if g.Private == db.PrivateVisibility {
if ctx.User == nil || ctx.User.ID != g.UserID {
return nil, &ErrorBody{Code: "not_found", Message: "gist not found"}
tok, _ := ctx.GetData("accessToken").(*db.AccessToken)
isOwnerWithScope := ctx.User != nil &&
ctx.User.ID == g.UserID &&
tok != nil && tok.HasGistReadPermission()
if !isOwnerWithScope {
return nil, gorm.ErrRecordNotFound
}
}
return g, nil
}
// GetGist handles GET /api/v1/gists/:uuid
// GetGist handles GET /api/v1/gists/:uuid.
// Public and unlisted gists are readable by anyone (including anonymous
// callers), matching the rest of the API's soft-scope rule. Private gists
// only resolve for their owner with a
// gist:read token - every other caller sees a 404 (existence hidden).
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)
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to serialize gist")
return ctx.ErrorJson(404, "Gist not found", nil)
}
resp, err := g.ToAPI(apiBaseURL(ctx), "HEAD")
if err != nil {
return ctx.ErrorJson(500, "failed to serialize gist", err)
}
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)")
}
// GetGistRevision handles GET /api/v1/gists/:uuid/:sha.
// Same shape as GetGist, but returns the gist as it stood at the given
// commit SHA instead of HEAD. Visibility rules are identical (lookup-side
// check). An unknown revision surfaces as 404 - same code as the not-found
// case, so we don't leak which commits exist on the gist.
func GetGistRevision(ctx *context.Context) error {
// Gist visibility check first - if the caller can't even see the gist,
// they get "Gist not found" rather than a misleading "Revision not
// found" or 400.
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to list gists")
return ctx.ErrorJson(404, "Gist not found", nil)
}
// db.GetAllGistsFromUser doesn't accept a limit; trim manually.
if len(gists) > perPage {
gists = gists[:perPage]
sha := ctx.Param("sha")
if !reCommitSHA.MatchString(sha) {
// Malformed input → 400. Also keeps `--all`-style values from ever
// reaching git as a flag.
return ctx.ErrorJson(400, "Invalid revision format", nil)
}
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)
resp, err := g.ToAPI(apiBaseURL(ctx), sha)
if err != nil {
return WriteJSONError(ctx, 500, "internal_error", "failed to serialize gist")
// SHA was well-formed but doesn't resolve in the gist's repo.
return ctx.ErrorJson(404, "Revision not found", nil)
}
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,47 @@
package v1
import (
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// ListCommits handles GET /api/v1/gists/:uuid/commits.
// Each commit's author is
// resolved to an Opengist user via db.Gist.Log's bulk email lookup (see
// db.GistCommit) so the API and the web revisions page share the same
// resolution. The per-commit shape comes from db.GistCommit.ToAPI, which
// also powers the 10-most-recent embed in the gist-detail response.
// Public/unlisted gists are readable by anyone; private gists only resolve
// for their owner with a gist:read token. Supports `page` and `per_page`
// (default 30, capped at 100); pagination is signaled via the Link header.
func ListCommits(ctx *context.Context) error {
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return ctx.ErrorJson(404, "Gist not found", nil)
}
page := parsePage(ctx)
perPage := parsePerPage(ctx)
// Fetch one extra row as the peek-next sentinel: a slice of perPage+1
// tells us there's another page worth fetching.
commits, err := g.Log("HEAD", (page-1)*perPage, perPage+1)
if err != nil {
return ctx.ErrorJson(500, "failed to read commit log", err)
}
hasMore := len(commits) > perPage
if hasMore {
commits = commits[:perPage]
}
baseURL := apiBaseURL(ctx)
out := make([]types.GistCommit, 0, len(commits))
for _, c := range commits {
out = append(out, c.ToAPI())
}
// total is omitted (nil) for commits: computing it would need an extra
// `git rev-list --count` subprocess per request, which isn't worth it.
writePaginationHeaders(ctx, baseURL, page, perPage, hasMore, nil)
return ctx.JSON(200, out)
}
@@ -0,0 +1,233 @@
package v1_test
import (
"fmt"
"net/url"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// makeCommits creates a public gist and patches its file `count-1` times so
// the resulting gist has `count` commits total. Returns the gist id.
func makeCommits(t *testing.T, s *webtest.Server, tok string, count int) string {
id := createGistViaAPI(t, s, tok, map[string]interface{}{
"visibility": "public",
"files": fileMap{"a.txt": {"content": "v0"}},
})
for i := 1; i < count; i++ {
body := fmt.Sprintf(`{"files": {"a.txt": {"content": "v%d"}}}`, i)
s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok, body, 200)
}
return id
}
// --- Visibility / access matrix ---
// TestListCommits_VisibilityAccess mirrors TestGetGist_VisibilityAccess for
// the commits endpoint: same lookup rules → same status codes.
func TestListCommits_VisibilityAccess(t *testing.T) {
s := setupGetGist(t)
_, gPub, _, _ := s.CreateGistAs(t, "owner", "0")
_, gUnl, _, _ := s.CreateGistAs(t, "owner", "1")
_, gPriv, _, _ := s.CreateGistAs(t, "owner", "2")
ownerTok := apiTokenFor(t, s, "owner", db.ReadPermission)
ownerNoScope := apiTokenFor(t, s, "owner", db.NoPermission)
otherTok := apiTokenFor(t, s, "other", db.ReadPermission)
cases := []struct {
name string
uuid string
tok string
want int
}{
// Public: readable by anyone.
{"public/anonymous", gPub.Uuid, "", 200},
{"public/no-scope owner", gPub.Uuid, ownerNoScope, 200},
{"public/scoped owner", gPub.Uuid, ownerTok, 200},
{"public/scoped other", gPub.Uuid, otherTok, 200},
// Unlisted: URL-shareable.
{"unlisted/anonymous", gUnl.Uuid, "", 200},
{"unlisted/scoped other", gUnl.Uuid, otherTok, 200},
// Private: owner + gist:read only.
{"private/anonymous", gPriv.Uuid, "", 404},
{"private/no-scope owner", gPriv.Uuid, ownerNoScope, 404},
{"private/scoped other", gPriv.Uuid, otherTok, 404},
{"private/scoped owner", gPriv.Uuid, ownerTok, 200},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s.APIRequest(t, "GET", "/api/v1/gists/"+c.uuid+"/commits", c.tok, nil, c.want)
})
}
}
func TestListCommits_NotFound(t *testing.T) {
s := setupGetGist(t)
s.APIRequest(t, "GET", "/api/v1/gists/does-not-exist/commits", "", nil, 404)
}
// --- Response shape ---
// TestListCommits_Shape verifies what each commit object carries: identity
// (version), raw git author info, change_status from the shortstat, and a
// non-zero committed_at. A freshly created gist has one commit and no prior
// state, so additions > 0 and deletions == 0.
func TestListCommits_Shape(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
commits, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+gist.Uuid+"/commits", "", 200)
require.Len(t, commits, 1, "fresh gist must have exactly one commit")
c := commits[0]
require.NotEmpty(t, c.Version, "version (commit SHA) must be set")
require.Len(t, c.Version, 40, "SHA-1 is 40 hex chars")
require.False(t, c.CommittedAt.IsZero(), "committed_at must be populated")
// Author is always populated from the raw commit metadata.
require.NotEmpty(t, c.Author.Name, "author.name must come from the commit's git metadata")
// change_status: one added file, no deletions, total == additions.
require.Equal(t, 1, c.ChangeStatus.Files, "one file changed (the new file)")
require.Greater(t, c.ChangeStatus.Additions, 0, "additions must be > 0 for an initial commit")
require.Equal(t, 0, c.ChangeStatus.Deletions, "deletions must be 0 on initial commit")
require.Equal(t, c.ChangeStatus.Additions+c.ChangeStatus.Deletions, c.ChangeStatus.Total,
"total must equal additions + deletions")
}
// TestListCommits_UserResolutionByEmail verifies that when the gist author's
// email matches a registered Opengist user, the commit's `user` field is
// populated with that account (not just the raw author info).
func TestListCommits_UserResolutionByEmail(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "owner")
// Set a real email on the owner *before* creating the gist so the
// resulting git commit's author email matches.
owner, err := db.GetUserByUsername("owner")
require.NoError(t, err)
owner.Email = "owner@example.com"
require.NoError(t, owner.Update())
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
commits, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+gist.Uuid+"/commits", "", 200)
require.Len(t, commits, 1)
c := commits[0]
require.NotNil(t, c.User, "commit author email matches an account → user must be resolved")
require.Equal(t, "owner", c.User.Login)
// Author block still carries the raw git-side info (the canonical commit metadata).
require.Equal(t, "owner@example.com", c.Author.Email)
}
// --- per_page ---
func TestListCommits_PerPage_LimitsResults(t *testing.T) {
s, tok := setupCreateGist(t)
id := makeCommits(t, s, tok, 5)
commits, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2", tok, 200)
require.Len(t, commits, 2)
}
// TestListCommits_OmitsTotalHeaders - commits deliberately skip X-Total /
// X-Total-Pages (computing them would need an extra `git rev-list` call). The
// X-Page / X-Per-Page and Link headers still apply.
func TestListCommits_OmitsTotalHeaders(t *testing.T) {
s, tok := setupCreateGist(t)
id := makeCommits(t, s, tok, 3)
w, _ := s.APIRequest(t, "GET", "/api/v1/gists/"+id+"/commits?per_page=2", tok, nil, 200)
require.Empty(t, w.Header().Get("X-Total"), "commits must not emit X-Total")
require.Empty(t, w.Header().Get("X-Total-Pages"), "commits must not emit X-Total-Pages")
require.Equal(t, "1", w.Header().Get("X-Page"))
require.Equal(t, "2", w.Header().Get("X-Per-Page"))
}
// --- page + Link header ---
func TestListCommits_Page_FirstPage_OnlyNext(t *testing.T) {
s, tok := setupCreateGist(t)
id := makeCommits(t, s, tok, 5)
commits, rels := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=1", tok, 200)
require.Len(t, commits, 2)
require.Contains(t, rels, "next", "first page must advertise rel=next when more rows exist")
require.NotContains(t, rels, "prev", "first page must NOT advertise rel=prev")
// next URL bumps page to 2 and preserves per_page.
u, err := url.Parse(rels["next"])
require.NoError(t, err)
require.Equal(t, "2", u.Query().Get("page"))
require.Equal(t, "2", u.Query().Get("per_page"))
}
func TestListCommits_Page_MiddlePage_PrevAndNext(t *testing.T) {
s, tok := setupCreateGist(t)
id := makeCommits(t, s, tok, 5)
commits, rels := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=2", tok, 200)
require.Len(t, commits, 2)
require.Contains(t, rels, "next", "middle page must advertise rel=next")
require.Contains(t, rels, "prev", "middle page must advertise rel=prev")
}
func TestListCommits_Page_AcrossPagesNoDuplicates(t *testing.T) {
s, tok := setupCreateGist(t)
id := makeCommits(t, s, tok, 5)
page1, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=1", tok, 200)
page2, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=2", tok, 200)
page3, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits?per_page=2&page=3", tok, 200)
seen := map[string]bool{}
for _, c := range append(append(page1, page2...), page3...) {
require.False(t, seen[c.Version], "commit %s appeared on more than one page", c.Version)
seen[c.Version] = true
}
require.Len(t, seen, 5, "5 distinct commits across the three pages")
}
// TestListCommits_MultipleCommits_AfterPatch verifies that a subsequent
// PATCH produces a second commit and that the most recent commit appears
// first. The patch updates a file's content so deletions are also non-zero.
func TestListCommits_MultipleCommits_AfterPatch(t *testing.T) {
s, tok := setupCreateGist(t)
id := createGistViaAPI(t, s, tok, map[string]interface{}{
"visibility": "public",
"files": fileMap{"a.txt": {"content": "alpha"}},
})
// PATCH the content → second commit.
s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok,
`{"files": {"a.txt": {"content": "alpha v2"}}}`, 200)
commits, _ := apiList[types.GistCommit](t, s, "/api/v1/gists/"+id+"/commits", tok, 200)
require.Len(t, commits, 2, "create + PATCH = two commits")
// git log is newest-first.
require.True(t, !commits[0].CommittedAt.Before(commits[1].CommittedAt),
"first entry must be at least as recent as the second")
// The PATCH commit modifies an existing file → both additions and
// deletions are non-zero.
patchCommit := commits[0]
require.Greater(t, patchCommit.ChangeStatus.Additions, 0)
require.Greater(t, patchCommit.ChangeStatus.Deletions, 0)
}
+309
View File
@@ -0,0 +1,309 @@
package v1
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// strOrEmpty dereferences an optional string field, returning "" when nil.
// Handy for the CREATE flow where "no value provided" and "explicit empty
// string" land on the same DB column.
func strOrEmpty(p *string) string {
if p == nil {
return ""
}
return *p
}
// CreateGist handles POST /api/v1/gists.
// The DTO is built the same way ProcessCreate builds its form DTO - entries
// without `content` are skipped, empty filenames become "gistfileN.txt" -
// and is then run through ctx.Validate so the API and the web form share
// rules (length caps on title/description/filename, forbidden chars in
// filenames, `min=1` on files). Returns 201 with the full gist and a
// `Location` header on success; validation errors → 422.
func CreateGist(ctx *context.Context) error {
var req types.GistInput
if err := ctx.Bind(&req); err != nil {
return ctx.ErrorJson(422, "could not bind data", nil)
}
// Sort filenames so the Title fallback (first filename) and auto-generated
// "gistfileN.txt" names are deterministic.
filenames := make([]string, 0, len(req.Files))
for name := range req.Files {
filenames = append(filenames, name)
}
sort.Strings(filenames)
dto := &db.GistDTO{
Title: strOrEmpty(req.Title),
Description: strOrEmpty(req.Description),
VisibilityDTO: db.VisibilityDTO{Private: db.ParseVisibility(strOrEmpty(req.Visibility))},
}
for _, rawName := range filenames {
f := req.Files[rawName]
if f == nil || f.Content == nil || *f.Content == "" {
// Matches ProcessCreate: entries without content are silently
// dropped. min=1 on Files then catches "no files at all".
continue
}
// On create the map key is the filename; the per-entry `filename`
// field is ignored here (it only matters on update, for renames).
name := git.CleanTreePathName(rawName)
if name == "" {
name = "gistfile" + strconv.Itoa(len(dto.Files)+1) + ".txt"
}
dto.Files = append(dto.Files, db.FileDTO{
Filename: strings.TrimSpace(name),
Content: *f.Content,
})
}
if err := ctx.Validate(dto); err != nil {
return ctx.ErrorJson(422, err.Error(), nil)
}
user := ctx.User
gist := dto.ToGist()
gist.UserID = user.ID
gist.User = *user
gist.NbFiles = len(dto.Files)
id, err := uuid.NewRandom()
if err != nil {
return ctx.ErrorJson(500, "uuid generation failed", err)
}
gist.Uuid = strings.ReplaceAll(id.String(), "-", "")
if gist.Title == "" {
if dto.Files[0].Filename == "" {
gist.Title = "gist:" + gist.Uuid
} else {
gist.Title = dto.Files[0].Filename
}
}
if err := gist.InitRepository(); err != nil {
return ctx.ErrorJson(500, "failed to init repo", err)
}
if err := gist.AddAndCommitFiles(&dto.Files); err != nil {
_ = gist.DeleteRepository()
return ctx.ErrorJson(500, "failed to commit files", err)
}
if err := gist.Create(); err != nil {
_ = gist.DeleteRepository()
return ctx.ErrorJson(500, "failed to create gist", err)
}
gist.AddInIndex()
gist.UpdateLanguages()
_ = gist.UpdatePreviewAndCount(true)
saved, err := db.GetGistByID(strconv.FormatUint(uint64(gist.ID), 10))
if err != nil {
return ctx.ErrorJson(500, "failed to reload gist", err)
}
baseURL := apiBaseURL(ctx)
resp, err := saved.ToAPI(baseURL, "HEAD")
if err != nil {
return ctx.ErrorJson(500, "failed to serialize gist", err)
}
ctx.Response().Header().Set("Location", baseURL+"/api/v1/gists/"+saved.Uuid)
return ctx.JSON(201, resp)
}
// UpdateGist handles PATCH /api/v1/gists/:uuid.
// Only fields present in the body are touched. Files not mentioned in `files`
// stay unchanged. A file entry
// can:
//
// - Set `content` to replace the file body.
// - Set `filename` to rename the file.
// - Set both to do both at once.
// - Be JSON null (or have neither field set) to delete the file.
//
// Keys in the `files` map that don't match any current filename are treated
// as new files; their `content` is required. `title` and `visibility` are
// Opengist extensions - supplying them updates those fields, omission leaves
// them alone. Returns the full updated gist on success; 422 on validation
// failure, 404 if the caller can't see the gist.
func UpdateGist(ctx *context.Context) error {
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
// Lookup failure already obeys the "hide existence" rule for private
// gists, so we just propagate 404.
return ctx.ErrorJson(404, "Gist not found", nil)
}
if g.UserID != ctx.User.ID {
// At this point the gist is public or unlisted (private would have
// 404'd above) - existence is already disclosed, so a 403 is honest.
return ctx.ErrorJson(403, "You are not the owner of this gist", nil)
}
var req types.GistInput
if err := ctx.Bind(&req); err != nil {
return ctx.ErrorJson(422, "could not bind data", nil)
}
// PATCH requires at least one actionable field - otherwise we'd just
// rewrite the gist's updated_at for no reason.
if req.Description == nil && req.Title == nil && req.Visibility == nil && len(req.Files) == 0 {
return ctx.ErrorJson(422, "at least one of description, title, visibility, or files must be set", nil)
}
if req.Title != nil {
g.Title = *req.Title
}
if req.Description != nil {
g.Description = *req.Description
}
if req.Visibility != nil {
g.Private = db.ParseVisibility(*req.Visibility)
}
// File patch: only rebuild the working tree if `files` carried at least
// one entry. (`files: {}` is a no-op.)
if len(req.Files) > 0 {
merged, err := mergePatchFiles(g, req.Files)
if err != nil {
return ctx.ErrorJson(422, err.Error(), nil)
}
dto := &db.GistDTO{
Title: g.Title,
Description: g.Description,
VisibilityDTO: db.VisibilityDTO{Private: g.Private},
Files: merged,
}
if err := ctx.Validate(dto); err != nil {
return ctx.ErrorJson(422, err.Error(), nil)
}
g.NbFiles = len(dto.Files)
if err := g.AddAndCommitFiles(&dto.Files); err != nil {
return ctx.ErrorJson(500, "failed to commit files", err)
}
}
if err := g.Update(); err != nil {
return ctx.ErrorJson(500, "failed to update gist", err)
}
g.UpdateLanguages()
_ = g.UpdatePreviewAndCount(true)
resp, err := g.ToAPI(apiBaseURL(ctx), "HEAD")
if err != nil {
return ctx.ErrorJson(500, "failed to serialize gist", err)
}
return ctx.JSON(200, resp)
}
// DeleteGist handles DELETE /api/v1/gists/:uuid.
// Owner-only - the route's apiScope(ScopeGist, ReadWritePermission) middleware
// enforces the token scope before we get here, so we just confirm ownership and
// drop the repo + row. Returns 204 No Content on success.
func DeleteGist(ctx *context.Context) error {
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return ctx.ErrorJson(404, "Gist not found", nil)
}
if g.UserID != ctx.User.ID {
return ctx.ErrorJson(403, "You are not the owner of this gist", nil)
}
// db.Gist.Delete deletes the repo first, then the DB row - no need to
// call DeleteRepository ourselves.
if err := g.Delete(); err != nil {
return ctx.ErrorJson(500, "failed to delete gist", err)
}
return ctx.NoContent(204)
}
// mergePatchFiles applies a PATCH file map to a gist's current files and
// returns the post-merge file list. Behavior per entry:
//
// - Key matches an existing filename:
// - patch == nil OR (Content == nil AND Filename == nil) → delete.
// - patch.Filename set → rename (Content unchanged unless also set).
// - patch.Content set → update content (Filename unchanged unless set).
// - Key doesn't match any existing filename:
// - patch.Content set → add as a new file.
// - otherwise → no-op (null on an unknown key just does nothing instead
// of erroring).
//
// Detects post-merge filename collisions and returns an error rather than
// letting the second write silently overwrite the first.
func mergePatchFiles(g *db.Gist, patch map[string]*types.GistFileInput) ([]db.FileDTO, error) {
// Full content, no truncation - we have to round-trip everything.
current, _, err := g.Files("HEAD", false)
if err != nil {
return nil, fmt.Errorf("failed to read current files")
}
merged := make([]db.FileDTO, 0, len(current))
handled := make(map[string]bool, len(patch))
for _, cf := range current {
entry, mentioned := patch[cf.Filename]
if mentioned {
handled[cf.Filename] = true
// Delete: explicit null, or no content + no filename change.
if entry == nil || (entry.Content == nil && entry.Filename == nil) {
continue
}
name := cf.Filename
if entry.Filename != nil {
name = *entry.Filename
}
content := cf.Content
if entry.Content != nil {
content = *entry.Content
}
merged = append(merged, db.FileDTO{Filename: name, Content: content})
continue
}
merged = append(merged, db.FileDTO{Filename: cf.Filename, Content: cf.Content})
}
// Sort the leftover patch keys so the addition order is deterministic.
leftover := make([]string, 0, len(patch))
for k := range patch {
if !handled[k] {
leftover = append(leftover, k)
}
}
sort.Strings(leftover)
for _, k := range leftover {
entry := patch[k]
if entry == nil || entry.Content == nil {
// null on a non-existing key is a no-op; new files need content.
continue
}
name := k
if entry.Filename != nil {
name = *entry.Filename
}
merged = append(merged, db.FileDTO{Filename: name, Content: *entry.Content})
}
// Clean filenames + check for collisions in the post-merge list.
seen := make(map[string]bool, len(merged))
for i, f := range merged {
clean := strings.TrimSpace(git.CleanTreePathName(f.Filename))
if clean == "" {
return nil, fmt.Errorf("files: filename cannot be empty")
}
if seen[clean] {
return nil, fmt.Errorf("files: duplicate filename after merge: %s", clean)
}
seen[clean] = true
merged[i].Filename = clean
}
return merged, nil
}
@@ -0,0 +1,555 @@
package v1_test
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// setupCreateGist registers "thomas" (after a stub admin), enables the API,
// and mints a token with full gist + user scope. Logs the user out before
// returning so requests go through the token-auth path.
func setupCreateGist(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, "tok", db.ReadWritePermission, db.ReadPermission)
s.Logout()
return s, tok
}
// fileMap is the shape clients send for the request `files` map: filename →
// {"content": "..."}.
type fileMap = map[string]map[string]string
// TestUpdateGist_ChangeRenameDelete exercises the three PATCH operations in
// one go: rewrite a file's content, rename a file, and delete a file (via
// JSON null). The gist starts with three files; after the patch only the
// expected two should remain, with their expected names and contents.
func TestUpdateGist_ChangeRenameDelete(t *testing.T) {
s, tok := setupCreateGist(t)
id := createGistViaAPI(t, s, tok, map[string]interface{}{
"visibility": "public",
"files": fileMap{
"a.txt": {"content": "alpha"},
"b.txt": {"content": "beta"},
"c.txt": {"content": "gamma"},
},
})
// Patch:
// - a.txt → update content
// - b.txt → rename to renamed.txt (content preserved)
// - c.txt → delete (JSON null)
// Sent as raw JSON because the entry-level `null` for delete doesn't
// round-trip through a typed map[string]map[string]string.
patch := `{
"files": {
"a.txt": {"content": "alpha v2"},
"b.txt": {"filename": "renamed.txt"},
"c.txt": null
}
}`
_, raw := s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok, patch, 200)
var resp fullGist
require.NoError(t, json.Unmarshal(raw, &resp), "response: %s", string(raw))
// a.txt: content updated, name unchanged.
require.Contains(t, resp.Files, "a.txt", "a.txt must still exist after content update")
require.Equal(t, "alpha v2", resp.Files["a.txt"].Content, "a.txt content must reflect the patch")
// b.txt: renamed, content preserved.
require.NotContains(t, resp.Files, "b.txt", "old name b.txt must be gone after rename")
require.Contains(t, resp.Files, "renamed.txt", "renamed.txt must appear")
require.Equal(t, "beta", resp.Files["renamed.txt"].Content,
"renamed file must keep its original content when only filename is set")
// c.txt: deleted.
require.NotContains(t, resp.Files, "c.txt", "c.txt must be deleted (set to null)")
// Exactly the two expected files.
require.Len(t, resp.Files, 2)
}
// createSeedGist makes a public gist with predictable title/description/files
// used by the PATCH metadata tests. Returns the gist's id.
func createSeedGist(t *testing.T, s *webtest.Server, tok string) string {
return createGistViaAPI(t, s, tok, map[string]interface{}{
"title": "seed-title",
"description": "seed-description",
"visibility": "public",
"files": fileMap{"a.txt": {"content": "seed"}},
})
}
// TestUpdateGist_VisibilityChange - PATCHing only `visibility` changes the
// visibility and leaves title/description untouched.
func TestUpdateGist_VisibilityChange(t *testing.T) {
s, tok := setupCreateGist(t)
id := createSeedGist(t, s, tok)
_, raw := s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok,
`{"visibility": "private"}`, 200)
var resp fullGist
require.NoError(t, json.Unmarshal(raw, &resp))
require.Equal(t, "private", resp.Visibility)
require.False(t, resp.Public)
// Untouched fields.
require.Equal(t, "seed-title", resp.Title)
require.Equal(t, "seed-description", resp.Description)
}
// TestUpdateGist_TitleChange - PATCHing only `title` changes the title and
// leaves description/visibility untouched.
func TestUpdateGist_TitleChange(t *testing.T) {
s, tok := setupCreateGist(t)
id := createSeedGist(t, s, tok)
_, raw := s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok,
`{"title": "renamed-title"}`, 200)
var resp fullGist
require.NoError(t, json.Unmarshal(raw, &resp))
require.Equal(t, "renamed-title", resp.Title)
// Untouched fields.
require.Equal(t, "seed-description", resp.Description)
require.Equal(t, "public", resp.Visibility)
}
// TestUpdateGist_DescriptionChange - PATCHing only `description` changes the
// description (including overwriting with empty string) and leaves the rest
// alone.
func TestUpdateGist_DescriptionChange(t *testing.T) {
s, tok := setupCreateGist(t)
id := createSeedGist(t, s, tok)
_, raw := s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok,
`{"description": "updated-description"}`, 200)
var resp fullGist
require.NoError(t, json.Unmarshal(raw, &resp))
require.Equal(t, "updated-description", resp.Description)
// Untouched fields.
require.Equal(t, "seed-title", resp.Title)
require.Equal(t, "public", resp.Visibility)
}
// TestUpdateGist_NoAccess mirrors TestDeleteGist_NoAccess for the PATCH
// endpoint: anonymous → 401; authenticated non-owner gets 403 for
// public/unlisted gists (visible via GET so a 403 is honest) and 404 for
// private gists (existence stays hidden).
func TestUpdateGist_NoAccess(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
s.Register(t, "other")
_, gPub, _, _ := s.CreateGistAs(t, "thomas", "0")
_, gUnl, _, _ := s.CreateGistAs(t, "thomas", "1")
_, gPriv, _, _ := s.CreateGistAs(t, "thomas", "2")
s.Login(t, "other")
otherTok := s.CreateAccessToken(t, "other-tok", db.ReadWritePermission, db.ReadPermission)
s.Logout()
// Minimal valid body - satisfies the "at least one field must be set"
// guard so a 422 doesn't pre-empt the access-control check we're after.
body := `{"description": "hacked"}`
cases := []struct {
name string
uuid string
tok string
want int
}{
{"anon/public", gPub.Uuid, "", 401},
{"anon/unlisted", gUnl.Uuid, "", 401},
{"anon/private", gPriv.Uuid, "", 401},
{"other/public", gPub.Uuid, otherTok, 403},
{"other/unlisted", gUnl.Uuid, otherTok, 403},
{"other/private", gPriv.Uuid, otherTok, 404},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s.APIRequest(t, "PATCH", "/api/v1/gists/"+c.uuid, c.tok, body, c.want)
})
}
// Reload each gist from the DB - descriptions must NOT have been touched.
for _, want := range []*db.Gist{gPub, gUnl, gPriv} {
got, err := db.GetGistByUUID(want.Uuid)
require.NoError(t, err)
require.NotEqual(t, "hacked", got.Description,
"failed PATCH attempt mutated description on gist %s", want.Uuid)
}
}
// TestDeleteGist_NoAccess covers the failure-code semantics for callers who
// can't delete a gist. The route's apiRequireAuth catches missing tokens
// first (→ 401). When the caller is authenticated but not the owner, the
// handler returns:
//
// - 403 for public / unlisted gists (existence already disclosed via GET)
// - 404 for private gists (existence stays hidden)
func TestDeleteGist_NoAccess(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
s.Register(t, "other")
// thomas owns one gist of each visibility.
_, gPub, _, _ := s.CreateGistAs(t, "thomas", "0")
_, gUnl, _, _ := s.CreateGistAs(t, "thomas", "1")
_, gPriv, _, _ := s.CreateGistAs(t, "thomas", "2")
// "other" has a write-scoped token (anything less would be 403'd by the
// apiScope(ScopeGist, ReadWritePermission) middleware before reaching the
// handler - uninteresting for these assertions).
s.Login(t, "other")
otherTok := s.CreateAccessToken(t, "other-tok", db.ReadWritePermission, db.ReadPermission)
s.Logout()
cases := []struct {
name string
uuid string
tok string
want int
}{
// Anonymous (no token) is rejected by apiRequireAuth → 401 across
// every visibility.
{"anon/public", gPub.Uuid, "", 401},
{"anon/unlisted", gUnl.Uuid, "", 401},
{"anon/private", gPriv.Uuid, "", 401},
// Authenticated non-owner: 403 when existence is already disclosed
// (public + unlisted), 404 when it isn't (private).
{"other/public", gPub.Uuid, otherTok, 403},
{"other/unlisted", gUnl.Uuid, otherTok, 403},
{"other/private", gPriv.Uuid, otherTok, 404},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s.APIRequest(t, "DELETE", "/api/v1/gists/"+c.uuid, c.tok, nil, c.want)
})
}
// All three gists should still be there - none of the failed deletes
// touched DB or filesystem state.
for _, g := range []*db.Gist{gPub, gUnl, gPriv} {
_, err := db.GetGistByUUID(g.Uuid)
require.NoError(t, err, "gist %s must still exist after failed delete attempts", g.Uuid)
}
}
// TestUpdateGist_EmptyBody_422 - a PATCH with no actionable fields must
// 422 instead of silently no-opping (and bumping updated_at for nothing).
func TestUpdateGist_EmptyBody_422(t *testing.T) {
s, tok := setupCreateGist(t)
id := createSeedGist(t, s, tok)
s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok, `{}`, 422)
// Same when only an empty files map is supplied - no actual change.
s.APIRequest(t, "PATCH", "/api/v1/gists/"+id, tok, `{"files": {}}`, 422)
}
func TestCreateGist_NoAuth(t *testing.T) {
s, _ := setupCreateGist(t)
body := map[string]interface{}{
"files": fileMap{"test.txt": {"content": "hello"}},
}
s.APIRequest(t, "POST", "/api/v1/gists", "", body, 401)
count, err := db.CountAll(db.Gist{})
require.NoError(t, err)
require.Equal(t, int64(0), count, "no gist must have been created")
}
func TestCreateGist(t *testing.T) {
s, tok := setupCreateGist(t)
tests := []struct {
name string
body interface{}
expectedCode int
expectGistCreated bool
expectedTitle string
expectedDesc string
expectedVis string // "public"|"unlisted"|"private"
expectedPublic bool
expectedFilenames []string
expectedContents map[string]string // filename → content
}{
{
name: "NoFiles",
body: map[string]interface{}{"title": "Test GistSimple"},
expectedCode: 422,
},
{
name: "EmptyContent",
body: map[string]interface{}{
"title": "Test GistSimple",
"files": fileMap{"test.txt": {"content": ""}},
},
// Empty content is silently skipped; min=1 on Files then fails.
expectedCode: 422,
},
{
name: "TitleTooLong",
body: map[string]interface{}{
"title": strings.Repeat("a", 251),
"files": fileMap{"test.txt": {"content": "hello"}},
},
expectedCode: 422,
},
{
name: "DescriptionTooLong",
body: map[string]interface{}{
"description": strings.Repeat("a", 1001),
"files": fileMap{"test.txt": {"content": "hello"}},
},
expectedCode: 422,
},
{
name: "FilenameTooLong",
body: map[string]interface{}{
"files": fileMap{strings.Repeat("a", 256) + ".txt": {"content": "hello"}},
},
expectedCode: 422,
},
{
name: "UnknownVisibilityCoercesToPublic",
body: map[string]interface{}{
"visibility": "secret", // not a known string
"files": fileMap{"test.txt": {"content": "hello"}},
},
// db.ParseVisibility defaults unknown values to public - same as the
// web form path. The API doesn't 422 on this.
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "test.txt",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"test.txt"},
expectedContents: map[string]string{"test.txt": "hello"},
},
{
name: "Valid",
body: map[string]interface{}{
"title": "My Test GistSimple",
"visibility": "public",
"files": fileMap{"test.txt": {"content": "hello world"}},
},
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "My Test GistSimple",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"test.txt"},
expectedContents: map[string]string{"test.txt": "hello world"},
},
{
name: "AutoNamedFile",
body: map[string]interface{}{
"title": "Auto Named",
"visibility": "public",
"files": fileMap{"": {"content": "content without name"}},
},
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "Auto Named",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"gistfile1.txt"},
expectedContents: map[string]string{"gistfile1.txt": "content without name"},
},
{
name: "MultipleFiles",
body: map[string]interface{}{
"title": "Multi File GistSimple",
"visibility": "public",
"files": fileMap{
"a.txt": {"content": "content 1"},
"file2.md": {"content": "content 2"},
},
},
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "Multi File GistSimple",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"a.txt", "file2.md"},
expectedContents: map[string]string{
"a.txt": "content 1",
"file2.md": "content 2",
},
},
{
name: "NoTitle_FallsBackToFirstFilename",
body: map[string]interface{}{
"visibility": "public",
"files": fileMap{"readme.md": {"content": "# README"}},
},
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "readme.md",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"readme.md"},
expectedContents: map[string]string{"readme.md": "# README"},
},
{
name: "Unlisted",
body: map[string]interface{}{
"title": "Unlisted GistSimple",
"visibility": "unlisted",
"files": fileMap{"secret.txt": {"content": "secret content"}},
},
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "Unlisted GistSimple",
expectedVis: "unlisted",
expectedPublic: false,
expectedFilenames: []string{"secret.txt"},
expectedContents: map[string]string{"secret.txt": "secret content"},
},
{
name: "Private",
body: map[string]interface{}{
"title": "Private GistSimple",
"visibility": "private",
"files": fileMap{"secret.txt": {"content": "secret content"}},
},
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "Private GistSimple",
expectedVis: "private",
expectedPublic: false,
expectedFilenames: []string{"secret.txt"},
expectedContents: map[string]string{"secret.txt": "secret content"},
},
{
name: "FilenameWithUnicode",
body: map[string]interface{}{
"title": "Unicode Filename",
"visibility": "public",
"files": fileMap{"文件.txt": {"content": "hello world"}},
},
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "Unicode Filename",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"文件.txt"},
expectedContents: map[string]string{"文件.txt": "hello world"},
},
{
name: "FilenamePathTraversal",
body: map[string]interface{}{
"title": "Path Traversal",
"visibility": "public",
"files": fileMap{"../../../etc/passwd": {"content": "malicious"}},
},
// CleanTreePathName strips path separators, leaving just the base name.
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "Path Traversal",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"passwd"},
expectedContents: map[string]string{"passwd": "malicious"},
},
{
name: "EmptyAndValidContent",
body: map[string]interface{}{
"title": "Mixed Content",
"visibility": "public",
"files": fileMap{
"empty.txt": {"content": ""},
"valid.txt": {"content": "valid content"},
"also-empty.txt": {"content": ""},
},
},
// Empty-content entries are dropped; only valid.txt survives.
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "Mixed Content",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"valid.txt"},
expectedContents: map[string]string{"valid.txt": "valid content"},
},
{
name: "ContentMultibyteUnicode",
body: map[string]interface{}{
"title": "Unicode Content",
"visibility": "public",
"files": fileMap{"unicode.txt": {"content": "Hello 世界 🌍 Привет"}},
},
expectedCode: 201,
expectGistCreated: true,
expectedTitle: "Unicode Content",
expectedVis: "public",
expectedPublic: true,
expectedFilenames: []string{"unicode.txt"},
expectedContents: map[string]string{"unicode.txt": "Hello 世界 🌍 Привет"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w, body := s.APIRequest(t, "POST", "/api/v1/gists", tok, tt.body, tt.expectedCode)
if !tt.expectGistCreated {
return
}
var resp types.Gist
require.NoError(t, json.Unmarshal(body, &resp), "response body: %s", string(body))
require.Equal(t, tt.expectedTitle, resp.Title, "title mismatch")
require.Equal(t, tt.expectedVis, resp.Visibility, "visibility mismatch")
require.Equal(t, tt.expectedPublic, resp.Public, "public bool mismatch")
require.Equal(t, "thomas", resp.Owner.Login, "owner mismatch")
require.NotEmpty(t, resp.ID, "gist UUID must be set in response")
// Location header: required on 201.
require.NotEmpty(t, w.Header().Get("Location"), "Location header missing")
require.Contains(t, w.Header().Get("Location"), "/api/v1/gists/"+resp.ID)
// Files map keyed by filename.
require.Len(t, resp.Files, len(tt.expectedFilenames), "file count mismatch")
for _, name := range tt.expectedFilenames {
require.Contains(t, resp.Files, name, "expected file %s missing from response", name)
}
for name, want := range tt.expectedContents {
require.Equal(t, want, resp.Files[name].Content, "content mismatch for file %s", name)
}
// Verify the gist actually landed in the DB.
saved, err := db.GetGistByUUID(resp.ID)
require.NoError(t, err)
require.Equal(t, "thomas", saved.User.Username)
require.Equal(t, tt.expectedTitle, saved.Title)
require.Equal(t, len(tt.expectedFilenames), saved.NbFiles)
})
}
}
+185
View File
@@ -0,0 +1,185 @@
package v1
import (
"errors"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// ListForkedGists handles GET /api/v1/gists/forked.
// Lists gists the authenticated user has forked. Auth is mandatory (the
// route uses apiRequireAuth) but the gist:read scope is soft-checked here
// so a token without it degrades to the public subset of forked gists
// rather than 403ing.
//
// - Token with gist:read → every forked gist the caller is allowed to
// see (public + caller's own private/unlisted forks).
// - Token without gist:read → only the public gists the caller has
// forked.
//
// Supports the same `page`, `per_page`, and `since` (RFC 3339) query params
// as the other list endpoints; pagination is signaled via the Link header.
func ListForkedGists(ctx *context.Context) error {
uid := ctx.User.ID
tok, _ := ctx.GetData("accessToken").(*db.AccessToken)
if tok != nil && tok.HasGistReadPermission() {
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsForkedByUser(uid, uid, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsForkedByUserSince(uid, uid, since)
return gists, total, err
})
}
// currentUserId=0 collapses the visibility OR-clause (`private=0 OR
// user_id=0`) to just `private=0`, leaving public-only forks.
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsForkedByUser(uid, 0, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsForkedByUserSince(uid, 0, since)
return gists, total, err
})
}
// ListForks handles GET /api/v1/gists/:uuid/forks.
// Returns the gists that fork
// the targeted gist as a list of GistSimple. Same visibility rules as
// /:uuid (and /:uuid/commits) - public/unlisted readable by anyone,
// private only resolves for its owner with a gist:read token. Supports
// `page` and `per_page` (default 30, capped at 100); pagination is
// signaled via the Link header.
func ListForks(ctx *context.Context) error {
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return ctx.ErrorJson(404, "Gist not found", nil)
}
page := parsePage(ctx)
perPage := parsePerPage(ctx)
// Visibility-filter forks to what the caller can see: their own
// private/unlisted forks are included alongside any public fork. callerID
// = 0 (anonymous) trims to public-only, matching the gist endpoints.
var callerID uint
if ctx.User != nil {
callerID = ctx.User.ID
}
// perPage+1 is the peek-next sentinel for the Link header.
forks, err := g.GetForks(callerID, page-1, perPage+1, perPage)
if err != nil {
return ctx.ErrorJson(500, "failed to list forks", err)
}
hasMore := len(forks) > perPage
if hasMore {
forks = forks[:perPage]
}
total, err := g.CountForks(callerID)
if err != nil {
return ctx.ErrorJson(500, "failed to count forks", err)
}
baseURL := apiBaseURL(ctx)
out := make([]types.GistSimple, 0, len(forks))
for _, f := range forks {
out = append(out, f.ToAPISimple(baseURL))
}
writePaginationHeaders(ctx, baseURL, page, perPage, hasMore, &total)
return ctx.JSON(200, out)
}
// ForkGist handles POST /api/v1/gists/:uuid/forks.
// The authenticated caller
// gets a new gist owned by them whose content is a clone of the parent,
// with `forked_id` pointing back. Visibility (public/unlisted/private) is
// inherited from the parent. Returns 201 with the new fork's GistSimple
// and a `Location` header pointing to the new gist's API URL.
//
// Rules:
// - The parent's visibility decides whether the caller can see (and
// therefore fork) it; the same lookup rule as /gists/:uuid applies.
// - Forking your own gist is rejected with 422 - matches the web flow.
// - Forking the same parent twice is idempotent: returns 200 with the
// existing fork (vs 201 for a newly created one), plus a `Location` header.
func ForkGist(ctx *context.Context) error {
parent, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return ctx.ErrorJson(404, "Gist not found", nil)
}
user := ctx.User
if parent.UserID == user.ID {
return ctx.ErrorJson(422, "cannot fork your own gist", nil)
}
// Has the caller already forked this gist? (GetForkParent is misnamed -
// it actually returns the caller's fork of `parent`.) Return the existing
// fork idempotently so retries are safe.
existing, err := parent.GetForkParent(user)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorJson(500, "failed to check existing fork", err)
}
if existing.ID != 0 {
// Already forked → return the existing fork idempotently with 200 (vs
// 201 for a freshly created one). Reload so User/Forked.User are
// preloaded for ToAPISimple.
saved, err := db.GetGistByID(strconv.FormatUint(uint64(existing.ID), 10))
if err != nil {
return ctx.ErrorJson(500, "failed to reload existing fork", err)
}
baseURL := apiBaseURL(ctx)
ctx.Response().Header().Set("Location", baseURL+"/api/v1/gists/"+saved.Uuid)
return ctx.JSON(200, saved.ToAPISimple(baseURL))
}
id, err := uuid.NewRandom()
if err != nil {
return ctx.ErrorJson(500, "uuid generation failed", err)
}
newGist := &db.Gist{
Uuid: strings.ReplaceAll(id.String(), "-", ""),
Title: parent.Title,
Preview: parent.Preview,
PreviewFilename: parent.PreviewFilename,
Description: parent.Description,
Private: parent.Private,
UserID: user.ID,
ForkedID: parent.ID,
NbFiles: parent.NbFiles,
Topics: parent.Topics,
}
if err := newGist.CreateForked(); err != nil {
return ctx.ErrorJson(500, "failed to create fork in database", err)
}
if err := parent.ForkClone(user.Username, newGist.Uuid); err != nil {
return ctx.ErrorJson(500, "failed to clone repository", err)
}
if err := parent.IncrementForkCount(); err != nil {
return ctx.ErrorJson(500, "failed to increment fork count", err)
}
// Reload so User/Forked.User are preloaded for ToAPISimple.
saved, err := db.GetGistByID(strconv.FormatUint(uint64(newGist.ID), 10))
if err != nil {
return ctx.ErrorJson(500, "failed to reload new fork", err)
}
baseURL := apiBaseURL(ctx)
resp := saved.ToAPISimple(baseURL)
ctx.Response().Header().Set("Location", baseURL+"/api/v1/gists/"+saved.Uuid)
return ctx.JSON(201, resp)
}
@@ -0,0 +1,468 @@
package v1_test
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// forkAs has `forker` fork the gist at /{parentUser}/{parentIdent} via the
// web /fork endpoint and returns the resulting db.Gist (looked up by the
// redirect Location). Caller is logged out on return.
func forkAs(t *testing.T, s *webtest.Server, forker, parentUser, parentIdent string) *db.Gist {
s.Login(t, forker)
resp := s.Request(t, "POST", "/"+parentUser+"/"+parentIdent+"/fork", nil, 302)
loc := resp.Header.Get("Location")
parts := strings.Split(strings.TrimPrefix(loc, "/"), "/")
require.Len(t, parts, 2, "fork redirect must be /{user}/{ident}, got %q", loc)
s.Logout()
fork, err := db.GetGist(forker, parts[1])
require.NoError(t, err)
return fork
}
// setVisibility flips a gist's visibility in the DB directly - saves
// chaining PATCHes through the API just to set up test fixtures.
func setVisibility(t *testing.T, g *db.Gist, v db.Visibility) {
g.Private = v
require.NoError(t, g.Update())
}
// --- Visibility / access matrix on the parent gist ---
// TestListForks_VisibilityAccess mirrors the matrix for /commits: the access
// rule comes from lookupGistByUUID, so the response codes must be identical.
func TestListForks_VisibilityAccess(t *testing.T) {
s := setupGetGist(t)
_, gPub, _, _ := s.CreateGistAs(t, "owner", "0")
_, gUnl, _, _ := s.CreateGistAs(t, "owner", "1")
_, gPriv, _, _ := s.CreateGistAs(t, "owner", "2")
ownerTok := apiTokenFor(t, s, "owner", db.ReadPermission)
ownerNoScope := apiTokenFor(t, s, "owner", db.NoPermission)
otherTok := apiTokenFor(t, s, "other", db.ReadPermission)
cases := []struct {
name string
uuid string
tok string
want int
}{
// Public: readable by anyone.
{"public/anonymous", gPub.Uuid, "", 200},
{"public/no-scope owner", gPub.Uuid, ownerNoScope, 200},
{"public/scoped owner", gPub.Uuid, ownerTok, 200},
{"public/scoped other", gPub.Uuid, otherTok, 200},
// Unlisted: URL-shareable.
{"unlisted/anonymous", gUnl.Uuid, "", 200},
{"unlisted/scoped other", gUnl.Uuid, otherTok, 200},
// Private: owner + gist:read only.
{"private/anonymous", gPriv.Uuid, "", 404},
{"private/no-scope owner", gPriv.Uuid, ownerNoScope, 404},
{"private/scoped other", gPriv.Uuid, otherTok, 404},
{"private/scoped owner", gPriv.Uuid, ownerTok, 200},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s.APIRequest(t, "GET", "/api/v1/gists/"+c.uuid+"/forks", c.tok, nil, c.want)
})
}
}
func TestListForks_NotFound(t *testing.T) {
s := setupGetGist(t)
s.APIRequest(t, "GET", "/api/v1/gists/does-not-exist/forks", "", nil, 404)
}
// --- Visibility filter on the forks list itself ---
// forksFixture sets up a public parent and three forks: alice's = public,
// bob's = unlisted, charlie's = private. Returned alongside everyone's
// tokens so tests can assert what each viewer sees.
type forksFixture struct {
s *webtest.Server
parent *db.Gist
aliceFork, bobFork, charlieFork *db.Gist
aliceTok, bobTok, charlieTok string
}
func setupForksVisibility(t *testing.T) *forksFixture {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "parentowner")
s.Register(t, "alice")
s.Register(t, "bob")
s.Register(t, "charlie")
_, parent, parentUser, parentIdent := s.CreateGistAs(t, "parentowner", "0")
// Each user forks; fork inherits the parent's public visibility.
aliceFork := forkAs(t, s, "alice", parentUser, parentIdent)
bobFork := forkAs(t, s, "bob", parentUser, parentIdent)
charlieFork := forkAs(t, s, "charlie", parentUser, parentIdent)
// Diversify visibilities on the forks. alice stays public.
setVisibility(t, bobFork, db.UnlistedVisibility)
setVisibility(t, charlieFork, db.PrivateVisibility)
return &forksFixture{
s: s,
parent: parent,
aliceFork: aliceFork,
bobFork: bobFork,
charlieFork: charlieFork,
aliceTok: apiTokenFor(t, s, "alice", db.ReadPermission),
bobTok: apiTokenFor(t, s, "bob", db.ReadPermission),
charlieTok: apiTokenFor(t, s, "charlie", db.ReadPermission),
}
}
// idSetSimple returns the set of ids from a []GistSimple slice.
func idSetSimple(arr []types.GistSimple) map[string]bool {
out := make(map[string]bool, len(arr))
for _, g := range arr {
out[g.ID] = true
}
return out
}
// TestListForks_Anonymous_OnlyPublicForks - an unauthenticated caller sees
// only public forks; unlisted and private forks stay hidden regardless of
// who owns them.
func TestListForks_Anonymous_OnlyPublicForks(t *testing.T) {
f := setupForksVisibility(t)
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/"+f.parent.Uuid+"/forks?per_page=20", "", 200)
ids := idSetSimple(arr)
require.True(t, ids[f.aliceFork.Uuid], "alice's PUBLIC fork must appear")
require.False(t, ids[f.bobFork.Uuid], "bob's UNLISTED fork must NOT appear for anonymous")
require.False(t, ids[f.charlieFork.Uuid], "charlie's PRIVATE fork must NOT appear for anonymous")
for _, g := range arr {
require.Equal(t, "public", g.Visibility,
"non-public fork leaked into anonymous response: %+v", g)
}
}
// TestListForks_AuthenticatedSeesOwnUnlistedFork - bob's token must
// surface his own unlisted fork (in addition to public forks), but not
// other users' non-public forks.
func TestListForks_AuthenticatedSeesOwnUnlistedFork(t *testing.T) {
f := setupForksVisibility(t)
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/"+f.parent.Uuid+"/forks?per_page=20", f.bobTok, 200)
ids := idSetSimple(arr)
require.True(t, ids[f.aliceFork.Uuid], "alice's PUBLIC fork must appear")
require.True(t, ids[f.bobFork.Uuid], "bob's own UNLISTED fork must appear when bob is the caller")
require.False(t, ids[f.charlieFork.Uuid], "charlie's PRIVATE fork must NOT leak to bob")
}
// TestListForks_AuthenticatedSeesOwnPrivateFork - same idea, swapped to
// charlie's token + private fork.
func TestListForks_AuthenticatedSeesOwnPrivateFork(t *testing.T) {
f := setupForksVisibility(t)
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/"+f.parent.Uuid+"/forks?per_page=20", f.charlieTok, 200)
ids := idSetSimple(arr)
require.True(t, ids[f.aliceFork.Uuid], "alice's PUBLIC fork must appear")
require.False(t, ids[f.bobFork.Uuid], "bob's UNLISTED fork must NOT leak to charlie")
require.True(t, ids[f.charlieFork.Uuid], "charlie's own PRIVATE fork must appear when charlie is the caller")
}
// TestListForks_ScopedThirdParty_OnlyPublic - alice owns only the public
// fork in this fixture, so even with a scoped token she sees just her own
// public fork; bob's unlisted and charlie's private stay hidden.
func TestListForks_ScopedThirdParty_OnlyPublic(t *testing.T) {
f := setupForksVisibility(t)
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/"+f.parent.Uuid+"/forks?per_page=20", f.aliceTok, 200)
ids := idSetSimple(arr)
require.True(t, ids[f.aliceFork.Uuid], "alice's PUBLIC fork must appear")
require.False(t, ids[f.bobFork.Uuid], "bob's UNLISTED fork must NOT leak to alice")
require.False(t, ids[f.charlieFork.Uuid], "charlie's PRIVATE fork must NOT leak to alice")
}
// =========================================================================
// GET /api/v1/gists/forked - ListForkedGists (the caller's forks)
// =========================================================================
// forkedListFixture: parent_owner owns a public parent gist; the caller
// ("caller") has forked it once. "stranger" has also forked the parent
// independently to check we don't leak other users' forks. Caller also has
// a non-fork gist (regular) which must NOT appear in /gists/forked.
type forkedListFixture struct {
s *webtest.Server
parent *db.Gist
callerFork *db.Gist
strangerFork *db.Gist
regularGist *db.Gist
}
func setupForkedList(t *testing.T) *forkedListFixture {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "parentowner")
s.Register(t, "caller")
s.Register(t, "stranger")
_, parent, parentUser, parentIdent := s.CreateGistAs(t, "parentowner", "0")
callerFork := forkAs(t, s, "caller", parentUser, parentIdent)
strangerFork := forkAs(t, s, "stranger", parentUser, parentIdent)
_, regular, _, _ := s.CreateGistAs(t, "caller", "0") // not a fork
return &forkedListFixture{
s: s,
parent: parent,
callerFork: callerFork,
strangerFork: strangerFork,
regularGist: regular,
}
}
func TestListForkedGists_NoAuth(t *testing.T) {
s := setupGetGist(t)
s.APIRequest(t, "GET", "/api/v1/gists/forked", "", nil, 401)
}
func TestListForkedGists_EmptyWhenNoForks(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
tok := apiTokenFor(t, s, "thomas", db.ReadPermission)
arr, _ := apiList[types.GistSimple](t, s, "/api/v1/gists/forked", tok, 200)
require.Empty(t, arr)
}
// TestListForkedGists_ReturnsOnlyCallersForks - caller's token returns only
// their fork; stranger's fork stays hidden, and the caller's non-fork
// regular gist must NOT appear (the endpoint is forks-only).
func TestListForkedGists_ReturnsOnlyCallersForks(t *testing.T) {
f := setupForkedList(t)
callerTok := apiTokenFor(t, f.s, "caller", db.ReadPermission)
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/forked?per_page=20", callerTok, 200)
ids := idSetSimple(arr)
require.True(t, ids[f.callerFork.Uuid], "caller's fork must appear")
require.False(t, ids[f.strangerFork.Uuid], "stranger's fork must NOT appear in caller's /forked")
require.False(t, ids[f.regularGist.Uuid], "caller's regular (non-fork) gist must NOT appear")
}
// TestListForkedGists_TokenWithGistRead_IncludesOwnPrivateFork - with
// gist:read, the caller's private/unlisted forks come through too.
func TestListForkedGists_TokenWithGistRead_IncludesOwnPrivateFork(t *testing.T) {
f := setupForkedList(t)
setVisibility(t, f.callerFork, db.PrivateVisibility)
callerTok := apiTokenFor(t, f.s, "caller", db.ReadPermission)
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/forked?per_page=20", callerTok, 200)
ids := idSetSimple(arr)
require.True(t, ids[f.callerFork.Uuid],
"caller's own PRIVATE fork must appear when token has gist:read")
}
// TestListForkedGists_TokenWithoutGistRead_OnlyPublicForks - token without
// gist:read drops to public-only: a private fork the caller owns is hidden
// (same soft-scope rule as /gists and /gists/liked).
func TestListForkedGists_TokenWithoutGistRead_OnlyPublicForks(t *testing.T) {
f := setupForkedList(t)
setVisibility(t, f.callerFork, db.UnlistedVisibility)
noScopeTok := apiTokenFor(t, f.s, "caller", db.NoPermission)
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/forked?per_page=20", noScopeTok, 200)
ids := idSetSimple(arr)
require.False(t, ids[f.callerFork.Uuid],
"caller's UNLISTED fork must be hidden when token lacks gist:read")
for _, g := range arr {
require.Equal(t, "public", g.Visibility,
"non-public fork leaked into no-scope response: %+v", g)
}
}
// --- Shape ---
func TestListForks_Shape(t *testing.T) {
s := setupGetGist(t)
_, parent, parentUser, parentIdent := s.CreateGistAs(t, "owner", "0")
fork := forkAs(t, s, "other", parentUser, parentIdent)
arr, _ := apiList[types.GistSimple](t, s, "/api/v1/gists/"+parent.Uuid+"/forks", "", 200)
require.Len(t, arr, 1)
got := arr[0]
require.Equal(t, fork.Uuid, got.ID)
require.Equal(t, "other", got.Owner.Login)
require.Equal(t, "public", got.Visibility)
require.True(t, got.Public)
require.False(t, got.CreatedAt.IsZero())
}
// =========================================================================
// POST /api/v1/gists/:uuid/forks
// =========================================================================
// --- Auth / scope ---
func TestForkGist_NoAuth(t *testing.T) {
s := setupGetGist(t)
_, parent, _, _ := s.CreateGistAs(t, "owner", "0")
// apiRequireAuth on the route → 401 before the handler runs.
s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", "", nil, 401)
}
func TestForkGist_TokenWithoutWriteScope_403(t *testing.T) {
s := setupGetGist(t)
_, parent, _, _ := s.CreateGistAs(t, "owner", "0")
// Read-only token can read but can't fork (creates a new gist).
roTok := apiTokenFor(t, s, "other", db.ReadPermission)
s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", roTok, nil, 403)
}
// --- Visibility / access matrix on the parent ---
// TestForkGist_VisibilityAccess focuses on the lookup-side rule. A caller
// without a token (anonymous) is already rejected by apiRequireAuth above;
// this matrix uses a write-scoped token to drive the handler logic:
// - Public/unlisted parent owned by someone else → 201 (forkable).
// - Private parent owned by someone else → 404 (hidden by lookup).
// - Any parent owned by the caller → 422 (self-fork).
func TestForkGist_VisibilityAccess(t *testing.T) {
s := setupGetGist(t)
_, gPub, _, _ := s.CreateGistAs(t, "owner", "0")
_, gUnl, _, _ := s.CreateGistAs(t, "owner", "1")
_, gPriv, _, _ := s.CreateGistAs(t, "owner", "2")
ownerTok := apiTokenFor(t, s, "owner", db.ReadWritePermission)
otherTok := apiTokenFor(t, s, "other", db.ReadWritePermission)
cases := []struct {
name string
uuid string
tok string
want int
}{
// Non-owner forks public/unlisted → 201.
{"public/other", gPub.Uuid, otherTok, 201},
{"unlisted/other", gUnl.Uuid, otherTok, 201},
// Non-owner forks private → 404 (lookup hides existence).
{"private/other", gPriv.Uuid, otherTok, 404},
// Owner tries to fork own gists → 422 (self-fork rule).
{"public/self", gPub.Uuid, ownerTok, 422},
{"unlisted/self", gUnl.Uuid, ownerTok, 422},
{"private/self", gPriv.Uuid, ownerTok, 422},
// Unknown gist → 404.
{"missing", "doesnotexist", otherTok, 404},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s.APIRequest(t, "POST", "/api/v1/gists/"+c.uuid+"/forks", c.tok, nil, c.want)
})
}
}
// --- Success path: response shape + side effects ---
func TestForkGist_Success_ResponseAndState(t *testing.T) {
s := setupGetGist(t)
_, parent, _, _ := s.CreateGistAs(t, "owner", "0")
otherTok := apiTokenFor(t, s, "other", db.ReadWritePermission)
w, body := s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
var resp types.GistSimple
require.NoError(t, json.Unmarshal(body, &resp), "body: %s", string(body))
// Owner of the fork is the caller.
require.Equal(t, "other", resp.Owner.Login)
// Visibility inherited from the parent.
require.Equal(t, "public", resp.Visibility)
require.True(t, resp.Public)
// Title carried over.
require.Equal(t, parent.Title, resp.Title)
// Location header points to the new fork.
loc := w.Header().Get("Location")
require.NotEmpty(t, loc)
require.Contains(t, loc, "/api/v1/gists/"+resp.ID)
// DB-side: fork row exists with ForkedID, parent's NbForks bumped.
fork, err := db.GetGistByUUID(resp.ID)
require.NoError(t, err)
require.Equal(t, parent.ID, fork.ForkedID, "fork.ForkedID must point at parent")
parentReloaded, err := db.GetGistByUUID(parent.Uuid)
require.NoError(t, err)
require.Equal(t, 1, parentReloaded.NbForks, "parent's NbForks must increment")
}
// TestForkGist_VisibilityInheritedFromUnlistedParent - confirms forks of
// unlisted parents are also unlisted (and of private - private).
func TestForkGist_VisibilityInheritedFromUnlistedParent(t *testing.T) {
s := setupGetGist(t)
_, parent, _, _ := s.CreateGistAs(t, "owner", "1") // unlisted
otherTok := apiTokenFor(t, s, "other", db.ReadWritePermission)
_, body := s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
var resp types.GistSimple
require.NoError(t, json.Unmarshal(body, &resp))
require.Equal(t, "unlisted", resp.Visibility)
require.False(t, resp.Public)
}
// --- Idempotency: forking the same gist twice ---
// TestForkGist_AlreadyForked_200 - second fork attempt by the same user must
// NOT create another fork. The handler returns 200 with the existing fork in
// the body (vs 201 for a fresh fork) and a Location header pointing at it.
func TestForkGist_AlreadyForked_200(t *testing.T) {
s := setupGetGist(t)
_, parent, _, _ := s.CreateGistAs(t, "owner", "0")
otherTok := apiTokenFor(t, s, "other", db.ReadWritePermission)
// First fork → 201.
_, firstBody := s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", otherTok, nil, 201)
var first types.GistSimple
require.NoError(t, json.Unmarshal(firstBody, &first))
// Second attempt → 200 with the existing fork echoed back, Location →
// existing fork.
w, secondBody := s.APIRequest(t, "POST", "/api/v1/gists/"+parent.Uuid+"/forks", otherTok, nil, 200)
var second types.GistSimple
require.NoError(t, json.Unmarshal(secondBody, &second))
require.Equal(t, first.ID, second.ID, "the existing fork must be returned, not a new one")
require.Contains(t, w.Header().Get("Location"), "/api/v1/gists/"+first.ID,
"Location must point at the existing fork")
// And the parent's NbForks must not double-count.
parentReloaded, err := db.GetGistByUUID(parent.Uuid)
require.NoError(t, err)
require.Equal(t, 1, parentReloaded.NbForks, "duplicate fork attempt must not bump NbForks")
}
+92
View File
@@ -0,0 +1,92 @@
package v1
import (
"time"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
)
// ListLikedGists handles GET /api/v1/gists/liked.
// Lists gists the authenticated user has liked. Auth is mandatory (the route
// uses apiRequireAuth) but the
// gist:read scope is soft-checked here so a token without it degrades to the
// public subset of liked gists rather than 403ing.
//
// - Token with gist:read → every liked gist the caller is allowed to see
// (public + caller's own private/unlisted).
// - Token without gist:read → only the public gists the caller has liked.
//
// Supports the same `page`, `per_page`, and `since` (RFC 3339) query params
// as the other list endpoints; pagination is signaled via the Link header.
func ListLikedGists(ctx *context.Context) error {
uid := ctx.User.ID
tok, _ := ctx.GetData("accessToken").(*db.AccessToken)
if tok != nil && tok.HasGistReadPermission() {
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsLikedByUser(uid, uid, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsLikedByUserSince(uid, uid, since)
return gists, total, err
})
}
// currentUserId=0 collapses the visibility OR-clause (`private=0 OR
// user_id=0`) to just `private=0`, leaving public-only stars.
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsLikedByUser(uid, 0, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsLikedByUserSince(uid, 0, since)
return gists, total, err
})
}
// ToggleLike handles PUT /api/v1/gists/:uuid/like.
// Idempotent toggle: if the authenticated user has liked the gist, it
// removes the like; otherwise it adds one. Either way the response is 204
// No Content. 404 when the gist isn't visible to the caller (same
// existence-hiding rule as the rest of the API). Mutates the caller's own
// like state, so the route requires the user:write scope.
func ToggleLike(ctx *context.Context) error {
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return ctx.NoContent(404)
}
liked, err := ctx.User.HasLiked(g)
if err != nil {
return ctx.ErrorJson(500, "failed to check like state", err)
}
if liked {
if err := g.RemoveUserLike(ctx.User); err != nil {
return ctx.ErrorJson(500, "failed to unlike", err)
}
} else {
if err := g.AppendUserLike(ctx.User); err != nil {
return ctx.ErrorJson(500, "failed to like", err)
}
}
return ctx.NoContent(204)
}
// CheckLike handles GET /api/v1/gists/:uuid/like.
// Returns 204 No Content if the authenticated user has liked the gist, 404
// otherwise. The gist's visibility is enforced first via lookupGistByUUID -
// a hidden private gist returns 404 just like the rest of the API, without
// revealing whether the caller had ever liked it.
func CheckLike(ctx *context.Context) error {
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return ctx.NoContent(404)
}
liked, err := ctx.User.HasLiked(g)
if err != nil {
return ctx.ErrorJson(500, "failed to check like", err)
}
if !liked {
return ctx.NoContent(404)
}
return ctx.NoContent(204)
}
@@ -0,0 +1,337 @@
package v1_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// likedFixture stands up the env shared by the ListLikedGists tests:
// "caller" and "other" each own one gist of every visibility, and "caller"
// stars all six. The API is enabled. Logged out at the end so requests go
// through the token-auth path.
type likedFixture struct {
s *webtest.Server
callerPub, callerUnl, callerPriv *db.Gist
otherPub, otherUnl, otherPriv *db.Gist
}
func setupLiked(t *testing.T) *likedFixture {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "other")
s.Register(t, "caller")
_, otherPub, _, _ := s.CreateGistAs(t, "other", "0")
_, otherUnl, _, _ := s.CreateGistAs(t, "other", "1")
_, otherPriv, _, _ := s.CreateGistAs(t, "other", "2")
_, callerPub, _, _ := s.CreateGistAs(t, "caller", "0")
_, callerUnl, _, _ := s.CreateGistAs(t, "caller", "1")
_, callerPriv, _, _ := s.CreateGistAs(t, "caller", "2")
// "caller" stars all 6 gists. Stars are inserted directly via the DB
// helper so the test doesn't depend on the (potentially scope-gated)
// HTTP /like endpoint.
caller, err := db.GetUserByUsername("caller")
require.NoError(t, err)
for _, g := range []*db.Gist{otherPub, otherUnl, otherPriv, callerPub, callerUnl, callerPriv} {
require.NoError(t, g.AppendUserLike(caller))
}
return &likedFixture{
s: s,
callerPub: callerPub,
callerUnl: callerUnl,
callerPriv: callerPriv,
otherPub: otherPub,
otherUnl: otherUnl,
otherPriv: otherPriv,
}
}
func TestListLikedGists_NoAuth(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "thomas")
s.Logout()
// apiRequireAuth on the route rejects anonymous callers.
s.APIRequest(t, "GET", "/api/v1/gists/liked", "", nil, 401)
}
func TestListLikedGists_EmptyWhenNoStars(t *testing.T) {
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, "tok", db.ReadPermission, db.ReadPermission)
s.Logout()
arr, _ := apiList[types.GistSimple](t, s, "/api/v1/gists/liked", tok, 200)
require.Empty(t, arr)
}
// TestListLikedGists_TokenWithGistRead_AllAllowed - token has gist:read, so
// the caller sees the visibility-allowed subset of every gist they liked:
// public from anyone, plus their own unlisted/private. Other users'
// unlisted/private (which they shouldn't really be able to see anyway) stay
// hidden.
func TestListLikedGists_TokenWithGistRead_AllAllowed(t *testing.T) {
f := setupLiked(t)
f.s.Login(t, "caller")
tok := f.s.CreateAccessToken(t, "read", db.ReadPermission, db.ReadPermission)
f.s.Logout()
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/liked?per_page=20", tok, 200)
ids := idSet(arr)
require.True(t, ids[f.callerPub.Uuid], "caller's own PUBLIC liked gist visible")
require.True(t, ids[f.callerUnl.Uuid], "caller's own UNLISTED liked gist visible (they own it)")
require.True(t, ids[f.callerPriv.Uuid], "caller's own PRIVATE liked gist visible (they own it)")
require.True(t, ids[f.otherPub.Uuid], "other user's PUBLIC liked gist visible")
require.False(t, ids[f.otherUnl.Uuid], "other user's UNLISTED liked gist NOT visible")
require.False(t, ids[f.otherPriv.Uuid], "other user's PRIVATE liked gist NOT visible")
}
// =========================================================================
// GET /api/v1/gists/:uuid/like - CheckLike
// =========================================================================
// likeGist directly inserts a like row for `username` on `g`, avoiding the
// HTTP /like endpoint (which is scope-gated and would complicate the
// fixture).
func likeGist(t *testing.T, g *db.Gist, username string) {
u, err := db.GetUserByUsername(username)
require.NoError(t, err)
require.NoError(t, g.AppendUserLike(u))
}
// TestCheckLike_NoAuth - anonymous calls hit apiRequireAuth and 401 before
// the handler ever sees them.
func TestCheckLike_NoAuth(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid+"/like", "", nil, 401)
}
// TestCheckLike_StatusCodes is the headline matrix: combinations of (gist
// visibility, caller is owner?, gist liked by caller?) → expected code.
// Several cases collapse on the "hidden private gist" rule (always 404),
// which doubles as a check that like existence doesn't leak past the
// visibility filter.
func TestCheckLike_StatusCodes(t *testing.T) {
s := setupGetGist(t)
// gists owned by "owner" with different visibilities and pre-baked like
// state for "other". The private-not-owned case uses a separate gist
// without a like (since we expect 404 regardless).
_, gPubLiked, _, _ := s.CreateGistAs(t, "owner", "0")
likeGist(t, gPubLiked, "other")
_, gPubUnliked, _, _ := s.CreateGistAs(t, "owner", "0")
_, gUnlLiked, _, _ := s.CreateGistAs(t, "owner", "1")
likeGist(t, gUnlLiked, "other")
_, gPrivOwnLiked, _, _ := s.CreateGistAs(t, "owner", "2")
likeGist(t, gPrivOwnLiked, "owner")
_, gPrivOwnUnliked, _, _ := s.CreateGistAs(t, "owner", "2")
_, gPrivHidden, _, _ := s.CreateGistAs(t, "owner", "2")
ownerTok := apiTokenFor(t, s, "owner", db.ReadPermission)
otherTok := apiTokenFor(t, s, "other", db.ReadPermission)
cases := []struct {
name string
uuid string
tok string
want int
}{
// Visible gist + caller has liked → 204.
{"public/other/liked", gPubLiked.Uuid, otherTok, 204},
{"unlisted/other/liked", gUnlLiked.Uuid, otherTok, 204},
{"private/owner/liked", gPrivOwnLiked.Uuid, ownerTok, 204},
// Visible gist + caller hasn't liked → 404.
{"public/other/not liked", gPubUnliked.Uuid, otherTok, 404},
{"private/owner/not liked", gPrivOwnUnliked.Uuid, ownerTok, 404},
// Hidden private gist → 404 (visibility check kicks in before the
// like check; existence stays hidden).
{"private/other (hidden)", gPrivHidden.Uuid, otherTok, 404},
// Unknown UUID → 404.
{"missing", "does-not-exist", otherTok, 404},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s.APIRequest(t, "GET", "/api/v1/gists/"+c.uuid+"/like", c.tok, nil, c.want)
})
}
}
// TestCheckLike_204HasEmptyBody - RFC 7230: 2xx with No Content body. We
// don't carry the gist in the response; just verify it's empty.
func TestCheckLike_204HasEmptyBody(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
likeGist(t, gist, "other")
otherTok := apiTokenFor(t, s, "other", db.ReadPermission)
_, body := s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
require.Empty(t, body, "204 No Content responses must carry an empty body")
}
// =========================================================================
// PUT /api/v1/gists/:uuid/like - ToggleLike
// =========================================================================
func TestToggleLike_NoAuth(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", "", nil, 401)
}
// TestToggleLike_LikesIfUnliked - first PUT on a never-liked gist adds the
// like. Verifies the response code, NbLikes increment, and HasLiked state.
func TestToggleLike_LikesIfUnliked(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
otherTok := likeTokenFor(t, s, "other")
_, body := s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
require.Empty(t, body, "204 must have empty body")
reloaded, err := db.GetGistByUUID(gist.Uuid)
require.NoError(t, err)
require.Equal(t, 1, reloaded.NbLikes, "NbLikes must increment to 1")
other, err := db.GetUserByUsername("other")
require.NoError(t, err)
liked, err := other.HasLiked(reloaded)
require.NoError(t, err)
require.True(t, liked, "other must now have liked the gist")
}
// TestToggleLike_UnlikesIfLiked - PUT on an already-liked gist removes the
// like. Decrements NbLikes back down to 0.
func TestToggleLike_UnlikesIfLiked(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
likeGist(t, gist, "other")
otherTok := likeTokenFor(t, s, "other")
// AppendUserLike bumps NbLikes to 1; confirm precondition.
reloaded, err := db.GetGistByUUID(gist.Uuid)
require.NoError(t, err)
require.Equal(t, 1, reloaded.NbLikes, "fixture precondition: NbLikes=1 after likeGist")
s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", otherTok, nil, 204)
reloaded, err = db.GetGistByUUID(gist.Uuid)
require.NoError(t, err)
require.Equal(t, 0, reloaded.NbLikes, "NbLikes must decrement back to 0")
other, err := db.GetUserByUsername("other")
require.NoError(t, err)
liked, err := other.HasLiked(reloaded)
require.NoError(t, err)
require.False(t, liked, "other must no longer have liked the gist")
}
// TestToggleLike_FullCycle - PUT twice on a fresh gist returns to the
// initial state (no like, NbLikes=0). Also verifies that toggling is
// genuinely idempotent across the pair.
func TestToggleLike_FullCycle(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
otherTok := likeTokenFor(t, s, "other")
url := "/api/v1/gists/" + gist.Uuid + "/like"
s.APIRequest(t, "PUT", url, otherTok, nil, 204) // like
s.APIRequest(t, "PUT", url, otherTok, nil, 204) // unlike
reloaded, err := db.GetGistByUUID(gist.Uuid)
require.NoError(t, err)
require.Equal(t, 0, reloaded.NbLikes, "two PUTs must net to no change in NbLikes")
// And CheckLike now agrees: no like, 404.
s.APIRequest(t, "GET", url, otherTok, nil, 404)
}
// TestToggleLike_HiddenPrivateGist_404 - same visibility rule as CheckLike.
// A private gist not owned by the caller is invisible, so the toggle just
// 404s without leaking existence or mutating state.
func TestToggleLike_HiddenPrivateGist_404(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "2") // private
otherTok := likeTokenFor(t, s, "other")
s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", otherTok, nil, 404)
// State unchanged.
reloaded, err := db.GetGistByUUID(gist.Uuid)
require.NoError(t, err)
require.Equal(t, 0, reloaded.NbLikes, "failed PUT must NOT mutate NbLikes")
}
func TestToggleLike_NotFound(t *testing.T) {
s := setupGetGist(t)
otherTok := likeTokenFor(t, s, "other")
s.APIRequest(t, "PUT", "/api/v1/gists/does-not-exist/like", otherTok, nil, 404)
}
// TestToggleLike_TokenWithoutUserWrite_403 - the toggle mutates the caller's
// own like state, so a token lacking the user:write scope is rejected with
// 403 before the handler runs, and no like state is mutated.
func TestToggleLike_TokenWithoutUserWrite_403(t *testing.T) {
s := setupGetGist(t)
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
// gist:read but only user:read - enough to read, not to toggle a like.
s.Login(t, "other")
tok := s.CreateAccessToken(t, "no-user-write", db.ReadPermission, db.ReadPermission)
s.Logout()
s.APIRequest(t, "PUT", "/api/v1/gists/"+gist.Uuid+"/like", tok, nil, 403)
reloaded, err := db.GetGistByUUID(gist.Uuid)
require.NoError(t, err)
require.Equal(t, 0, reloaded.NbLikes, "rejected PUT must NOT mutate NbLikes")
}
// TestListLikedGists_TokenWithoutGistRead_OnlyPublic - token lacks
// gist:read, so the caller only sees public gists they liked. Their own
// unlisted/private liked gists are hidden even though they own them.
func TestListLikedGists_TokenWithoutGistRead_OnlyPublic(t *testing.T) {
f := setupLiked(t)
f.s.Login(t, "caller")
tok := f.s.CreateAccessToken(t, "no-read", db.NoPermission, db.ReadPermission)
f.s.Logout()
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists/liked?per_page=20", tok, 200)
ids := idSet(arr)
require.True(t, ids[f.callerPub.Uuid], "caller's own PUBLIC liked gist visible")
require.True(t, ids[f.otherPub.Uuid], "other user's PUBLIC liked gist visible")
require.False(t, ids[f.callerUnl.Uuid], "own UNLISTED hidden without gist:read")
require.False(t, ids[f.callerPriv.Uuid], "own PRIVATE hidden without gist:read")
require.False(t, ids[f.otherUnl.Uuid], "other's UNLISTED hidden")
require.False(t, ids[f.otherPriv.Uuid], "other's PRIVATE hidden")
for _, g := range arr {
require.Equal(t, "public", g.Visibility, "non-public gist leaked into no-scope liked response: %+v", g)
}
}
+106
View File
@@ -0,0 +1,106 @@
package v1
import (
"time"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// gistFetcher is the per-mode db query used by listGistsCommon. Different
// list modes (anonymous, scoped token, unscoped token, /public) all share
// pagination + Link-header plumbing; only the underlying SQL differs. It
// returns the page of gists plus the total count matching the query (across
// all pages) for the response's pagination metadata.
type gistFetcher func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error)
// listGistsCommon handles the shared pagination + Link-header plumbing for
// the list endpoints. The caller supplies a gistFetcher that runs the right
// query for the current mode.
func listGistsCommon(ctx *context.Context, fetch gistFetcher) error {
page := parsePage(ctx)
perPage := parsePerPage(ctx)
since, err := parseSince(ctx)
if err != nil {
return ctx.ErrorJson(400, "GistSimple not found", nil)
}
// Fetch one extra row as the peek-next sentinel: if we get back perPage+1
// rows, there's another page.
gists, total, err := fetch(since, page-1, perPage+1, perPage, "updated", "desc")
if err != nil {
return ctx.ErrorJson(500, "failed to list gists", err)
}
hasMore := len(gists) > perPage
if hasMore {
gists = gists[:perPage]
}
baseURL := apiBaseURL(ctx)
out := make([]types.GistSimple, 0, len(gists))
for _, g := range gists {
out = append(out, g.ToAPISimple(baseURL))
}
writePaginationHeaders(ctx, baseURL, page, perPage, hasMore, &total)
return ctx.JSON(200, out)
}
// ListGists handles GET /api/v1/gists.
// Returns a JSON array; pagination is signaled via the X-* and Link response
// headers. Scope-gated visibility of the caller's own gists; other users' gists
// are never returned here (use /gists/public for that).
// - Token with gist:read → caller's own gists in every visibility (public,
// unlisted, private).
// - Token without gist:read → only the caller's own public gists.
// - Anonymous → all public gists from everyone (matches /gists/public so
// unauthenticated GET /gists is still useful).
//
// Supports `since` (RFC 3339) and `page` query params.
func ListGists(ctx *context.Context) error {
if ctx.User != nil {
uid := ctx.User.ID
tok, _ := ctx.GetData("accessToken").(*db.AccessToken)
if tok != nil && tok.HasGistReadPermission() {
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsOfUser(uid, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsOfUser(uid, since)
return gists, total, err
})
}
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllPublicGistsOfUser(uid, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllPublicGistsOfUser(uid, since)
return gists, total, err
})
}
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsForCurrentUser(0, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsForCurrentUser(0, since)
return gists, total, err
})
}
// ListPublicGists handles GET /api/v1/gists/public.
// Returns only public gists regardless of the caller's auth state.
func ListPublicGists(ctx *context.Context) error {
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsForCurrentUser(0, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsForCurrentUser(0, since)
return gists, total, err
})
}
@@ -0,0 +1,188 @@
package v1_test
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// listGistsVisibilityFixture stands up the env shared by the ListGists
// visibility tests: a "caller" user with one gist of each visibility, plus an
// "other" user with one gist of each visibility. The API is enabled. The
// caller is left logged in so the test can mint a token if it needs one.
type listGistsVisibilityFixture struct {
s *webtest.Server
callerPub, callerUnl, callerPriv *db.Gist
otherPub, otherUnl, otherPriv *db.Gist
}
func setupListVisibility(t *testing.T) *listGistsVisibilityFixture {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
// First-registered user is auto-admin in some flows - register an admin
// stub so the actors below are plain users.
s.Register(t, "admin")
s.Logout()
s.Register(t, "other")
s.Register(t, "caller")
// Other user: one of each visibility.
_, otherPub, _, _ := s.CreateGistAs(t, "other", "0")
_, otherUnl, _, _ := s.CreateGistAs(t, "other", "1")
_, otherPriv, _, _ := s.CreateGistAs(t, "other", "2")
// Caller: one of each visibility. Left logged in afterwards.
_, callerPub, _, _ := s.CreateGistAs(t, "caller", "0")
_, callerUnl, _, _ := s.CreateGistAs(t, "caller", "1")
_, callerPriv, _, _ := s.CreateGistAs(t, "caller", "2")
return &listGistsVisibilityFixture{
s: s,
callerPub: callerPub,
callerUnl: callerUnl,
callerPriv: callerPriv,
otherPub: otherPub,
otherUnl: otherUnl,
otherPriv: otherPriv,
}
}
// idSet returns the set of gist IDs seen in the response so tests can check
// membership without caring about order.
func idSet(arr []types.GistSimple) map[string]bool {
out := make(map[string]bool, len(arr))
for _, g := range arr {
out[g.ID] = true
}
return out
}
// TestListGists_GistObjectShape verifies every field of a types.GistSimple coming
// back from /api/v1/gists is populated as expected. HttpGit and SshGit are
// toggled on so the URL-bearing fields aren't empty; the test restores config
// on cleanup.
func TestListGists_GistObjectShape(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "thomas")
config.C.HttpGit = true
config.C.SshGit = true
config.C.SshExternalDomain = "gist.example.com"
config.C.SshPort = "22"
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
tok := s.CreateAccessToken(t, "shape", db.ReadPermission, db.ReadPermission)
s.Logout()
arr, _ := apiList[types.GistSimple](t, s, "/api/v1/gists", tok, 200)
require.Len(t, arr, 1)
got := arr[0]
// Identity + visibility.
require.Equal(t, gist.Uuid, got.ID)
require.Equal(t, "Test", got.Title) // CreateGistAs hardcodes title="Test"
require.Equal(t, "", got.Description)
require.Equal(t, "public", got.Visibility)
require.True(t, got.Public)
// Fork / counts default to zero on a fresh gist.
require.Equal(t, 0, got.LikeCount)
require.Equal(t, 0, got.ForkCount)
// URLs (caller-derived from the request's host).
require.Contains(t, got.HTMLUrl, "/thomas/"+gist.Identifier())
require.Contains(t, got.CloneUrl, "/thomas/"+gist.Identifier()+".git")
require.Equal(t, "gist.example.com:thomas/"+gist.Identifier()+".git", got.SSHUrl,
"scp-style SSH URL when SshPort=22")
// Timestamps populated.
require.False(t, got.CreatedAt.IsZero())
require.False(t, got.UpdatedAt.IsZero())
// Topics default to no topics.
require.Empty(t, got.Topics)
}
// TestListGists_Anonymous_OnlyPublicFromEveryone - caller passes no
// Authorization header. Should get every public gist regardless of owner, and
// no unlisted or private gist from anyone.
func TestListGists_Anonymous_OnlyPublicFromEveryone(t *testing.T) {
f := setupListVisibility(t)
f.s.Logout()
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists?per_page=20", "", 200)
require.Len(t, arr, 2, "expected 2 gists, got %d", len(arr))
ids := idSet(arr)
require.True(t, ids[f.callerPub.Uuid], "caller's PUBLIC gist must appear")
require.True(t, ids[f.otherPub.Uuid], "other user's PUBLIC gist must appear")
require.False(t, ids[f.callerUnl.Uuid], "caller's UNLISTED gist must NOT appear (anonymous)")
require.False(t, ids[f.callerPriv.Uuid], "caller's PRIVATE gist must NOT appear (anonymous)")
require.False(t, ids[f.otherUnl.Uuid], "other user's UNLISTED gist must NOT appear (anonymous)")
require.False(t, ids[f.otherPriv.Uuid], "other user's PRIVATE gist must NOT appear (anonymous)")
// Every returned gist must be public.
for _, g := range arr {
require.Equal(t, "public", g.Visibility, "non-public gist leaked into anonymous response: %+v", g)
}
}
// TestListGists_TokenWithoutGistRead_OnlyCallerPublic - caller authenticates
// with a token whose gist scope is NoPermission. Should get only their own
// public gists; their unlisted/private and everyone else's content stay
// hidden.
func TestListGists_TokenWithoutGistRead_OnlyCallerPublic(t *testing.T) {
f := setupListVisibility(t)
// Setup leaves caller logged in.
tok := f.s.CreateAccessToken(t, "no-read", db.NoPermission, db.ReadPermission)
f.s.Logout()
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists?per_page=20", tok, 200)
require.Len(t, arr, 1, "expected 1 gist, got %d", len(arr))
ids := idSet(arr)
require.True(t, ids[f.callerPub.Uuid], "caller's own PUBLIC gist must appear")
require.False(t, ids[f.callerUnl.Uuid], "caller's UNLISTED gist hidden when token lacks gist:read")
require.False(t, ids[f.callerPriv.Uuid], "caller's PRIVATE gist hidden when token lacks gist:read")
require.False(t, ids[f.otherPub.Uuid], "other user's PUBLIC gist must NOT leak (use /gists/public)")
require.False(t, ids[f.otherUnl.Uuid], "other user's UNLISTED gist must NOT appear")
require.False(t, ids[f.otherPriv.Uuid], "other user's PRIVATE gist must NOT appear")
for _, g := range arr {
require.Equal(t, "public", g.Visibility, "non-public gist leaked into no-scope response: %+v", g)
require.Equal(t, "caller", g.Owner.Login, "other user's gist leaked: %+v", g)
}
}
// TestListGists_TokenWithGistRead_AllOwnRegardlessOfVisibility - caller has
// a token with gist:read. Should get every one of their own gists (public,
// unlisted, private) and nothing from other users.
func TestListGists_TokenWithGistRead_AllOwnRegardlessOfVisibility(t *testing.T) {
f := setupListVisibility(t)
tok := f.s.CreateAccessToken(t, "read", db.ReadPermission, db.ReadPermission)
f.s.Logout()
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/gists?per_page=20", tok, 200)
require.Len(t, arr, 3, "expected 3 gists, got %d", len(arr))
ids := idSet(arr)
require.True(t, ids[f.callerPub.Uuid], "caller's PUBLIC gist must appear")
require.True(t, ids[f.callerUnl.Uuid], "caller's UNLISTED gist must appear (token has gist:read)")
require.True(t, ids[f.callerPriv.Uuid], "caller's PRIVATE gist must appear (token has gist:read)")
require.False(t, ids[f.otherPub.Uuid], "other user's PUBLIC gist must NOT appear (use /gists/public)")
require.False(t, ids[f.otherUnl.Uuid], "other user's UNLISTED gist must NOT appear")
require.False(t, ids[f.otherPriv.Uuid], "other user's PRIVATE gist must NOT appear")
for _, g := range arr {
require.Equal(t, "caller", g.Owner.Login, "other user's gist leaked: %+v", g)
}
}
+67
View File
@@ -0,0 +1,67 @@
package v1
import (
"net/url"
"github.com/thomiceli/opengist/internal/web/context"
)
// RawFile handles GET /api/v1/gists/:uuid/files/:sha/:filename.
// Returns the raw bytes of `filename` as committed at `sha`. Visibility
// rules mirror GetGist/GetGistRevision:
//
// - Public/unlisted gists readable by anyone (anonymous OK).
// - Private gists only by their owner with a gist:read token.
//
// Error codes split the failure modes for the client:
// - 404 "Gist not found" - gist doesn't exist or caller can't see it.
// - 400 "Invalid revision format" - :sha isn't pure hex (also guards
// against argv injection into the underlying git command).
// - 404 "File not found" - sha is fine but the file (or the revision)
// doesn't resolve in the repo.
//
// The body is the file's content, with Content-Type / Content-Disposition
// derived from the detected mime type. SVG and PDF responses also carry a
// restrictive Content-Security-Policy header, matching the web RawFile so
// embedding hostile content can't break out into script execution.
func RawFile(ctx *context.Context) error {
g, err := lookupGistByUUID(ctx, ctx.Param("uuid"))
if err != nil {
return ctx.ErrorJson(404, "Gist not found", nil)
}
sha := ctx.Param("sha")
if !reCommitSHA.MatchString(sha) {
return ctx.ErrorJson(400, "Invalid revision format", nil)
}
file, err := g.File(sha, ctx.Param("filename"), false)
if err != nil {
return ctx.ErrorJson(500, "failed to read file", err)
}
if file == nil {
return ctx.ErrorJson(404, "File not found", nil)
}
// Mirror the web RawFile's security headers for SVG/PDF - these formats
// can carry script that runs when rendered inline.
if file.MimeType.IsSVG() {
ctx.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
} else if file.MimeType.IsPDF() {
ctx.Response().Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}
switch {
case file.MimeType.CanBeEmbedded():
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
case file.MimeType.IsText():
ctx.Response().Header().Set("Content-Type", "text/plain; charset=utf-8")
default:
ctx.Response().Header().Set("Content-Type", "application/octet-stream")
}
ctx.Response().Header().Set("Content-Disposition",
"inline; filename=\""+url.PathEscape(file.Filename)+"\"")
ctx.Response().Header().Set("X-Content-Type-Options", "nosniff")
return ctx.PlainText(200, file.Content)
}
+232 -193
View File
@@ -1,244 +1,283 @@
package v1_test
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
v1 "github.com/thomiceli/opengist/internal/web/handlers/api/v1"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
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) {
// fullGist is a typed shim that decodes the GET /gists/:uuid response. The
// real types.Gist has ForkOf as interface{}; here we pin it to a pointer so
// assertions stay sane.
type fullGist struct {
types.GistSimple
ForkOf *types.GistSimple `json:"fork_of"`
Forks []types.GistSimple `json:"forks"`
Files map[string]types.GistFile `json:"files"`
Truncated bool `json:"truncated"`
}
// setupGetGist registers an admin stub + owner + other, enables the API, and
// logs out. Caller is responsible for session/token setup.
func setupGetGist(t *testing.T) *webtest.Server {
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
s.Register(t, "owner")
s.Register(t, "other")
s.Logout()
return s
}
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)
// apiTokenFor logs in as `user`, mints a token with the given gist scope (user
// scope fixed to read) and logs out. Use NoPermission for "no-scope" tokens.
func apiTokenFor(t *testing.T, s *webtest.Server, user string, gistScope uint) string {
s.Login(t, user)
tok := s.CreateAccessToken(t, user+"-tok", gistScope, db.ReadPermission)
s.Logout()
return tok
}
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)
// likeTokenFor returns a token for `user` carrying the user:write scope that
// the PUT /like|/star toggle requires, plus gist:read so private-gist
// visibility checks still pass.
func likeTokenFor(t *testing.T, s *webtest.Server, user string) string {
s.Login(t, user)
tok := s.CreateAccessToken(t, user+"-like-tok", db.ReadPermission, db.ReadWritePermission)
s.Logout()
return tok
}
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"},
},
// createGistViaAPI posts a body to /api/v1/gists and returns the resulting
// gist's id. Useful for multi-file / large-content fixtures that can't be
// built through the web form helper.
func createGistViaAPI(t *testing.T, s *webtest.Server, tok string, body interface{}) string {
_, raw := s.APIRequest(t, "POST", "/api/v1/gists", tok, body, 201)
var resp struct {
ID string `json:"id"`
}
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)
require.NoError(t, json.Unmarshal(raw, &resp))
require.NotEmpty(t, resp.ID)
return resp.ID
}
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"])
}
// --- Visibility / access matrix ---
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"))
func TestGetGist_VisibilityAccess(t *testing.T) {
s := setupGetGist(t)
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"])
}
_, gistPub, _, _ := s.CreateGistAs(t, "owner", "0")
_, gistUnl, _, _ := s.CreateGistAs(t, "owner", "1")
_, gistPriv, _, _ := s.CreateGistAs(t, "owner", "2")
func TestGetGist_Public(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
ownerTok := apiTokenFor(t, s, "owner", db.ReadPermission)
ownerNoScope := apiTokenFor(t, s, "owner", db.NoPermission)
otherTok := apiTokenFor(t, s, "other", db.ReadPermission)
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)
}
type tc struct {
name string
uuid string
tok string
want int
}
cases := []tc{
// Public gist - readable by everyone.
{"public/anonymous", gistPub.Uuid, "", 200},
{"public/no-scope owner", gistPub.Uuid, ownerNoScope, 200},
{"public/scoped owner", gistPub.Uuid, ownerTok, 200},
{"public/scoped other", gistPub.Uuid, otherTok, 200},
func TestGetGist_PrivateOwner(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "2")
// Unlisted gist - URL-shareable: readable by anyone with the UUID.
{"unlisted/anonymous", gistUnl.Uuid, "", 200},
{"unlisted/no-scope owner", gistUnl.Uuid, ownerNoScope, 200},
{"unlisted/scoped owner", gistUnl.Uuid, ownerTok, 200},
{"unlisted/scoped other", gistUnl.Uuid, otherTok, 200},
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"])
// Private gist - owner with gist:read only.
{"private/anonymous", gistPriv.Uuid, "", 404},
{"private/no-scope owner", gistPriv.Uuid, ownerNoScope, 404},
{"private/scoped other (non-owner)", gistPriv.Uuid, otherTok, 404},
{"private/scoped owner", gistPriv.Uuid, ownerTok, 200},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s.APIRequest(t, "GET", "/api/v1/gists/"+c.uuid, c.tok, nil, c.want)
})
}
}
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"])
s := setupGetGist(t)
s.APIRequest(t, "GET", "/api/v1/gists/does-not-exist", "", nil, 404)
}
func TestUpdateGist_Title(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
// --- Response structure ---
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 TestGetGist_ResponseShape(t *testing.T) {
s := setupGetGist(t)
// Enable both git transports so the URL-bearing fields aren't empty.
prevHTTP, prevSSH := config.C.HttpGit, config.C.SshGit
prevDomain, prevPort := config.C.SshExternalDomain, config.C.SshPort
t.Cleanup(func() {
config.C.HttpGit, config.C.SshGit = prevHTTP, prevSSH
config.C.SshExternalDomain, config.C.SshPort = prevDomain, prevPort
})
config.C.HttpGit = true
config.C.SshGit = true
config.C.SshExternalDomain = "gist.example.com"
config.C.SshPort = "22"
_, gist, _, _ := s.CreateGistAs(t, "owner", "0")
_, raw := s.APIRequest(t, "GET", "/api/v1/gists/"+gist.Uuid, "", nil, 200)
var got fullGist
require.NoError(t, json.Unmarshal(raw, &got))
// Identity + visibility.
require.Equal(t, gist.Uuid, got.ID)
require.Equal(t, "Test", got.Title) // CreateGistAs hardcodes title="Test"
require.Equal(t, "", got.Description)
require.Equal(t, "public", got.Visibility)
require.True(t, got.Public)
// Counters default to zero for a fresh gist.
require.Equal(t, 0, got.LikeCount)
require.Equal(t, 0, got.ForkCount)
// Owner block.
require.Equal(t, "owner", got.Owner.Login)
require.Equal(t, "User", got.Owner.Type)
require.NotZero(t, got.Owner.ID)
// URLs (derived from the request's host).
require.Contains(t, got.HTMLUrl, "/owner/"+gist.Identifier())
require.Contains(t, got.CloneUrl, "/owner/"+gist.Identifier()+".git")
require.Equal(t, "gist.example.com:owner/"+gist.Identifier()+".git", got.SSHUrl,
"scp-style SSH URL when SshPort=22")
// Timestamps populated.
require.False(t, got.CreatedAt.IsZero())
require.False(t, got.UpdatedAt.IsZero())
// Topics default to empty.
require.Empty(t, got.Topics)
// Files map populated with file.txt (CreateGistAs hardcodes that).
require.NotEmpty(t, got.Files)
require.Contains(t, got.Files, "file.txt")
f := got.Files["file.txt"]
require.Equal(t, "file.txt", f.Filename)
require.Equal(t, "hello", f.Content)
require.False(t, f.Truncated)
require.NotEmpty(t, f.Encoding)
// Gist-level fork/truncate defaults.
require.Nil(t, got.ForkOf, "non-forked gist must have fork_of=null")
require.Empty(t, got.Forks)
require.False(t, got.Truncated)
}
func TestUpdateGist_ReplaceFiles(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
// --- ForkOf ---
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 TestGetGist_ForkOfPopulated(t *testing.T) {
s := setupGetGist(t)
func TestUpdateGist_NotOwner_404(t *testing.T) {
s := webtest.Setup(t)
defer webtest.Teardown(t)
s.Register(t, "admin")
_, parent, parentUser, parentIdent := s.CreateGistAs(t, "owner", "0")
// "other" forks the gist via the web /fork endpoint. The redirect Location
// gives us /{forker}/{newIdentifier}.
s.Login(t, "other")
resp := s.Request(t, "POST", "/"+parentUser+"/"+parentIdent+"/fork", nil, 302)
loc := resp.Header.Get("Location")
parts := strings.Split(strings.TrimPrefix(loc, "/"), "/")
require.Len(t, parts, 2, "fork redirect must be /{user}/{ident}, got %q", loc)
forkIdent := parts[1]
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"))
// Look the fork up to grab its UUID (Identifier == UUID when no custom URL).
forkGist, err := db.GetGist("other", forkIdent)
require.NoError(t, err)
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"])
// GET the fork - fork_of must point at the parent.
_, raw := s.APIRequest(t, "GET", "/api/v1/gists/"+forkGist.Uuid, "", nil, 200)
var got fullGist
require.NoError(t, json.Unmarshal(raw, &got))
require.NotNil(t, got.ForkOf, "fork_of must be populated for a forked gist")
require.Equal(t, parent.Uuid, got.ForkOf.ID, "fork_of.id must point at the parent gist")
require.Equal(t, "owner", got.ForkOf.Owner.Login, "fork_of.owner must be the parent's owner")
// Sanity: getting the parent reflects the bumped fork count and nil fork_of.
_, parentRaw := s.APIRequest(t, "GET", "/api/v1/gists/"+parent.Uuid, "", nil, 200)
var parentGot fullGist
require.NoError(t, json.Unmarshal(parentRaw, &parentGot))
require.Nil(t, parentGot.ForkOf, "parent gist itself is not a fork")
require.Equal(t, 1, parentGot.ForkCount, "parent's fork_count must reflect the new fork")
}
func TestDeleteGist(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
// --- Truncation: too many files ---
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 TestGetGist_GistTruncatedWhenManyFiles(t *testing.T) {
s := setupGetGist(t)
tok := apiTokenFor(t, s, "owner", db.ReadWritePermission)
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")
// CatFileBatch caps the file listing at 50 entries - push 51 to flip the
// gist-level Truncated flag.
files := map[string]map[string]string{}
for i := 0; i < 51; i++ {
files[fmt.Sprintf("file%02d.txt", i)] = map[string]string{"content": fmt.Sprintf("body %d", i)}
}
id := createGistViaAPI(t, s, tok, map[string]interface{}{
"title": "many files",
"visibility": "public",
"files": files,
})
_, raw := s.APIRequest(t, "GET", "/api/v1/gists/"+id, "", nil, 200)
var got fullGist
require.NoError(t, json.Unmarshal(raw, &got))
require.True(t, got.Truncated, "Truncated must be true when the gist has more than 50 files")
require.LessOrEqual(t, len(got.Files), 50, "Files map must be capped at 50 entries when truncated")
}
func TestRawFile(t *testing.T) {
s, tok := setupAPIUser(t)
_, gist, _, _ := s.CreateGistAs(t, "thomas", "0")
// CreateGistAs uses {name: file.txt, content: hello}
// --- Truncation: file content too large ---
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"])
func TestGetGist_FileContentTruncatedWhenLarge(t *testing.T) {
s := setupGetGist(t)
tok := apiTokenFor(t, s, "owner", db.ReadWritePermission)
// truncateLimit in internal/git is 2<<18 (512 KiB). A 600 KiB file forces
// per-file truncation while keeping the gist-level flag false (1 file).
const truncateLimit = 2 << 18
bigContent := strings.Repeat("a", truncateLimit+1024)
id := createGistViaAPI(t, s, tok, map[string]interface{}{
"title": "big file",
"visibility": "public",
"files": map[string]map[string]string{
"big.txt": {"content": bigContent},
},
})
_, raw := s.APIRequest(t, "GET", "/api/v1/gists/"+id, "", nil, 200)
var got fullGist
require.NoError(t, json.Unmarshal(raw, &got))
require.False(t, got.Truncated, "gist-level Truncated stays false for a single file")
require.Contains(t, got.Files, "big.txt")
f := got.Files["big.txt"]
require.True(t, f.Truncated, "per-file Truncated must be true for >512 KiB content")
require.LessOrEqual(t, len(f.Content), truncateLimit,
"truncated content length must be at most truncateLimit (got %d)", len(f.Content))
}
+27 -30
View File
@@ -12,30 +12,23 @@ import (
webtest "github.com/thomiceli/opengist/internal/web/test"
)
// apiError mirrors the JSON error envelope produced by context.HTTPError:
// {"message": "...", "status": <code>}.
type apiError struct {
Message string `json:"message"`
Status int `json:"status"`
}
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
var body apiError
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")
require.Equal(t, 401, body.Status)
require.NotEmpty(t, body.Message)
}
func TestApiAuth_BearerAndTokenPrefix(t *testing.T) {
@@ -44,7 +37,6 @@ func TestApiAuth_BearerAndTokenPrefix(t *testing.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)
@@ -61,7 +53,6 @@ func TestApiAuth_ExpiredToken(t *testing.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)
@@ -69,9 +60,10 @@ func TestApiAuth_ExpiredToken(t *testing.T) {
all[0].ExpiresAt = 1
require.NoError(t, db.SaveAccessTokenForTest(all[0]))
var body map[string]string
var body apiError
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", tok, nil, &body, 401)
require.Equal(t, "unauthorized", body["code"])
require.Equal(t, 401, body.Status)
require.NotEmpty(t, body.Message)
}
func newJSONReqWithAuth(method, uri, authHeader string) *http.Request {
@@ -81,28 +73,33 @@ func newJSONReqWithAuth(method, uri, authHeader string) *http.Request {
return req
}
func TestApiScope_GistReadInsufficient(t *testing.T) {
// TestApiScope_GistWriteInsufficient - a token without gist:write is rejected
// with 403 on a write endpoint. (Read endpoints are soft-scoped and return the
// public subset instead of 403, so the hard-scope check lives on a write route.)
func TestApiScope_GistWriteInsufficient(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"))
tok := s.CreateAccessToken(t, "no-write", db.ReadPermission, db.ReadPermission)
var body map[string]string
s.APIRequestUnmarshal(t, "GET", "/api/v1/gists", tok, nil, &body, 403)
require.Equal(t, "forbidden", body["code"])
var body apiError
s.APIRequestUnmarshal(t, "POST", "/api/v1/gists", tok, nil, &body, 403)
require.Equal(t, 403, body.Status)
require.NotEmpty(t, body.Message)
}
// TestApiScope_UserReadInsufficient - /user is a hard-scoped private resource,
// so a token lacking user:read gets 403 (not a reduced response).
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
var body apiError
s.APIRequestUnmarshal(t, "GET", "/api/v1/user", tok, nil, &body, 403)
require.Equal(t, "forbidden", body["code"])
require.Equal(t, 403, body.Status)
require.NotEmpty(t, body.Message)
}
@@ -0,0 +1,46 @@
package types
import "time"
// GistFile is one entry in the `files` object of a list-shape gist (no
// content; clients fetch raw_url).
type GistFile struct {
Filename string `json:"filename"`
Type string `json:"type"`
Language string `json:"language,omitempty"`
Size int `json:"size"`
Truncated bool `json:"truncated"`
Content string `json:"content"`
Encoding string `json:"encoding"`
}
// GistSimple is the list-shape gist. Used for the `GET /gists` list and
// any other place where content isn't included. The `Visibility` field
// preserves the public/unlisted/private distinction that the `Public` bool
// can't express.
type GistSimple struct {
ID string `json:"id"`
SlugUrl string `json:"slug_url"`
Owner SimpleUser `json:"owner"`
Title string `json:"title"`
HTMLUrl string `json:"html_url"`
Description string `json:"description"`
Public bool `json:"public"`
Visibility string `json:"visibility"` // Opengist extension: public/unlisted/private
LikeCount int `json:"like_count"`
ForkCount int `json:"fork_count"`
CloneUrl string `json:"clone_url"`
SSHUrl string `json:"ssh_url"`
Topics []string `json:"topics"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Gist struct {
GistSimple
ForkOf interface{} `json:"fork_of"`
Forks []GistSimple `json:"forks"`
Files map[string]GistFile `json:"files"`
Commits []GistCommit `json:"commits"`
Truncated bool `json:"truncated"`
}
@@ -0,0 +1,32 @@
package types
import "time"
// CommitAuthor is the raw git-side author info for a commit (always
// populated from the commit metadata).
type CommitAuthor struct {
Name string `json:"name"`
Email string `json:"email"`
}
// CommitChangeStatus is the shortstat breakdown for a commit. Total equals
// additions + deletions.
type CommitChangeStatus struct {
Files int `json:"files_changed"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Total int `json:"total"`
}
// GistCommit is one entry in the GET /gists/:id/commits response.
// - `author` always carries the raw git author name + email.
// - `user` is the Opengist account whose email matches; null when no
// account matches the commit's email.
// - `change_status` is the shortstat for the commit.
type GistCommit struct {
Version string `json:"version"` // commit SHA
Author CommitAuthor `json:"author"`
User *SimpleUser `json:"user"`
ChangeStatus CommitChangeStatus `json:"change_status"`
CommittedAt time.Time `json:"committed_at"`
}
@@ -0,0 +1,39 @@
package types
// GistFileInput is the per-file body shape used by both POST and PATCH
// /gists. Pointer fields let JSON `null` / missing keys be distinguished from
// real values, which matters most on PATCH:
//
// - content - file body. On CREATE, required for the file to be added
// (entries without content are silently dropped). On PATCH, leave it
// unset to keep the current content; set it to replace.
// - filename - only used on PATCH, where a set value renames the targeted
// file. On CREATE it is ignored: the map key is the filename.
type GistFileInput struct {
Content *string `json:"content,omitempty"`
Filename *string `json:"filename,omitempty"`
}
// GistInput is the unified request body for POST and PATCH /api/v1/gists.
// Every field is optional / nilable so handlers can tell "client didn't send
// this" from "client explicitly set this", which is what the PATCH semantics
// require: files from the previous version of the gist that aren't explicitly
// changed during an edit are unchanged.
//
// Handler-specific interpretation:
//
// - Description - CREATE: nil treated as empty. PATCH: nil = no change.
// - Title - Opengist extension. CREATE: nil = derive from first
// filename. PATCH: nil = no change.
// - Visibility - Opengist extension. CREATE: nil = defaults to public.
// PATCH: nil = no change.
// - Files - CREATE: keys define filenames; entries with nil
// content are skipped. PATCH: keys must match existing filenames;
// null entry (or empty content+filename) deletes; unknown key with
// content adds a new file.
type GistInput struct {
Description *string `json:"description,omitempty"`
Files map[string]*GistFileInput `json:"files,omitempty"`
Title *string `json:"title,omitempty"`
Visibility *string `json:"visibility,omitempty"`
}
@@ -0,0 +1,25 @@
package types
import "time"
// SimpleUser is the public user shape used as the `owner` field on gist
// responses and by the public user-lookup endpoints. It carries no private
// fields (no email). Fields whose underlying feature doesn't exist in Opengist
// (followers, repos, etc.) are still populated with the spec-shaped URLs so
// clients parse cleanly.
type SimpleUser struct {
ID uint `json:"id"`
Login string `json:"login"`
Username string `json:"username"`
AvatarURL string `json:"avatar_url"`
Type string `json:"type"`
CreatedAt time.Time `json:"created_at"`
}
// PrivateUser is the self shape returned only by the authenticated-user
// endpoints (GET/PATCH /user). It extends SimpleUser with the caller's own
// email, which is never exposed on public endpoints.
type PrivateUser struct {
SimpleUser
Email string `json:"email"`
}
@@ -0,0 +1,15 @@
package types
// UpdateUserRequest is the PATCH /api/v1/user body. Pointer fields let the
// handler distinguish "absent" from "explicit empty" so partial updates
// don't accidentally clear other fields.
//
// - username - change the caller's username. Goes through the same
// validator the web settings page uses (max 24 chars, alphanumeric +
// dashes, not a reserved word).
// - email - change the caller's email. Lowercased server-side and the
// gravatar MD5 hash is recomputed.
type UpdateUserRequest struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
}
+203 -10
View File
@@ -1,20 +1,213 @@
package v1
import (
"crypto/md5"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
)
// GetUser handles GET /api/v1/user
// GetUser handles GET /api/v1/user.
// Returns the authenticated caller's own user record.
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)
return ctx.JSON(200, ctx.User.ToPrivateAPI())
}
// UpdateUser handles PATCH /api/v1/user.
// Updates the authenticated caller's username and/or email. Both fields are
// optional - only fields present in the body are touched. Returns the
// updated user on success (200), 422 on validation failures, 409 if the
// requested username is taken.
func UpdateUser(ctx *context.Context) error {
var req types.UpdateUserRequest
if err := ctx.Bind(&req); err != nil {
return ctx.ErrorJson(422, "could not bind data", nil)
}
if req.Username == nil && req.Email == nil {
return ctx.ErrorJson(422, "at least one of username or email must be set", nil)
}
user := ctx.User
if req.Username != nil && !strings.EqualFold(*req.Username, user.Username) {
// Same validator the web settings page uses (max=24, alphanumdash,
// notreserved).
dto := &db.UserUsernameDTO{Username: *req.Username}
if err := ctx.Validate(dto); err != nil {
return ctx.ErrorJson(422, err.Error(), nil)
}
exists, err := db.UserExists(dto.Username)
if err != nil {
return ctx.ErrorJson(500, "failed to check username uniqueness", err)
}
if exists {
return ctx.ErrorJson(409, "username already taken", nil)
}
// Rename the user's repos directory on disk so subsequent git
// operations resolve correctly under the new name.
sourceDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(user.Username))
destinationDir := filepath.Join(config.GetHomeDir(), git.ReposDirectory, strings.ToLower(dto.Username))
if sourceDir != destinationDir {
if _, err := os.Stat(sourceDir); !os.IsNotExist(err) {
if err := os.Rename(sourceDir, destinationDir); err != nil {
return ctx.ErrorJson(500, "failed to rename user directory", err)
}
}
}
user.Username = dto.Username
}
if req.Email != nil {
email := strings.ToLower(strings.TrimSpace(*req.Email))
user.Email = email
// Gravatar key: hash of the lowercased email (or a random value when
// no email is set, matching EmailProcess).
if email == "" {
user.MD5Hash = fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String())))
} else {
user.MD5Hash = fmt.Sprintf("%x", md5.Sum([]byte(email)))
}
}
if err := user.Update(); err != nil {
return ctx.ErrorJson(500, "failed to update user", err)
}
return ctx.JSON(200, user.ToPrivateAPI())
}
// GetUserByID handles GET /api/v1/user/:id.
// Looks up a user by numeric ID and returns the SimpleUser shape (no
// private fields like email or admin flag). Anonymous-readable.
func GetUserByID(ctx *context.Context) error {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
if err != nil {
return ctx.ErrorJson(400, "Invalid user id", nil)
}
u, err := db.GetUserById(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorJson(404, "User not found", nil)
}
return ctx.ErrorJson(500, "failed to look up user", err)
}
return ctx.JSON(200, u.ToSimpleAPI())
}
// GetUserByUsername handles GET /api/v1/users/:username.
// Looks up a user by username and returns the SimpleUser shape.
// Anonymous-readable.
func GetUserByUsername(ctx *context.Context) error {
u, err := db.GetUserByUsername(ctx.Param("username"))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorJson(404, "User not found", nil)
}
return ctx.ErrorJson(500, "failed to look up user", err)
}
return ctx.JSON(200, u.ToSimpleAPI())
}
// userVisibleAs resolves the `currentUserId` parameter the visibility
// OR-clauses on gist queries use. Returns target.ID only when the caller IS
// the target AND their token has gist:read; otherwise 0 (public-only). Same
// soft-scope policy as the /gists endpoint family.
func userVisibleAs(ctx *context.Context, target *db.User) uint {
if ctx.User != nil && ctx.User.ID == target.ID {
if tok, ok := ctx.GetData("accessToken").(*db.AccessToken); ok && tok.HasGistReadPermission() {
return target.ID
}
}
return 0
}
// ListUserLikedGists handles GET /api/v1/users/:username/liked.
// Lists gists liked by :username, filtered to what the caller is allowed
// to see. The target user's own private/unlisted liked gists only surface
// when the caller IS that user AND holds gist:read.
func ListUserLikedGists(ctx *context.Context) error {
target, err := db.GetUserByUsername(ctx.Param("username"))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorJson(404, "User not found", nil)
}
return ctx.ErrorJson(500, "failed to look up user", err)
}
visibleAs := userVisibleAs(ctx, target)
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsLikedByUser(target.ID, visibleAs, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsLikedByUserSince(target.ID, visibleAs, since)
return gists, total, err
})
}
// ListUserForkedGists handles GET /api/v1/users/:username/forked.
// Lists gists forked by :username. Same caller-visibility rule as
// ListUserLikedGists.
func ListUserForkedGists(ctx *context.Context) error {
target, err := db.GetUserByUsername(ctx.Param("username"))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorJson(404, "User not found", nil)
}
return ctx.ErrorJson(500, "failed to look up user", err)
}
visibleAs := userVisibleAs(ctx, target)
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsForkedByUser(target.ID, visibleAs, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsForkedByUserSince(target.ID, visibleAs, since)
return gists, total, err
})
}
// ListUserGists handles GET /api/v1/users/:username/gists.
// Returns the named user's gists with visibility filtering:
//
// - Anonymous, or any caller other than the named user → only public
// gists.
// - Caller is the named user AND token has gist:read → all of their
// gists (public + own private/unlisted).
// - Caller is the named user but token lacks gist:read → public only,
// matching the soft-scope rule used by /gists.
//
// Supports `page`, `per_page` (default 30, cap 100), and `since`
// (RFC 3339). Pagination via the Link header.
func ListUserGists(ctx *context.Context) error {
username := ctx.Param("username")
target, err := db.GetUserByUsername(username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorJson(404, "User not found", nil)
}
return ctx.ErrorJson(500, "failed to look up user", err)
}
visibleAs := userVisibleAs(ctx, target)
return listGistsCommon(ctx, func(since *time.Time, offset, limit, perPage int, sort, order string) ([]*db.Gist, int64, error) {
gists, err := db.GetAllGistsFromUserVisibleTo(target.ID, visibleAs, since, offset, sort, order, limit, perPage)
if err != nil {
return nil, 0, err
}
total, err := db.CountAllGistsFromUserVisibleTo(target.ID, visibleAs, since)
return gists, total, err
})
}
+222 -17
View File
@@ -5,24 +5,229 @@ import (
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
v1 "github.com/thomiceli/opengist/internal/web/handlers/api/v1"
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
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())
// userGistsFixture stands up the env shared by the ListUserGists visibility
// tests: a "target" user with one gist of each visibility, plus an "other"
// user used as a separate viewer.
type userGistsFixture struct {
s *webtest.Server
targetPub, targetUnl, targetPriv *db.Gist
}
func setupListUserGists(t *testing.T) *userGistsFixture {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "target")
s.Register(t, "other")
_, targetPub, _, _ := s.CreateGistAs(t, "target", "0")
_, targetUnl, _, _ := s.CreateGistAs(t, "target", "1")
_, targetPriv, _, _ := s.CreateGistAs(t, "target", "2")
return &userGistsFixture{
s: s,
targetPub: targetPub,
targetUnl: targetUnl,
targetPriv: targetPriv,
}
}
// TestListUserGists_UnknownUser_404 - the lookup-side rule: a username that
// doesn't exist returns 404 (not an empty array). Kept separate because it
// doesn't need the per-visibility fixture.
func TestListUserGists_UnknownUser_404(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "thomas")
s.APIRequest(t, "GET", "/api/v1/users/nobody/gists", "", nil, 404)
}
// userLikedFixture sets up a "target" user who has liked one public + one
// unlisted + one private gist (all owned by target so the visibility filter
// is exercisable). Plus an "other" user used as a separate viewer.
type userLikedFixture struct {
s *webtest.Server
targetPub, targetUnl, targetPriv *db.Gist
}
func setupUserLiked(t *testing.T) *userLikedFixture {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "target")
s.Register(t, "other")
_, pub, _, _ := s.CreateGistAs(t, "target", "0")
_, unl, _, _ := s.CreateGistAs(t, "target", "1")
_, priv, _, _ := s.CreateGistAs(t, "target", "2")
// target likes all three of their own gists. The visibility filter
// (`private = 0 OR user_id = currentUserId`) then decides per-caller
// whether the unlisted/private rows surface.
for _, g := range []*db.Gist{pub, unl, priv} {
likeGist(t, g, "target")
}
return &userLikedFixture{s: s, targetPub: pub, targetUnl: unl, targetPriv: priv}
}
// TestListUserLikedGists_Visibility - same visibility matrix as
// ListUserGists, applied to the liked-by-:username view.
func TestListUserLikedGists_Visibility(t *testing.T) {
f := setupUserLiked(t)
otherTok := apiTokenFor(t, f.s, "other", db.ReadPermission)
selfScopedTok := apiTokenFor(t, f.s, "target", db.ReadPermission)
selfNoScopeTok := apiTokenFor(t, f.s, "target", db.NoPermission)
cases := []struct {
name string
tok string
seePub bool
seeUnl bool
seePriv bool
}{
{"anonymous", "", true, false, false},
{"other user (scoped)", otherTok, true, false, false},
{"self (gist:read)", selfScopedTok, true, true, true},
{"self (no scope)", selfNoScopeTok, true, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/users/target/liked?per_page=20", c.tok, 200)
ids := idSetSimple(arr)
require.Equal(t, c.seePub, ids[f.targetPub.Uuid], "PUBLIC visibility expectation")
require.Equal(t, c.seeUnl, ids[f.targetUnl.Uuid], "UNLISTED visibility expectation")
require.Equal(t, c.seePriv, ids[f.targetPriv.Uuid], "PRIVATE visibility expectation")
})
}
}
func TestListUserLikedGists_UnknownUser_404(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "thomas")
s.APIRequest(t, "GET", "/api/v1/users/nobody/liked", "", nil, 404)
}
// userForkedFixture sets up parentowner → public parent. target forks the
// parent (so a fork owned by target exists). Then target's fork is left
// public, but we also create a second fork by target on a different parent
// (also public) and flip its visibility to unlisted / private to exercise
// the per-row visibility filter.
type userForkedFixture struct {
s *webtest.Server
targetPub, targetUnl, targetPriv *db.Gist
}
func setupUserForked(t *testing.T) *userForkedFixture {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "admin")
s.Logout()
s.Register(t, "parentowner")
s.Register(t, "target")
s.Register(t, "other")
// Three independent parents so target can create three distinct forks
// (forking the same parent twice would be idempotent).
_, _, p1user, p1ident := s.CreateGistAs(t, "parentowner", "0")
_, _, p2user, p2ident := s.CreateGistAs(t, "parentowner", "0")
_, _, p3user, p3ident := s.CreateGistAs(t, "parentowner", "0")
pubFork := forkAs(t, s, "target", p1user, p1ident)
unlFork := forkAs(t, s, "target", p2user, p2ident)
privFork := forkAs(t, s, "target", p3user, p3ident)
setVisibility(t, unlFork, db.UnlistedVisibility)
setVisibility(t, privFork, db.PrivateVisibility)
return &userForkedFixture{s: s, targetPub: pubFork, targetUnl: unlFork, targetPriv: privFork}
}
// TestListUserForkedGists_Visibility - same matrix as the liked one.
func TestListUserForkedGists_Visibility(t *testing.T) {
f := setupUserForked(t)
otherTok := apiTokenFor(t, f.s, "other", db.ReadPermission)
selfScopedTok := apiTokenFor(t, f.s, "target", db.ReadPermission)
selfNoScopeTok := apiTokenFor(t, f.s, "target", db.NoPermission)
cases := []struct {
name string
tok string
seePub bool
seeUnl bool
seePriv bool
}{
{"anonymous", "", true, false, false},
{"other user (scoped)", otherTok, true, false, false},
{"self (gist:read)", selfScopedTok, true, true, true},
{"self (no scope)", selfNoScopeTok, true, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/users/target/forked?per_page=20", c.tok, 200)
ids := idSetSimple(arr)
require.Equal(t, c.seePub, ids[f.targetPub.Uuid], "PUBLIC fork visibility expectation")
require.Equal(t, c.seeUnl, ids[f.targetUnl.Uuid], "UNLISTED fork visibility expectation")
require.Equal(t, c.seePriv, ids[f.targetPriv.Uuid], "PRIVATE fork visibility expectation")
})
}
}
func TestListUserForkedGists_UnknownUser_404(t *testing.T) {
s := webtest.Setup(t)
t.Cleanup(func() { webtest.Teardown(t) })
s.Register(t, "thomas")
s.APIRequest(t, "GET", "/api/v1/users/nobody/forked", "", nil, 404)
}
// TestListUserGists_Visibility - table-driven matrix of caller types ×
// expected visibility of target's three gists.
//
// - Anonymous and other users see only public.
// - The target user with gist:read sees everything (public + own
// unlisted + own private).
// - The target user without gist:read soft-degrades to public-only,
// same as anonymous.
func TestListUserGists_Visibility(t *testing.T) {
f := setupListUserGists(t)
otherTok := apiTokenFor(t, f.s, "other", db.ReadPermission)
selfScopedTok := apiTokenFor(t, f.s, "target", db.ReadPermission)
selfNoScopeTok := apiTokenFor(t, f.s, "target", db.NoPermission)
cases := []struct {
name string
tok string
seePub bool
seeUnl bool
seePriv bool
}{
{"anonymous", "", true, false, false},
{"other user (scoped)", otherTok, true, false, false},
{"self (gist:read)", selfScopedTok, true, true, true},
{"self (no scope)", selfNoScopeTok, true, false, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
arr, _ := apiList[types.GistSimple](t, f.s, "/api/v1/users/target/gists?per_page=20", c.tok, 200)
ids := idSetSimple(arr)
require.Equal(t, c.seePub, ids[f.targetPub.Uuid], "PUBLIC visibility expectation")
require.Equal(t, c.seeUnl, ids[f.targetUnl.Uuid], "UNLISTED visibility expectation")
require.Equal(t, c.seePriv, ids[f.targetPriv.Uuid], "PRIVATE visibility expectation")
})
}
}
+3 -3
View File
@@ -70,7 +70,7 @@ func AllGists(ctx *context.Context) error {
case "all":
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all"))
urlPage = "all"
gists, err = db.GetAllGistsForCurrentUser(currentUserId, pageInt-1, sort, order)
gists, err = db.GetAllGistsForCurrentUser(currentUserId, nil, pageInt-1, sort, order, 11, 10)
}
} else {
var fromUser *db.User
@@ -107,11 +107,11 @@ func AllGists(ctx *context.Context) error {
case "liked":
urlPage = fromUserStr + "/liked"
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-liked-by", fromUserStr))
gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
gists, err = db.GetAllGistsLikedByUser(fromUser.ID, currentUserId, nil, pageInt-1, sort, order, 11, 10)
case "forked":
urlPage = fromUserStr + "/forked"
ctx.SetData("htmlTitle", ctx.TrH("gist.list.all-forked-by", fromUserStr))
gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, pageInt-1, sort, order)
gists, err = db.GetAllGistsForkedByUser(fromUser.ID, currentUserId, nil, pageInt-1, sort, order, 11, 10)
case "fromUser":
urlPage = fromUserStr
+2 -2
View File
@@ -28,7 +28,7 @@ func RawFile(ctx *context.Context) error {
}
if file.MimeType.CanBeEmbedded() {
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
ctx.Response().Header().Set("Content-Type", file.MimeType.Header())
} else if file.MimeType.IsText() {
ctx.Response().Header().Set("Content-Type", "text/plain; charset=utf-8")
} else {
@@ -51,7 +51,7 @@ func DownloadFile(ctx *context.Context) error {
return ctx.NotFound("File not found")
}
ctx.Response().Header().Set("Content-Type", file.MimeType.ContentType)
ctx.Response().Header().Set("Content-Type", file.MimeType.Header())
ctx.Response().Header().Set("Content-Disposition", "attachment; filename=\""+url.PathEscape(file.Filename)+"\"")
ctx.Response().Header().Set("Content-Length", strconv.Itoa(len(file.Content)))
ctx.Response().Header().Set("X-Content-Type-Options", "nosniff")
+1 -1
View File
@@ -73,7 +73,7 @@ func Forks(ctx *context.Context) error {
fromUserID = currentUser.ID
}
forks, err := gist.GetForks(fromUserID, pageInt-1)
forks, err := gist.GetForks(fromUserID, pageInt-1, 11, 10)
if err != nil {
return ctx.ErrorRes(500, "Error getting users who liked this gist", err)
}
+2 -2
View File
@@ -46,12 +46,12 @@ func TestFork(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 1, original.NbForks)
forks, err := original.GetForks(2, 0)
forks, err := original.GetForks(2, 0, 11, 10)
require.NoError(t, err)
require.Len(t, forks, 1)
require.Equal(t, forkedGist.ID, forks[0].ID)
forkedGists, err := db.GetAllGistsForkedByUser(2, 2, 0, "created", "asc")
forkedGists, err := db.GetAllGistsForkedByUser(2, 2, nil, 0, "created", "asc", 11, 10)
require.NoError(t, err)
require.Len(t, forkedGists, 1)
require.Equal(t, forkedGist.ID, forkedGists[0].ID)
+1 -1
View File
@@ -92,7 +92,7 @@ func TestGistIndex(t *testing.T) {
}
require.True(t, found)
commits, err := gist.Log(0)
commits, err := gist.Log("HEAD", 0, 11)
require.NoError(t, err)
require.Len(t, commits, 2)
+1 -17
View File
@@ -1,8 +1,6 @@
package gist
import (
"strings"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/web/context"
"github.com/thomiceli/opengist/internal/web/handlers"
@@ -15,7 +13,7 @@ func Revisions(ctx *context.Context) error {
pageInt := handlers.GetPage(ctx)
commits, err := gist.Log((pageInt - 1) * 10)
commits, err := gist.Log("HEAD", (pageInt-1)*10, 11)
if err != nil {
return ctx.ErrorRes(500, "Error fetching commits log", err)
}
@@ -24,22 +22,8 @@ func Revisions(ctx *context.Context) error {
return ctx.ErrorRes(404, ctx.Tr("error.page-not-found"), nil)
}
emailsSet := map[string]struct{}{}
for _, commit := range commits {
if commit.AuthorEmail == "" {
continue
}
emailsSet[strings.ToLower(commit.AuthorEmail)] = struct{}{}
}
emailsUsers, err := db.GetUsersFromEmails(emailsSet)
if err != nil {
return ctx.ErrorRes(500, "Error fetching users emails", err)
}
ctx.SetData("page", "revisions")
ctx.SetData("revision", "HEAD")
ctx.SetData("emails", emailsUsers)
ctx.SetData("htmlTitle", ctx.TrH("gist.revision-of", gist.Title))
return ctx.Html("revisions.html")
+77 -65
View File
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
webtest "github.com/thomiceli/opengist/internal/web/test"
)
@@ -32,7 +33,7 @@ func TestRevisions(t *testing.T) {
"content": {"updated content", "okay"},
}, 302)
commits, err := gist.Log(0)
commits, err := gist.Log("HEAD", 0, 11)
require.NoError(t, err)
require.Len(t, commits, 3)
@@ -41,95 +42,106 @@ func TestRevisions(t *testing.T) {
require.Regexp(t, "^[a-f0-9]{40}$", commits[1].Hash)
require.Regexp(t, "^[a-f0-9]{40}$", commits[2].Hash)
require.Equal(t, &git.Commit{
Hash: commits[0].Hash,
Timestamp: commits[0].Timestamp,
AuthorName: "thomas",
Changed: "1 file changed, 0 insertions, 0 deletions",
Files: []git.File{
{
Filename: "renamed.txt",
Size: 0,
HumanSize: "",
OldFilename: "file.txt",
Content: ``,
Truncated: false,
IsCreated: false,
IsDeleted: false,
IsBinary: false,
MimeType: git.MimeType{},
require.Equal(t, &db.GistCommit{
Commit: &git.Commit{
Hash: commits[0].Hash,
Timestamp: commits[0].Timestamp,
AuthorName: "thomas",
FilesChanged: 1,
Additions: 0,
Deletions: 0,
Files: []git.File{
{
Filename: "renamed.txt",
Size: 0,
HumanSize: "",
OldFilename: "file.txt",
Content: ``,
Truncated: false,
IsCreated: false,
IsDeleted: false,
IsBinary: false,
MimeType: git.MimeType{},
},
},
},
}, commits[0])
require.Equal(t, &git.Commit{
Hash: commits[1].Hash,
Timestamp: commits[1].Timestamp,
AuthorName: "thomas",
Changed: "3 files changed, 2 insertions, 2 deletions",
Files: []git.File{
{
Filename: "file.txt",
OldFilename: "file.txt",
Content: `@@ -1 +1 @@
require.Equal(t, &db.GistCommit{
Commit: &git.Commit{
Hash: commits[1].Hash,
Timestamp: commits[1].Timestamp,
AuthorName: "thomas",
FilesChanged: 3,
Additions: 2,
Deletions: 2,
Files: []git.File{
{
Filename: "file.txt",
OldFilename: "file.txt",
Content: `@@ -1 +1 @@
-hello world
\ No newline at end of file
+updated content
\ No newline at end of file
`,
IsCreated: false,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "ok.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
IsCreated: false,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "ok.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+okay
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "otherfile.txt",
OldFilename: "",
Content: `@@ -1 +0,0 @@
IsCreated: true,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "otherfile.txt",
OldFilename: "",
Content: `@@ -1 +0,0 @@
-other content
\ No newline at end of file
`,
IsCreated: false,
IsDeleted: true,
IsBinary: false,
IsCreated: false,
IsDeleted: true,
IsBinary: false,
},
},
},
}, commits[1])
require.Equal(t, &git.Commit{
Hash: commits[2].Hash,
Timestamp: commits[2].Timestamp,
AuthorName: "thomas",
Changed: "2 files changed, 2 insertions",
Files: []git.File{
{
Filename: "file.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
require.Equal(t, &db.GistCommit{
Commit: &git.Commit{
Hash: commits[2].Hash,
Timestamp: commits[2].Timestamp,
AuthorName: "thomas",
FilesChanged: 2,
Additions: 2,
Files: []git.File{
{
Filename: "file.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+hello world
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "otherfile.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
IsCreated: true,
IsDeleted: false,
IsBinary: false,
}, {
Filename: "otherfile.txt",
OldFilename: "",
Content: `@@ -0,0 +1 @@
+other content
\ No newline at end of file
`,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
IsCreated: true,
IsDeleted: false,
IsBinary: false,
},
},
},
}, commits[2])
@@ -3,6 +3,7 @@ package settings
import (
"strconv"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator"
@@ -17,10 +18,8 @@ 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("apiEnabled", config.C.ApiEnabled)
ctx.SetData("userIsAdmin", user.IsAdmin)
ctx.SetData("settingsHeaderPage", "tokens")
ctx.SetData("htmlTitle", ctx.TrH("settings"))
+53 -83
View File
@@ -23,6 +23,7 @@ import (
"github.com/thomiceli/opengist/internal/web/handlers"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"gorm.io/gorm"
)
func (s *Server) useCustomContext() {
@@ -45,7 +46,11 @@ func (s *Server) registerMiddlewares() {
Getter: middleware.MethodFromForm("_method"),
}))
s.echo.Pre(middleware.RemoveTrailingSlash())
s.echo.Pre(middleware.CORS())
// Expose the API's pagination headers so cross-origin browser clients can
// read them (the CORS spec hides non-safelisted response headers by default).
s.echo.Pre(middleware.CORSWithConfig(middleware.CORSConfig{
ExposeHeaders: []string{"Link", "X-Page", "X-Per-Page", "X-Total", "X-Total-Pages"},
}))
s.echo.Pre(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
LogURI: true, LogStatus: true, LogMethod: true,
LogValuesFunc: func(ctx echo.Context, v middleware.RequestLoggerValues) error {
@@ -85,12 +90,12 @@ func (s *Server) registerMiddlewares() {
}
func (s *Server) errorHandler(err error, ctx echo.Context) {
var httpErr *echo.HTTPError
var httpErr *context.HTTPError
data := ctx.Request().Context().Value(context.DataKeyStr).(echo.Map)
if errors.As(err, &httpErr) {
acceptJson := strings.Contains(ctx.Request().Header.Get("Accept"), "application/json")
data["error"] = err
if acceptJson {
if acceptJson || data["err_render"] == "json" {
if err := ctx.JSON(httpErr.Code, httpErr); err != nil {
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
return
@@ -113,7 +118,7 @@ func (s *Server) errorHandler(err error, ctx echo.Context) {
return
}
log.Error().Err(err).Send()
httpErr = echo.NewHTTPError(http.StatusInternalServerError, err.Error())
httpErr = &context.HTTPError{Message: err.Error(), Code: http.StatusInternalServerError}
data["error"] = httpErr
if err := ctx.Render(500, "error", data); err != nil {
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) {
@@ -243,6 +248,10 @@ func noRouteFound(ctx *context.Context) error {
return ctx.NotFound("Page not found")
}
func noRouteFoundApi(ctx *context.Context) error {
return ctx.ErrorJson(404, "Not found", nil)
}
func locale(next Handler) Handler {
return func(ctx *context.Context) error {
// Check URL arguments
@@ -340,42 +349,15 @@ 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",
})
func apiScope(scope, permission uint) Middleware {
return func(next Handler) Handler {
return func(ctx *context.Context) error {
tok, ok := ctx.GetData("accessToken").(*db.AccessToken)
if !ok || !tok.CheckForPermission(scope, permission) {
return ctx.ErrorJson(403, "token lacks required scope/permission", nil)
}
return next(ctx)
}
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)
}
}
@@ -449,26 +431,14 @@ func gistInit(next Handler) Handler {
ctx.SetData("gist", gist)
if config.C.SshGit {
var sshDomain string
if config.C.SshExternalDomain != "" {
sshDomain = config.C.SshExternalDomain
} else {
sshDomain = strings.Split(ctx.Request().Host, ":")[0]
}
if config.C.SshPort == "22" {
ctx.SetData("sshCloneUrl", sshDomain+":"+userName+"/"+gistName+".git")
} else {
ctx.SetData("sshCloneUrl", "ssh://"+sshDomain+":"+config.C.SshPort+"/"+userName+"/"+gistName+".git")
}
if ssh := gist.SSHCloneURL(ctx.Request().Host); ssh != "" {
ctx.SetData("sshCloneUrl", ssh)
}
baseHttpUrl := ctx.GetData("baseHttpUrl").(string)
if config.C.HttpGit {
ctx.SetData("httpCloneUrl", baseHttpUrl+"/"+userName+"/"+gistName+".git")
if cloneURL := gist.HTTPCloneURL(baseHttpUrl); cloneURL != "" {
ctx.SetData("httpCloneUrl", cloneURL)
}
ctx.SetData("httpCopyUrl", baseHttpUrl+"/"+userName+"/"+gistName)
@@ -529,26 +499,21 @@ 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 {
// apiBindAuth gates /api/v1 on the api.enabled config option and optionally
// resolves a caller identity from the Authorization header. A missing header is
// allowed (the downstream handler/scope middleware decides whether anonymous
// access is OK); a malformed/expired/unknown token is always rejected with 401.
// On success, sets ctx.User and stashes the access token under "accessToken".
func apiBindAuth(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",
})
if !config.C.ApiEnabled {
return ctx.ErrorJson(403, "API is disabled", nil)
}
h := ctx.Request().Header.Get("Authorization")
if h == "" {
return next(ctx)
}
var plain string
switch {
case strings.HasPrefix(h, "Bearer "):
@@ -556,24 +521,18 @@ func apiAuth(next Handler) Handler {
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",
})
return ctx.ErrorJson(401, "Bad crendentials", nil)
}
tok, err := db.GetAccessTokenByToken(plain)
if err != nil {
return ctx.JSON(401, map[string]string{
"error": "invalid token",
"code": "unauthorized",
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.ErrorJson(401, "Bad crendentials", nil)
}
return ctx.ErrorJson(500, "Error fetching access token", err)
}
if tok.IsExpired() {
return ctx.JSON(401, map[string]string{
"error": "token expired",
"code": "unauthorized",
})
return ctx.ErrorJson(401, "Bad crendentials", nil)
}
ctx.User = &tok.User
@@ -588,3 +547,14 @@ func apiAuth(next Handler) Handler {
return next(ctx)
}
}
// apiRequireAuth rejects requests that apiAuth didn't attach a user to. Use on
// routes where anonymous access is not allowed (e.g. /user, write endpoints).
func apiRequireAuth(next Handler) Handler {
return func(ctx *context.Context) error {
if ctx.User == nil {
return ctx.ErrorJson(401, "Requires authentication", nil)
}
return next(ctx)
}
}
+40 -15
View File
@@ -10,10 +10,11 @@ import (
"github.com/labstack/echo/v4"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"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"
"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"
@@ -104,6 +105,44 @@ func (s *Server) registerRoutes() {
r.Any("/init/*", git.GitHttp, gistNewPushSoftInit)
}
apiV1 := r.SubGroup("/api/v1")
{
apiV1.Use(apiBindAuth)
apiV1.GET("/gists", apiv1.ListGists)
apiV1.POST("/gists", apiv1.CreateGist, apiRequireAuth, apiScope(db.ScopeGist, db.ReadWritePermission))
apiV1.GET("/gists/public", apiv1.ListPublicGists)
apiV1.GET("/gists/liked", apiv1.ListLikedGists, apiRequireAuth)
// /starred and /star are GitHub-compat aliases of the canonical
// /liked and /like routes (same handlers); openapi.yaml mentions them
// in a note but gives them no path entries of their own.
apiV1.GET("/gists/starred", apiv1.ListLikedGists, apiRequireAuth)
apiV1.GET("/gists/forked", apiv1.ListForkedGists, apiRequireAuth)
apiV1.GET("/gists/:uuid", apiv1.GetGist)
apiV1.PATCH("/gists/:uuid", apiv1.UpdateGist, apiRequireAuth, apiScope(db.ScopeGist, db.ReadWritePermission))
apiV1.DELETE("/gists/:uuid", apiv1.DeleteGist, apiRequireAuth, apiScope(db.ScopeGist, db.ReadWritePermission))
apiV1.GET("/gists/:uuid/commits", apiv1.ListCommits)
apiV1.GET("/gists/:uuid/:sha", apiv1.GetGistRevision)
apiV1.GET("/gists/:uuid/forks", apiv1.ListForks)
apiV1.POST("/gists/:uuid/forks", apiv1.ForkGist, apiRequireAuth, apiScope(db.ScopeGist, db.ReadWritePermission))
apiV1.GET("/gists/:uuid/like", apiv1.CheckLike, apiRequireAuth)
apiV1.GET("/gists/:uuid/star", apiv1.CheckLike, apiRequireAuth)
apiV1.PUT("/gists/:uuid/like", apiv1.ToggleLike, apiRequireAuth, apiScope(db.ScopeUser, db.ReadWritePermission))
apiV1.PUT("/gists/:uuid/star", apiv1.ToggleLike, apiRequireAuth, apiScope(db.ScopeUser, db.ReadWritePermission))
apiV1.GET("/gists/:uuid/files/:sha/:filename", apiv1.RawFile)
apiV1.GET("/user", apiv1.GetUser, apiRequireAuth, apiScope(db.ScopeUser, db.ReadPermission))
apiV1.PATCH("/user", apiv1.UpdateUser, apiRequireAuth, apiScope(db.ScopeUser, db.ReadWritePermission))
apiV1.GET("/user/:id", apiv1.GetUserByID)
apiV1.GET("/users/:username", apiv1.GetUserByUsername)
apiV1.GET("/users/:username/gists", apiv1.ListUserGists)
apiV1.GET("/users/:username/liked", apiv1.ListUserLikedGists)
apiV1.GET("/users/:username/starred", apiv1.ListUserLikedGists)
apiV1.GET("/users/:username/forked", apiv1.ListUserForkedGists)
apiV1.Any("", noRouteFoundApi)
}
r.GET("/api/v1/openapi.yaml", api.OpenAPISpec)
r.Any("/api/v1/*", noRouteFoundApi)
r.GET("/all", gist.AllGists, checkRequireLogin, setAllGistsMode("all"))
if index.IndexEnabled() {
@@ -164,20 +203,6 @@ 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)
}
+13 -15
View File
@@ -11,11 +11,18 @@ import (
"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 {
// 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))
}
}
// APIRequest is the response-returning variant of APIRequest: tests
// that need to inspect headers (e.g. Link for pagination) use this.
func (s *Server) APIRequest(t *testing.T, method, uri, token string, body interface{}, expectedCode int) (*httptest.ResponseRecorder, []byte) {
var bodyReader *bytes.Reader
switch v := body.(type) {
case nil:
@@ -42,16 +49,7 @@ func (s *Server) APIRequest(t *testing.T, method, uri, token string, body interf
"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))
}
return w, w.Body.Bytes()
}
// CreateAccessToken creates an access token for the currently logged-in user
-11
View File
@@ -143,17 +143,6 @@
</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>
+4 -4
View File
@@ -10,19 +10,19 @@
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-1 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
{{ $user := (index $.emails $commit.AuthorEmail) }}
{{ $user := $commit.User }}
{{ if not (shouldGenerateAvatar $user $.DisableGravatar) }}
<img class="h-5 w-5 rounded-full inline" src="{{ avatarUrl $user $.DisableGravatar }}" {{if $user }}alt="{{ $user.Username }}'s Avatar"{{end}} />
{{ else }}
<svg class="h-5 w-5 rounded-full inline" data-jdenticon-value="{{ $commit.AuthorName }}" width="20" height="20"></svg>
{{ end }}
<span class="font-bold">{{if $user}}<a href="{{ $.c.ExternalUrl }}/{{$user.Username}}" class="text-slate-300 hover:text-slate-300 hover:underline">{{ $commit.AuthorName }}</a>{{else}}{{ $commit.AuthorName }}{{end}}</span> {{ $.locale.Tr "gist.revision.revised" }} <span class="font-bold">{{ $commit.Timestamp | humanTimeDiffStr }}</span>. <a href="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/rev/{{ $commit.Hash }}">{{ $.locale.Tr "gist.revision.go-to-revision" }}</a></h3>
{{ if ne $commit.Changed "" }}
<span class="font-bold">{{if $user}}<a href="{{ $.c.ExternalUrl }}/{{$user.Username}}" class="text-slate-300 hover:text-slate-300 hover:underline">{{$user.Username}}</a>{{else}}{{ $commit.AuthorName }}{{end}}</span> {{ $.locale.Tr "gist.revision.revised" }} <span class="font-bold">{{ $commit.Timestamp | humanTimeDiffStr }}</span>. <a href="{{ $.c.ExternalUrl }}/{{ $.gist.User.Username }}/{{ $.gist.Identifier }}/rev/{{ $commit.Hash }}">{{ $.locale.Tr "gist.revision.go-to-revision" }}</a></h3>
{{ if gt $commit.FilesChanged 0 }}
<p class="text-sm float-right py-2">
<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 inline-flex">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
{{ $commit.Changed }}
{{ $commit.FilesChanged }} {{ if eq $commit.FilesChanged 1 }}file{{ else }}files{{ end }} changed{{ if gt $commit.Additions 0 }}, {{ $commit.Additions }} {{ if eq $commit.Additions 1 }}insertion{{ else }}insertions{{ end }}{{ end }}{{ if gt $commit.Deletions 0 }}, {{ $commit.Deletions }} {{ if eq $commit.Deletions 1 }}deletion{{ else }}deletions{{ end }}{{ end }}
{{ end }}
</p>
</div>
+7 -1
View File
@@ -46,10 +46,10 @@
<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>
<option value="2">{{ .locale.Tr "settings.token-permission-read-write" }}</option>
</select>
</div>
</div>
@@ -87,6 +87,12 @@
{{ if eq .ScopeGist 1 }}{{ $.locale.Tr "settings.token-permission-read" }}{{ end }}
{{ if eq .ScopeGist 2 }}{{ $.locale.Tr "settings.token-permission-read-write" }}{{ end }}
</p>
<p class="text-xs text-gray-500">
{{ $.locale.Tr "settings.token-user-permission" }}:
{{ if eq .ScopeUser 0 }}{{ $.locale.Tr "settings.token-permission-none" }}{{ end }}
{{ if eq .ScopeUser 1 }}{{ $.locale.Tr "settings.token-permission-read" }}{{ end }}
{{ if eq .ScopeUser 2 }}{{ $.locale.Tr "settings.token-permission-read-write" }}{{ end }}
</p>
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.token-created-at" }} <span>{{ .CreatedAt | humanDate }}</span></p>
{{ if eq .ExpiresAt 0 }}
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.token-no-expiration" }}</p>