Add access tokens (#602)

This commit is contained in:
Thomas Miceli
2026-01-27 14:43:12 +08:00
committed by GitHub
parent 1128a81071
commit 5ddea2265d
14 changed files with 772 additions and 4 deletions

View File

@@ -55,6 +55,7 @@ export default defineConfig({
text: 'Usage', base: '/docs/usage', items: [
{text: 'Init via Git', link: '/init-via-git'},
{text: 'Embed Gist', link: '/embed'},
{text: 'Access Tokens', link: '/access-tokens'},
{text: 'Gist as JSON', link: '/gist-json'},
{text: 'Import Gists from Github', link: '/import-from-github-gist'},
{text: 'Git push options', link: '/git-push-options'},

View File

@@ -0,0 +1,26 @@
# Access tokens
Access tokens are used to access your private gists and their raw content. For now, it is the only use while a future API is being developed.
## Creating an access token
To create an access token, follow these steps:
1. Go to Settings
2. Select the "Access Tokens" menu
3. Choose a name for your token, the scope and an expiration date (optional), then click "Create Access Token"
## Using an access token
Once you have created an access token, you can use it to access your private gists with it.
Replace `<token>` with your actual access token in the following examples.
```shell
# Access raw content of a private gist, latest revision for "file.txt". Note: this URL is obtained from the "Raw" button on the gist page.
curl -H "Authorization: Token <token>" \
http://opengist.example.com/user/gist/raw/HEAD/file.txt
# Access the JSON representation of a private gist. See "Gist as JSON" documentation for more details.
curl -H "Authorization: Token <token>" \
http://opengist.example.com/user/gist.json
```

125
internal/db/access_token.go Normal file
View File

@@ -0,0 +1,125 @@
package db
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"time"
)
const (
NoPermission = 0
ReadPermission = 1
ReadWritePermission = 2
)
type AccessToken struct {
ID uint `gorm:"primaryKey"`
Name string
TokenHash string `gorm:"uniqueIndex,size:64"` // SHA-256 hash of the token
CreatedAt int64
ExpiresAt int64 // 0 means no expiration
LastUsedAt int64
UserID uint
User User `validate:"-"`
ScopeGist uint // 0 = none, 1 = read, 2 = read+write
}
// GenerateToken creates a new random token and returns the plain text token.
// The token hash is stored in the AccessToken struct.
// The plain text token should be shown to the user once and never stored.
func (t *AccessToken) GenerateToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
plainToken := "og_" + hex.EncodeToString(bytes)
hash := sha256.Sum256([]byte(plainToken))
t.TokenHash = hex.EncodeToString(hash[:])
return plainToken, nil
}
func GetAccessTokenByID(tokenID uint) (*AccessToken, error) {
token := new(AccessToken)
err := db.
Where("id = ?", tokenID).
First(&token).Error
return token, err
}
func GetAccessTokenByToken(plainToken string) (*AccessToken, error) {
hash := sha256.Sum256([]byte(plainToken))
tokenHash := hex.EncodeToString(hash[:])
token := new(AccessToken)
err := db.
Preload("User").
Where("token_hash = ?", tokenHash).
First(&token).Error
return token, err
}
func GetAccessTokensByUserID(userID uint) ([]*AccessToken, error) {
var tokens []*AccessToken
err := db.
Where("user_id = ?", userID).
Order("created_at desc").
Find(&tokens).Error
return tokens, err
}
func (t *AccessToken) Create() error {
t.CreatedAt = time.Now().Unix()
return db.Create(t).Error
}
func (t *AccessToken) Delete() error {
return db.Delete(t).Error
}
func (t *AccessToken) UpdateLastUsed() error {
return db.Model(t).Update("last_used_at", time.Now().Unix()).Error
}
func (t *AccessToken) IsExpired() bool {
if t.ExpiresAt == 0 {
return false
}
return time.Now().Unix() > t.ExpiresAt
}
func (t *AccessToken) HasGistReadPermission() bool {
return t.ScopeGist >= ReadPermission
}
func (t *AccessToken) HasGistWritePermission() bool {
return t.ScopeGist >= ReadWritePermission
}
// -- DTO -- //
type AccessTokenDTO struct {
Name string `form:"name" validate:"required,max=255"`
ScopeGist uint `form:"scope_gist" validate:"min=0,max=2"`
ExpiresAt string `form:"expires_at"` // empty means no expiration, otherwise date format (YYYY-MM-DD)
}
func (dto *AccessTokenDTO) ToAccessToken() *AccessToken {
var expiresAt int64
if dto.ExpiresAt != "" {
// date input format: 2006-01-02, expires at end of day
if t, err := time.ParseInLocation("2006-01-02", dto.ExpiresAt, time.Local); err == nil {
expiresAt = t.Add(24*time.Hour - time.Second).Unix()
}
}
return &AccessToken{
Name: dto.Name,
ScopeGist: dto.ScopeGist,
ExpiresAt: expiresAt,
}
}

View File

@@ -155,7 +155,7 @@ func Setup(dbUri string) error {
return err
}
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{}); err != nil {
return err
}
@@ -269,5 +269,5 @@ func DeprecationDBFilename() {
}
func TruncateDatabase() error {
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{})
return db.Migrator().DropTable("likes", &User{}, "gists", &SSHKey{}, &AdminSetting{}, &Invitation{}, &WebAuthnCredential{}, &TOTP{}, &GistTopic{}, &GistLanguage{}, &GistInitQueue{}, &AccessToken{})
}

