mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
This commit also adds an /api/v1/oauth/logout endpoint that allows clients to delete their sessions (access tokens) as needed. Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -77,3 +77,20 @@ func (acl ACL) AllowAll(resource Resource, role Role, perms Permissions) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Resources returns the resources specified in the ACL.
|
||||
func (acl ACL) Resources() (result []string) {
|
||||
if len(acl) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
result = make([]string, 0, len(acl))
|
||||
|
||||
for resource := range acl {
|
||||
if resource != ResourceDefault && resource.String() != "" {
|
||||
result = append(result, resource.String())
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -120,3 +120,10 @@ func TestACL_DenyAll(t *testing.T) {
|
||||
assert.False(t, Resources.DenyAll(ResourceFiles, RoleAdmin, Permissions{FullAccess, AccessShared, ActionView}))
|
||||
})
|
||||
}
|
||||
|
||||
func TestACL_Resources(t *testing.T) {
|
||||
t.Run("Resources", func(t *testing.T) {
|
||||
result := Resources.Resources()
|
||||
assert.Len(t, result, 21)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -250,7 +250,8 @@ func DeleteAlbum(router *gin.RouterGroup) {
|
||||
|
||||
// LikeAlbum sets the favorite flag for an album.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Album UID
|
||||
//
|
||||
// POST /api/v1/albums/:uid/like
|
||||
@@ -297,7 +298,8 @@ func LikeAlbum(router *gin.RouterGroup) {
|
||||
|
||||
// DislikeAlbum removes the favorite flag from an album.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Album UID
|
||||
//
|
||||
// DELETE /api/v1/albums/:uid/like
|
||||
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
StatusUpdated Event = "updated"
|
||||
StatusDeleted Event = "deleted"
|
||||
StatusSuccess Event = "success"
|
||||
StatusFailed Event = "failed"
|
||||
)
|
||||
|
||||
// String returns the event type as string.
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/server/limiter"
|
||||
"github.com/photoprism/photoprism/pkg/header"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type CloseableResponseRecorder struct {
|
||||
@@ -31,15 +31,22 @@ func (r *CloseableResponseRecorder) closeClient() {
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Init test logger.
|
||||
log = logrus.StandardLogger()
|
||||
log.SetLevel(logrus.TraceLevel)
|
||||
event.AuditLog = log
|
||||
|
||||
// Init test config.
|
||||
c := config.TestConfig()
|
||||
get.SetConfig(c)
|
||||
|
||||
// Increase login rate limit for testing.
|
||||
limiter.Login = limiter.NewLimit(1, 10000)
|
||||
|
||||
// Run unit tests.
|
||||
code := m.Run()
|
||||
|
||||
// Close database connection.
|
||||
_ = c.CloseDb()
|
||||
|
||||
os.Exit(code)
|
||||
|
||||
@@ -23,7 +23,8 @@ const (
|
||||
|
||||
// AlbumCover returns an album cover image.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string album uid
|
||||
// - token: string security token (see config)
|
||||
// - size: string thumb type, see photoprism.ThumbnailTypes
|
||||
@@ -135,7 +136,8 @@ func AlbumCover(router *gin.RouterGroup) {
|
||||
|
||||
// LabelCover returns a label cover image.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string label uid
|
||||
// - token: string security token (see config)
|
||||
// - size: string thumb type, see photoprism.ThumbnailTypes
|
||||
|
||||
@@ -35,7 +35,8 @@ func DownloadName(c *gin.Context) customize.DownloadName {
|
||||
|
||||
// GetDownload returns the raw file data.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - hash: string The file hash as returned by the files/photos endpoint
|
||||
//
|
||||
// GET /api/v1/dl/:hash
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestSendFeedback(t *testing.T) {
|
||||
t.Run("not available in public mode", func(t *testing.T) {
|
||||
t.Run("PublicMode", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
SendFeedback(router)
|
||||
r := PerformRequestWithBody(app, "POST", "/api/v1/feedback", `{"Subject": "Send feedback from unit test", "Message": "Test message"}`)
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
|
||||
// DeleteFile removes a file from storage.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Photo UID as returned by the API
|
||||
// - file_uid: string File UID as returned by the API
|
||||
//
|
||||
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
|
||||
// ChangeFileOrientation changes the orientation of a file.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Photo UID as returned by the API
|
||||
// - file_uid: string File UID as returned by the API
|
||||
//
|
||||
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
|
||||
// GetFile returns file details as JSON.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - hash (string) SHA-1 hash of the file
|
||||
//
|
||||
// GET /api/v1/files/:hash
|
||||
|
||||
@@ -21,7 +21,8 @@ const (
|
||||
|
||||
// FolderCover returns a folder cover image.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string folder uid
|
||||
// - token: string url security token, see config
|
||||
// - size: string thumb type, see thumb.Sizes
|
||||
|
||||
@@ -54,7 +54,8 @@ func UpdateLabel(router *gin.RouterGroup) {
|
||||
|
||||
// LikeLabel flags a label as favorite.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Label UID
|
||||
//
|
||||
// POST /api/v1/labels/:uid/like
|
||||
@@ -93,7 +94,8 @@ func LikeLabel(router *gin.RouterGroup) {
|
||||
|
||||
// DislikeLabel removes the favorite flag from a label.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Label UID
|
||||
//
|
||||
// DELETE /api/v1/labels/:uid/like
|
||||
|
||||
@@ -174,7 +174,8 @@ func CreateMarker(router *gin.RouterGroup) {
|
||||
|
||||
// UpdateMarker updates an existing file area marker to assign faces or other subjects.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - marker_uid: string Marker UID as returned by the API
|
||||
//
|
||||
// PUT /api/v1/markers/:marker_uid
|
||||
@@ -265,7 +266,8 @@ func UpdateMarker(router *gin.RouterGroup) {
|
||||
|
||||
// ClearMarkerSubject removes an existing marker subject association.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Photo UID as returned by the API
|
||||
// - file_uid: string File UID as returned by the API
|
||||
// - id: int Marker ID as returned by the API
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
|
||||
// AddPhotoLabel adds a label to a photo.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string PhotoUID as returned by the API
|
||||
//
|
||||
// POST /api/v1/photos/:uid/label
|
||||
@@ -93,7 +94,8 @@ func AddPhotoLabel(router *gin.RouterGroup) {
|
||||
|
||||
// RemovePhotoLabel removes a label from a photo.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string PhotoUID as returned by the API
|
||||
// - id: int LabelId as returned by the API
|
||||
//
|
||||
@@ -158,7 +160,8 @@ func RemovePhotoLabel(router *gin.RouterGroup) {
|
||||
|
||||
// UpdatePhotoLabel changes a photo labels.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string PhotoUID as returned by the API
|
||||
// - id: int LabelId as returned by the API
|
||||
//
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
|
||||
// PhotoUnstack removes a file from an existing photo stack.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Photo UID as returned by the API
|
||||
// - file_uid: string File UID as returned by the API
|
||||
//
|
||||
|
||||
@@ -38,7 +38,8 @@ func SavePhotoAsYaml(p entity.Photo) {
|
||||
|
||||
// GetPhoto returns photo details as JSON.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid (string) PhotoUID as returned by the API
|
||||
//
|
||||
// GET /api/v1/photos/:uid
|
||||
@@ -124,7 +125,9 @@ func UpdatePhoto(router *gin.RouterGroup) {
|
||||
// GetPhotoDownload returns the primary file matching that belongs to the photo.
|
||||
//
|
||||
// Route :GET /api/v1/photos/:uid/dl
|
||||
// Request Parameters:
|
||||
//
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid (string) PhotoUID as returned by the API
|
||||
func GetPhotoDownload(router *gin.RouterGroup) {
|
||||
router.GET("/photos/:uid/dl", func(c *gin.Context) {
|
||||
@@ -158,7 +161,8 @@ func GetPhotoDownload(router *gin.RouterGroup) {
|
||||
|
||||
// GetPhotoYaml returns photo details as YAML.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string PhotoUID as returned by the API
|
||||
//
|
||||
// GET /api/v1/photos/:uid/yaml
|
||||
@@ -194,7 +198,8 @@ func GetPhotoYaml(router *gin.RouterGroup) {
|
||||
|
||||
// ApprovePhoto marks a photo in review as approved.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string PhotoUID as returned by the API
|
||||
//
|
||||
// POST /api/v1/photos/:uid/approve
|
||||
@@ -230,7 +235,8 @@ func ApprovePhoto(router *gin.RouterGroup) {
|
||||
|
||||
// PhotoPrimary sets the primary file for a photo.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string PhotoUID as returned by the API
|
||||
// - file_uid: string File UID as returned by the API
|
||||
//
|
||||
|
||||
@@ -9,63 +9,71 @@ import (
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/i18n"
|
||||
"github.com/photoprism/photoprism/internal/session"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// DeleteSession deletes an existing client session (logout).
|
||||
//
|
||||
// DELETE /api/v1/session
|
||||
// DELETE /api/v1/session/:id
|
||||
func DeleteSession(router *gin.RouterGroup) {
|
||||
router.DELETE("/session/:id", func(c *gin.Context) {
|
||||
deleteSessionHandler := func(c *gin.Context) {
|
||||
// Abort if running in public mode.
|
||||
if get.Config().Public() {
|
||||
// Return JSON response for confirmation.
|
||||
c.JSON(http.StatusOK, DeleteSessionResponse(session.PublicID))
|
||||
return
|
||||
}
|
||||
|
||||
id := clean.ID(c.Param("id"))
|
||||
|
||||
// Abort if authentication token is missing or empty.
|
||||
if id == "" {
|
||||
AbortBadRequest(c)
|
||||
// Get client IP address for logs and rate limiting checks.
|
||||
clientIP := ClientIP(c)
|
||||
|
||||
// Find session based on auth token.
|
||||
sess, err := entity.FindSession(rnd.SessionID(AuthToken(c)))
|
||||
|
||||
if err != nil || sess == nil {
|
||||
Abort(c, http.StatusUnauthorized, i18n.ErrUnauthorized)
|
||||
return
|
||||
} else if get.Config().Public() {
|
||||
// Return JSON response for confirmation.
|
||||
c.JSON(http.StatusOK, DeleteSessionResponse(id))
|
||||
} else if sess.Abort(c) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only admins may delete other sessions by reference id.
|
||||
// Only admins may delete other sessions by ref id.
|
||||
if rnd.IsRefID(id) {
|
||||
if s := Session(AuthToken(c)); s == nil {
|
||||
entity.SessionStatusUnauthorized().Abort(c)
|
||||
return
|
||||
} else if s.Abort(c) {
|
||||
return
|
||||
} else if !acl.Resources.AllowAll(acl.ResourceUsers, s.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
|
||||
s.Abort(c)
|
||||
return
|
||||
} else if ref := entity.FindSessionByRefID(id); ref == nil {
|
||||
AbortNotFound(c)
|
||||
return
|
||||
} else {
|
||||
id = ref.ID
|
||||
}
|
||||
} else {
|
||||
if s := Session(AuthToken(c)); s == nil {
|
||||
entity.SessionStatusUnauthorized().Abort(c)
|
||||
return
|
||||
} else if s.Abort(c) {
|
||||
return
|
||||
} else if s.ID != id {
|
||||
entity.SessionStatusForbidden().Abort(c)
|
||||
if !acl.Resources.AllowAll(acl.ResourceUsers, sess.User().AclRole(), acl.Permissions{acl.AccessAll, acl.ActionManage}) {
|
||||
event.AuditErr([]string{clientIP, "session %s", "delete session %s as %s", "denied"}, sess.RefID, id, sess.User().AclRole())
|
||||
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
event.AuditInfo([]string{clientIP, "session %s", "delete session %s as %s", "granted"}, sess.RefID, id, sess.User().AclRole())
|
||||
|
||||
if sess = entity.FindSessionByRefID(id); sess == nil {
|
||||
Abort(c, http.StatusNotFound, i18n.ErrNotFound)
|
||||
return
|
||||
}
|
||||
} else if id != "" && sess.ID != id {
|
||||
event.AuditWarn([]string{clientIP, "session %s", "delete session as %s", "ids do not match"}, sess.RefID, sess.User().AclRole())
|
||||
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete session cache and database record.
|
||||
if err := get.Session().Delete(id); err != nil {
|
||||
event.AuditErr([]string{ClientIP(c), "session %s"}, err)
|
||||
if err = sess.Delete(); err != nil {
|
||||
event.AuditErr([]string{clientIP, "session %s", "delete session as %s", "%s"}, sess.RefID, sess.User().AclRole(), err)
|
||||
} else {
|
||||
event.AuditDebug([]string{ClientIP(c), "session deleted"})
|
||||
event.AuditDebug([]string{clientIP, "session %s", "deleted"}, sess.RefID)
|
||||
}
|
||||
|
||||
// Return JSON response for confirmation.
|
||||
c.JSON(http.StatusOK, DeleteSessionResponse(id))
|
||||
})
|
||||
c.JSON(http.StatusOK, DeleteSessionResponse(sess.ID))
|
||||
}
|
||||
|
||||
router.DELETE("/session", deleteSessionHandler)
|
||||
router.DELETE("/session/:id", deleteSessionHandler)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
//
|
||||
// GET /api/v1/session/:id
|
||||
func GetSession(router *gin.RouterGroup) {
|
||||
router.GET("/session/:id", func(c *gin.Context) {
|
||||
getSessionHandler := func(c *gin.Context) {
|
||||
id := clean.ID(c.Param("id"))
|
||||
|
||||
// Check authentication token.
|
||||
@@ -60,5 +60,7 @@ func GetSession(router *gin.RouterGroup) {
|
||||
|
||||
// Return JSON response.
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
router.GET("/session/:id", getSessionHandler)
|
||||
}
|
||||
|
||||
@@ -6,27 +6,37 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/photoprism/photoprism/pkg/authn"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/acl"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/internal/event"
|
||||
"github.com/photoprism/photoprism/internal/form"
|
||||
"github.com/photoprism/photoprism/internal/get"
|
||||
"github.com/photoprism/photoprism/internal/i18n"
|
||||
"github.com/photoprism/photoprism/internal/server/limiter"
|
||||
"github.com/photoprism/photoprism/pkg/clean"
|
||||
"github.com/photoprism/photoprism/pkg/rnd"
|
||||
)
|
||||
|
||||
// CreateOauthToken creates a new access token for clients that
|
||||
// CreateOAuthToken creates a new access token for clients that
|
||||
// authenticate with valid OAuth2 client credentials.
|
||||
//
|
||||
// POST /api/v1/oauth/token
|
||||
func CreateOauthToken(router *gin.RouterGroup) {
|
||||
func CreateOAuthToken(router *gin.RouterGroup) {
|
||||
router.POST("/oauth/token", func(c *gin.Context) {
|
||||
// Get client IP address for logs and rate limiting checks.
|
||||
clientIP := ClientIP(c)
|
||||
|
||||
// Abort if running in public mode.
|
||||
if get.Config().Public() {
|
||||
event.AuditErr([]string{clientIP, "create client session in public mode", "denied"})
|
||||
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// client_id, client_secret
|
||||
var err error
|
||||
var f form.ClientCredentials
|
||||
|
||||
// Get client IP address for logs and rate limiting checks.
|
||||
clientIP := ClientIP(c)
|
||||
|
||||
// Allow authentication with basic auth and form values.
|
||||
if clientId, clientSecret, _ := BasicAuth(c); clientId != "" && clientSecret != "" {
|
||||
f.ClientID = clientId
|
||||
@@ -55,20 +65,20 @@ func CreateOauthToken(router *gin.RouterGroup) {
|
||||
|
||||
// Abort if the client ID or secret are invalid.
|
||||
if client == nil {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create access token", "invalid client id"}, f.ClientID)
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create session", "invalid client_id"}, f.ClientID)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
limiter.Login.Reserve(clientIP)
|
||||
return
|
||||
} else if !client.AuthEnabled {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create access token", "running in public mode"}, f.ClientID)
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create session", "disabled"}, f.ClientID)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
} else if client.AuthMethod != authn.MethodOAuth2.String() {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create access token", "%s authentication not supported"}, f.ClientID, client.AuthMethod)
|
||||
} else if method := client.Method(); !method.IsDefault() && method != authn.MethodOAuth2 {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create session", "method %s not supported"}, f.ClientID, clean.LogQuote(method.String()))
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
} else if client.WrongSecret(f.ClientSecret) {
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create access token", "invalid client secret"}, f.ClientID)
|
||||
event.AuditWarn([]string{clientIP, "client %s", "create session", "invalid client_secret"}, f.ClientID)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
limiter.Login.Reserve(clientIP)
|
||||
return
|
||||
@@ -81,15 +91,15 @@ func CreateOauthToken(router *gin.RouterGroup) {
|
||||
|
||||
// Try to log in and save session if successful.
|
||||
if sess, err = get.Session().Save(sess); err != nil {
|
||||
event.AuditErr([]string{clientIP, "client %s", "create access token", "%s"}, f.ClientID, err)
|
||||
event.AuditErr([]string{clientIP, "client %s", "create session", "%s"}, f.ClientID, err)
|
||||
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
|
||||
return
|
||||
} else if sess == nil {
|
||||
event.AuditErr([]string{clientIP, "client %s", "create access token", "failed unexpectedly"}, f.ClientID)
|
||||
event.AuditErr([]string{clientIP, "client %s", "create session", StatusFailed.String()}, f.ClientID)
|
||||
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
|
||||
return
|
||||
} else {
|
||||
event.AuditInfo([]string{clientIP, "client %s", "session %s", "access token created"}, f.ClientID, sess.RefID)
|
||||
event.AuditInfo([]string{clientIP, "client %s", "session %s", "created"}, f.ClientID, sess.RefID)
|
||||
}
|
||||
|
||||
// Response includes access token, token type, and token lifetime.
|
||||
@@ -103,3 +113,59 @@ func CreateOauthToken(router *gin.RouterGroup) {
|
||||
c.JSON(http.StatusOK, data)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteOAuthToken creates a new access token for clients that
|
||||
// authenticate with valid OAuth2 client credentials.
|
||||
//
|
||||
// POST /api/v1/oauth/logout
|
||||
func DeleteOAuthToken(router *gin.RouterGroup) {
|
||||
router.POST("/oauth/logout", func(c *gin.Context) {
|
||||
// Get client IP address for logs and rate limiting checks.
|
||||
clientIP := ClientIP(c)
|
||||
|
||||
// Abort if running in public mode.
|
||||
if get.Config().Public() {
|
||||
event.AuditErr([]string{clientIP, "delete client session in public mode", "denied"})
|
||||
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Find session based on auth token.
|
||||
sess, err := entity.FindSession(rnd.SessionID(AuthToken(c)))
|
||||
|
||||
if err != nil {
|
||||
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "%s"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String(), err.Error())
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized))
|
||||
return
|
||||
} else if sess == nil {
|
||||
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(http.StatusUnauthorized, i18n.ErrUnauthorized))
|
||||
return
|
||||
} else if sess.Abort(c) {
|
||||
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
|
||||
return
|
||||
} else if !sess.IsClient() {
|
||||
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "denied"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, i18n.NewResponse(http.StatusForbidden, i18n.ErrForbidden))
|
||||
return
|
||||
} else {
|
||||
event.AuditInfo([]string{clientIP, "client %s", "session %s", "delete session as %s", "granted"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String())
|
||||
}
|
||||
|
||||
// Delete session cache and database record.
|
||||
if err = sess.Delete(); err != nil {
|
||||
// Log error.
|
||||
event.AuditErr([]string{clientIP, "client %s", "session %s", "delete session as %s", "%s"}, clean.Log(sess.AuthID), clean.Log(sess.RefID), acl.RoleClient.String(), err)
|
||||
|
||||
// Return JSON error.
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, i18n.NewResponse(http.StatusNotFound, i18n.ErrNotFound))
|
||||
return
|
||||
}
|
||||
|
||||
// Log event.
|
||||
event.AuditInfo([]string{clientIP, "client %s", "session %s", "deleted"}, clean.Log(sess.AuthID), clean.Log(sess.RefID))
|
||||
|
||||
// Return JSON response for confirmation.
|
||||
c.JSON(http.StatusOK, DeleteSessionResponse(sess.ID))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,12 +8,20 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/entity"
|
||||
"github.com/photoprism/photoprism/pkg/header"
|
||||
)
|
||||
|
||||
func TestCreateOauthToken(t *testing.T) {
|
||||
func TestCreateOAuthToken(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateOauthToken(router)
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
CreateOAuthToken(router)
|
||||
|
||||
var method = "POST"
|
||||
var path = "/api/v1/oauth/token"
|
||||
@@ -35,10 +43,37 @@ func TestCreateOauthToken(t *testing.T) {
|
||||
t.Logf("BODY: %s", w.Body.String())
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
})
|
||||
|
||||
t.Run("InvalidClientID", func(t *testing.T) {
|
||||
t.Run("PublicMode", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateOauthToken(router)
|
||||
|
||||
CreateOAuthToken(router)
|
||||
|
||||
var method = "POST"
|
||||
var path = "/api/v1/oauth/token"
|
||||
|
||||
data := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {"cs5cpu17n6gj2qo5"},
|
||||
"client_secret": {"xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"},
|
||||
"scope": {"metrics"},
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(method, path, strings.NewReader(data.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, req)
|
||||
|
||||
t.Logf("Header: %s", w.Header())
|
||||
t.Logf("BODY: %s", w.Body.String())
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
})
|
||||
t.Run("InvalidClientID", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
CreateOAuthToken(router)
|
||||
|
||||
var method = "POST"
|
||||
var path = "/api/v1/oauth/token"
|
||||
@@ -60,10 +95,12 @@ func TestCreateOauthToken(t *testing.T) {
|
||||
t.Logf("BODY: %s", w.Body.String())
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
|
||||
t.Run("WrongClient", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateOauthToken(router)
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
CreateOAuthToken(router)
|
||||
|
||||
var method = "POST"
|
||||
var path = "/api/v1/oauth/token"
|
||||
@@ -85,10 +122,12 @@ func TestCreateOauthToken(t *testing.T) {
|
||||
t.Logf("BODY: %s", w.Body.String())
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
|
||||
t.Run("WrongSecret", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateOauthToken(router)
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
CreateOAuthToken(router)
|
||||
|
||||
var method = "POST"
|
||||
var path = "/api/v1/oauth/token"
|
||||
@@ -110,10 +149,12 @@ func TestCreateOauthToken(t *testing.T) {
|
||||
t.Logf("BODY: %s", w.Body.String())
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
|
||||
t.Run("AuthNotEnabled", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateOauthToken(router)
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
CreateOAuthToken(router)
|
||||
|
||||
var method = "POST"
|
||||
var path = "/api/v1/oauth/token"
|
||||
@@ -135,10 +176,12 @@ func TestCreateOauthToken(t *testing.T) {
|
||||
t.Logf("BODY: %s", w.Body.String())
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
|
||||
t.Run("UnknownAuthMethod", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
CreateOauthToken(router)
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
CreateOAuthToken(router)
|
||||
|
||||
var method = "POST"
|
||||
var path = "/api/v1/oauth/token"
|
||||
@@ -161,3 +204,62 @@ func TestCreateOauthToken(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDeleteOAuthToken(t *testing.T) {
|
||||
t.Run("Success", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
CreateOAuthToken(router)
|
||||
DeleteOAuthToken(router)
|
||||
|
||||
var tokenPath = "/api/v1/oauth/token"
|
||||
var logoutPath = "/api/v1/oauth/logout"
|
||||
|
||||
data := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {"cs5cpu17n6gj2qo5"},
|
||||
"client_secret": {"xcCbOrw6I0vcoXzhnOmXhjpVSyFq0l0e"},
|
||||
"scope": {"metrics"},
|
||||
}
|
||||
|
||||
createToken, _ := http.NewRequest("POST", tokenPath, strings.NewReader(data.Encode()))
|
||||
createToken.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
createResp := httptest.NewRecorder()
|
||||
app.ServeHTTP(createResp, createToken)
|
||||
|
||||
t.Logf("Header: %s", createResp.Header())
|
||||
t.Logf("BODY: %s", createResp.Body.String())
|
||||
assert.Equal(t, http.StatusOK, createResp.Code)
|
||||
authToken := gjson.Get(createResp.Body.String(), "access_token").String()
|
||||
|
||||
deleteToken, _ := http.NewRequest("POST", logoutPath, nil)
|
||||
deleteToken.Header.Add(header.AuthToken, authToken)
|
||||
|
||||
deleteResp := httptest.NewRecorder()
|
||||
app.ServeHTTP(deleteResp, deleteToken)
|
||||
|
||||
t.Logf("Header: %s", deleteResp.Header())
|
||||
t.Logf("BODY: %s", deleteResp.Body.String())
|
||||
assert.Equal(t, http.StatusOK, deleteResp.Code)
|
||||
})
|
||||
t.Run("PublicMode", func(t *testing.T) {
|
||||
app, router, _ := NewApiTest()
|
||||
|
||||
DeleteOAuthToken(router)
|
||||
|
||||
sess := entity.SessionFixtures.Get("alice_token")
|
||||
|
||||
deleteToken, _ := http.NewRequest("POST", "/api/v1/oauth/logout", nil)
|
||||
deleteToken.Header.Add(header.AuthToken, sess.AuthToken())
|
||||
|
||||
deleteResp := httptest.NewRecorder()
|
||||
app.ServeHTTP(deleteResp, deleteToken)
|
||||
|
||||
t.Logf("Header: %s", deleteResp.Header())
|
||||
t.Logf("BODY: %s", deleteResp.Body.String())
|
||||
assert.Equal(t, http.StatusForbidden, deleteResp.Code)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -248,6 +248,17 @@ func TestDeleteSession(t *testing.T) {
|
||||
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+rnd.SessionID(authToken), authToken)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
t.Run("AdminAuthenticatedLogout", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
defer conf.SetAuthMode(config.AuthModePublic)
|
||||
|
||||
DeleteSession(router)
|
||||
authToken := AuthenticateAdmin(app, router)
|
||||
|
||||
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session", authToken)
|
||||
assert.Equal(t, http.StatusOK, r.Code)
|
||||
})
|
||||
t.Run("UserWithoutAuthentication", func(t *testing.T) {
|
||||
app, router, conf := NewApiTest()
|
||||
conf.SetAuthMode(config.AuthModePasswd)
|
||||
|
||||
@@ -94,7 +94,8 @@ func UpdateSubject(router *gin.RouterGroup) {
|
||||
|
||||
// LikeSubject flags a subject as favorite.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Subject UID
|
||||
//
|
||||
// POST /api/v1/subjects/:uid/like
|
||||
@@ -127,7 +128,8 @@ func LikeSubject(router *gin.RouterGroup) {
|
||||
|
||||
// DislikeSubject removes the favorite flag from a subject.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - uid: string Subject UID
|
||||
//
|
||||
// DELETE /api/v1/subjects/:uid/like
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
|
||||
// GetThumb returns a thumbnail image matching the file hash, crop area, and type.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - thumb: string sha1 file hash plus optional crop area
|
||||
// - token: string url security token, see config
|
||||
// - size: string thumb type, see thumb.Sizes
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
|
||||
// GetVideo streams video content.
|
||||
//
|
||||
// Request Parameters:
|
||||
// The request parameters are:
|
||||
//
|
||||
// - hash: string The photo or video file hash as returned by the search API
|
||||
// - type: string Video format
|
||||
//
|
||||
|
||||
@@ -619,6 +619,7 @@ func (m *Session) Valid() bool {
|
||||
if m.AuthMethod == authn.MethodOAuth2.String() {
|
||||
return true
|
||||
}
|
||||
|
||||
return m.User().IsRegistered() || m.IsVisitor() && m.HasShares()
|
||||
}
|
||||
|
||||
|
||||
@@ -15,24 +15,30 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
||||
// handler for the path with (without) the trailing slash exists.
|
||||
router.RedirectTrailingSlash = true
|
||||
|
||||
// Static assets and templates.
|
||||
// Register static asset and templates routes.
|
||||
registerStaticRoutes(router, conf)
|
||||
|
||||
// Web app bootstrapping and configuration.
|
||||
// Register PWA bootstrap and config routes.
|
||||
registerPWARoutes(router, conf)
|
||||
|
||||
// Built-in WebDAV server.
|
||||
// Register built-in WebDAV server routes.
|
||||
registerWebDAVRoutes(router, conf)
|
||||
|
||||
// Sharing routes start with "/s".
|
||||
// Register sharing routes starting with "/s".
|
||||
registerSharingRoutes(router, conf)
|
||||
|
||||
// JSON-REST API Version 1
|
||||
// Register ".well-known" service discovery routes.
|
||||
registerWellknownRoutes(router, conf)
|
||||
|
||||
// Register JSON REST-API version 1 (APIv1) routes, grouped by functionality.
|
||||
// Docs: https://pkg.go.dev/github.com/photoprism/photoprism/internal/api
|
||||
|
||||
// Authentication.
|
||||
api.CreateSession(APIv1)
|
||||
api.GetSession(APIv1)
|
||||
api.DeleteSession(APIv1)
|
||||
api.CreateOauthToken(APIv1)
|
||||
api.CreateOAuthToken(APIv1)
|
||||
api.DeleteOAuthToken(APIv1)
|
||||
|
||||
// Server Config.
|
||||
api.GetConfigOptions(APIv1)
|
||||
|
||||
39
internal/server/routes_wellknown.go
Normal file
39
internal/server/routes_wellknown.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/acl"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/server/wellknown"
|
||||
)
|
||||
|
||||
// registerWellknownRoutes configures the ".well-known" service discovery routes.
|
||||
func registerWellknownRoutes(router *gin.Engine, conf *config.Config) {
|
||||
router.Any(conf.BaseUri("/.well-known/oauth-authorization-server"), func(c *gin.Context) {
|
||||
response := &wellknown.OAuthAuthorizationServer{
|
||||
Issuer: conf.SiteUrl(),
|
||||
TokenEndpoint: fmt.Sprintf("%sapi/v1/oauth/token", conf.SiteUrl()),
|
||||
EndSessionEndpoint: fmt.Sprintf("%sapi/v1/oauth/logout", conf.SiteUrl()),
|
||||
ScopesSupported: acl.Resources.Resources(),
|
||||
ResponseTypesSupported: []string{"token"},
|
||||
GrantTypesSupported: []string{"client_credentials"},
|
||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"},
|
||||
ResponseModesSupported: []string{},
|
||||
SubjectTypesSupported: []string{},
|
||||
ClaimsSupported: []string{},
|
||||
CodeChallengeMethodsSupported: []string{},
|
||||
IntrospectionEndpointAuthMethodsSupported: []string{},
|
||||
RevocationEndpointAuthMethodsSupported: []string{},
|
||||
RequestParameterSupported: false,
|
||||
RequestObjectSigningAlgValuesSupported: []string{},
|
||||
DeviceAuthorizationEndpoint: "",
|
||||
DpopSigningAlgValuesSupported: []string{},
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
26
internal/server/wellknown/oauth.go
Normal file
26
internal/server/wellknown/oauth.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package wellknown
|
||||
|
||||
// OAuthAuthorizationServer represents the values returned by the "/.well-known/oauth-authorization-server" endpoint.
|
||||
type OAuthAuthorizationServer struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
RegistrationEndpoint string `json:"registration_endpoint"`
|
||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||
ResponseModesSupported []string `json:"response_modes_supported"`
|
||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||
SubjectTypesSupported []string `json:"subject_types_supported"`
|
||||
ScopesSupported []string `json:"scopes_supported"`
|
||||
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
|
||||
ClaimsSupported []string `json:"claims_supported"`
|
||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||
IntrospectionEndpoint string `json:"introspection_endpoint"`
|
||||
IntrospectionEndpointAuthMethodsSupported []string `json:"introspection_endpoint_auth_methods_supported"`
|
||||
RevocationEndpoint string `json:"revocation_endpoint"`
|
||||
RevocationEndpointAuthMethodsSupported []string `json:"revocation_endpoint_auth_methods_supported"`
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
RequestParameterSupported bool `json:"request_parameter_supported"`
|
||||
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
|
||||
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
|
||||
DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
|
||||
}
|
||||
25
internal/server/wellknown/wellknown.go
Normal file
25
internal/server/wellknown/wellknown.go
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Package wellknown provides data types and abstractions for service discovery endpoints.
|
||||
|
||||
Copyright (c) 2018 - 2024 PhotoPrism UG. All rights reserved.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
|
||||
<https://docs.photoprism.app/license/agpl>
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
The AGPL is supplemented by our Trademark and Brand Guidelines,
|
||||
which describe how our Brand Assets may be used:
|
||||
<https://www.photoprism.app/trademark>
|
||||
|
||||
Feel free to send an email to hello@photoprism.app if you have questions,
|
||||
want to support our work, or just want to say hello.
|
||||
|
||||
Additional information can be found in our Developer Guide:
|
||||
<https://docs.photoprism.app/developer-guide/>
|
||||
*/
|
||||
package wellknown
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// Get returns an existing client session.
|
||||
func (s *Session) Get(id string) (m *entity.Session, err error) {
|
||||
if id == "" {
|
||||
return &entity.Session{}, fmt.Errorf("new")
|
||||
return &entity.Session{}, fmt.Errorf("invalid session id")
|
||||
}
|
||||
|
||||
return entity.FindSession(id)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
// Attr represents a list of key-value attributes.
|
||||
type Attr []*KeyValue
|
||||
|
||||
// ParseAttr parses a string into a new Attr since and returns it.
|
||||
// ParseAttr parses a string into a new Attr slice and returns it.
|
||||
func ParseAttr(s string) Attr {
|
||||
fields := strings.Fields(s)
|
||||
result := make(Attr, 0, len(fields))
|
||||
|
||||
@@ -8,23 +8,27 @@ import (
|
||||
|
||||
func TestParseAttr(t *testing.T) {
|
||||
t.Run("Empty", func(t *testing.T) {
|
||||
f := ParseAttr("")
|
||||
assert.Len(t, f, 0)
|
||||
assert.Equal(t, Attr{}, f)
|
||||
attr := ParseAttr("")
|
||||
|
||||
assert.Len(t, attr, 0)
|
||||
assert.Equal(t, Attr{}, attr)
|
||||
})
|
||||
t.Run("Keys", func(t *testing.T) {
|
||||
f := ParseAttr("foo bar baz")
|
||||
assert.Len(t, f, 3)
|
||||
assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, f)
|
||||
attr := ParseAttr("foo bar baz")
|
||||
|
||||
assert.Len(t, attr, 3)
|
||||
assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, attr)
|
||||
})
|
||||
t.Run("WhitespaceKeys", func(t *testing.T) {
|
||||
f := ParseAttr(" foo bar baz ")
|
||||
assert.Len(t, f, 3)
|
||||
assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, f)
|
||||
attr := ParseAttr(" foo bar baz ")
|
||||
|
||||
assert.Len(t, attr, 3)
|
||||
assert.Equal(t, Attr{{Key: "foo", Value: "true"}, {Key: "bar", Value: "true"}, {Key: "baz", Value: "true"}}, attr)
|
||||
})
|
||||
t.Run("Values", func(t *testing.T) {
|
||||
f := ParseAttr("foo:yes bar:disable baz:true biZZ:false BIG CAT:FISH berghain:berlin:germany hello:off")
|
||||
assert.Len(t, f, 8)
|
||||
attr := ParseAttr("foo:yes bar:disable baz:true biZZ:false BIG CAT:FISH berghain:berlin:germany hello:off")
|
||||
|
||||
assert.Len(t, attr, 8)
|
||||
assert.Equal(t,
|
||||
Attr{
|
||||
{Key: "foo", Value: "true"},
|
||||
@@ -35,52 +39,75 @@ func TestParseAttr(t *testing.T) {
|
||||
{Key: "CAT", Value: "FISH"},
|
||||
{Key: "berghain", Value: "berlin:germany"},
|
||||
{Key: "hello", Value: "false"},
|
||||
}, f,
|
||||
}, attr,
|
||||
)
|
||||
})
|
||||
t.Run("Scopes", func(t *testing.T) {
|
||||
attr := ParseAttr("files files.read:true photos photos.create:false albums:true people.view:true config.view:false")
|
||||
|
||||
assert.Len(t, attr, 7)
|
||||
assert.Equal(t, Attr{
|
||||
{Key: "files", Value: "true"},
|
||||
{Key: "files.read", Value: "true"},
|
||||
{Key: "photos", Value: "true"},
|
||||
{Key: "photos.create", Value: "false"},
|
||||
{Key: "albums", Value: "true"},
|
||||
{Key: "people.view", Value: "true"},
|
||||
{Key: "config.view", Value: "false"},
|
||||
}, attr)
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseKeyValue(t *testing.T) {
|
||||
t.Run("Any", func(t *testing.T) {
|
||||
kv := ParseKeyValue("*")
|
||||
|
||||
assert.Equal(t, "*", kv.Key)
|
||||
assert.Equal(t, "true", kv.Value)
|
||||
})
|
||||
t.Run("Scope", func(t *testing.T) {
|
||||
kv := ParseKeyValue("files.read:true")
|
||||
|
||||
t.Logf("Scope: %#v", kv)
|
||||
assert.Equal(t, &KeyValue{Key: "files.read", Value: "true"}, kv)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAttr_String(t *testing.T) {
|
||||
t.Run("SlackScope", func(t *testing.T) {
|
||||
s := "admin.conversations.removeCustomRetention admin.usergroups:read"
|
||||
f := ParseAttr(s)
|
||||
attr := ParseAttr(s)
|
||||
|
||||
expected := Attr{
|
||||
{Key: "admin.conversations.removeCustomRetention", Value: "true"},
|
||||
{Key: "admin.usergroups", Value: "read"},
|
||||
}
|
||||
|
||||
assert.Len(t, f, 2)
|
||||
assert.Equal(t, expected, f)
|
||||
assert.Equal(t, s, f.String())
|
||||
assert.Len(t, attr, 2)
|
||||
assert.Equal(t, expected, attr)
|
||||
assert.Equal(t, s, attr.String())
|
||||
})
|
||||
t.Run("Random", func(t *testing.T) {
|
||||
s := " admin.conversations.removeCustomRetention admin.usergroups:read me:yes FOOt0-2U 6VU #$#%$ cm,Nu"
|
||||
f := ParseAttr(s)
|
||||
attr := ParseAttr(s)
|
||||
|
||||
assert.Len(t, f, 6)
|
||||
assert.Equal(t, "6VU FOOt0-2U admin.conversations.removeCustomRetention admin.usergroups:read cmNu me", f.String())
|
||||
assert.Len(t, attr, 6)
|
||||
assert.Equal(t, "6VU FOOt0-2U admin.conversations.removeCustomRetention admin.usergroups:read cmNu me", attr.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAttr_Contains(t *testing.T) {
|
||||
t.Run("Any", func(t *testing.T) {
|
||||
s := "*"
|
||||
a := ParseAttr(s)
|
||||
attr := ParseAttr(s)
|
||||
|
||||
assert.Len(t, a, 1)
|
||||
assert.Len(t, attr, 1)
|
||||
assert.True(t, attr.Contains("metrics"))
|
||||
})
|
||||
t.Run("Scopes", func(t *testing.T) {
|
||||
attr := ParseAttr("files files.read:true photos photos.create:false albums:true people.view:true config.view:false")
|
||||
|
||||
t.Logf("Contains: %s", a[0])
|
||||
assert.True(t, a.Contains("metrics"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseKeyValue(t *testing.T) {
|
||||
t.Run("Any", func(t *testing.T) {
|
||||
v := ParseKeyValue("*")
|
||||
t.Logf("Key: '%s'", v.Key)
|
||||
t.Logf("Value: '%s'", v.Value)
|
||||
assert.Equal(t, "*", v.Key)
|
||||
assert.Equal(t, "true", v.Value)
|
||||
assert.Len(t, attr, 7)
|
||||
assert.True(t, attr.Contains("people.view"))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user