mirror of
https://github.com/thomiceli/opengist.git
synced 2026-06-23 04:10:18 +00:00
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
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -170,7 +170,6 @@ func Setup(dbUri string) error {
|
||||
SettingAllowGistsWithoutLogin: "0",
|
||||
SettingDisableLoginForm: "0",
|
||||
SettingDisableGravatar: "0",
|
||||
SettingApiEnabled: "0",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+254
-37
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: 读写
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, ", "))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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, 4–40 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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Vendored
-11
@@ -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>
|
||||
|
||||
Vendored
+4
-4
@@ -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>
|
||||
|
||||
Vendored
+7
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user