View File

@@ -25,6 +25,7 @@ type User struct {
SSHKeys []SSHKey `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
Liked []Gist `gorm:"many2many:likes;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
WebAuthnCredentials []WebAuthnCredential `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
AccessTokens []AccessToken `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;foreignKey:UserID"`
}
func (user *User) BeforeDelete(tx *gorm.DB) error {
@@ -72,6 +73,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
return err
}
err = tx.Where("user_id = ?", user.ID).Delete(&AccessToken{}).Error
if err != nil {
return err
}
err = tx.Where("user_id = ?", user.ID).Delete(&Gist{}).Error
if err != nil {
return err

View File

@@ -157,6 +157,7 @@ settings.password-label-title: Password
settings.header.account: Account
settings.header.mfa: MFA
settings.header.ssh: SSH
settings.header.tokens: Access tokens
settings.header.style: Style
settings.style.gist-code: Gist code
settings.style.no-soft-wrap: No Soft Wrap
@@ -169,6 +170,26 @@ settings.style.theme: Theme
settings.style.theme-light: Light
settings.style.theme-dark: Dark
settings.style.theme-auto: Auto
settings.create-token: Create access token
settings.create-token-help: Access tokens can be used to access the API
settings.token-name: Name
settings.token-permissions: Permissions
settings.token-gist-permission: Gists
settings.token-permission-none: No access
settings.token-permission-read: Read
settings.token-permission-read-write: Read & Write
settings.delete-token: Delete
settings.delete-token-confirm: Confirm deletion of access token
settings.token-created-at: Created
settings.token-never-used: Never used
settings.token-last-used: Last used
settings.token-expiration: Expiration
settings.token-expiration-help: Leave empty for no expiration
settings.token-expires-at: Expires
settings.token-no-expiration: No expiration
settings.token-expired: expired
settings.token-created: Token created, make sure to copy it now, you won't be able to see it again!
settings.token-deleted: Access token deleted
auth.signup-disabled: Administrator has disabled signing up
auth.login: Login
@@ -344,4 +365,4 @@ validation.not-enough: Not enough %s
validation.invalid: Invalid %s
validation.invalid-gist-topics: Invalid gist topics, they must start with a letter or number, consist of 50 characters or less, and can include hyphens
html.title.admin-panel: Admin panel
html.title.admin-panel: Admin panel

View File

@@ -0,0 +1,75 @@
package settings
import (
"strconv"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/i18n"
"github.com/thomiceli/opengist/internal/validator"
"github.com/thomiceli/opengist/internal/web/context"
)
func AccessTokens(ctx *context.Context) error {
user := ctx.User
tokens, err := db.GetAccessTokensByUserID(user.ID)
if err != nil {
return ctx.ErrorRes(500, "Cannot get access tokens", err)
}
ctx.SetData("accessTokens", tokens)
ctx.SetData("settingsHeaderPage", "tokens")
ctx.SetData("htmlTitle", ctx.TrH("settings"))
return ctx.Html("settings_tokens.html")
}
func AccessTokensProcess(ctx *context.Context) error {
user := ctx.User
dto := new(db.AccessTokenDTO)
if err := ctx.Bind(dto); err != nil {
return ctx.ErrorRes(400, ctx.Tr("error.cannot-bind-data"), err)
}
if err := ctx.Validate(dto); err != nil {
ctx.AddFlash(validator.ValidationMessages(&err, ctx.GetData("locale").(*i18n.Locale)), "error")
return ctx.RedirectTo("/settings/access-tokens")
}
token := dto.ToAccessToken()
token.UserID = user.ID
plainToken, err := token.GenerateToken()
if err != nil {
return ctx.ErrorRes(500, "Cannot generate token", err)
}
if err := token.Create(); err != nil {
return ctx.ErrorRes(500, "Cannot create access token", err)
}
// Show the token once to the user
ctx.AddFlash(ctx.Tr("settings.token-created"), "success")
ctx.AddFlash(plainToken, "success")
return ctx.RedirectTo("/settings/access-tokens")
}
func AccessTokensDelete(ctx *context.Context) error {
user := ctx.User
tokenID, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
return ctx.RedirectTo("/settings/access-tokens")
}
token, err := db.GetAccessTokenByID(uint(tokenID))
if err != nil || token.UserID != user.ID {
return ctx.RedirectTo("/settings/access-tokens")
}
if err := token.Delete(); err != nil {
return ctx.ErrorRes(500, "Cannot delete access token", err)
}
ctx.AddFlash(ctx.Tr("settings.token-deleted"), "success")
return ctx.RedirectTo("/settings/access-tokens")
}

