Config: Update CORS header defaults and add /api/v1/echo endpoint #3931

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-01-16 14:36:08 +01:00
parent c660c729e2
commit 02a1b12edb
16 changed files with 148 additions and 28 deletions

View file

@ -62,7 +62,7 @@ func AbortDeleteFailed(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrDeleteFailed)
}
func AbortUnexpected(c *gin.Context) {
func AbortUnexpectedError(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrUnexpected)
}

View file

@ -100,7 +100,7 @@ func CreateAlbum(router *gin.RouterGroup) {
if err := a.Create(); err != nil {
// Report unexpected error.
log.Errorf("album: %s (create)", err)
AbortUnexpected(c)
AbortUnexpectedError(c)
return
}
} else {
@ -112,7 +112,7 @@ func CreateAlbum(router *gin.RouterGroup) {
} else if err := a.Restore(); err != nil {
// Report unexpected error.
log.Errorf("album: %s (restore)", err)
AbortUnexpected(c)
AbortUnexpectedError(c)
return
}
}

View file

@ -8,13 +8,15 @@ import (
"testing"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"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 {
@ -109,7 +111,7 @@ func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, pas
Password: password,
}))
authToken = r.Header().Get(header.XAuthToken)
authToken = gjson.Get(r.Body.String(), "access_token").String()
return
}

View file

@ -80,7 +80,7 @@ func SaveSettings(router *gin.RouterGroup) {
user := s.User()
if user == nil {
AbortUnexpected(c)
AbortUnexpectedError(c)
return
}

49
internal/api/echo.go Normal file
View file

@ -0,0 +1,49 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/get"
)
// Echo returns the request and response headers as JSON if debug mode is enabled.
//
// The supported request methods are:
//
// - GET
// - POST
// - PUT
// - PATCH
// - HEAD
// - OPTIONS
// - DELETE
// - CONNECT
// - TRACE
//
// ANY /api/v1/echo
func Echo(router *gin.RouterGroup) {
router.Any("/echo", func(c *gin.Context) {
// Abort if debug mode is disabled.
if !get.Config().Debug() {
AbortFeatureDisabled(c)
return
} else if c.Request == nil || c.Writer == nil {
AbortUnexpectedError(c)
return
}
// Return request information.
echoResponse := gin.H{
"url": c.Request.URL.String(),
"method": c.Request.Method,
"headers": map[string]http.Header{
"request": c.Request.Header,
"response": c.Writer.Header(),
},
}
c.JSON(http.StatusOK, echoResponse)
})
}

63
internal/api/echo_test.go Normal file
View file

