diff --git a/internal/api/abort.go b/internal/api/abort.go index aa432dec1..9c6ddc76c 100644 --- a/internal/api/abort.go +++ b/internal/api/abort.go @@ -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) } diff --git a/internal/api/albums.go b/internal/api/albums.go index 629701127..17974bd75 100644 --- a/internal/api/albums.go +++ b/internal/api/albums.go @@ -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 } } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index b0879a57e..53818437c 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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 } diff --git a/internal/api/config_settings.go b/internal/api/config_settings.go index 8786f31a1..9759b93bf 100644 --- a/internal/api/config_settings.go +++ b/internal/api/config_settings.go @@ -80,7 +80,7 @@ func SaveSettings(router *gin.RouterGroup) { user := s.User() if user == nil { - AbortUnexpected(c) + AbortUnexpectedError(c) return } diff --git a/internal/api/echo.go b/internal/api/echo.go new file mode 100644 index 000000000..8ffbe7117 --- /dev/null +++ b/internal/api/echo.go @@ -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) + }) +} diff --git a/internal/api/echo_test.go b/internal/api/echo_test.go new file mode 100644 index 000000000..38391cfa6 --- /dev/null +++ b/internal/api/echo_test.go @@ -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) + }) +} diff --git a/internal/api/photo_label.go b/internal/api/photo_label.go index 6fd5075ec..89c284bd1 100644 --- a/internal/api/photo_label.go +++ b/internal/api/photo_label.go @@ -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 } diff --git a/internal/api/photo_unstack.go b/internal/api/photo_unstack.go index fa03a30fa..84391df04 100644 --- a/internal/api/photo_unstack.go +++ b/internal/api/photo_unstack.go @@ -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 } diff --git a/internal/api/session_create.go b/internal/api/session_create.go index 3039a2167..0eb15d5da 100644 --- a/internal/api/session_create.go +++ b/internal/api/session_create.go @@ -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)) diff --git a/internal/api/session_get.go b/internal/api/session_get.go index ccce11948..80da17df8 100644 --- a/internal/api/session_get.go +++ b/internal/api/session_get.go @@ -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)) diff --git a/internal/api/session_test.go b/internal/api/session_test.go index 7b59e737e..901dc3731 100644 --- a/internal/api/session_test.go +++ b/internal/api/session_test.go @@ -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) diff --git a/internal/server/routes.go b/internal/server/routes.go index 45019fc73..d11b50790 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -174,4 +174,5 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.Connect(APIv1) api.WebSocket(APIv1) api.GetMetrics(APIv1) + api.Echo(APIv1) } diff --git a/pkg/header/content.go b/pkg/header/content.go index ead4f4b5d..bb2d80043 100644 --- a/pkg/header/content.go +++ b/pkg/header/content.go @@ -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" ) diff --git a/pkg/header/cors.go b/pkg/header/cors.go index 3c2ab6098..937b04e7c 100644 --- a/pkg/header/cors.go +++ b/pkg/header/cors.go @@ -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" ) diff --git a/pkg/header/request.go b/pkg/header/request.go index 68f92ce8d..8783e6ba1 100644 --- a/pkg/header/request.go +++ b/pkg/header/request.go @@ -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) { diff --git a/pkg/header/vary.go b/pkg/header/vary.go deleted file mode 100644 index d53495c1c..000000000 --- a/pkg/header/vary.go +++ /dev/null @@ -1,6 +0,0 @@ -package header - -const ( - Vary = "Vary" - Origin = "Origin" -)