API: Add OPTIONS wildcard handler to serve CORS preflight requests #5133

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-08-13 15:59:38 +02:00
parent d47b38bc8b
commit a7a41fe000
11 changed files with 239 additions and 28 deletions

View File

@@ -50,7 +50,7 @@ func GetSettings(router *gin.RouterGroup) {
// @Produce json // @Produce json
// @Success 200 {object} customize.Settings // @Success 200 {object} customize.Settings
// @Failure 400,401,403,404,500 {object} i18n.Response // @Failure 400,401,403,404,500 {object} i18n.Response
// @Param settings body customize.Settings true "user settings" // @Param settings body customize.Settings true "user settings"
// @Router /api/v1/settings [post] // @Router /api/v1/settings [post]
func SaveSettings(router *gin.RouterGroup) { func SaveSettings(router *gin.RouterGroup) {
router.POST("/settings", func(c *gin.Context) { router.POST("/settings", func(c *gin.Context) {

View File

@@ -10,21 +10,22 @@ import (
// Echo returns the request and response headers as JSON if debug mode is enabled. // Echo returns the request and response headers as JSON if debug mode is enabled.
// //
// The supported request methods are: // @Summary returns the request and response headers as JSON if debug mode is enabled
// // @Id Echo
// - GET // @Success 200
// - POST // @Router /api/v1/echo [get]
// - PUT
// - PATCH
// - HEAD
// - OPTIONS
// - DELETE
// - CONNECT
// - TRACE
//
// ANY /api/v1/echo
func Echo(router *gin.RouterGroup) { func Echo(router *gin.RouterGroup) {
router.Any("/echo", func(c *gin.Context) { methods := []string{
http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
http.MethodPatch,
http.MethodDelete,
http.MethodConnect,
http.MethodTrace,
}
router.Match(methods, "/echo", func(c *gin.Context) {
// Abort if debug mode is disabled. // Abort if debug mode is disabled.
if !get.Config().Debug() { if !get.Config().Debug() {
AbortFeatureDisabled(c) AbortFeatureDisabled(c)

View File

@@ -75,7 +75,7 @@ func findFileMarker(c *gin.Context) (file *entity.File, marker *entity.Marker, e
// //
// See internal/form/marker.go for the values required to create a new marker. // See internal/form/marker.go for the values required to create a new marker.
// //
// @Tags Files // @Tags Files
// @Router /api/v1/markers [post] // @Router /api/v1/markers [post]
func CreateMarker(router *gin.RouterGroup) { func CreateMarker(router *gin.RouterGroup) {
router.POST("/markers", func(c *gin.Context) { router.POST("/markers", func(c *gin.Context) {

19
internal/api/options.go Normal file
View File

@@ -0,0 +1,19 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Options returns an empty response to handle CORS preflight requests.
//
// @Summary returns CORS headers with an empty response body
// @Id Options
// @Success 204
// @Router /api/v1/{any} [options]
func Options(router *gin.RouterGroup) {
router.OPTIONS("/*any", func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
}

View File

@@ -1714,6 +1714,17 @@
} }
} }
}, },
"/api/v1/echo": {
"get": {
"summary": "returns the request and response headers as JSON if debug mode is enabled",
"operationId": "Echo",
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/v1/errors": { "/api/v1/errors": {
"get": { "get": {
"produces": [ "produces": [
@@ -5270,6 +5281,17 @@
"responses": {} "responses": {}
} }
}, },
"/api/v1/{any}": {
"options": {
"summary": "returns CORS headers with an empty response body",
"operationId": "Options",
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/api/v1/{entity}/{uid}/links": { "/api/v1/{entity}/{uid}/links": {
"post": { "post": {
"tags": [ "tags": [
@@ -7560,19 +7582,24 @@
"type": "object", "type": "object",
"properties": { "properties": {
"drawing": { "drawing": {
"type": "number" "type": "number",
"format": "float32"
}, },
"hentai": { "hentai": {
"type": "number" "type": "number",
"format": "float32"
}, },
"neutral": { "neutral": {
"type": "number" "type": "number",
"format": "float32"
}, },
"porn": { "porn": {
"type": "number" "type": "number",
"format": "float32"
}, },
"sexy": { "sexy": {
"type": "number" "type": "number",
"format": "float32"
} }
} }
}, },
@@ -8223,9 +8250,138 @@
} }
} }
}, },
"time.Duration": { "tensorflow.ColorChannelOrder": {
"type": "integer", "type": "integer",
"enum": [ "enum": [
0
],
"x-enum-varnames": [
"UndefinedOrder"
]
},
"tensorflow.Interval": {
"type": "object",
"properties": {
"end": {
"type": "number"
},
"mean": {
"type": "number"
},
"start": {
"type": "number"
},
"stdDev": {
"type": "number"
}
}
},
"tensorflow.ModelInfo": {
"type": "object",
"properties": {
"input": {
"$ref": "#/definitions/tensorflow.PhotoInput"
},
"output": {
"$ref": "#/definitions/tensorflow.ModelOutput"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"tensorflow.ModelOutput": {
"type": "object",
"properties": {
"index": {
"type": "integer"
},
"logits": {
"type": "boolean"
},
"name": {
"type": "string"
},
"outputs": {
"type": "integer"
}
}
},
"tensorflow.PhotoInput": {
"type": "object",
"properties": {
"height": {
"type": "integer"
},
"index": {
"type": "integer"
},
"inputOrder": {
"$ref": "#/definitions/tensorflow.ColorChannelOrder"
},
"intervals": {
"type": "array",
"items": {
"$ref": "#/definitions/tensorflow.Interval"
}
},
"name": {
"type": "string"
},
"resizeOperation": {
"$ref": "#/definitions/tensorflow.ResizeOperation"
},
"width": {
"type": "integer"
}
}
},
"tensorflow.ResizeOperation": {
"type": "integer",
"enum": [
0,
1,
2,
3
],
"x-enum-varnames": [
"UndefinedResizeOperation",
"ResizeBreakAspectRatio",
"CenterCrop",
"Padding"
]
},
"time.Duration": {
"type": "integer",
"format": "int64",
"enum": [
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
-9223372036854775808,
9223372036854775807,
1,
1000,
1000000,
1000000000,
60000000000,
3600000000000,
-9223372036854775808, -9223372036854775808,
9223372036854775807, 9223372036854775807,
1, 1,
@@ -8236,6 +8392,30 @@
3600000000000 3600000000000
], ],
"x-enum-varnames": [ "x-enum-varnames": [
"minDuration",
"maxDuration",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"minDuration",
"maxDuration",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"minDuration",
"maxDuration",
"Nanosecond",
"Microsecond",
"Millisecond",
"Second",
"Minute",
"Hour",
"minDuration", "minDuration",
"maxDuration", "maxDuration",
"Nanosecond", "Nanosecond",
@@ -8433,7 +8613,8 @@
"items": { "items": {
"type": "array", "type": "array",
"items": { "items": {
"type": "number" "type": "number",
"format": "float64"
} }
} }
} }
@@ -8498,9 +8679,15 @@
"Service": { "Service": {
"$ref": "#/definitions/vision.Service" "$ref": "#/definitions/vision.Service"
}, },
"default": {
"type": "boolean"
},
"disabled": { "disabled": {
"type": "boolean" "type": "boolean"
}, },
"meta": {
"$ref": "#/definitions/tensorflow.ModelInfo"
},
"name": { "name": {
"type": "string" "type": "string"
}, },

View File

@@ -19,7 +19,7 @@ import (
// UpdateUserPassword changes the password of the currently authenticated user. // UpdateUserPassword changes the password of the currently authenticated user.
// //
// @Tags Users, Authentication // @Tags Users, Authentication
// @Router /api/v1/users/{uid}/password [put] // @Router /api/v1/users/{uid}/password [put]
func UpdateUserPassword(router *gin.RouterGroup) { func UpdateUserPassword(router *gin.RouterGroup) {
router.PUT("/users/:uid/password", func(c *gin.Context) { router.PUT("/users/:uid/password", func(c *gin.Context) {
conf := get.Config() conf := get.Config()

View File

@@ -19,7 +19,7 @@ import (
// FindUserSessions finds user sessions and returns them as JSON. // FindUserSessions finds user sessions and returns them as JSON.
// //
// @Tags Users, Authentication // @Tags Users, Authentication
// @Router /api/v1/users/{uid}/sessions [get] // @Router /api/v1/users/{uid}/sessions [get]
func FindUserSessions(router *gin.RouterGroup) { func FindUserSessions(router *gin.RouterGroup) {
router.GET("/users/:uid/sessions", func(c *gin.Context) { router.GET("/users/:uid/sessions", func(c *gin.Context) {
// Check if the session user is has user management privileges. // Check if the session user is has user management privileges.

View File

@@ -19,7 +19,7 @@ import (
// UpdateUser updates the profile information of the currently authenticated user. // UpdateUser updates the profile information of the currently authenticated user.
// //
// @Tags Users // @Tags Users
// @Router /api/v1/users/{uid} [put] // @Router /api/v1/users/{uid} [put]
func UpdateUser(router *gin.RouterGroup) { func UpdateUser(router *gin.RouterGroup) {
router.PUT("/users/:uid", func(c *gin.Context) { router.PUT("/users/:uid", func(c *gin.Context) {
conf := get.Config() conf := get.Config()

View File

@@ -28,7 +28,7 @@ import (
// UploadUserFiles adds files to the user upload folder, from where they can be moved and indexed. // UploadUserFiles adds files to the user upload folder, from where they can be moved and indexed.
// //
// @Tags Users, Files // @Tags Users, Files
// @Router /users/{uid}/upload/{token} [post] // @Router /users/{uid}/upload/{token} [post]
func UploadUserFiles(router *gin.RouterGroup) { func UploadUserFiles(router *gin.RouterGroup) {
router.POST("/users/:uid/upload/:token", func(c *gin.Context) { router.POST("/users/:uid/upload/:token", func(c *gin.Context) {
conf := get.Config() conf := get.Config()

View File

@@ -24,11 +24,14 @@ var Api = func(conf *config.Config) gin.HandlerFunc {
if origin := conf.CORSOrigin(); origin != "" { if origin := conf.CORSOrigin(); origin != "" {
c.Header(header.AccessControlAllowOrigin, origin) c.Header(header.AccessControlAllowOrigin, origin)
// Add additional information to preflight OPTION requests. // Handle OPTIONS preflight requests by adding CORS headers
// and aborting the request with HTTP status code 204.
if c.Request.Method == http.MethodOptions { if c.Request.Method == http.MethodOptions {
c.Header(header.AccessControlAllowHeaders, conf.CORSHeaders()) c.Header(header.AccessControlAllowHeaders, conf.CORSHeaders())
c.Header(header.AccessControlAllowMethods, conf.CORSMethods()) c.Header(header.AccessControlAllowMethods, conf.CORSMethods())
c.Header(header.AccessControlMaxAge, header.DefaultAccessControlMaxAge) c.Header(header.AccessControlMaxAge, header.DefaultAccessControlMaxAge)
c.AbortWithStatus(http.StatusNoContent)
return
} }
} }
} }

View File

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