@ -0,0 +1,63 @@
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/config"
)
func TestEcho(t *testing.T) {
t.Run("GET", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
Echo(router)
authToken := AuthenticateAdmin(app, router)
t.Logf("Auth Token: %s", authToken)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/echo", authToken)
t.Logf("Response Body: %s", r.Body.String())
body := r.Body.String()
url := gjson.Get(body, "url").String()
method := gjson.Get(body, "method").String()
request := gjson.Get(body, "headers.request")
response := gjson.Get(body, "headers.response")
assert.Equal(t, "/api/v1/echo", url)
assert.Equal(t, "GET", method)
assert.Equal(t, "Bearer "+authToken, request.Get("Authorization.0").String())
assert.Equal(t, "application/json; charset=utf-8", response.Get("Content-Type.0").String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("POST", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
Echo(router)
authToken := AuthenticateAdmin(app, router)
t.Logf("Auth Token: %s", authToken)
r := AuthenticatedRequest(app, http.MethodPost, "/api/v1/echo", authToken)
body := r.Body.String()
url := gjson.Get(body, "url").String()
method := gjson.Get(body, "method").String()
request := gjson.Get(body, "headers.request")
response := gjson.Get(body, "headers.response")
assert.Equal(t, "/api/v1/echo", url)
assert.Equal(t, "POST", method)
assert.Equal(t, "Bearer "+authToken, request.Get("Authorization.0").String())
assert.Equal(t, "application/json; charset=utf-8", response.Get("Content-Type.0").String())
assert.Equal(t, http.StatusOK, r.Code)
})
}

View file

@ -40,7 +40,7 @@ func AddPhotoLabel(router *gin.RouterGroup) {
var f form.Label
if err := c.BindJSON(&f); err != nil {
if err = c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
@ -52,8 +52,9 @@ func AddPhotoLabel(router *gin.RouterGroup) {
return
}
if err := labelEntity.Restore(); err != nil {
if err = labelEntity.Restore(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "could not restore label"})
return
}
photoLabel := entity.FirstOrCreatePhotoLabel(entity.NewPhotoLabel(m.ID, labelEntity.ID, f.Uncertainty, "manual"))
@ -79,7 +80,7 @@ func AddPhotoLabel(router *gin.RouterGroup) {
return
}
if err := p.SaveLabels(); err != nil {
if err = p.SaveLabels(); err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": txt.UpperFirst(err.Error())})
return
}

View file

@ -78,7 +78,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
if err != nil {
log.Errorf("photo: cannot find primary file for %s (unstack)", clean.Log(baseName))
AbortUnexpected(c)
AbortUnexpectedError(c)
return
}
@ -115,7 +115,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
if err := unstackFile.Move(destName); err != nil {
log.Errorf("photo: cannot rename %s to %s (unstack)", clean.Log(unstackFile.BaseName()), clean.Log(filepath.Base(destName)))
AbortUnexpected(c)
AbortUnexpectedError(c)
return
}
@ -182,7 +182,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
// Reset type for existing photo stack to image.
if err := stackPhoto.Update("PhotoType", entity.MediaImage); err != nil {
log.Errorf("photo: %s (unstack %s)", err, clean.Log(baseName))
AbortUnexpected(c)
AbortUnexpectedError(c)
return
}

View file

@ -83,9 +83,6 @@ func CreateSession(router *gin.RouterGroup) {
event.AuditInfo([]string{clientIp, "session %s", "updated"}, sess.RefID)
}
// Add auth token to response header.
AddAuthTokenHeader(c, sess.AuthToken())
// Response includes user data, session data, and client config values.
response := CreateSessionResponse(sess.AuthToken(), sess, conf.ClientSession(sess))

View file

@ -69,9 +69,6 @@ func GetSession(router *gin.RouterGroup) {
// Update user information.
sess.RefreshUser()
// Add auth token to response header.
AddAuthTokenHeader(c, authToken)
// Response includes user data, session data, and client config values.
response := GetSessionResponse(authToken, sess, get.Config().ClientSession(sess))

View file

@ -22,7 +22,7 @@ func TestSession(t *testing.T) {
})
}
func TestSessionResponse(t *testing.T) {
func TestGetSessionResponse(t *testing.T) {
t.Run("Public", func(t *testing.T) {
sess := get.Session().Public()
conf := get.Config().ClientSession(sess)

View file

@ -174,4 +174,5 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.Connect(APIv1)
api.WebSocket(APIv1)
api.GetMetrics(APIv1)
api.Echo(APIv1)
}

View file

@ -1,8 +1,15 @@
package header
const (
Accept = "Accept"
AcceptRanges = "Accept-Ranges"
ContentType = "Content-Type"
ContentTypeForm = "application/x-www-form-urlencoded"
ContentTypeJson = "application/json"
ContentDisposition = "Content-Disposition"
ContentEncoding = "Content-Encoding"
ContentRange = "Content-Range"
Location = "Location"
Origin = "Origin"
Vary = "Vary"
)

View file

@ -1,5 +1,10 @@
package header
import (
"net/http"
"strings"
)
// Cross-Origin Resource Sharing (CORS) headers.
const (
AccessControlAllowOrigin = "Access-Control-Allow-Origin" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
@ -13,7 +18,9 @@ const (
var (
DefaultAccessControlAllowOrigin = ""
DefaultAccessControlAllowCredentials = ""
DefaultAccessControlAllowHeaders = "Origin, Accept, Accept-Ranges, Content-Range"
DefaultAccessControlAllowMethods = "GET, HEAD, OPTIONS"
SafeHeaders = []string{Accept, AcceptRanges, ContentDisposition, ContentEncoding, ContentRange, Location, Vary}
DefaultAccessControlAllowHeaders = strings.Join(SafeHeaders, ", ")
SafeMethods = []string{http.MethodGet, http.MethodHead, http.MethodOptions}
DefaultAccessControlAllowMethods = strings.Join(SafeMethods, ", ")
DefaultAccessControlMaxAge = "3600"
)

View file

@ -4,7 +4,9 @@ import (
"github.com/gin-gonic/gin"
)
const UnknownIP = "0.0.0.0"
const (
UnknownIP = "0.0.0.0"
)
// ClientIP returns the client IP address from the request context or a placeholder if it is unknown.
func ClientIP(c *gin.Context) (ip string) {

View file

@ -1,6 +0,0 @@
package header
const (
Vary = "Vary"
Origin = "Origin"
)