View File

@@ -326,6 +326,40 @@ func loadSettings(ctx *context.Context) error {
return nil
}
// getUserByToken checks the Authorization header for token-based auth.
// Expects format: Authorization: Token <token>
// Returns the user if the token is valid and has gist read permission, nil otherwise.
func getUserByToken(ctx *context.Context) *db.User {
authHeader := ctx.Request().Header.Get("Authorization")
if authHeader == "" {
return nil
}
if !strings.HasPrefix(authHeader, "Token ") {
return nil
}
plainToken := strings.TrimPrefix(authHeader, "Token ")
accessToken, err := db.GetAccessTokenByToken(plainToken)
if err != nil {
return nil
}
if accessToken.IsExpired() {
return nil
}
if !accessToken.HasGistReadPermission() {
return nil
}
// Update last used timestamp
_ = accessToken.UpdateLastUsed()
return &accessToken.User
}
func gistInit(next Handler) Handler {
return func(ctx *context.Context) error {
currUser := ctx.User
@@ -352,7 +386,12 @@ func gistInit(next Handler) Handler {
if gist.Private == db.PrivateVisibility {
if currUser == nil || currUser.ID != gist.UserID {
return ctx.NotFound("Gist not found")
// Check for token-based auth via Authorization header
if tokenUser := getUserByToken(ctx); tokenUser != nil && tokenUser.ID == gist.UserID {
// Token is valid and belongs to gist owner, allow access
} else {
return ctx.NotFound("Gist not found")
}
}
}

View File

@@ -192,6 +192,9 @@ func (s *Server) setFuncMap() {
"humanDate": func(t int64) string {
return time.Unix(t, 0).Format("02/01/2006 15:04")
},
"humanDateOnly": func(t int64) string {
return time.Unix(t, 0).Format("02/01/2006")
},
"mainTheme": func(theme *db.UserStyleDTO) string {
if theme == nil {
return "auto"

View File

@@ -62,6 +62,9 @@ func (s *Server) registerRoutes() {
sA.DELETE("/account", settings.AccountDeleteProcess)
sA.POST("/ssh-keys", settings.SshKeysProcess)
sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete)
sA.GET("/access-tokens", settings.AccessTokens)
sA.POST("/access-tokens", settings.AccessTokensProcess)
sA.DELETE("/access-tokens/:id", settings.AccessTokensDelete)
sA.DELETE("/passkeys/:id", settings.PasskeyDelete)
sA.PUT("/password", settings.PasswordProcess)
sA.PUT("/username", settings.UsernameProcess)

View File

@@ -0,0 +1,361 @@
package test
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/thomiceli/opengist/internal/db"
)
func TestAccessTokensCRUD(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register and login
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
// Access tokens page requires login
s.sessionCookie = ""
err := s.Request("GET", "/settings/access-tokens", nil, 302)
require.NoError(t, err)
login(t, s, user1)
// Access tokens page
err = s.Request("GET", "/settings/access-tokens", nil, 200)
require.NoError(t, err)
// Create a token with read permission
tokenDTO := db.AccessTokenDTO{
Name: "test-token",
ScopeGist: db.ReadPermission,
}
err = s.Request("POST", "/settings/access-tokens", tokenDTO, 302)
require.NoError(t, err)
// Verify token was created in database
tokens, err := db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, "test-token", tokens[0].Name)
require.Equal(t, uint(db.ReadPermission), tokens[0].ScopeGist)
require.Equal(t, int64(0), tokens[0].ExpiresAt)
// Create another token with expiration
tomorrow := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
tokenDTO2 := db.AccessTokenDTO{
Name: "expiring-token",
ScopeGist: db.ReadWritePermission,
ExpiresAt: tomorrow,
}
err = s.Request("POST", "/settings/access-tokens", tokenDTO2, 302)
require.NoError(t, err)
tokens, err = db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 2)
// Delete the first token
err = s.Request("DELETE", "/settings/access-tokens/1", nil, 302)
require.NoError(t, err)
tokens, err = db.GetAccessTokensByUserID(1)
require.NoError(t, err)
require.Len(t, tokens, 1)
require.Equal(t, "expiring-token", tokens[0].Name)
}
func TestAccessTokenPrivateGistAccess(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register user and create a private gist
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"secret.txt"},
Content: []string{"secret content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
// Create access token with read permission
token := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
err = token.Create()
require.NoError(t, err)
// Clear session - simulate unauthenticated request
s.sessionCookie = ""
// Without token, private gist should return 404
err = s.Request("GET", "/thomas/"+gist1db.Uuid, nil, 404)
require.NoError(t, err)
// With valid token, private gist should be accessible
headers := map[string]string{"Authorization": "Token " + plainToken}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers)
require.NoError(t, err)
// Raw content should also be accessible with token
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+"/raw/HEAD/secret.txt", nil, 200, headers)
require.NoError(t, err)
// JSON endpoint should also be accessible with token
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid+".json", nil, 200, headers)
require.NoError(t, err)
// Invalid token should not work
invalidHeaders := map[string]string{"Authorization": "Token invalid_token"}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, invalidHeaders)
require.NoError(t, err)
}
func TestAccessTokenPermissions(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register user and create a private gist
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
// Create token with NO permission
noPermToken := &db.AccessToken{
Name: "no-perm-token",
UserID: 1,
ScopeGist: db.NoPermission,
}
noPermPlain, err := noPermToken.GenerateToken()
require.NoError(t, err)
err = noPermToken.Create()
require.NoError(t, err)
// Create token with READ permission
readToken := &db.AccessToken{
Name: "read-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
readPlain, err := readToken.GenerateToken()
require.NoError(t, err)
err = readToken.Create()
require.NoError(t, err)
s.sessionCookie = ""
// No permission token should not grant access
noPermHeaders := map[string]string{"Authorization": "Token " + noPermPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, noPermHeaders)
require.NoError(t, err)
// Read permission token should grant access
readHeaders := map[string]string{"Authorization": "Token " + readPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, readHeaders)
require.NoError(t, err)
}
func TestAccessTokenExpiration(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register user and create a private gist
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
// Create an expired token
expiredToken := &db.AccessToken{
Name: "expired-token",
UserID: 1,
ScopeGist: db.ReadPermission,
ExpiresAt: time.Now().Add(-24 * time.Hour).Unix(), // Expired yesterday
}
expiredPlain, err := expiredToken.GenerateToken()
require.NoError(t, err)
err = expiredToken.Create()
require.NoError(t, err)
// Create a valid (non-expired) token
validToken := &db.AccessToken{
Name: "valid-token",
UserID: 1,
ScopeGist: db.ReadPermission,
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), // Expires tomorrow
}
validPlain, err := validToken.GenerateToken()
require.NoError(t, err)
err = validToken.Create()
require.NoError(t, err)
s.sessionCookie = ""
// Expired token should not grant access
expiredHeaders := map[string]string{"Authorization": "Token " + expiredPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, expiredHeaders)
require.NoError(t, err)
// Valid token should grant access
validHeaders := map[string]string{"Authorization": "Token " + validPlain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, validHeaders)
require.NoError(t, err)
}
func TestAccessTokenWrongUser(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register two users
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
// Create a private gist for user1
gist1 := db.GistDTO{
Title: "thomas-private-gist",
Description: "thomas private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
s.sessionCookie = ""
user2 := db.UserDTO{Username: "kaguya", Password: "kaguya"}
register(t, s, user2)
// Create token for user2
user2Token := &db.AccessToken{
Name: "kaguya-token",
UserID: 2,
ScopeGist: db.ReadPermission,
}
user2Plain, err := user2Token.GenerateToken()
require.NoError(t, err)
err = user2Token.Create()
require.NoError(t, err)
s.sessionCookie = ""
// User2's token should NOT grant access to user1's private gist
user2Headers := map[string]string{"Authorization": "Token " + user2Plain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 404, user2Headers)
require.NoError(t, err)
// Create token for user1
user1Token := &db.AccessToken{
Name: "thomas-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
user1Plain, err := user1Token.GenerateToken()
require.NoError(t, err)
err = user1Token.Create()
require.NoError(t, err)
// User1's token SHOULD grant access to user1's private gist
user1Headers := map[string]string{"Authorization": "Token " + user1Plain}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, user1Headers)
require.NoError(t, err)
}
func TestAccessTokenLastUsedUpdate(t *testing.T) {
s := Setup(t)
defer Teardown(t, s)
// Register user and create a private gist
user1 := db.UserDTO{Username: "thomas", Password: "thomas"}
register(t, s, user1)
gist1 := db.GistDTO{
Title: "private-gist",
Description: "my private gist",
VisibilityDTO: db.VisibilityDTO{
Private: db.PrivateVisibility,
},
Name: []string{"file.txt"},
Content: []string{"content"},
}
err := s.Request("POST", "/", gist1, 302)
require.NoError(t, err)
gist1db, err := db.GetGistByID("1")
require.NoError(t, err)
// Create token
token := &db.AccessToken{
Name: "test-token",
UserID: 1,
ScopeGist: db.ReadPermission,
}
plainToken, err := token.GenerateToken()
require.NoError(t, err)
err = token.Create()
require.NoError(t, err)
// Verify LastUsedAt is 0 initially
tokenFromDB, err := db.GetAccessTokenByID(token.ID)
require.NoError(t, err)
require.Equal(t, int64(0), tokenFromDB.LastUsedAt)
s.sessionCookie = ""
// Use the token
headers := map[string]string{"Authorization": "Token " + plainToken}
err = s.RequestWithHeaders("GET", "/thomas/"+gist1db.Uuid, nil, 200, headers)
require.NoError(t, err)
// Verify LastUsedAt was updated
tokenFromDB, err = db.GetAccessTokenByID(token.ID)
require.NoError(t, err)
require.NotEqual(t, int64(0), tokenFromDB.LastUsedAt)
}

View File

@@ -51,6 +51,10 @@ func (s *TestServer) stop() {
}
func (s *TestServer) Request(method, uri string, data interface{}, expectedCode int, responsePtr ...*http.Response) error {
return s.RequestWithHeaders(method, uri, data, expectedCode, nil, responsePtr...)
}
func (s *TestServer) RequestWithHeaders(method, uri string, data interface{}, expectedCode int, headers map[string]string, responsePtr ...*http.Response) error {
var bodyReader io.Reader
if method == http.MethodPost || method == http.MethodPut {
values := structToURLValues(data)
@@ -64,6 +68,10 @@ func (s *TestServer) Request(method, uri string, data interface{}, expectedCode
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for key, value := range headers {
req.Header.Set(key, value)
}
if s.sessionCookie != "" {
req.AddCookie(&http.Cookie{Name: "session", Value: s.sessionCookie})
}
@@ -120,6 +128,9 @@ func structToURLValues(s interface{}) url.Values {
if field.Type.Kind() == reflect.Int {
fieldValue := rValue.Field(i).Int()
v.Add(tag, strconv.FormatInt(fieldValue, 10))
} else if field.Type.Kind() == reflect.Uint {
fieldValue := rValue.Field(i).Uint()
v.Add(tag, strconv.FormatUint(fieldValue, 10))
} else if field.Type.Kind() == reflect.Slice {
fieldValue := rValue.Field(i).Interface().([]string)
for _, va := range fieldValue {

View File

@@ -15,6 +15,8 @@
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.mfa" }}</a>
<a href="{{ $.c.ExternalUrl }}/settings/ssh" class="{{ if eq .settingsHeaderPage "ssh" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.ssh" }}</a>
<a href="{{ $.c.ExternalUrl }}/settings/access-tokens" class="{{ if eq .settingsHeaderPage "tokens" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.tokens" }}</a>
<a href="{{ $.c.ExternalUrl }}/settings/style" class="{{ if eq .settingsHeaderPage "style" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "settings.header.style" }}</a>
</nav>

95
templates/pages/settings_tokens.html vendored Normal file
View File

@@ -0,0 +1,95 @@
{{ template "header" .}}
{{ template "settings_header" .}}
<div class="relative mx-auto max-w-160 space-y-8">
<div class="sm:grid grid-cols-2 gap-x-4 md:gap-x-8">
<div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
{{ .locale.Tr "settings.create-token" }}
</h2>
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
{{ .locale.Tr "settings.create-token-help" }}
</h3>
<form class="space-y-6" action="{{ $.c.ExternalUrl }}/settings/access-tokens" method="post">
<div>
<label for="name" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.token-name" }} </label>
<div class="mt-1">
<input id="name" name="name" type="text" required autocomplete="off" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"> {{ .locale.Tr "settings.token-permissions" }} </label>
<div class="space-y-2">
<label class="block text-sm text-slate-600 dark:text-slate-400">{{ .locale.Tr "settings.token-gist-permission" }}</label>
<select name="scope_gist" 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>
<div class="mt-4">
<label for="expires_at" class="block text-sm font-medium text-slate-700 dark:text-slate-300"> {{ .locale.Tr "settings.token-expiration" }} </label>
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-2">
{{ .locale.Tr "settings.token-expiration-help" }}
</h3>
<div class="mt-1">
<input id="expires_at" name="expires_at" type="date" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "settings.create-token" }}</button>
{{ .csrfHtml }}
</form>
</div>
</div>
<div>
<div class="mt-6 flow-root">
<ul role="list" class="-my-5 divide-y divide-gray-300 dark:divide-gray-700 list-none">
{{ if .accessTokens }}
{{ range $token := .accessTokens }}
<li class="py-5">
<div class="inline-flex">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-12 h-12 mr-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.864 4.243A7.5 7.5 0 0 1 19.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 0 0 4.5 10.5a7.464 7.464 0 0 1-1.15 3.993m1.989 3.559A11.209 11.209 0 0 0 8.25 10.5a3.75 3.75 0 1 1 7.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 0 1-3.386 9.522m6.772-6.521a6.02 6.02 0 0 1-2.122 4.256" />
</svg>
<div>
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-300">{{ .Name }}</h3>
<p class="text-xs text-gray-500">
{{ $.locale.Tr "settings.token-gist-permission" }}:
{{ if eq .ScopeGist 0 }}{{ $.locale.Tr "settings.token-permission-none" }}{{ end }}
{{ 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 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>
{{ else }}
<p class="text-xs {{ if .IsExpired }}text-rose-500{{ else }}text-gray-500{{ end }} line-clamp-2">{{ $.locale.Tr "settings.token-expires-at" }} <span>{{ .ExpiresAt | humanDateOnly }}</span>{{ if .IsExpired }} ({{ $.locale.Tr "settings.token-expired" }}){{ end }}</p>
{{ end }}
{{ if eq .LastUsedAt 0 }}
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.token-never-used" }}</p>
{{ else }}
<p class="text-xs text-gray-500 line-clamp-2">{{ $.locale.Tr "settings.token-last-used" }} <span>{{ .LastUsedAt | humanTimeDiff }}</span></p>
{{ end }}
</div>
<form action="{{ $.c.ExternalUrl }}/settings/access-tokens/{{.ID}}" method="post" class="inline-block">
<input type="hidden" name="_method" value="DELETE">
{{ $.csrfHtml }}
<button type="submit" onclick="return confirm('{{ $.locale.Tr "settings.delete-token-confirm" }}')" class="align-middle items-center leading-2 ml-2 px-3 py-1 border border-transparent border-gray-200 dark:border-gray-700 text-xs font-medium rounded-md shadow-sm text-white dark:text-white bg-rose-600 hover:bg-rose-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-rose-500">{{ $.locale.Tr "settings.delete-token" }}</button>
</form>
</div>
</li>
{{ end }}
{{ end }}
</ul>
</div>
</div>
</div>
</div>
{{ template "settings_footer" .}}
{{ template "footer" .}}