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
// @Success 200 {object} customize.Settings
// @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]
func SaveSettings(router *gin.RouterGroup) {
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.
//
// The supported request methods are:
//
// - GET
// - POST
// - PUT
// - PATCH
// - HEAD
// - OPTIONS
// - DELETE
// - CONNECT
// - TRACE
//
// ANY /api/v1/echo
// @Summary returns the request and response headers as JSON if debug mode is enabled
// @Id Echo
// @Success 200
// @Router /api/v1/echo [get]
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.
if !get.Config().Debug() {
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.
//
// @Tags Files
// @Tags Files
// @Router /api/v1/markers [post]
func CreateMarker(router *gin.RouterGroup) {
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": {
"get": {
"produces": [
@@ -5270,6 +5281,17 @@
"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": {
"post": {
"tags": [
@@ -7560,19 +7582,24 @@
"type": "object",
"properties": {
"drawing": {
"type": "number"
"type": "number",
"format": "float32"
},
"hentai": {
"type": "number"
"type": "number",
"format": "float32"
},
"neutral": {
"type": "number"
"type": "number",
"format": "float32"
},
"porn": {
"type": "number"
"type": "number",
"format": "float32"
},
"sexy": {
"type": "number"
"type": "number",
"format": "float32"
}
}
},
@@ -8223,9 +8250,138 @@
}
}
},
"time.Duration": {
"tensorflow.ColorChannelOrder": {
"type": "integer",
"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,
9223372036854775807,
1,
@@ -8236,6 +8392,30 @@
3600000000000
],
"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",
"maxDuration",
"Nanosecond",
@@ -8433,7 +8613,8 @@
"items": {
"type": "array",
"items": {
"type": "number"
"type": "number",
"format": "float64"
}
}
}
@@ -8498,9 +8679,15 @@
"Service": {
"$ref": "#/definitions/vision.Service"
},
"default": {
"type": "boolean"
},
"disabled": {
"type": "boolean"
},
"meta": {
"$ref": "#/definitions/tensorflow.ModelInfo"
},
"name": {
"type": "string"
},

View File

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

View File

@@ -19,7 +19,7 @@ import (
// FindUserSessions finds user sessions and returns them as JSON.
//
// @Tags Users, Authentication
// @Router /api/v1/users/{uid}/sessions [get]
// @Router /api/v1/users/{uid}/sessions [get]
func FindUserSessions(router *gin.RouterGroup) {
router.GET("/users/:uid/sessions", func(c *gin.Context) {
// 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.
//
// @Tags Users
// @Router /api/v1/users/{uid} [put]
// @Router /api/v1/users/{uid} [put]
func UpdateUser(router *gin.RouterGroup) {
router.PUT("/users/:uid", func(c *gin.Context) {
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.
//
// @Tags Users, Files
// @Router /users/{uid}/upload/{token} [post]
// @Router /users/{uid}/upload/{token} [post]
func UploadUserFiles(router *gin.RouterGroup) {
router.POST("/users/:uid/upload/:token", func(c *gin.Context) {
conf := get.Config()

View File

@@ -24,11 +24,14 @@ var Api = func(conf *config.Config) gin.HandlerFunc {
if origin := conf.CORSOrigin(); 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 {
c.Header(header.AccessControlAllowHeaders, conf.CORSHeaders())
c.Header(header.AccessControlAllowMethods, conf.CORSMethods())
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.WebSocket(APIv1)
api.GetMetrics(APIv1)
api.Options(APIv1)
api.Echo(APIv1)
}